To insert data into a Supabase table, use supabase.from('table_name').insert() with a single object or an array for bulk inserts. The insert respects Row Level Security, so you need an INSERT policy granting the authenticated role permission. Use upsert() with an onConflict option to handle duplicates. Always check the returned error object and use .select() chained after insert to get the created row back.
Inserting Data into Supabase Tables
Supabase provides a JavaScript client that wraps the auto-generated PostgREST API to perform CRUD operations on your PostgreSQL database. This tutorial covers inserting single rows, bulk inserts, upserts for handling duplicates, and the RLS policies required to allow inserts from your frontend application.
Prerequisites
- A Supabase project with at least one table created
- The Supabase JS client installed (@supabase/supabase-js v2+)
- RLS enabled on the target table
- A signed-in user (if your INSERT policy targets the authenticated role)
Step-by-step guide
Create a table and enable RLS
Create a table and enable RLS
Before inserting data, you need a table with RLS enabled. Use the SQL Editor in the Supabase Dashboard to create a table and enable RLS. Once RLS is enabled with no policies, all access is denied by default — inserts will silently fail (return no error but insert nothing) unless you add an INSERT policy.
1create table public.todos (2 id bigint generated always as identity primary key,3 user_id uuid references auth.users not null default auth.uid(),4 title text not null,5 completed boolean default false,6 created_at timestamptz default now()7);89alter table public.todos enable row level security;Expected result: A todos table exists with RLS enabled and no access policies yet.
Write an INSERT RLS policy
Write an INSERT RLS policy
Create an RLS policy that allows authenticated users to insert rows. The WITH CHECK clause validates the new row being inserted. A common pattern is to ensure the user_id column matches the authenticated user's ID, preventing users from creating records on behalf of other users. If your table has a default of auth.uid() on user_id, the policy still needs to verify it.
1-- Allow authenticated users to insert their own todos2create policy "Users can insert their own todos"3on public.todos for insert4to authenticated5with check (6 (select auth.uid()) = user_id7);89-- Also add a SELECT policy so inserted rows can be returned10create policy "Users can view their own todos"11on public.todos for select12to authenticated13using (14 (select auth.uid()) = user_id15);Expected result: Authenticated users can insert rows where user_id matches their own auth ID.
Insert a single row from the JS client
Insert a single row from the JS client
Use supabase.from('table').insert() to create a new row. Pass an object with the column names as keys. Chain .select() after insert to return the created row. Without .select(), the insert succeeds but returns no data. Always check the error object — a null error means the insert succeeded.
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// Insert a single row and return the created record9const { data, error } = await supabase10 .from('todos')11 .insert({ title: 'Buy groceries' })12 .select()1314if (error) {15 console.error('Insert error:', error.message)16} else {17 console.log('Created todo:', data[0])18}Expected result: A new row is inserted into the todos table and the created record is returned with all columns including the auto-generated id and timestamps.
Insert multiple rows in a single call
Insert multiple rows in a single call
Pass an array of objects to insert() to create multiple rows in one database round-trip. This is significantly faster than calling insert() in a loop for each row. All rows in the array are inserted in a single transaction, so either all succeed or all fail.
1// Bulk insert multiple todos2const { data, error } = await supabase3 .from('todos')4 .insert([5 { title: 'Buy groceries' },6 { title: 'Walk the dog' },7 { title: 'Finish project report' },8 ])9 .select()1011if (error) {12 console.error('Bulk insert error:', error.message)13} else {14 console.log(`Inserted ${data.length} todos`)15}Expected result: All rows in the array are inserted in a single transaction and the created records are returned.
Use upsert to handle duplicate conflicts
Use upsert to handle duplicate conflicts
The upsert() method inserts a row if it does not exist, or updates it if a row with the same unique/primary key already exists. Specify the onConflict option with the column name that has the unique constraint. This is useful for syncing data from external sources or implementing 'save' functionality where the row may or may not already exist.
1// Upsert: insert or update if title already exists2// (requires a unique constraint on the title column)3const { data, error } = await supabase4 .from('todos')5 .upsert(6 { title: 'Buy groceries', completed: true },7 { onConflict: 'title' }8 )9 .select()1011if (error) {12 console.error('Upsert error:', error.message)13} else {14 console.log('Upserted:', data[0])15}Expected result: If a row with the same title exists, it is updated with completed: true. Otherwise, a new row is created.
Complete working example
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// Insert a single row9async function createTodo(title: string) {10 const { data, error } = await supabase11 .from('todos')12 .insert({ title })13 .select()14 .single()1516 if (error) throw new Error(`Insert failed: ${error.message}`)17 return data18}1920// Insert multiple rows21async function createTodos(titles: string[]) {22 const rows = titles.map((title) => ({ title }))2324 const { data, error } = await supabase25 .from('todos')26 .insert(rows)27 .select()2829 if (error) throw new Error(`Bulk insert failed: ${error.message}`)30 return data31}3233// Upsert (insert or update on conflict)34async function upsertTodo(title: string, completed: boolean) {35 const { data, error } = await supabase36 .from('todos')37 .upsert({ title, completed }, { onConflict: 'title' })38 .select()39 .single()4041 if (error) throw new Error(`Upsert failed: ${error.message}`)42 return data43}4445// Insert with explicit user_id (server-side with service role)46async function adminCreateTodo(userId: string, title: string) {47 const adminClient = createClient(48 process.env.SUPABASE_URL!,49 process.env.SUPABASE_SERVICE_ROLE_KEY!50 )5152 const { data, error } = await adminClient53 .from('todos')54 .insert({ user_id: userId, title })55 .select()56 .single()5758 if (error) throw new Error(`Admin insert failed: ${error.message}`)59 return data60}6162// === Usage ===63const todo = await createTodo('Buy groceries')64console.log('Created:', todo)6566const todos = await createTodos(['Walk the dog', 'Read a book'])67console.log('Bulk created:', todos.length, 'todos')6869const upserted = await upsertTodo('Buy groceries', true)70console.log('Upserted:', upserted)Common mistakes when inserting Data into a Supabase Table
Why it's a problem: Insert succeeds with no error but the row does not appear in the table
How to avoid: This is almost always an RLS issue. When RLS is enabled with no INSERT policy, the insert is silently blocked. Add an INSERT policy for the authenticated role with the correct WITH CHECK clause.
Why it's a problem: Forgetting to chain .select() after insert and getting null data back
How to avoid: By default, insert() does not return the created row. Chain .select() to get the inserted data back, or .select().single() if you inserted one row and want a single object instead of an array.
Why it's a problem: Using the service role key in client-side code to bypass RLS for inserts
How to avoid: The service role key bypasses all RLS and must NEVER be used in browser code. Create proper INSERT policies instead. Use the service role key only in server-side code like Edge Functions or API routes.
Why it's a problem: Not handling the unique constraint violation error on upsert without onConflict
How to avoid: If you call upsert without specifying onConflict and there is no unique constraint on the table's primary key for the given data, PostgreSQL does not know which column to check for conflicts. Always specify onConflict with the column name.
Best practices
- Always enable RLS on tables and write explicit INSERT policies before inserting data from the client
- Use column defaults (like auth.uid() for user_id) to reduce client-side data that must be sent
- Chain .select() after insert to get the created row back, including auto-generated columns
- Use .single() when inserting one row to get a single object instead of an array
- Batch multiple inserts into a single array call instead of looping insert() calls
- Use upsert with onConflict for idempotent operations that may be retried
- Validate data on the client before inserting to provide instant feedback
- Use the service role key only on the server side for admin operations that bypass RLS
Still stuck?
Copy one of these prompts to get a personalized, step-by-step explanation.
Show me how to insert data into a Supabase table using the JavaScript client. Include single insert, bulk insert, and upsert examples. Also show the SQL to create the table with RLS and the INSERT policy needed for authenticated users.
Create a todos table in Supabase with RLS enabled. Write INSERT and SELECT policies for the authenticated role. Then implement TypeScript functions for creating a single todo, bulk creating multiple todos, and upserting a todo with conflict resolution on the title column.
Frequently asked questions
Why does my insert return no error but the row doesn't appear?
This is the most common Supabase issue. When RLS is enabled with no INSERT policy, inserts are silently blocked — no error is thrown but no row is created. Add an INSERT policy for the authenticated role.
How do I get the auto-generated ID after insert?
Chain .select() after insert(): supabase.from('table').insert({ title: 'test' }).select(). The returned data includes all columns, including auto-generated id and timestamp fields.
Can I insert data without a user being signed in?
Only if your INSERT policy targets the anon role (unauthenticated requests). By default, most policies target the authenticated role, requiring a signed-in user. For public data collection (like a contact form), create a policy for the anon role.
What is the difference between insert and upsert?
insert() always creates a new row and fails if a unique constraint is violated. upsert() creates a new row if no conflict exists, or updates the existing row if a matching unique key is found. Use upsert for idempotent operations.
How many rows can I insert in a single bulk call?
There is no hard limit on the number of rows per insert call, but very large batches may hit timeout or memory limits. For best performance, batch inserts in groups of 500-1000 rows.
Can I insert data into related tables in one call?
No, the Supabase JS client does not support multi-table inserts in a single call. Insert into the parent table first, get the ID, then insert into the child table. For atomic multi-table inserts, use a database function called via supabase.rpc().
Can RapidDev help build a data management layer with Supabase?
Yes, RapidDev can design your database schema, write RLS policies, and implement a complete data access layer with inserts, updates, deletes, and real-time subscriptions tailored to your application.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation