
No time to debug this Supabase issue? Tell us what's broken and our developers will get it working, usually within 24 hours.
To connect Next.js to Supabase: install @supabase/supabase-js and @supabase/ssr, add your project URL and publishable key to .env.local, create a browser client and a server client, and add a proxy.ts file (called middleware.ts before Next.js 16) that refreshes the user's session on each request. Then enable Row Level Security on every table so the public key can only reach the rows each user is allowed to see.
This guide walks through each step for the Next.js App Router, plus database queries, authentication, file uploads and real-time updates. It's current for Next.js 16 and Supabase's publishable and secret API keys.
What Is Supabase?
Supabase is an open-source backend-as-a-service built on top of PostgreSQL. It bundles four major services under a single project:
- Database — a fully managed PostgreSQL instance with a built-in REST and GraphQL API, generated automatically from your schema
- Auth — user management with email/password, magic links, OAuth providers (Google, GitHub, etc.), and phone auth
- Storage — an S3-compatible file storage service with access policies tied to your database permissions
- Realtime — a WebSocket layer that lets clients subscribe to database changes as they happen
Unlike Firebase, which is a NoSQL document store, Supabase gives you a real relational database with foreign keys, joins, and full SQL power. That makes it a substantially better fit for the structured data most applications actually need.
When to Use Supabase
Supabase is a strong default choice for any full-stack application where you want a complete backend without managing your own infrastructure. It's particularly well-suited for:
- SaaS products that need multi-tenant data isolation via Row Level Security
- Applications with complex data relationships that benefit from SQL and joins
- Projects that need auth, database, and file storage from a single provider
- Prototypes and MVPs where speed of development matters more than custom infrastructure
The free tier (500 MB database, 1 GB storage, 50,000 monthly active users) is generous enough to take a project from zero to early revenue.
Step 1: Create a Supabase Project and Get Your Credentials

