This page is how I wire Supabase into a Next.js App Router project. The integration choices here are mine, so it stays unverified until I sign off. The Supabase client APIs (especially the @supabase/ssr cookie helpers) change over time, so check the current Supabase docs for exact signatures before you copy a snippet into production.
The whole tutorial rests on one idea: Supabase gives you two keys at two trust levels, and your job is to never let them blur. By the end you will have a browser client and a server client in separate files, a service role key that never reaches the bundle, and Row Level Security doing the actual access control instead of trusting your code to behave.
Create the project and copy your keys
In the Supabase dashboard, create a new project and wait for it to finish provisioning. Then open Project Settings, find the API section, and copy three values:
- Project URL, something like
https://your-project.supabase.co. - anon public key. This is safe to ship to the browser, but only because Row Level Security governs it. More on that below.
- service_role key. This is a root credential. It bypasses every policy you write. Treat it like a database superuser password, because that is effectively what it is.
Install the Supabase packages
You need the JS client and the SSR helper that knows how to read and write Next.js cookies.
npm install @supabase/supabase-js @supabase/ssr@supabase/supabase-js is the core client. @supabase/ssr wraps it with cookie handling so a logged-in session survives across server requests in the App Router. You will use one helper from it in the browser and a different one on the server.
Set environment variables at the right trust level
This is the step people get wrong, and it is the step that matters most. The NEXT_PUBLIC_ prefix is not decoration: it is a decision to inline a value into the browser bundle at build time. The URL and the anon key earn that prefix because they are designed to be public. The service role key does not, so it gets no prefix and stays server-only.
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-service-role-keyNever give the service role key a NEXT_PUBLIC_ prefix, and never reference it from any file that carries "use client". Once a value is inlined into the bundle, it is public forever, and rotating it is your only recovery.
Make sure .env.local is in .gitignore (a fresh Next.js project ignores it already). Committing it ships your service role key to anyone who can read the repo.
Create the browser client
Keep the browser and server clients in separate files. The separation is structural, not a matter of remembering which import is safe where. The browser client uses the anon key and runs in Client Components.
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);
}This client only ever sees public values. There is nothing secret in this file, which is exactly why it is allowed in the browser.
Create the server client
The server client also uses the anon key, but it reads the request cookies so a logged-in user's session travels with each request. This file is imported into Server Components, route handlers, and server actions, never into a Client Component.
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_ANON_KEY!,
{
cookies: {
getAll: () => cookieStore.getAll(),
setAll: (items) => {
try {
items.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options),
);
} catch {
// Called from a Server Component, which cannot set cookies.
// Safe to ignore when session refresh happens in proxy.
}
},
},
},
);
}In Next.js 16 cookies() returns a Promise, so the helper is async and you await it. The try/catch around setAll matters: a Server Component cannot write cookies, so if your session refresh lives in proxy (the next step), swallowing that specific failure keeps reads working without masking real bugs.
Refresh the session in proxy
Supabase sessions expire and need refreshing. The clean place to do that in the App Router is proxy (renamed from middleware in Next.js 16). Read the session on every matched request, refresh it, and write the updated cookies onto the response.
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function proxy(request: NextRequest) {
const response = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => request.cookies.getAll(),
setAll: (items) =>
items.forEach(({ name, value, options }) =>
response.cookies.set(name, value, options),
),
},
},
);
// Touching the user refreshes an expiring session.
await supabase.auth.getUser();
return response;
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};Turn on Row Level Security and write a least-privilege policy
Here is the part that makes the anon key safe to expose. The anon key can reach any table that does not have Row Level Security enforcing who sees what. With RLS off, that public key is an open door. With RLS on and no policy, the table is locked to everyone. You then grant exactly the access you intend, and nothing more.
In the Supabase SQL editor:
-- Lock the table down first.
alter table public.notes enable row level security;
-- Then grant precisely the access you mean to allow.
create policy "Users read their own notes"
on public.notes for select
using (auth.uid() = user_id);
create policy "Users insert their own notes"
on public.notes for insert
with check (auth.uid() = user_id);using filters which rows a query can read or affect. with check validates rows on insert or update so a user cannot write a row that claims a different owner. auth.uid() is the authenticated user's id, derived from the session, so the database itself decides ownership rather than trusting a user_id your code passed in.
Enable RLS on every table the anon key can reach, then add policies that grant the minimum. The default posture is deny, and you open access deliberately, one policy at a time.
Read data on the server with the session attached
Now fetch data in a Server Component using the server client. Because RLS is on and the session rides along in cookies, the query returns only the rows this user is allowed to see. You do not write a where user_id = ... clause; the policy does it for you.
import { createClient } from "@/lib/supabase/server";
export default async function NotesPage() {
const supabase = await createClient();
const { data: notes, error } = await supabase
.from("notes")
.select("id, title");
if (error) {
return <p>Could not load notes.</p>;
}
return (
<ul>
{notes?.map((note) => (
<li key={note.id}>{note.title}</li>
))}
</ul>
);
}Reserve the service role key for trusted server-only work
The service role key bypasses RLS entirely. You build that client only inside trusted server code (a route handler, a server action, a background job) and only when you genuinely need to act outside a user's permissions, such as an admin task or a webhook with no user session.
Avoid
"use client";
import { createClient } from "@supabase/supabase-js";
// The service role key is now inlined into the browser bundle.
// Anyone can read it and own your entire database.
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
);Prefer
import { createClient } from "@supabase/supabase-js";
export async function POST(request: Request) {
// Built inline, server-only, after your own auth check.
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false } },
);
// ...do the privileged work, then respond.
return Response.json({ ok: true });
}Before you treat this as final, confirm the @supabase/ssr cookie helper signatures against the current Supabase docs, since those APIs evolve faster than this page does.