Supabase Relational Database with Expo: Relationships, Joins, and Nested Queries
Building an Expo app with Supabase becomes more powerful when your application needs to work with related data. Supabase uses PostgreSQL, a relational database, which allows you to connect tables using foreign keys and retrieve related records through SQL queries and Supabase's relational query features. In this guide, we'll learn how Supabase relational databases work with Expo and how to use relationships between tables in a practical application.
This article builds on the concepts covered in our previous Supabase and Expo guides. If you are new to Supabase or PostgreSQL, we recommend reading the articles in order before starting this guide. First, learn how to work with PostgreSQL using the Supabase SQL Editor. Then, follow our Supabase Auth guide to set up authentication and connect your Expo application to Supabase. Finally, read the Supabase Database guide to learn how to work with data in your application. Once you are familiar with these concepts, you will be ready to explore relationships between tables and learn how to retrieve related data with Supabase and Expo.
2.What's a Relational Database?
a.Foreign Keys
3.Creating a Relationship Between Tables
4.Using JOIN to Retrieve Related Data
5.Retrieving Related Data with Supabase Using Nested Queries
6.Supabase Relational Database Example with Expo
PostgreSQL Reference Table
Before looking at relational databases, it is helpful to review a few PostgreSQL commands and concepts that appear throughout this article. If you are primarily working with Supabase through Expo, you may not be familiar with all of the SQL syntax used in the Supabase SQL Editor. The following table provides a quick reference for the PostgreSQL commands we will use, including retrieving, adding, updating, and deleting data, as well as creating relationships and retrieving related data.
| Command / Concept | Purpose | Example |
|---|---|---|
SELECT |
Retrieve data from a table |
SELECT * FROM posts;
|
INSERT |
Add new data to a table |
INSERT INTO posts (title) VALUES ('My first post');
|
UPDATE |
Change existing data |
UPDATE posts SET title = 'Updated title' WHERE id = 1;
|
DELETE |
Delete data from a table |
DELETE FROM posts WHERE id = 1;
|
REFERENCES |
Create a foreign-key relationship between tables |
user_id uuid REFERENCES profiles(id)
|
JOIN |
Retrieve related data from multiple tables |
JOIN profiles ON posts.user_id = profiles.id
|
GRANT |
Give permissions to a database role |
GRANT SELECT ON posts TO authenticated;
|
CREATE POLICY |
Define row-level access rules |
CREATE POLICY "Users can read their own posts"
ON posts ...
|
What's a Relational Database?
A relational database stores data in separate tables and connects those tables through relationships. Instead of keeping all information in a single table, we can organize related information into different tables and connect them using keys. For example, an application might have a profiles table containing user information and a posts table containing posts created by those users:
profiles
----------------
id
username
created_at
posts
----------------
id
user_id
title
content
created_at
Foreign Keys
A foreign key is a column in one table that references a key in another table. It is used to create a relationship between the two tables and helps PostgreSQL ensure that the referenced data exists.
In our example, the user_id column in the posts table is a foreign key that references the id column in the profiles table:
posts.user_id → profiles.id
This means that a post's user_id should correspond to an existing profile id. For example, if a profile has an id of 123, a post can use 123 as its user_id to associate that post with the profile.
The important point is that the foreign key is defined in the database, not in the Expo application. Once the relationship is created, PostgreSQL knows how the two tables are connected, and Supabase can use that relationship when retrieving related data.
Creating a Relationship Between Tables
A relational database allows us to connect records from different tables. In our example, each post belongs to a profile. To represent this relationship, the user_id column in the posts table can reference the id column in the profiles table:
posts.user_id => profiles.id
This relationship tells PostgreSQL which profile is associated with each post. Relational databases become especially useful as an application grows. Instead of duplicating the same user information in every post, comment, or other record, we can store that information once and create relationships between tables.
Using JOIN to Retrieve Related Data
Now that we have established the relationship between the two tables, we can use it to retrieve related data. PostgreSQL provides the JOIN clause for combining data from related tables. For example, we can retrieve each post's title together with the username of the profile that created it:
SELECT
posts.title,
profiles.username
FROM posts
JOIN profiles
ON posts.user_id = profiles.id;
| Title | Username |
|---|---|
| My first post | John |
| Learning Supabase | John |
| My Expo app | Sarah |
Retrieving Related Data with Supabase Using Nested Queries
In the SQL Editor, we used JOIN to combine data from the posts and profiles tables. When using Supabase from our Expo application, we don't need to write the JOIN ourselves. Since the relationship between the tables already exists in the database, we can ask Supabase to return the related profiles data inside the posts result.
const { data, error } = await supabase
.from("posts")
.select(`
id,
user_id,
title,
content,
created_at,
profiles (
id,
username
)
`)
.order("created_at", {
ascending: false,
});
Here, profiles is the related table, and username is the column we want from that table. Supabase returns the profile information nested inside each post.
[
{
"title": "My first post",
"profiles": {
"username": "John"
}
},
{
"title": "Learning Supabase",
"profiles": {
"username": "John"
}
}
]
Because profiles is returned as a nested object, we can access the username with item.profiles.username.
posts
└── profiles
└── username
The nested profiles (...) syntax tells Supabase to include related profile data with each post, using the relationship we already defined between the two tables.
Supabase Relational Database Example with Expo
import { useEffect, useState } from "react";
import {
ActivityIndicator,
Alert,
Button,
FlatList,
SafeAreaView,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import { supabase } from "../lib/supabase";
export default function App() {
const [session, setSession] = useState(null);
const [user, setUser] = useState(null);
const [profile, setProfile] = useState(null);
const [posts, setPosts] = useState([]);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [username, setUsername] = useState("");
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [loading, setLoading] = useState(true);
const [authLoading, setAuthLoading] = useState(false);
const [profileLoading, setProfileLoading] = useState(false);
const [postLoading, setPostLoading] = useState(false);
useEffect(() => {
checkSession();
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, newSession) => {
setSession(newSession);
setUser(newSession?.user ?? null);
});
return () => {
subscription.unsubscribe();
};
}, []);
async function checkSession() {
try {
const {
data: { session },
error,
} = await supabase.auth.getSession();
if (error) {
console.error("Session error:", error);
return;
}
setSession(session);
setUser(session?.user ?? null);
if (session?.user) {
await loadUserData(session.user.id);
}
} catch (error) {
console.error("Unexpected error:", error);
} finally {
setLoading(false);
}
}
async function loadUserData(userId) {
await loadProfile(userId);
await fetchPosts();
}
async function signUp() {
if (!email || !password) {
Alert.alert("Error", "Enter an email and password.");
return;
}
setAuthLoading(true);
try {
const { data, error } = await supabase.auth.signUp({
email: email.trim(),
password,
});
if (error) {
Alert.alert("Sign up error", error.message);
return;
}
console.log("Sign up data:", data);
if (!data.session) {
Alert.alert(
"Check your email",
"Your account was created. Confirm your email before signing in.",
);
} else {
Alert.alert("Success", "Account created!");
}
} finally {
setAuthLoading(false);
}
}
async function signIn() {
if (!email || !password) {
Alert.alert("Error", "Enter an email and password.");
return;
}
setAuthLoading(true);
try {
const { data, error } = await supabase.auth.signInWithPassword({
email: email.trim(),
password,
});
if (error) {
Alert.alert("Sign in error", error.message);
return;
}
console.log("Signed in:", data.user?.id);
setSession(data.session);
setUser(data.user);
await loadUserData(data.user.id);
} finally {
setAuthLoading(false);
}
}
async function loadProfile(userId) {
const { data, error } = await supabase
.from("profiles")
.select("id, username, created_at")
.eq("id", userId)
.maybeSingle();
if (error) {
console.error("Profile error:", error);
return;
}
setProfile(data);
}
async function createProfile() {
if (!user) {
Alert.alert("Error", "You are not signed in.");
return;
}
if (!username.trim()) {
Alert.alert("Error", "Enter a username.");
return;
}
setProfileLoading(true);
try {
const { data, error } = await supabase
.from("profiles")
.insert({
id: user.id,
username: username.trim(),
})
.select()
.single();
if (error) {
console.error("Create profile error:", error);
Alert.alert("Profile error", error.message);
return;
}
setProfile(data);
setUsername("");
Alert.alert("Success", "Profile created!");
} finally {
setProfileLoading(false);
}
}
async function fetchPosts() {
const { data, error } = await supabase
.from("posts")
.select(
`
id,
user_id,
title,
content,
created_at,
profiles (
id,
username
)
`,
)
.order("created_at", {
ascending: false,
});
if (error) {
console.error("Fetch posts error:", error);
return;
}
setPosts(data || []);
}
async function createPost() {
if (!user) {
Alert.alert("Error", "You are not signed in.");
return;
}
if (!profile) {
Alert.alert(
"Create a profile first",
"Create your profile before creating a post.",
);
return;
}
if (!title.trim() || !content.trim()) {
Alert.alert("Error", "Enter a title and content.");
return;
}
setPostLoading(true);
try {
const { data, error } = await supabase
.from("posts")
.insert({
user_id: user.id,
title: title.trim(),
content: content.trim(),
})
.select()
.single();
if (error) {
console.error("Create post error:", error);
Alert.alert("Post error", error.message);
return;
}
console.log("Created post:", data);
setTitle("");
setContent("");
await fetchPosts();
} finally {
setPostLoading(false);
}
}
async function signOut() {
const { error } = await supabase.auth.signOut();
if (error) {
Alert.alert("Sign out error", error.message);
return;
}
setSession(null);
setUser(null);
setProfile(null);
setPosts([]);
}
if (loading) {
return (
<View style={styles.center}>
<ActivityIndicator size="large" />
<Text>Loading...</Text>
</View>
);
}
// LOGIN SCREEN
if (!session) {
return (
<SafeAreaView style={styles.container}>
<View style={styles.authContainer}>
<Text style={styles.heading}>Supabase Posts</Text>
<TextInput
style={styles.input}
placeholder="Email"
autoCapitalize="none"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
/>
<TextInput
style={styles.input}
placeholder="Password"
secureTextEntry
value={password}
onChangeText={setPassword}
/>
<Button
title={authLoading ? "Signing in..." : "Sign In"}
onPress={signIn}
disabled={authLoading}
/>
<View style={styles.buttonSpace} />
<Button
title={authLoading ? "Creating account..." : "Sign Up"}
onPress={signUp}
disabled={authLoading}
/>
</View>
</SafeAreaView>
);
}
// LOGGED-IN SCREEN
return (
<SafeAreaView style={styles.container}>
<Text style={styles.heading}>My Posts</Text>
<Text style={styles.email}>Signed in as: {user?.email}</Text>
{!profile ? (
<View style={styles.profileBox}>
<Text style={styles.sectionTitle}>Create Your Profile</Text>
<TextInput
style={styles.input}
placeholder="Username"
value={username}
onChangeText={setUsername}
/>
<Button
title={profileLoading ? "Creating..." : "Create Profile"}
onPress={createProfile}
disabled={profileLoading}
/>
</View>
) : (
<View style={styles.profileBox}>
<Text style={styles.welcome}>Welcome, {profile.username}!</Text>
</View>
)}
{profile && (
<View style={styles.form}>
<Text style={styles.sectionTitle}>Create a Post</Text>
<TextInput
style={styles.input}
placeholder="Title"
value={title}
onChangeText={setTitle}
/>
<TextInput
style={[styles.input, styles.contentInput]}
placeholder="Content"
value={content}
onChangeText={setContent}
multiline
/>
<Button
title={postLoading ? "Creating..." : "Create Post"}
onPress={createPost}
disabled={postLoading}
/>
</View>
)}
<Text style={styles.postsTitle}>Posts</Text>
<FlatList
data={posts}
keyExtractor={(item) => String(item.id)}
renderItem={({ item }) => (
<View style={styles.post}>
<Text style={styles.title}>{item.title}</Text>
<Text style={styles.content}>{item.content}</Text>
<Text style={styles.author}>
By {item.profiles?.username ?? "Unknown user"}
</Text>
</View>
)}
ListEmptyComponent={<Text style={styles.empty}>No posts yet.</Text>}
/>
<Button title="Sign Out" onPress={signOut} />
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 20,
},
authContainer: {
marginTop: 100,
},
center: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
heading: {
fontSize: 28,
fontWeight: "bold",
marginBottom: 20,
},
email: {
color: "#666",
marginBottom: 20,
},
input: {
borderWidth: 1,
borderColor: "#ccc",
borderRadius: 8,
padding: 12,
marginBottom: 10,
fontSize: 16,
},
buttonSpace: {
height: 10,
},
profileBox: {
backgroundColor: "#eef6ff",
padding: 16,
borderRadius: 10,
marginBottom: 20,
},
sectionTitle: {
fontSize: 18,
fontWeight: "bold",
marginBottom: 12,
},
welcome: {
fontSize: 18,
fontWeight: "bold",
},
form: {
marginBottom: 20,
},
contentInput: {
height: 100,
textAlignVertical: "top",
},
postsTitle: {
fontSize: 22,
fontWeight: "bold",
marginBottom: 10,
},
post: {
backgroundColor: "#f2f2f2",
padding: 16,
borderRadius: 8,
marginBottom: 12,
},
title: {
fontSize: 20,
fontWeight: "bold",
},
content: {
marginTop: 8,
fontSize: 16,
},
author: {
marginTop: 12,
color: "#666",
},
empty: {
textAlign: "center",
color: "#777",
marginVertical: 20,
},
});
The complete example contains authentication, profile creation, post creation, loading states, and the user interface. Since these parts were covered in the previous articles, let's focus on the code that is relevant to working with relational data.
The key part is the fetchPosts() function, where we request the posts together with their related profiles:
.select(`
id,
user_id,
title,
content,
created_at,
profiles (
id,
username
)
`)
The profiles section tells Supabase to include the related profile data for each post. Because posts.user_id is related to profiles.id, Supabase can use the existing relationship to retrieve the corresponding profile. The returned data has a nested structure:
post
├── title
├── content
└── profiles
├── id
└── username
We can then access the related username directly in our Expo component: item.profiles?.username
When creating a post, we also associate it with the authenticated user by storing their ID in user_id: When creating a post, we also associate it with the authenticated user by storing their ID in user_id: user_id: user.id
This connects the new post to the user's profile through the relationship we created in the database. These are the main parts to pay attention to in the full example. The remaining code handles the application's authentication, database operations, loading states, and user interface, which are covered in more detail in the previous database article.
If you have difficulty understanding any part of the example, especially the SQL or authentication code, we recommend reading our guides in order. Start with the SQL Editor guide to learn the basics of working with PostgreSQL commands, followed by the Supabase Auth guide to understand user authentication, and then the Supabase Database guide to learn how to connect your Expo application to the database. Once you are familiar with these topics, this article will show you how to work with relationships between tables and retrieve related data.
Conclusion
Relational databases make it easier to organize and connect data as an application grows. In our example, we connected the posts and profiles tables through the posts.user_id and profiles.id relationship, allowing each post to be associated with its author. We also looked at how PostgreSQL uses JOIN to retrieve related data in the SQL Editor. With Supabase, we can use the same database relationship directly from our Expo application through nested relational queries, allowing related profile information to be returned together with each post. Understanding these relationships is an important step toward building more organized and scalable applications with Supabase, PostgreSQL, and Expo. Once you understand how tables are connected, you can use the same approach for other types of related data, such as comments, likes, categories, or user settings.