Go to supabase.com, sign in, and click New Project. Give it a name, choose a region close to your users, and set a strong database password (save this — you'll need it for direct Postgres connections).
Once the project is provisioned (about 30 seconds), open Project Settings → API Keys. You'll need two values:
- Project URL — something like
https://abcdefgh.supabase.co - Publishable key — starts with
sb_publishable_. It's safe to use in client-side code as long as Row Level Security is enabled on your tables. Older projects show a legacy anon key (a long JWT) instead; it works the same way in the code below.
Keep the secret key (sb_secret_..., or the legacy service_role key) private. It bypasses RLS entirely and should only be used in server-side code where you explicitly need admin access to the database.
Step 2: Install the SDK and Configure Environment Variables


npm install @supabase/supabase-js @supabase/ssr
Create a .env.local file in your project root (add it to .gitignore if it isn't already):
NEXT_PUBLIC_SUPABASE_URL=https://abcdefgh.supabase.co
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=sb_publishable_your-key-here
The NEXT_PUBLIC_ prefix exposes these variables to the browser bundle. The publishable key is intentionally public — it's designed to be used in client-side code. Never give the secret key a NEXT_PUBLIC_ name. Its permissions are limited by Row Level Security policies on your database tables.
Step 3: Initialize the Client
Create src/lib/supabase.ts:
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseKey = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!;
export const supabase = createClient(supabaseUrl, supabaseKey);
Import this singleton wherever you need database or auth access. For plain React (Vite or Create React App) this single file is all you need. For Next.js App Router, you'll use a different setup covered below — but this client is still the right starting point for React-only projects.
Step 4: Database CRUD Operations
Supabase's query builder mirrors SQL closely. Here are the four essential operations.
Select (Read)
const { data, error } = await supabase
.from('posts')
.select('id, title, content, created_at')
.order('created_at', { ascending: false })
.limit(10);
Insert (Create)
const { data, error } = await supabase
.from('posts')
.insert({ title: 'My First Post', content: 'Hello world', user_id: userId })
.select()
.single();
The .select().single() chain returns the newly created row — useful when you need the auto-generated id.
Update
const { error } = await supabase
.from('posts')
.update({ title: 'Updated Title' })
.eq('id', postId);
Delete
const { error } = await supabase
.from('posts')
.delete()
.eq('id', postId);
Always handle the error return value. A common mistake is checking data before checking error — the error object tells you whether a query failed and why, including RLS policy violations.
Step 5: Authentication — Email/Password Signup and Login
Supabase Auth ships with pre-built flows for the most common authentication methods.
Sign Up
const { data, error } = await supabase.auth.signUp({
email: 'user@example.com',
password: 'securepassword123',
options: {
data: { full_name: 'Jane Smith' }, // stored in auth.users.raw_user_meta_data
},
});
if (error) {
console.error('Signup error:', error.message);
} else {
console.log('User created:', data.user?.id);
}
By default, Supabase sends a confirmation email. The user is not considered active until the link is clicked. You can disable email confirmation in Authentication → Settings for development, but always enable it in production.
Sign In
const { data, error } = await supabase.auth.signInWithPassword({
email: 'user@example.com',
password: 'securepassword123',
});
if (data.session) {
console.log('Logged in:', data.session.user.id);
}
Sign Out
await supabase.auth.signOut();
Listening to Auth State Changes
const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => {
if (event === 'SIGNED_IN') { /* update UI */ }
if (event === 'SIGNED_OUT') { /* redirect to login */ }
});
// Clean up on component unmount
return () => subscription.unsubscribe();
Step 6: Next.js App Router Setup With @supabase/ssr
The single createClient approach works for React SPAs, but Next.js App Router has both server and client components, Server Actions, and middleware — each running in a different context with different cookie access. Supabase provides @supabase/ssr to handle this correctly.
You installed @supabase/ssr in Step 2.
Browser Client (Client Components)
// src/lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr';
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
);
}
Use this in any 'use client' component. It reads and writes cookies in the browser, keeping the session in sync.
Server Client (Server Components, Route Handlers, Server Actions)
// src/lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
},
},
}
);
}
Proxy (Middleware) for Session Refresh
Without this file, a server component can render with an expired session. In Next.js 16, add src/proxy.ts (in Next.js 15 and earlier the file is src/middleware.ts and the function is named middleware):
import { createServerClient } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';
export async function proxy(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll: () => request.cookies.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
);
supabaseResponse = NextResponse.next({ request });
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
);
},
},
}
);
// Verifies the token and refreshes the session if needed.
// Don't run other code between createServerClient and this call.
await supabase.auth.getClaims();
return supabaseResponse;
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
};
This refreshes short-lived access tokens before your page renders, which prevents users from appearing logged out on the first server render.
Protecting a Page in a Server Component
Use getClaims() to check who is logged in on the server. It verifies the token's signature, so it's safe to trust for authorization. Don't use getSession() in server code for this: it reads the cookie without verifying it.
// src/app/dashboard/page.tsx
import { redirect } from 'next/navigation';
import { createClient } from '@/lib/supabase/server';
export default async function DashboardPage() {
const supabase = await createClient();
const { data } = await supabase.auth.getClaims();
if (!data?.claims) redirect('/login');
const { data: posts } = await supabase
.from('posts')
.select('id, title')
.order('created_at', { ascending: false });
return <PostList posts={posts ?? []} />;
}
Because the server client sends the user's session, RLS policies apply here exactly as they do in the browser. Use getUser() instead only when you need the latest user record from the Auth server, since it costs a network request.
Step 7: Row Level Security
Row Level Security (RLS) is PostgreSQL's mechanism for enforcing per-row access rules at the database level. With RLS enabled, even if a bug in your application code accidentally exposes a query, the database itself will only return rows the authenticated user is allowed to see.
Enable RLS on a table and add a policy in the Supabase SQL editor:
-- Enable RLS on the posts table
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- Users can only read their own posts
CREATE POLICY "Users can view their own posts"
ON posts FOR SELECT
USING (auth.uid() = user_id);
-- Users can only insert posts with their own user_id
CREATE POLICY "Users can insert their own posts"
ON posts FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Users can only update their own posts, and can't reassign them
CREATE POLICY "Users can update their own posts"
ON posts FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
auth.uid() is a Supabase built-in that returns the ID of the currently authenticated user from the JWT. With these policies in place, a query for posts using the anon key will only ever return rows where user_id matches the logged-in user — regardless of what filters the application code passes.
If inserts fail with new row violates row-level security policy, a policy is missing or the row's user_id doesn't match the logged-in user. Our guide to fixing the row-level security policy error covers every variation, including upserts and storage uploads.
Step 8: Real-Time Subscriptions
Supabase Realtime lets you subscribe to database changes using PostgreSQL's logical replication. Changes to any table — inserts, updates, deletes — are broadcast over a WebSocket to all subscribed clients.
const channel = supabase
.channel('posts-changes')
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'posts' },
(payload) => {
if (payload.eventType === 'INSERT') {
setPosts(prev => [payload.new as Post, ...prev]);
}
if (payload.eventType === 'DELETE') {
setPosts(prev => prev.filter(p => p.id !== payload.old.id));
}
}
)
.subscribe();
// Clean up on unmount
return () => { supabase.removeChannel(channel); };
Before subscriptions work, add the table to Supabase's realtime publication. Run this in the SQL editor:
alter publication supabase_realtime add table posts;
Realtime respects RLS, so each user only receives changes to rows their SELECT policy allows.
Step 9: File Uploads With Supabase Storage

