Build a Production-Ready Auth Flow with Claude Code
Build a Production-Ready Auth Flow with Claude Code
What you'll have at the end
A Next.js 15 app deployed on Vercel with working Google sign-in. Users click a button, end up on Google's consent screen, come back signed in, and land on a protected dashboard route. Sessions persist across refreshes. The middleware blocks anonymous users from anything under /app. The whole thing runs on Supabase Auth.
I'll link the finished reference deployment at the end. If you follow along, your version will look almost identical — the only thing different is the project name and the Google credentials.
Why auth is the wall
Every PM, BA, or non-engineer I've taught to build with AI hits the same wall at the same place. The landing page goes up in an afternoon. The form works. The dashboard mock looks great. Then the question lands: "How do users sign in?"
That's the moment the build stops. Not because the AI can't write the code — it can. Because auth is the first thing in a real product where you have to coordinate three systems at once: your app, an OAuth provider, and a session store. If any one of them is misconfigured, nothing works, and the error messages are uniformly terrible.
I've watched five different people stall here. The pattern is the same every time: they treat auth as "one more feature" instead of as a distinct stage with its own mental model. This walkthrough is the version I wish they'd had — one that names the manual steps, owns the gotchas, and gets you to a deployed sign-in flow in an hour.
Heads up
About 15 minutes of this walkthrough is clicking around in dashboards (Supabase, Google Cloud, Vercel). That part isn't slow because the tools are bad. It's slow because auth touches three companies' systems. Plan for the clicking. Don't try to AI your way around it.
The stack
Three pieces:
- Next.js 15 — App Router, server components by default. The middleware and session helpers are first-class.
- Supabase Auth — handles the OAuth dance, manages session cookies, and gives you a
userobject server-side without you writing any of it. - Google OAuth — the provider most of your users already have an account with. One sign-in button covers the majority of consumer apps.
This combo isn't the only way to do auth. It's the one I reach for because it's the shortest path from zero to "real users signing in," it scales past the toy stage without rewriting, and the free tiers cover everything you need to prove the product.
Step 1: Spin up Next.js + Supabase
Open a terminal in an empty directory and start Claude Code:
mkdir my-auth-app
cd my-auth-app
claudeFirst prompt:
Create a new Next.js 15 project in the current directory.
App Router, TypeScript, Tailwind v4
Use npm
Install @supabase/supabase-js and @supabase/ssr
Set up the Supabase client helpers at src/lib/supabase/client.ts (browser) and src/lib/supabase/server.ts (server)
Add a .env.local.example with NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY
Run the install and verify the dev server starts cleanlyClaude Code will scaffold the project, install everything, and create the client helpers. The @supabase/ssr package is the one you want — the older @supabase/auth-helpers-nextjs is deprecated and you'll see stale tutorials reaching for it. Make sure the install used @supabase/ssr.
Now create the Supabase project itself. Go to supabase.com, sign in, click "New project." Give it a name, pick a region close to you, set a database password (save it in 1Password). Wait two minutes for it to provision.
Once it's ready, go to Project Settings → API. Copy the Project URL and the anon public key. Paste them into .env.local:
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...your-anon-keyRestart the dev server. You now have a Next.js app that can talk to Supabase.
Step 2: Set up Google OAuth in Supabase
This is the manual part. There are no shortcuts — the OAuth consent screen lives in Google Cloud Console, and Supabase needs the credentials. Plan 10 minutes.
Open Google Cloud Console at console.cloud.google.com. Create a new project (top-left dropdown → New Project). Name it something memorable; you'll come back here every time you set up a new app.
In the left nav, go to APIs & Services → OAuth consent screen. Choose "External." Fill in the required fields — app name, support email, developer email. Save and continue. Add scopes: openid, email, profile. Save and continue. Add yourself as a test user. Save and continue.
Now go to APIs & Services → Credentials. Click "Create credentials" → "OAuth client ID." Choose "Web application." Name it.
For Authorized redirect URIs, paste exactly:
https://your-project.supabase.co/auth/v1/callbackReplace your-project with your actual Supabase project ref (it's in the URL of your Supabase dashboard). The redirect URI must point to Supabase, not to your Next.js app. Supabase handles the OAuth callback, then redirects to your app afterward.
I learned this the hard way
I spent 40 minutes once trying to figure out why Google was throwing redirect_uri_mismatch. The cause: I'd added https://my-app.vercel.app/auth/callback instead of the Supabase callback URL. Supabase is the OAuth client here, not your app. The redirect URI in Google goes to Supabase. Your app's callback comes after.
Click create. Google gives you a Client ID and Client Secret. Copy both.
Back in Supabase, go to Authentication → Providers → Google. Toggle it on. Paste the Client ID and Client Secret. Click save.
You now have OAuth wired between Google and Supabase. Your app still doesn't know about it — that's next.
Step 3: Add the auth UI components
Back to Claude Code:
Build a sign-in page at src/app/(auth)/sign-in/page.tsx.
- Server component for the layout
- A client component "SignInButton" that calls supabase.auth.signInWithOAuth({ provider: 'google' })
- After OAuth, Supabase redirects to /auth/callback, so set the redirectTo option to ${origin}/auth/callback
- Style the button with Tailwind: full-width on mobile, max-width 320px on desktop, dark border, Google logo on the left, "Continue with Google" label
- Add a heading and one-line subhead above the button
- Keep the page minimal — just the card centered in the viewport
Then build the callback route:
Create src/app/auth/callback/route.ts as a Route Handler.
Read the "code" query param from the request URL
Exchange the code for a session using supabase.auth.exchangeCodeForSession(code)
On success, redirect to /app/dashboard
On failure, redirect to /sign-in?error=auth_callback_failed
Use the server-side Supabase client from src/lib/supabase/server.tsTest it. Go to localhost:3000/sign-in. Click the button. You should bounce to Google, get the consent screen (in test mode you'll see a "this app isn't verified" warning — that's expected), approve, and land on /app/dashboard. That route doesn't exist yet, so you'll 404. The auth worked. Now we build the protected route.
Step 4: Protected routes with middleware
The middleware is what enforces "anonymous users can't see protected pages." Without it, your dashboard is just a URL anyone can hit.
Create src/middleware.ts using @supabase/ssr.
Import createServerClient from @supabase/ssr
Read the session using supabase.auth.getUser()
If the user is null and the path starts with /app, redirect to /sign-in
If the user exists and they're on /sign-in, redirect to /app/dashboard
Set up the config matcher to run on all routes except _next/static, _next/image, favicon, and public files
Pass cookies through correctly using the request and response cookie APIsThe cookie passing is the part that trips people up. The middleware has to read cookies from the request AND write updated cookies to the response, because Supabase rotates the refresh token on every request. If you only read, the session expires after an hour and the user gets randomly signed out.
Watch out
If you skip the cookie write-back step, sessions look like they work in dev but break unpredictably in production. The symptom is "I was just signed in and now I'm not." The cause is always the middleware not propagating the rotated refresh token cookie. Check the official Supabase Next.js middleware docs if Claude Code's first attempt is missing this — it's the one piece worth reading the source on.
Step 5: Session management — server vs client
You have two ways to get the current user. Use them deliberately.
On the server (server components, server actions, route handlers):
import { createClient } from '@/lib/supabase/server'
export default async function DashboardPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
// Middleware should have caught this, but defense-in-depth
return null
}
return <div>Welcome, {user.email}</div>
}
On the client (interactive components, forms, real-time UI):
'use client'
import { createClient } from '@/lib/supabase/client'
import { useEffect, useState } from 'react'
export function UserMenu() {
const supabase = createClient()
const [user, setUser] = useState(null)
useEffect(() => {
supabase.auth.getUser().then(({ data }) => setUser(data.user))
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => setUser(session?.user ?? null)
)
return () => subscription.unsubscribe()
}, [])
return user ? <span>{user.email}</span> : null
}
The rule I use: if the data influences what gets rendered on first paint, fetch it on the server. If it's interactive UI that responds to sign-in/sign-out events, fetch it on the client. Don't mix the two for the same value.
Build the dashboard:
Create src/app/app/dashboard/page.tsx as a server component.
Fetch the current user with the server Supabase client
Render a simple welcome card with their email and a "Sign out" button
The sign-out button should be a client component that calls supabase.auth.signOut() then router.push('/sign-in')
Style: centered card, dark border, Tailwind defaultsTest the whole loop: sign in, land on dashboard, sign out, back to sign-in. Refresh the dashboard while signed in — you should stay there. Refresh while signed out — you should bounce to sign-in.
Step 6: Deploy to Vercel + production credentials
Local works. Now ship it.
git init
git add .
git commit -m "feat: google oauth with supabase"
gh repo create my-auth-app --private --source=. --pushThen deploy:
npm install -g vercel
vercelWalk through the prompts. When it asks about environment variables, paste your NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY. Run vercel --prod to push to a production URL.
Now the production-credentials step. Your Vercel URL needs to be added as an authorized redirect.
Back to Google Cloud Console → Credentials → your OAuth client. Add a second authorized redirect URI for your Supabase project — wait, you already have that. What you actually need to add is in Supabase: go to Authentication → URL Configuration. Set the Site URL to your Vercel production URL. Add it to the Redirect URLs list too.
The redirect URL gotcha
Three different URL settings, three different places. (1) Google Cloud has the Supabase callback URL. (2) Supabase has your Vercel app's URL as the Site URL. (3) Supabase has your Vercel app's /auth/callback URL in the allowed redirect list. If any of the three is wrong, sign-in fails silently — you'll get bounced back to the sign-in page with no error. Triple-check all three before you debug anything else.
Test in production. Sign in. If it works on the live URL, you have a real auth flow.
What you've built
- A Next.js 15 app with Google sign-in
- Supabase managing sessions, OAuth, and the user table
- Middleware that protects
/app/*routes - Server-side and client-side patterns for reading the current user
- A deployed production URL with real credentials
This is the foundation. Everything else — magic links, password sign-in, email verification, role-based access, multi-tenant — builds on top of this same shape.
Common pitfalls I've hit
The redirect URL gotcha. Already covered above. It's the single most common failure. Always start debugging here.
The session refresh edge case. If you ever see a session that "works in incognito but not in your normal browser," you have a stale cookie. Clear the Supabase cookies for your domain and try again. The middleware should prevent this once it's set up correctly.
The getUser vs getSession trap. getSession() reads from the cookie without validating. getUser() round-trips to Supabase to verify. On the server, always use getUser(). The performance cost is real but small, and the security difference is the whole point.
The OAuth consent screen verification block. Once you go past 100 users, Google requires consent screen verification. Apply early — it takes 1–2 weeks. Until you're verified, users see the "unverified app" warning. It doesn't block sign-in, but it scares off non-technical users.
Forgetting to handle sign-out cleanup. If you store any client-side state tied to the user (Zustand, React Query cache), you have to clear it on sign-out. Otherwise the next user who signs in on that browser sees the previous user's stale data flash on screen before the new data loads.
What's next
The natural follow-ups:
- Email sign-in with magic links (Supabase makes this trivial — same
signInWithAPI, different provider) - Role-based access using a
profilestable with arolecolumn and RLS policies - Account settings page — let users update their email, name, profile photo
- Multi-provider sign-in — add GitHub or Apple alongside Google
Each is one walkthrough away. The architectural piece you just built — middleware-enforced session validation with server and client helpers — doesn't change. You add features on top of it.