Supabase Database with Expo React Native: A Complete Guide
Learn how to use the Supabase Database with Expo React Native to store, retrieve, insert, update, and delete data in your mobile application. In this guide, you'll learn how to connect an Expo React Native app to a Supabase PostgreSQL database, work with database tables using the Supabase JavaScript client, and implement common CRUD operations while keeping your data protected with Row Level Security (RLS).
This article builds on the Supabase and Expo setup covered in the previous articles in this series. If you're new to the series, I recommend starting with the Supabase Tutorial: Getting Started with the SQL Editor and PostgreSQL and Supabase Authentication with Expo React Native: A Complete Guide before continuing. This article assumes that you already have a Supabase project and an Expo application configured. If you haven't set up an Expo React Native project yet, see the Expo setup guide for step-by-step instructions on creating and configuring your project before continuing.
We'll also explore PostgreSQL privileges, Row Level Security (RLS), and Supabase policies. You'll learn how GRANT, USING, and WITH CHECK work together to control access to your database and how to apply these concepts when working with data from your Expo React Native application.
2.Database Permissions and Row Level Security
a.PostgreSQL Privileges with GRANT
b.Row Level Security (RLS)
c.How GRANT and RLS Work Together
d.Understanding USING and WITH CHECK
e.WITH CHECK
3.Creating a Database Table
a.Configuring Database Privileges and Row Level Security (RLS)
4.Connecting Supabase to Expo
a.Fetching Notes
b.Creating a Note
c.Deleting a Note
d.Updating a Note
5.Complete Supabase Database Example with Expo React Native
6.Common Errors and Troubleshooting
a.Permission Denied Errors
b.RLS Blocks a Query
c.auth.uid() Returns null
d.The Query Works in the Supabase Dashboard but Not in Expo
e.Environment Variables Are Not Loaded
f.Check the error Object
Prerequisites
Before working with the Supabase Database in your Expo React Native application, make sure you have completed the basic Supabase and Expo setup covered in the previous articles in this series.
You should have:
•A Supabase project
•An Expo React Native application
•The Supabase client configured in your Expo project
•Your Supabase project URL and publishable key configured
•A basic understanding of React Native and Expo
If you haven't completed the initial setup yet, I recommend starting with the Supabase Tutorial: Getting Started with the SQL Editor and PostgreSQL and Supabase Authentication with Expo React Native: A Complete Guide before continuing with this guide.
In the next section, we'll look at Row Level Security (RLS), an important part of working securely with your Supabase database.
Database Permissions and Row Level Security
Before working with your Supabase database, it's important to understand two related concepts: PostgreSQL privileges and Row Level Security (RLS). Together, they determine what your application can do and which rows users can access.
PostgreSQL Privileges with GRANT
PostgreSQL uses privileges to control which operations a role can perform on database objects such as tables. You can use the GRANT statement to give a role permissions such as SELECT, INSERT, UPDATE, or DELETE.
For example:
GRANT SELECT, INSERT, UPDATE, DELETE
ON TABLE profiles
TO authenticated;
This gives the authenticated role permission to perform these operations on the profiles table.
You can also use REVOKE to remove privileges:
REVOKE DELETE
ON TABLE profiles
FROM authenticated;
It's important to understand that granting a privilege doesn't automatically mean that a user can access every row in the table. When RLS is enabled, the relevant RLS policies also need to allow the requested operation.
Row Level Security (RLS)
Row Level Security (RLS) provides more fine-grained control by determining which rows a role can access or modify.
For example, suppose your profiles table contains a user_id column. You may want each authenticated user to access only their own profile.
First, enable RLS:
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
Then, create a policy that allows users to read only their own profile:
CREATE POLICY "Users can view their own profile"
ON profiles
FOR SELECT
TO authenticated
USING (auth.uid() = user_id);
The TO authenticated clause specifies the role the policy applies to, while the USING expression determines which existing rows can be returned.
You can create separate policies for different operations:
• SELECT — controls which rows can be read
• INSERT — controls which rows can be created
• UPDATE — controls which rows can be modified
• DELETE — controls which rows can be deleted
For example, an application might grant the authenticated role permission to SELECT from a table, while an RLS policy limits each user to rows belonging to their own account.
How GRANT and RLS Work Together
A useful way to think about the relationship is:
GRANT determines whether a role has permission to perform an operation on a database object, while RLS policies determine which rows that operation can affect.
For a database request to succeed, the necessary privilege must be available and, when RLS applies, the relevant policy must allow the operation.
This distinction becomes particularly important when building Expo React Native applications with Supabase. Your application may have the necessary database privileges, but RLS policies should still be used to ensure users can access only the data they're authorized to access.
This gives you a much better foundation for the rest of the article, because when you later show SELECT, INSERT, UPDATE, and DELETE, readers will understand both the database privilege and the RLS policy behind each operation.
Understanding USING and WITH CHECK
When creating RLS policies, you'll commonly see two clauses: USING and WITH CHECK. Although they look similar, they are used for different purposes. The easiest way to remember the difference is:
USING determines which existing rows a user can access or modify.
WITH CHECK determines whether a new or modified row is allowed to be created or stored.
For example, suppose your profiles table has a user_id column and you want users to access only their own profiles. For reading data, you can use USING:
CREATE POLICY "Users can view their own profile"
ON profiles
FOR SELECT
TO authenticated
USING (auth.uid() = user_id);
When a user runs a SELECT query, PostgreSQL evaluates the USING expression against the rows in the table. Only rows where auth.uid() matches user_id are visible to that user.
WITH CHECK
WITH CHECK is used when a new row is being created or an existing row is being changed. For example, if users should only be allowed to insert rows that belong to themselves:
CREATE POLICY "Users can create their own profile"
ON profiles
FOR INSERT
TO authenticated
WITH CHECK (auth.uid() = user_id);
Here, PostgreSQL checks the values of the row being inserted. If the user_id doesn't match the authenticated user's ID, the insert is rejected. The same concept applies to updates. Suppose a user is allowed to update their own profile:
CREATE POLICY "Users can update their own profile"
ON profiles
FOR UPDATE
TO authenticated
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
Here, each clause has a different job. The USING condition ensures that the user can update only posts that already belong to them. The WITH CHECK condition ensures that the updated row still belongs to that user after the change.
This is important because an update can change the value of user_id. Using both conditions prevents a user from updating their own row and changing its user_id to another user's ID.
The WITH CHECK condition ensures that the row still satisfies the policy after the update. For example, if users should only modify their own posts, it prevents them from changing the user_id to another user's ID.
• For SELECT, you generally use USING, because you're filtering existing rows.
• For INSERT, you generally use WITH CHECK, because there is no existing row to filter; PostgreSQL needs to check whether the new row is allowed.
• For UPDATE, you can use both: USING controls which existing rows can be updated, while WITH CHECK controls what the updated rows are allowed to contain.
• For DELETE, you generally use USING, because the operation removes an existing row rather than creating a new version of it.
Create a Database Table
To demonstrate how Supabase Database works with Expo React Native, we'll create a simple notes table. Each note will belong to an authenticated user and contain a title and note content.
In the Supabase Dashboard, open Table Editor and create a new table called notes. Add the following columns:
id (type: int8), user_id (type: uuid), title (type: text), note (type: text), created_at (type: timestamptz).
Set id as the primary key and enable Identity so Supabase automatically generates a unique ID for each new note.
For user_id, use the uuid type and create a foreign key relationship with the id column in the auth.users table. This allows each note to be associated with the authenticated user who created it.
For created_at, set the default value to now() so the database automatically records when each note is created.
Once the notes table is created, the next step is to configure Row Level Security (RLS) so users can access only their own notes.
Configuring Database Privileges and Row Level Security (RLS)
-- 1. Grant table privileges
GRANT SELECT, INSERT, DELETE
ON TABLE notes
TO authenticated;
-- 2. Enable RLS
ALTER TABLE notes ENABLE ROW LEVEL SECURITY;
-- 3. Create RLS policies
CREATE POLICY "Users can view their own notes"
ON notes
FOR SELECT
TO authenticated
USING (auth.uid() = user_id);
CREATE POLICY "Users can create their own notes"
ON notes
FOR INSERT
TO authenticated
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can delete their own notes"
ON notes
FOR DELETE
TO authenticated
USING (auth.uid() = user_id);
CREATE POLICY "Users can update their own notes"
ON notes
FOR UPDATE
TO authenticated
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
Connecting Supabase to Expo
With the notes table and its RLS policies configured, the next step is to access the database from your Expo React Native application.
Before connecting to the database, your Expo application needs a configured Supabase client. This requires the Supabase project URL and publishable key to be loaded from your environment variables.
If you haven't configured the Supabase client and environment variables yet, see Supabase Authentication with Expo React Native: A Complete Guide for the complete setup, including how to configure the .env file and use the Supabase publishable key.
Once the client is configured, you can use the same Supabase instance throughout your application to interact with database tables.
We'll use that existing client to interact with the notes table.
In the following sections, we'll use the Supabase client to perform common database operations on the notes table, starting with fetching notes.
Fetching Notes
Once the Supabase client is configured, you can retrieve data from your database using the Supabase JavaScript client. In this example, we'll fetch the notes stored in the notes table and handle both the returned data and any errors from the request.
const { data, error } = await supabase
.from('notes')
.select('*');
Creating a Note
Now that the notes table and its RLS policies are configured, we can create a new note from our Expo React Native application. To insert a new row into the notes table, use the insert() method:
const { data, error } = await supabase
.from('notes')
.insert({
user_id: user.id,
title: 'My first note',
note: 'This is my first note.',
})
.select();
The from('notes') method specifies the table we want to work with, while insert() adds a new row to the table. The user_id, title, and note values correspond to the columns we created earlier.
When the request is made, Supabase checks whether the user_id in the new row matches the ID of the authenticated user. If they match, the note can be inserted. If they don't, the database rejects the request. The select() method at the end is optional. When included, it returns the newly created row, which can be useful if you need the generated id or created_at value.
Deleting a Note
Users may also need to remove notes they no longer need. Supabase provides the delete() method for removing rows from a table. For example, to delete a note by its ID:
const { error } = await supabase
.from('notes')
.delete()
.eq('id', noteId);
Here, delete() specifies that we want to remove a row from the notes table, while .eq('id', noteId) filters the request to the note with the specified ID. It's important to understand that .eq() is only a query filter. It doesn't determine whether the user is allowed to delete the note.
When the delete request is made, the USING condition ensures that the note belongs to the authenticated user. If the note belongs to another user, the database won't allow it to be deleted, even if its ID was provided in the request.
Extra: Updating a Note
If you also want users to edit their notes, you can use Supabase's update() method.
const { data, error } = await supabase
.from('notes')
.update({
title: 'Updated title',
note: 'Updated note content',
})
.eq('id', noteId)
.select();
The .eq('id', noteId) filter identifies the note you want to update, while the RLS policy ensures that the authenticated user is allowed to modify it. If you don't need editing functionality in your application, you can simply omit the UPDATE privilege and policy.
Complete Supabase Database Example with Expo React Native
import { useEffect, useState } from "react";
import {
Button,
FlatList,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { supabase } from "../lib/supabase";
export default function HomeScreen() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [user, setUser] = useState(null);
const [title, setTitle] = useState("");
const [notes, setNotes] = useState([]);
const [note, setNote] = useState("");
// State for editing a note
const [editingNoteId, setEditingNoteId] = useState(null);
useEffect(() => {
checkUser();
}, []);
// Check whether a user is already logged in
async function checkUser() {
const {
data: { user },
error,
} = await supabase.auth.getUser();
console.log("CHECK USER:", user);
console.log("CHECK USER ERROR:", error);
if (user) {
setUser(user);
await loadNotes();
}
}
// Sign up
async function handleSignUp() {
console.log("SIGN UP STARTED");
const { data, error } = await supabase.auth.signUp({
email,
password,
});
console.log("SIGN UP DATA:", data);
console.log("SIGN UP ERROR:", error);
if (error) {
console.log("SIGN UP FAILED:", error.message);
return;
}
console.log("SIGN UP SUCCESSFUL");
}
// Sign in
async function handleSignIn() {
console.log("LOGIN BUTTON PRESSED");
console.log("EMAIL:", email);
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
console.log("LOGIN DATA:", data);
console.log("LOGIN ERROR:", error);
if (error) {
console.log("LOGIN FAILED:", error.message);
return;
}
console.log("LOGIN SUCCESSFUL!");
console.log("USER ID:", data.user.id);
console.log("USER EMAIL:", data.user.email);
setUser(data.user);
await loadNotes();
}
// Sign out
async function handleSignOut() {
const { error } = await supabase.auth.signOut();
console.log("SIGN OUT ERROR:", error);
if (!error) {
setUser(null);
setNotes([]);
}
}
// Get notes
async function loadNotes() {
console.log("LOADING NOTES");
const { data, error } = await supabase
.from("notes")
.select("*")
.order("created_at", {
ascending: false,
});
console.log("NOTES:", data);
console.log("LOAD NOTES ERROR:", error);
if (error) {
return;
}
setNotes(data);
}
// Add note
async function addNote() {
console.log("ADD NOTE BUTTON PRESSED");
if (!user) {
console.log("NO USER");
return;
}
if (!title.trim()) {
console.log("NO TITLE");
return;
}
if (!note.trim()) {
console.log("NO NOTE");
return;
}
const { data, error } = await supabase
.from("notes")
.insert({
title: title.trim(),
note: note.trim(),
user_id: user.id,
})
.select()
.single();
console.log("INSERT DATA:", data);
console.log("INSERT ERROR:", error);
if (error) {
return;
}
setNotes((currentNotes) => [data, ...currentNotes]);
setTitle("");
setNote("");
}
// Start editing a note
function startEditing(note) {
setEditingNoteId(note.id);
setTitle(note.title);
setNote(note.note);
}
// Update note
async function updateNote() {
if (!editingNoteId) {
return;
}
if (!title.trim()) {
console.log("NO TITLE");
return;
}
if (!note.trim()) {
console.log("NO NOTE");
return;
}
console.log("UPDATING NOTE:", editingNoteId);
const { data, error } = await supabase
.from("notes")
.update({
title: title.trim(),
note: note.trim(),
})
.eq("id", editingNoteId)
.select()
.single();
console.log("UPDATE DATA:", data);
console.log("UPDATE ERROR:", error);
if (error) {
return;
}
setNotes((currentNotes) =>
currentNotes.map((item) => (item.id === editingNoteId ? data : item)),
);
setEditingNoteId(null);
setTitle("");
setNote("");
}
// Delete note
async function deleteNote(id) {
console.log("DELETING NOTE:", id);
const { error } = await supabase.from("notes").delete().eq("id", id);
console.log("DELETE ERROR:", error);
if (error) {
return;
}
setNotes((currentNotes) => currentNotes.filter((note) => note.id !== id));
}
// Login screen
if (!user) {
return (
<View style={styles.container}>
<Text style={styles.title}>Supabase + Expo</Text>
<TextInput
style={styles.input}
placeholder="Email"
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
<TextInput
style={styles.input}
placeholder="Password"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
<Button title="Sign In" onPress={handleSignIn} />
<View style={styles.space} />
<Button title="Create Account" onPress={handleSignUp} />
</View>
);
}
// Notes screen
return (
<View style={styles.container}>
<Text style={styles.title}>My Notes</Text>
<Text>Logged in as: {user.email}</Text>
<View style={styles.space} />
<Button title="Sign Out" onPress={handleSignOut} />
<TextInput
style={styles.input}
placeholder="Title"
value={title}
onChangeText={setTitle}
/>
<TextInput
style={styles.input}
placeholder="Note"
value={note}
onChangeText={setNote}
/>
{editingNoteId ? (
<Button title="Update Note" onPress={updateNote} />
) : (
<Button title="Add Note" onPress={addNote} />
)}
<FlatList
data={notes}
keyExtractor={(item) => item.id.toString()}
renderItem={({ item }) => (
<View style={styles.todo}>
<Text style={styles.todoText}>{item.title}</Text>
<Text style={styles.todoText}>{item.note}</Text>
<Button title="Edit" onPress={() => startEditing(item)} />
<View style={styles.space} />
<Button title="Delete" onPress={() => deleteNote(item.id)} />
</View>
)}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
paddingTop: 60,
},
title: {
fontSize: 28,
fontWeight: "bold",
marginBottom: 20,
},
input: {
borderWidth: 1,
borderColor: "#ccc",
padding: 12,
marginBottom: 10,
},
todo: {
padding: 15,
borderBottomWidth: 1,
borderBottomColor: "#ddd",
},
todoText: {
fontSize: 18,
marginBottom: 10,
},
space: {
height: 10,
},
});
Common Errors and Troubleshooting
When working with Supabase Database and Expo, problems can come from database permissions, Row Level Security (RLS), authentication, or the Expo environment. The following sections cover some of the most common issues and how to troubleshoot them.
Permission Denied Errors
One common error is a permission error when trying to read or modify data:
permission denied for table notes
This usually means that the role making the request does not have the required PostgreSQL privileges. For example, you can grant the required privileges to the authenticated role:
GRANT SELECT, INSERT, UPDATE, DELETE
ON TABLE public.notes
TO authenticated;
Keep in mind that GRANT and RLS are separate layers of authorization. Granting table privileges does not bypass RLS. The request must also satisfy the applicable RLS policy. If you are still receiving a permission error, check both the PostgreSQL privileges and the RLS policies for the table.
RLS Blocks a Query
If RLS is enabled, Supabase checks the applicable RLS policies before allowing access to rows. For example:
const { data, error } = await supabase
.from('notes')
.select('*');
console.log(data);
console.log(error);
If the request is authenticated but no RLS policy allows the user to access the rows, the query may return no rows. For user-specific data, a policy might look like this:
CREATE POLICY "Users can view their own notes"
ON notes
FOR SELECT
TO authenticated
USING (auth.uid() = user_id);
When troubleshooting an RLS issue, check:
• RLS is enabled on the table.
• A policy exists for the operation you are performing.
• The policy applies to the role making the request.
• The user has a valid authentication session.
• The row satisfies the policy's USING condition.
Remember that RLS policies are operation-specific. A policy for SELECT does not automatically allow INSERT, UPDATE, or DELETE.
auth.uid() Returns null
If your RLS policy uses auth.uid():
auth.uid() = user_id
but auth.uid() is null, the request does not have an authenticated user identity.
You can check the current session from Expo:
const { data, error } = await supabase.auth.getSession();
console.log(data.session);
If data.session is null, make sure the user has signed in and that your Supabase client is correctly configured to persist and restore the session. This is particularly important when your RLS policies depend on auth.uid() to determine which rows a user can access.
The Query Works in the Supabase Dashboard but Not in Expo
You may sometimes find that a query works in the Supabase SQL Editor but fails when the same operation is performed from Expo.
This can happen because the two requests may run with different database roles and authentication contexts.
For example, the SQL Editor may execute a query with elevated database privileges, while your Expo application accesses the database through the Supabase API using the user's role and session.
Therefore, successfully running a query in the SQL Editor does not necessarily mean that the same query will be allowed from Expo.
When troubleshooting this situation, check:
• The role used by the Expo request.
• The PostgreSQL privileges granted to that role.
• Whether RLS is enabled.
• Whether an appropriate RLS policy exists.
• Whether the user is authenticated.
• Whether the row satisfies the RLS policy.
Environment Variables Are Not Loaded
If the Supabase client cannot connect correctly, check that your environment variables are configured correctly.
For example:
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
const supabaseKey = process.env.EXPO_PUBLIC_SUPABASE_PUBLISHABLE_KEY;
You can temporarily check whether the variables are available:
console.log('Supabase URL:', supabaseUrl);
console.log(
'Supabase key:',
supabaseKey ? 'loaded' : 'missing'
);
Avoid logging the actual key value in your application.
Also make sure the variable names start with EXPO_PUBLIC_ when they need to be accessible in Expo application code.
After changing environment variables, restart the Expo development server so the new values are loaded.
Check the error Object
When debugging a Supabase query, always check the returned error object. For example:
const { data, error } = await supabase
.from('notes')
.select('*');
if (error) {
console.error('Failed to fetch notes:', error);
return;
}
console.log(data);
The error object can provide useful information about what went wrong, including database permissions, RLS, invalid queries, or other request errors. Instead of only checking whether data is empty, inspect the error object whenever a Supabase operation does not behave as expected. This is often the fastest way to identify the source of the problem.
Conclusion
In this article, we explored how to work with a Supabase database from an Expo React Native application. We started with the two layers that control database access: PostgreSQL privileges and Row Level Security. Understanding how GRANT, RLS policies, USING, and WITH CHECK work together is essential when building applications that interact with Supabase. We then created a notes table, configured its database permissions and RLS policies, and connected the database to Expo using the Supabase client. From there, we implemented the main CRUD operations for fetching, creating, updating, and deleting notes. We also looked at how database security can work with Supabase Authentication. By using auth.uid() in RLS policies, you can associate database records with authenticated users and control which rows each user is allowed to access. If you have not worked through the authentication part of this series yet, see the Supabase Authentication with Expo article first. At this point, you have the main pieces needed to build an Expo application with Supabase as its backend: an Expo application, authentication, a PostgreSQL database, and database-level access control through RLS. In the next articles, you can build on these concepts to add more tables, relationships, queries, and application features while keeping database access controlled at the PostgreSQL level.