Create a storage bucket in Storage → New Bucket. Set it to private unless files need to be publicly accessible.
// Upload a file
const { data, error } = await supabase.storage
.from('avatars')
.upload(`${userId}/avatar.png`, file, {
contentType: 'image/png',
upsert: true,
});
// Get a public URL (for public buckets)
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(`${userId}/avatar.png`);
// Get a signed URL (for private buckets, valid for 1 hour)
const { data: { signedUrl } } = await supabase.storage
.from('avatars')
.createSignedUrl(`${userId}/avatar.png`, 3600);
Storage access is controlled by storage policies, similar in syntax to RLS. A common policy for user avatars: allow a user to upload and read files only under their own user_id path.
Common Mistakes
Using the Anon Key Server-Side Without RLS
The anon key is safe on the client because RLS limits what it can access. But if you use it in a Server Action or Route Handler without RLS enabled on your tables, every authenticated user can query every row — including other users' data.
Fix: Enable RLS on every table that contains user data. Use the Supabase dashboard's Advisors → Security tab to get a report of tables with RLS disabled.Not Refreshing the Session in the Proxy
If you skip the proxy.ts (or middleware.ts) setup, server components render with a stale or expired session. The user appears logged out on the first server render even though their browser has a valid session cookie.
Fetching Data in Client Components When Server Components Would Work
Many developers default to useEffect + Supabase client for all data fetching. In Next.js App Router, Server Components can fetch data directly without a loading state, waterfall, or client-side JavaScript.
Frequently Asked Questions
What's the difference between the publishable key and the anon key?Both are public keys for client-side code, and both rely on RLS for security. The publishable key (sb_publishable_...) is Supabase's newer key format and can be rotated without downtime; the anon key is the legacy JWT-based key. New projects should use the publishable key.
Next.js 16 renamed middleware to proxy: the file is proxy.ts and the exported function is proxy. On Next.js 15 and earlier, use middleware.ts with a function named middleware. The Supabase code inside is the same.
The publishable key is designed to be public — it identifies your project, not your access level. It's safe to expose it in client-side code. What makes it safe is RLS. Without RLS on your tables, the anon key grants full read/write access to all rows, which is why enabling RLS is non-negotiable for any production application.
What's the difference betweencreateClient from @supabase/supabase-js and from @supabase/ssr?
The base createClient stores the session in localStorage, which doesn't work in server environments. The @supabase/ssr variants (createBrowserClient and createServerClient) use cookies instead, enabling the session to be read by both browser and server code in Next.js.
Yes. The base @supabase/supabase-js client works in any JavaScript environment — Vite, Create React App, React Native, or plain HTML. The @supabase/ssr package is specifically for server-rendering frameworks like Next.js, Remix, and SvelteKit.
Supabase integrates with the Supabase CLI and supports migration files that you can version control alongside your application code. Run supabase db diff to generate a migration from schema changes, then supabase db push to apply it. For teams, this is the recommended approach over manually editing tables in the dashboard.
Yes. PostgreSQL has built-in full-text search using tsvector and tsquery. Supabase exposes this through the .textSearch() method on the query builder. For more advanced search (fuzzy matching, relevance ranking, multi-language), you can use the pg_trgm extension, which Supabase supports out of the box.
Build Production-Grade Supabase Applications With Amex Technology
Setting up Supabase correctly is the beginning. A production application needs a hardened schema with proper indexes, RLS policies that cover every access pattern, a migration strategy that works with CI/CD, and monitoring that alerts you when query performance degrades.
At Amex Technology, we design and build full-stack applications on the Next.js and Supabase stack — from schema design and RLS policy architecture to the deployment pipeline and observability layer. If you're starting a new project or scaling an existing one, we'd be glad to help you get it right from the foundation.
Explore our web app development and SaaS development services, see our portfolio, or get in touch to talk through your project. Already have a Supabase app that's misbehaving? Our 24-hour fix service can help.
Fixed it? Check the rest of your Supabase app for free. Our App Checker scans for exposed API keys, public Supabase service keys, SEO problems and slow loading in about 30 seconds.
Related Services
Bug Fix Service
Still stuck? We'll fix your Supabase error for you.
Skip the hours of trial and error. Send us the error and access to your project, and a senior developer will get it working, usually within 24 hours. We'll review it for free before any work starts.
- Your bug or error fixed by a senior developer
- Most fixes delivered within 24 hours
- Short write-up of the root cause and what changed
- Free assessment before any work starts