Supabase uses standard PostgreSQL foreign keys to model one-to-one, one-to-many, and many-to-many relationships. Define foreign key columns that reference other tables, create join tables for many-to-many links, and use the Supabase JS client's nested select syntax to fetch related data in a single query without manual joins.
Modeling One-to-One, One-to-Many, and Many-to-Many Relationships in Supabase
Supabase is built on PostgreSQL, so you model relationships the same way you would in any relational database: with foreign keys. This tutorial walks you through creating each type of relationship, setting up the corresponding RLS policies, and querying related data efficiently using the Supabase JS client's nested select syntax. You will build a simple project with authors, posts, and tags to demonstrate all three relationship types.
Prerequisites
- A Supabase project (free tier works)
- Access to the SQL Editor in the Supabase Dashboard
- Basic understanding of SQL tables and columns
- @supabase/supabase-js installed in your frontend project
Step-by-step guide
Create the parent tables for authors and tags
Create the parent tables for authors and tags
Open the SQL Editor in your Supabase Dashboard and create two parent tables: authors and tags. These will be referenced by other tables through foreign keys. Use UUID primary keys for authors so you can link them to auth.users later, and bigint identity keys for tags since they are standalone lookup data.
1create table public.authors (2 id uuid primary key default gen_random_uuid(),3 name text not null,4 bio text,5 created_at timestamptz default now()6);78create table public.tags (9 id bigint generated always as identity primary key,10 label text unique not null11);1213alter table public.authors enable row level security;14alter table public.tags enable row level security;Expected result: Both tables appear in the Table Editor. RLS is enabled with no policies, so data is locked down by default.
Create a one-to-many relationship between authors and posts
Create a one-to-many relationship between authors and posts
A one-to-many relationship means one author can have many posts, but each post belongs to exactly one author. Add a posts table with an author_id column that references the authors table. The foreign key constraint ensures referential integrity — you cannot insert a post pointing to a non-existent author. Adding ON DELETE CASCADE means deleting an author automatically removes all their posts.
1create table public.posts (2 id bigint generated always as identity primary key,3 title text not null,4 body text,5 author_id uuid not null references public.authors(id) on delete cascade,6 created_at timestamptz default now()7);89alter table public.posts enable row level security;1011-- Allow anyone to read posts12create policy "Public read posts" on public.posts13 for select to anon, authenticated14 using (true);1516-- Allow authenticated users to insert posts17create policy "Auth users insert posts" on public.posts18 for insert to authenticated19 with check (true);Expected result: The posts table is created with a foreign key arrow visible in the Table Editor pointing to authors.
Create a many-to-many relationship between posts and tags
Create a many-to-many relationship between posts and tags
A many-to-many relationship means one post can have many tags, and one tag can appear on many posts. You model this with a join table (also called a junction or bridge table) that holds two foreign keys. Each row in the join table represents one link between a post and a tag. A composite unique constraint prevents duplicate pairings.
1create table public.post_tags (2 id bigint generated always as identity primary key,3 post_id bigint not null references public.posts(id) on delete cascade,4 tag_id bigint not null references public.tags(id) on delete cascade,5 unique(post_id, tag_id)6);78alter table public.post_tags enable row level security;910create policy "Public read post_tags" on public.post_tags11 for select to anon, authenticated12 using (true);1314create policy "Auth users insert post_tags" on public.post_tags15 for insert to authenticated16 with check (true);Expected result: The post_tags join table is created with foreign keys pointing to both posts and tags.
Query nested relationships with the Supabase JS client
Query nested relationships with the Supabase JS client
The Supabase JS client uses PostgREST's resource embedding to let you fetch related data in a single request. Pass the related table name inside the select string, and Supabase automatically resolves the foreign key join. For many-to-many relationships, reference the join table and then nest the final target table inside it. This avoids manual SQL joins and keeps your client code clean.
1import { createClient } from '@supabase/supabase-js'23const supabase = createClient(4 process.env.NEXT_PUBLIC_SUPABASE_URL!,5 process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!6)78// One-to-many: fetch posts with their author9const { data: posts } = await supabase10 .from('posts')11 .select('id, title, authors(name, bio)')1213// Many-to-many: fetch posts with their tags14const { data: postsWithTags } = await supabase15 .from('posts')16 .select('id, title, post_tags(tags(label))')1718// Combined: posts with author AND tags19const { data: full } = await supabase20 .from('posts')21 .select('id, title, body, authors(name), post_tags(tags(label))')Expected result: Each query returns an array of posts with nested author and tag objects already resolved — no client-side joining needed.
Link a profiles table to auth.users for a one-to-one relationship
Link a profiles table to auth.users for a one-to-one relationship
The most common one-to-one relationship in Supabase links a public profiles table to auth.users. The profiles table uses the same UUID as its primary key, referencing auth.users(id). Create a trigger so that a profile row is automatically created every time a new user signs up. This pattern is used in nearly every Supabase application.
1create table public.profiles (2 id uuid not null references auth.users(id) on delete cascade,3 display_name text,4 avatar_url text,5 primary key (id)6);78alter table public.profiles enable row level security;910create policy "Users can view own profile" on public.profiles11 for select to authenticated12 using ((select auth.uid()) = id);1314create policy "Users can update own profile" on public.profiles15 for update to authenticated16 using ((select auth.uid()) = id)17 with check ((select auth.uid()) = id);1819-- Auto-create profile on signup20create or replace function public.handle_new_user()21returns trigger language plpgsql security definer set search_path = ''22as $$23begin24 insert into public.profiles (id, display_name)25 values (new.id, new.raw_user_meta_data ->> 'full_name');26 return new;27end;28$$;2930create trigger on_auth_user_created31 after insert on auth.users32 for each row execute function public.handle_new_user();Expected result: When a new user signs up, a profile row is automatically created. Each user can only read and update their own profile.
Insert related data and verify the relationships
Insert related data and verify the relationships
Test your schema by inserting sample data. Insert an author, then a post linked to that author, then some tags, and finally link them through the join table. Then run the nested select query to verify everything resolves correctly. This confirms that foreign keys, RLS policies, and the nested select syntax all work together.
1// Insert an author2const { data: author } = await supabase3 .from('authors')4 .insert({ name: 'Jane Smith', bio: 'Full-stack developer' })5 .select()6 .single()78// Insert a post linked to that author9const { data: post } = await supabase10 .from('posts')11 .insert({ title: 'Getting Started with Supabase', body: 'A beginner guide...', author_id: author.id })12 .select()13 .single()1415// Insert tags16const { data: tags } = await supabase17 .from('tags')18 .insert([{ label: 'supabase' }, { label: 'postgresql' }])19 .select()2021// Link post to tags via join table22await supabase.from('post_tags').insert([23 { post_id: post.id, tag_id: tags[0].id },24 { post_id: post.id, tag_id: tags[1].id }25])2627// Verify with nested select28const { data } = await supabase29 .from('posts')30 .select('title, authors(name), post_tags(tags(label))')31console.log(JSON.stringify(data, null, 2))Expected result: The console output shows each post with its author name and an array of tag labels nested inside the response object.
Complete working example
1-- Authors table2create table public.authors (3 id uuid primary key default gen_random_uuid(),4 name text not null,5 bio text,6 created_at timestamptz default now()7);89-- Tags table10create table public.tags (11 id bigint generated always as identity primary key,12 label text unique not null13);1415-- Posts table (one-to-many with authors)16create table public.posts (17 id bigint generated always as identity primary key,18 title text not null,19 body text,20 author_id uuid not null references public.authors(id) on delete cascade,21 created_at timestamptz default now()22);2324-- Join table for many-to-many (posts <-> tags)25create table public.post_tags (26 id bigint generated always as identity primary key,27 post_id bigint not null references public.posts(id) on delete cascade,28 tag_id bigint not null references public.tags(id) on delete cascade,29 unique(post_id, tag_id)30);3132-- Profiles table (one-to-one with auth.users)33create table public.profiles (34 id uuid not null references auth.users(id) on delete cascade,35 display_name text,36 avatar_url text,37 primary key (id)38);3940-- Enable RLS on all tables41alter table public.authors enable row level security;42alter table public.tags enable row level security;43alter table public.posts enable row level security;44alter table public.post_tags enable row level security;45alter table public.profiles enable row level security;4647-- RLS policies48create policy "Public read authors" on public.authors for select to anon, authenticated using (true);49create policy "Public read tags" on public.tags for select to anon, authenticated using (true);50create policy "Public read posts" on public.posts for select to anon, authenticated using (true);51create policy "Auth insert posts" on public.posts for insert to authenticated with check (true);52create policy "Public read post_tags" on public.post_tags for select to anon, authenticated using (true);53create policy "Auth insert post_tags" on public.post_tags for insert to authenticated with check (true);54create policy "Users read own profile" on public.profiles for select to authenticated using ((select auth.uid()) = id);55create policy "Users update own profile" on public.profiles for update to authenticated using ((select auth.uid()) = id) with check ((select auth.uid()) = id);5657-- Auto-create profile on signup58create or replace function public.handle_new_user()59returns trigger language plpgsql security definer set search_path = ''60as $$61begin62 insert into public.profiles (id, display_name)63 values (new.id, new.raw_user_meta_data ->> 'full_name');64 return new;65end;66$$;6768create trigger on_auth_user_created69 after insert on auth.users70 for each row execute function public.handle_new_user();Common mistakes when modeling Relationships in Supabase
Why it's a problem: Forgetting to add a SELECT RLS policy alongside INSERT, causing inserts to appear to fail because the returned data is empty
How to avoid: Always create a SELECT policy on any table where you insert data via the JS client. The client runs a SELECT after INSERT to return the new row.
Why it's a problem: Using ON DELETE CASCADE when you want to prevent accidental parent deletion
How to avoid: Use ON DELETE RESTRICT if child records should block parent deletion. CASCADE silently removes all children when the parent is deleted.
Why it's a problem: Trying to query a many-to-many relationship directly without going through the join table
How to avoid: Always reference the join table in your nested select: select('id, post_tags(tags(label))') — not select('id, tags(label)').
Why it's a problem: Not enabling RLS on newly created tables, leaving data exposed to anyone with the anon key
How to avoid: Run ALTER TABLE table_name ENABLE ROW LEVEL SECURITY immediately after creating every table. Then add specific policies.
Best practices
- Always enable RLS on every table and add explicit policies for each operation you need
- Use UUID primary keys for tables that reference auth.users so the foreign key matches the user ID type
- Add ON DELETE CASCADE for child records that should be removed when the parent is deleted
- Add indexes on foreign key columns to speed up joins and nested select queries
- Use composite unique constraints on join tables to prevent duplicate relationships
- Wrap auth.uid() in a select subquery inside RLS policies for per-statement caching: (select auth.uid())
- Use the nested select syntax instead of manual joins to keep client code simple and reduce round trips
- Create a profiles table linked to auth.users as a one-to-one relationship for storing public user data
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
I have a Supabase project and need to model a blog with authors, posts, and tags. Show me the SQL to create one-to-many (author to posts) and many-to-many (posts to tags) relationships with proper foreign keys, RLS policies, and the JavaScript queries to fetch nested data.
Create a Supabase schema with authors, posts, and tags tables. Posts belong to one author (one-to-many) and can have multiple tags (many-to-many via a post_tags join table). Enable RLS on all tables with public read access. Include the JS client queries that use nested select to fetch posts with their author and tags in one request.
Frequently asked questions
What is the difference between a one-to-many and a many-to-many relationship in Supabase?
A one-to-many relationship uses a single foreign key column on the child table pointing to the parent. A many-to-many relationship requires a separate join table with two foreign key columns, one pointing to each related table.
Can I query nested relationships in a single request with the Supabase JS client?
Yes. Use the nested select syntax: supabase.from('posts').select('title, authors(name), post_tags(tags(label))'). Supabase resolves foreign key joins automatically through PostgREST resource embedding.
Do I need to manually write SQL JOIN statements when using Supabase?
No. The Supabase JS client's nested select syntax handles joins automatically based on foreign key relationships. You only need raw SQL joins if you are writing custom database functions or complex queries in the SQL Editor.
How do I prevent duplicate entries in a many-to-many join table?
Add a composite unique constraint: UNIQUE(post_id, tag_id). This ensures the same combination of foreign keys cannot appear more than once in the join table.
Should I use UUID or bigint for primary keys in Supabase?
Use UUID for tables that reference auth.users, since Supabase user IDs are UUIDs. Use bigint generated always as identity for standalone tables where sequential IDs are simpler and more storage-efficient.
Why does my insert return an empty array instead of the new row?
The Supabase client runs a SELECT after INSERT to return the new data. If you have an INSERT RLS policy but no SELECT policy, the insert succeeds but the returned data is blocked. Add a matching SELECT policy to fix this.
Can RapidDev help me design a relational schema for my Supabase project?
Yes. RapidDev can design your database schema, set up foreign key relationships, write RLS policies, and build the corresponding API queries for your specific application requirements.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation