The problem
Your Bolt.new app has a login page that looks great. But one or more of these is broken:
- Users log in with Google/GitHub and get sent back to the login page in a loop
- The session disappears on page refresh — users have to log in every time
useUser()orsupabase.auth.getUser()returnsnulleven after login- Protected routes are accessible without authentication
- You see
JWT expiredorInvalid JWTerrors in the console
These are the four most common Supabase auth failures in Bolt-generated code, and they all have the same root cause: Bolt generates client-side auth patterns that don't work when combined with Next.js server components and middleware.
Why Bolt gets this wrong
Bolt generates sensible auth code for a pure client-side React app. The problem is that most Bolt projects use Next.js, and Next.js App Router requires a different approach.
Specifically, Bolt typically generates:
// ❌ What Bolt generates — works in client components, breaks everywhere else
import { createClient } from '@supabase/supabase-js'
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)This single client is used everywhere — in server components, in middleware, in API routes. It breaks because:
- It uses the anon key for everything (can't access server-side session)
- It doesn't know how to read cookies in a server context
- Middleware can't verify the session, so protection fails silently
The fix is replacing this with @supabase/ssr — Supabase's package built specifically for server-side rendering.
The fix
Step 1: Install @supabase/ssr
npm install @supabase/ssrRemove the old client import from wherever Bolt put it — usually a lib/supabase.ts or utils/supabase.ts file.
Step 2: Create a browser client (for client components)
Create 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_ANON_KEY!
)
}Step 3: Create a server client (for server components and API routes)
Create 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_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch {
// setAll called from a Server Component — middleware will handle session refresh
}
},
},
}
)
}Step 4: Create middleware to keep sessions alive
This is the step Bolt almost never generates, and it's why sessions disappear on refresh. Without middleware, Supabase's session token isn't refreshed between requests.
Create or replace middleware.ts at the root of your project:
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(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)
)
},
},
}
)
// Refresh the session — this is what keeps users logged in across page loads
const { data: { user } } = await supabase.auth.getUser()
// Redirect unauthenticated users away from protected routes
if (
!user &&
!request.nextUrl.pathname.startsWith('/login') &&
!request.nextUrl.pathname.startsWith('/auth') &&
!request.nextUrl.pathname.startsWith('/') // adjust: add your public routes
) {
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url)
}
return supabaseResponse
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}Critical: The supabaseResponse object must be returned from middleware — not a plain NextResponse.next(). The session cookie updates are written into this response object. If you create a new response object and return it instead, the session is silently lost.
Step 5: Fix the OAuth callback route
Bolt often generates an incomplete callback handler. Replace or create app/auth/callback/route.ts:
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
import { NextResponse, type NextRequest } from 'next/server'
export async function GET(request: NextRequest) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
const next = searchParams.get('next') ?? '/'
if (code) {
const cookieStore = await cookies()
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
},
},
}
)
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) {
return NextResponse.redirect(`${origin}${next}`)
}
}
// Return to login with error — don't silently redirect to home
return NextResponse.redirect(`${origin}/login?error=auth_callback_failed`)
}Step 6: Update your Supabase project's redirect URLs
In the Supabase dashboard → Authentication → URL Configuration:
- Site URL:
https://yourdomain.com(your production URL) - Redirect URLs: Add both:
https://yourdomain.com/auth/callbackhttp://localhost:3000/auth/callback(for local dev)
If you forget this step, OAuth will work locally but silently fail in production.
Step 7: Update all auth calls to use the right client
In client components (files with 'use client' at the top):
import { createClient } from '@/lib/supabase/client'
export function LoginButton() {
const supabase = createClient()
const handleLogin = async () => {
await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
},
})
}
return <button onClick={handleLogin}>Sign in with Google</button>
}In server components (no 'use client'):
import { createClient } from '@/lib/supabase/server'
export default async function Dashboard() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return null // middleware should handle this, but belt-and-suspenders
return <div>Welcome, {user.email}</div>
}Never use getSession() in server components — use getUser(). getSession() returns cached data that isn't re-validated against the server, which means you can get stale session data after a user logs out.
Verify it works
After making these changes:
- Sign in with email/password → you should land on your dashboard and stay there on refresh
- Sign in with Google/GitHub → no loop, lands on dashboard, stays on refresh
- Open a protected route in an incognito window without logging in → should redirect to
/login - Sign in, then open browser DevTools → Application → Cookies. You should see
sb-*cookies set - Hard refresh (Cmd+Shift+R) while logged in → you should still be logged in
Common mistakes
Mistake 1: Mixing the old createClient with the new SSR clients
If you have any file still importing from the old @supabase/supabase-js single client, sessions will break inconsistently. Search your whole project:
grep -r "from '@supabase/supabase-js'" --include="*.ts" --include="*.tsx" .Every result should be replaced with the appropriate lib/supabase/client or lib/supabase/server import.
Mistake 2: Returning a new response from middleware instead of supabaseResponse
// ❌ This loses the session cookie updates
if (!user) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next() // ← loses cookies
// ✅ Return supabaseResponse (which has the cookies) for the redirect
if (!user) {
const url = request.nextUrl.clone()
url.pathname = '/login'
return NextResponse.redirect(url) // redirect is fine
}
return supabaseResponse // ← returns updated cookiesMistake 3: Using getSession() instead of getUser() in server code
getSession() reads from the cookie without verifying with Supabase's servers. A manipulated cookie would pass. getUser() makes a network call to verify — it's the right one for anything security-sensitive.
Mistake 4: Forgetting to add localhost to Supabase redirect URLs
Supabase blocks redirects to any URL not in your allow-list. Add both your production URL and http://localhost:3000 during development. Without this, OAuth works nowhere or only in one environment.
Going further
Once auth is working:
- Set up Row Level Security (RLS) so users can only read their own data
- Add role-based access: store roles in
public.profilesand check them in middleware - See the AI Build Security Checklist — auth being broken is usually the first of several security issues in AI-generated code
This rescue typically takes a Smith 3–5 hours including testing across OAuth providers. If you'd rather have it done right the first time, post an Auth Rescue job on Smith.