Adding Authentication With Claude Code (Supabase Auth)
Add email/password signup, Google OAuth, magic links, protected routes, and session handling to a Next.js 16 app using Claude Code and Supabase Auth.
Arrête de tout configurer. Place à la construction.
Des templates SaaS avec orchestration IA.
Supabase Auth gives you a hosted user system with sessions, JWTs, and OAuth already built. You don't write a password hashing routine or a token refresh loop. What you do write is the plumbing that connects it to Next.js: two Supabase clients, a session refresh step in proxy.ts, and the forms and Server Actions that call it. Claude Code can generate all of it correctly if you give it the right patterns, which is what this post walks through with working code.
Arrête de tout configurer. Place à la construction.
Des templates SaaS avec orchestration IA.
What Supabase Auth Gives You
Supabase Auth runs on top of your Postgres database. Users live in a schema called auth.users that you never query directly. Every sign-up, login, and OAuth flow ends the same way: Supabase issues a JWT, and @supabase/ssr stores it in cookies so your Next.js server can read it on every request.
You get email/password auth, magic links (passwordless sign-in via a one-time emailed link), and OAuth with 30-plus providers out of the box. This post covers Google as the OAuth example, since it's the one most builders reach for first. The pattern is the same for any other provider once Google works.
Install the Packages
Two packages, both from Supabase. @supabase/supabase-js is the core client. @supabase/ssr adds cookie-based session handling built for server-rendering frameworks like Next.js.
npm install @supabase/supabase-js @supabase/ssrYou also need two environment variables from your Supabase project dashboard, under Project Settings > API.
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://your-project-ref.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-keyThe anon key is safe to expose in the browser. Access control comes from row-level security policies on your tables, not from hiding this key.
Two Clients: Browser and Server
Supabase Auth in Next.js needs two separate clients because cookies work differently on each side. The browser client reads and writes cookies through document.cookie. The server client reads and writes them through the request and response objects Next.js gives you.
Ask Claude to create the browser client first. It's short.
// utils/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}The server client is used inside Server Components, Server Actions, and Route Handlers. In Next.js 16, cookies() from next/headers returns a Promise, so it needs await.
// utils/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_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// Called from a Server Component that can't set cookies.
// proxy.ts refreshes the session instead, so this is safe to ignore.
}
},
},
}
)
}That try/catch isn't decoration. Server Components can't set cookies at all, only read them. The session refresh step in proxy.ts (next section) is what actually keeps the session alive, so a Server Component failing to set a cookie here is expected, not a bug.
Email/Password Signup and Login
Server Actions handle the form submissions. Both signup and login follow the same shape: pull fields off the FormData, call the Supabase method, redirect on success.
// app/login/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { createClient } from '@/utils/supabase/server'
export async function signup(formData: FormData) {
const supabase = await createClient()
const email = formData.get('email') as string
const password = formData.get('password') as string
const { error } = await supabase.auth.signUp({ email, password })
if (error) {
redirect(`/login?error=${encodeURIComponent(error.message)}`)
}
revalidatePath('/', 'layout')
redirect('/check-email')
}
export async function login(formData: FormData) {
const supabase = await createClient()
const email = formData.get('email') as string
const password = formData.get('password') as string
const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error) {
redirect(`/login?error=${encodeURIComponent(error.message)}`)
}
revalidatePath('/', 'layout')
redirect('/dashboard')
}
export async function signOut() {
const supabase = await createClient()
await supabase.auth.signOut()
revalidatePath('/', 'layout')
redirect('/login')
}By default, Supabase requires email confirmation before a new account can log in, which is why signup redirects to a /check-email page instead of straight to the dashboard. You can turn confirmation off in the Supabase dashboard under Authentication > Providers for local testing, but leave it on in production.
The form itself is a Client Component so it can show validation state, but the submit action runs on the server.
// app/login/page.tsx
import { login, signup } from './actions'
export default function LoginPage({
searchParams,
}: {
searchParams: Promise<{ error?: string }>
}) {
return (
<form className="max-w-sm mx-auto py-12 space-y-4">
<h1 className="text-2xl font-bold">Log in</h1>
<input
id="email"
name="email"
type="email"
required
placeholder="you@example.com"
className="w-full border rounded px-3 py-2"
/>
<input
id="password"
name="password"
type="password"
required
placeholder="Password"
className="w-full border rounded px-3 py-2"
/>
<div className="flex gap-2">
<button formAction={login} className="flex-1 bg-black text-white rounded py-2">
Log in
</button>
<button formAction={signup} className="flex-1 border rounded py-2">
Sign up
</button>
</div>
</form>
)
}Ask Claude to add the searchParams.error display and you get error messaging for free. Since searchParams is a Promise in Next.js 16, reading it inside a Server Component still needs await if you use the value directly in the component body.
Session Refresh in proxy.ts
Next.js 16 renamed middleware.ts to proxy.ts, and the exported function is now named proxy instead of middleware. Claude still writes middleware.ts by default without a project instruction telling it otherwise, so add a line to CLAUDE.md: "Middleware logic goes in proxy.ts, exported as proxy, not middleware.ts."
The refresh logic lives in a helper file first, since it's easier to test and reuse.
// utils/supabase/proxy.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return 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)
)
},
},
}
)
const {
data: { user },
} = await supabase.auth.getUser()
const protectedPaths = ['/dashboard', '/account']
const isProtected = protectedPaths.some((path) => request.nextUrl.pathname.startsWith(path))
if (!user && isProtected) {
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
return supabaseResponse
}Calling getUser() here does two things at once: it verifies the token against Supabase's server, and it refreshes the session cookie if the token is close to expiring. That's why this runs on every request instead of only on protected pages, an expired session needs to refresh before it expires, not after.
The root proxy.ts file just calls the helper and defines which paths it runs on.
// proxy.ts
import { type NextRequest } from 'next/server'
import { updateSession } from '@/utils/supabase/proxy'
export async function proxy(request: NextRequest) {
return updateSession(request)
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}The matcher excludes static assets and images so the proxy doesn't run session checks on every favicon request. It still runs on every page and API route, which is what keeps sessions fresh across the whole app.
Protecting Routes (Defense in Depth)
The proxy redirect above covers most cases, but don't treat it as the only check. A matcher pattern is easy to get subtly wrong, and if it silently excludes a route you meant to protect, that route is now open. Verify again inside the page.
// app/dashboard/layout.tsx
import { redirect } from 'next/navigation'
import { createClient } from '@/utils/supabase/server'
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) {
redirect('/login')
}
return <>{children}</>
}This costs one extra network round trip to Supabase per protected page load. That's a fair trade for not depending on a regex matcher as your only authorization boundary. The real backstop underneath both of these checks is row-level security on your tables, covered below, since that's the layer an attacker can't route around.
Google OAuth
Turn on the Google provider in the Supabase dashboard under Authentication > Providers before writing any code. You'll need a Client ID and Client Secret from the Google Cloud Console, and the redirect URI Google needs to accept is the one Supabase shows you on that page, in the shape https://your-project-ref.supabase.co/auth/v1/callback.
The sign-in action requests a URL from Supabase and redirects the browser to it.
// app/login/actions.ts (add to the same file)
export async function signInWithGoogle() {
const supabase = await createClient()
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
},
})
if (error || !data.url) {
redirect('/login?error=Could not sign in with Google')
}
redirect(data.url)
}NEXT_PUBLIC_SITE_URL needs to be set to your actual deployed URL (or http://localhost:3000 in development) so the redirect lands back on your app, not Supabase's default.
<form>
<button formAction={signInWithGoogle} className="w-full border rounded py-2">
Continue with Google
</button>
</form>Magic Links
A magic link is a one-time sign-in link sent by email. No password to set or remember. Supabase calls this a passwordless OTP flow under the hood, and it uses the same callback route as OAuth.
// app/login/actions.ts (add to the same file)
export async function sendMagicLink(formData: FormData) {
const supabase = await createClient()
const email = formData.get('email') as string
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: `${process.env.NEXT_PUBLIC_SITE_URL}/auth/callback`,
},
})
if (error) {
redirect(`/login?error=${encodeURIComponent(error.message)}`)
}
redirect('/check-email')
}Wire it to its own small form since it only needs an email field, no password.
<form className="space-y-2">
<input name="email" type="email" required placeholder="you@example.com" className="w-full border rounded px-3 py-2" />
<button formAction={sendMagicLink} className="w-full border rounded py-2">
Send magic link
</button>
</form>The Callback Route Both Flows Share
Google OAuth and magic links both end with Supabase redirecting the browser back to your app with a code query parameter. A single Route Handler exchanges that code for a session.
// app/auth/callback/route.ts
import { NextResponse } from 'next/server'
import { createClient } from '@/utils/supabase/server'
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
const next = searchParams.get('next') ?? '/dashboard'
if (code) {
const supabase = await createClient()
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) {
return NextResponse.redirect(`${origin}${next}`)
}
}
return NextResponse.redirect(`${origin}/login?error=Could not authenticate`)
}This is the only place exchangeCodeForSession gets called. Every OAuth provider and every magic link points here, so adding a second provider later doesn't need a new callback route, just a new sign-in action that redirects to the same /auth/callback.
Row-Level Security Keyed to auth.uid()
None of the auth flow above restricts what a logged-in user can see in your database. That's Postgres's job, enforced through row-level security (RLS). A common pattern is a profiles table that gets a row automatically whenever someone signs up, using a Postgres trigger on auth.users.
create table public.profiles (
id uuid primary key references auth.users(id) on delete cascade,
email text,
display_name text,
created_at timestamptz default now()
);
create or replace function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
insert into public.profiles (id, email)
values (new.id, new.email);
return new;
end;
$$;
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();With the table in place, turn on RLS and write policies that check the row's owner against the logged-in user's ID.
alter table public.profiles enable row level security;
create policy "Users can view their own profile"
on public.profiles for select
to authenticated
using ( (select auth.uid()) = id );
create policy "Users can update their own profile"
on public.profiles for update
to authenticated
using ( (select auth.uid()) = id )
with check ( (select auth.uid()) = id );auth.uid() reads the user ID out of the verified JWT that @supabase/ssr attaches to every request. There's no separate authorization check to write in your application code for this table, Postgres refuses the query outright if the row doesn't belong to the caller. This is the same guarantee that backs the proxy.ts redirect and the layout check above, except it can't be skipped by a missing route check. For the full RLS picture (INSERT/DELETE policies, views, and the security_invoker gotcha), see Claude Code With Supabase.
Ask Claude to run this whole flow end to end once it's wired: sign up with a new email, confirm it, log in, sign in with Google, request a magic link, hit a protected page while logged out and confirm the redirect. That's the actual test that matters, not just a clean type check.
Every piece here (the two clients, the proxy.ts refresh, the callback route, the RLS policy shape) is something Claude Code writes correctly once, but re-derives from scratch on every new project unless you've saved the pattern somewhere. The $29 Code Kit ships this already wired: protected pages, Google OAuth, and RLS on every table from the first migration, so a new project starts past this plumbing instead of rebuilding it. It's a one-time harness on top of Claude Code, not a subscription, and Claude Code itself still needs its own paid Anthropic plan underneath it.
Arrête de tout configurer. Place à la construction.
Des templates SaaS avec orchestration IA.
Posted by @speedy_devv
Arrête de tout configurer. Place à la construction.
Des templates SaaS avec orchestration IA.
How to Build a SaaS MVP With Claude Code
A weekend build log: scaffold a Next.js 16 app, add Supabase auth and Postgres, wire up Stripe billing, and deploy to Vercel, all with Claude Code.
Adding Transactional Email With Claude Code (Resend + React Email)
How to build welcome, receipt, and password reset emails in a Next.js 16 app using Resend and React Email, with Claude Code writing the templates and the send logic.