Server-Side Auth in Next.js with Supabase: My Setup
This is the approach I use to get secure, SSR-friendly authentication working in the Next.js App Router with Supabase. It uses HTTP-only cookies, a small serve
This is the approach I use to get secure, SSR-friendly authentication working in the Next.js App Router with Supabase. It uses HTTP-only cookies, a small server client helper, a browser client for client components, and middleware to keep sessions fresh.
What we are building
- A server client you can call from Server Components, Server Actions, and Route Handlers
- A browser client for Client Components (for example, realtime)
- Middleware that refreshes sessions and writes cookies correctly
- A simple login and signup flow with an email confirmation route
- A protected page that redirects unauthenticated users
Security note: in server code, check auth with
supabase.auth.getUser()
so the token is verified with the auth server. Do not rely ongetSession()
for protection logic.
0) Prerequisites
- Next.js 14 or newer (App Router)
- A Supabase project with Project URL and Anon Key
1) Install packages
npm install @supabase/supabase-js @supabase/ssr
2) Environment variables
Create a .env.local
file:
NEXT_PUBLIC_SUPABASE_URL=<your_supabase_project_url>
NEXT_PUBLIC_SUPABASE_ANON_KEY=<your_supabase_anon_key>
These values are safe to expose in the browser because they are anon keys, not service role keys.
3) Supabase clients (server and browser)
Create a utils/supabase/
folder and add two files.
utils/supabase/client.ts
(for Client Components)
// 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!
)
}
Use this only in the browser, for example when subscribing to realtime.
utils/supabase/server.ts
(for Server Components, Server Actions, Route Handlers)
// utils/supabase/server.ts
import { createServerClient, type CookieOptions } 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: { name: string; value: string; options: CookieOptions }[]) {
try {
cookiesToSet.forEach(({ name, value, options }) => cookieStore.set(name, value, options))
} catch {
// Called from a Server Component: ignore writes, middleware will persist them
}
},
},
}
)
}
This wiring makes Supabase use HTTP-only cookies for SSR and token refresh.
4) Session middleware
Create utils/supabase/middleware.ts
and a project root middleware.ts
. The middleware revalidates the session and ensures cookies are up to date on every request that needs it.
utils/supabase/middleware.ts
// utils/supabase/middleware.ts
import { createServerClient, type CookieOptions } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function updateSession(request: NextRequest) {
let response = 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().map(({ name, value }) => ({ name, value }))
},
setAll(cookiesToSet: { name: string; value: string; options: CookieOptions }[]) {
cookiesToSet.forEach(({ name, value, options }) => {
response.cookies.set(name, value, options)
})
},
},
}
)
// Refresh the session if needed and sync cookies
await supabase.auth.getUser()
return response
}
middleware.ts
// middleware.ts
import { type NextRequest } from 'next/server'
import { updateSession } from '@/utils/supabase/middleware'
export async function middleware(request: NextRequest) {
return updateSession(request)
}
export const config = {
matcher: [
// Run on all paths except static and image assets and favicon
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
Why middleware: Server Components can read cookies but cannot write them. The middleware runs first and persists refreshed tokens for both server and browser before your pages render.
5) Login UI and Server Actions
A simple email and password login that keeps credentials off the client.
app/login/page.tsx
// app/login/page.tsx
import { login, signup } from './actions'
export default function LoginPage() {
return (
<form>
<label htmlFor="email">Email:</label>
<input id="email" name="email" type="email" required />
<label htmlFor="password">Password:</label>
<input id="password" name="password" type="password" required />
<button formAction={login}>Log in</button>
<button formAction={signup}>Sign up</button>
</form>
)
}
app/login/actions.ts
// app/login/actions.ts
'use server'
import { redirect } from 'next/navigation'
import { createClient } from '@/utils/supabase/server'
export async function login(formData: FormData) {
const email = String(formData.get('email') ?? '')
const password = String(formData.get('password') ?? '')
const supabase = await createClient()
const { error } = await supabase.auth.signInWithPassword({ email, password })
if (error) {
throw new Error(error.message)
}
redirect('/private')
}
export async function signup(formData: FormData) {
const email = String(formData.get('email') ?? '')
const password = String(formData.get('password') ?? '')
const supabase = await createClient()
const { error } = await supabase.auth.signUp({ email, password })
if (error) throw new Error(error.message)
// If confirmations are enabled, the user must confirm via email before login
redirect('/check-email')
}
Tip: call cookies()
before any Supabase fetches inside Server Actions if you need to avoid caching for authenticated data.
6) Email confirmation route
If email confirmations are on, update the Confirm signup template in the Supabase dashboard to use this URL pattern:
{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email
Then add a confirmation route:
app/auth/confirm/route.ts
// app/auth/confirm/route.ts
import { type EmailOtpType } from '@supabase/supabase-js'
import { type NextRequest } from 'next/server'
import { createClient } from '@/utils/supabase/server'
import { redirect } from 'next/navigation'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const token_hash = searchParams.get('token_hash')
const type = searchParams.get('type') as EmailOtpType | null
const next = searchParams.get('next') ?? '/'
if (token_hash && type) {
const supabase = await createClient()
const { error } = await supabase.auth.verifyOtp({ type, token_hash })
if (!error) redirect(next)
}
redirect('/error')
}
7) Protect a server page
app/private/page.tsx
// app/private/page.tsx
import { redirect } from 'next/navigation'
import { createClient } from '@/utils/supabase/server'
export default async function PrivatePage() {
const supabase = await createClient()
const { data, error } = await supabase.auth.getUser()
if (error || !data?.user) {
redirect('/login')
}
return <p>Hello {data.user.email}</p>
}
Use getUser()
in server code so the token is verified against the auth server.
8) Sign out with a Server Action
// app/logout/actions.ts
'use server'
import { redirect } from 'next/navigation'
import { createClient } from '@/utils/supabase/server'
export async function signOut() {
const supabase = await createClient()
await supabase.auth.signOut()
redirect('/login')
}
Common pitfalls and notes
- This writeup targets the App Router. Do not mix this cookie-based server approach with older client-only patterns.
- Prefer
getUser()
overgetSession()
for server-side protection checks. - Update the middleware matcher to exclude static assets to avoid unnecessary work.
- If your project warns that
cookies()
should be awaited, keepconst cookieStore = await cookies()
in the server client helper. If not, you can remove theawait
.
Further reading
- Supabase guide: https://supabase.com/docs/guides/auth/server-side/nextjs
Ryan Katayi
Full-stack developer who turns coffee into code. Building things that make the web a better place, one commit at a time.
more about me→