Next.js 16 + Supabase + GitHub OAuth
End-to-end GitHub OAuth on Next.js 16 with Supabase — the proxy.ts rename, cookie-aware SSR clients, the callback exchange, and the silent failure modes that make auth look broken for no reason.
- Step 1
What you are building
A Next.js 16 App Router app where users sign in with GitHub, sessions survive server-side rendering, and the database enforces access control through Row Level Security. Three pieces have to agree for this to work: a cookie-aware Supabase client on both sides of the network, a
proxy.tsfile that refreshes expired tokens, and an OAuth callback route that exchanges the code for a session. Get one wrong and auth fails in a way that is quiet rather than loud.Browser ──sign in──▶ Supabase ──▶ GitHub │ GitHub ──callback──▶ Supabase ◀──────┘ │ ▼ /auth/callback?code=... ──exchange──▶ session cookies │ ▼ proxy.ts refreshes on every request │ ▼ Server Components read the user - Step 2
Scaffold the project and install the SSR helpers
@supabase/ssris the package that matters — it wrapssupabase-jswith cookie storage that works in Server Components, Route Handlers, and the proxy. The older@supabase/auth-helpers-nextjspackage is deprecated; do not mix the two.npx create-next-app@latest my-app --typescript --tailwind --app --no-src-dir cd my-app npm install @supabase/supabase-js @supabase/ssr - Step 3
Set the environment variables
Both values are safe to expose to the browser — the anon/publishable key carries no privileges of its own, and RLS is what actually protects your data. Newer Supabase projects label this key
PUBLISHABLE_KEY; older ones call itANON_KEY. Match whichever your dashboard shows.# .env.local (gitignored) NEXT_PUBLIC_SUPABASE_URL=https://<your-project-ref>.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=<your-anon-or-publishable-key> # Server-only. Bypasses RLS entirely — never prefix this with NEXT_PUBLIC_. SUPABASE_SERVICE_ROLE_KEY=<your-service-role-key>⚠ Heads up: Any variable named `NEXT_PUBLIC_*` is inlined into the client bundle. Putting the service role key behind that prefix hands every visitor full read/write access to your database. - Step 4
Create the browser client
Used from Client Components only. It reads and writes the session cookies that the proxy keeps fresh.
// lib/supabase/client.ts import { createBrowserClient } from "@supabase/ssr"; import type { Database } from "@/types/supabase"; export function createClient() { return createBrowserClient<Database>( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, ); } - Step 5
Create the server client
This one is used from Server Components, Server Actions, and Route Handlers. The
setAllcall is wrapped in a try/catch on purpose: Server Components are not allowed to write cookies, so the write throws there and the proxy handles refresh instead. That swallow is correct, not lazy.// lib/supabase/server.ts import { createServerClient } from "@supabase/ssr"; import { cookies } from "next/headers"; import type { Database } from "@/types/supabase"; export async function createClient() { const cookieStore = await cookies(); return createServerClient<Database>( 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, which cannot set cookies. // Safe to ignore — proxy.ts refreshes the session instead. } }, }, }, ); } - Step 6
proxy.ts — the Next.js 16 rename that breaks every older tutorial
Next.js 16 deprecated
middleware.tsand renamed the convention toproxy.ts. The file must sit at the project root, next toapp/. The exported function is renamed too:middleware()becomesproxy(), and theNextMiddlewaretype becomesNextProxy. A file still namedmiddleware.tssimply does not execute — there is no warning.# Official codemod handles both the filename and the function name npx @next/codemod@canary middleware-to-proxy .⚠ Heads up: Copy a Next.js 14 or 15 Supabase tutorial verbatim and your auth will fail silently. The session never refreshes, users appear logged out after roughly an hour, and nothing is logged. - Step 7
Write the session refresh proxy
The proxy exists to do one thing: refresh the auth token and hand the refreshed cookies to both the Server Components and the browser. Note that
responseis reassigned insidesetAll— that is what propagates updated cookies correctly, and skipping it is a common source of sessions that refuse to persist.// proxy.ts import { createServerClient } from "@supabase/ssr"; import { NextResponse, type NextRequest } from "next/server"; import type { Database } from "@/types/supabase"; export async function proxy(request: NextRequest) { let response = NextResponse.next({ request }); const supabase = createServerClient<Database>( 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), ); response = NextResponse.next({ request }); cookiesToSet.forEach(({ name, value, options }) => response.cookies.set(name, value, options), ); }, }, }, ); // Revalidates the token. Required for Server Components to see the user. await supabase.auth.getClaims(); return response; }⚠ Heads up: Do not put other logic between `createServerClient` and the auth call. Anything that returns early or short-circuits there will leave the session unrefreshed and produce intermittent logouts that are very hard to reproduce. - Step 8
Scope the proxy with a matcher
Without a matcher the proxy runs on every request, including static assets — which means a Supabase round trip before serving each image. Exclude static files and any crawler-facing metadata routes. Two behaviours are worth knowing:
_next/dataruns the proxy even when excluded (deliberately, so you cannot protect a page but forget its data route), and Server Functions are POSTs to the route that uses them, so a matcher exclusion silently removes proxy coverage from them.export const config = { matcher: [ /* * Everything except: * - _next/static, _next/image (build output) * - favicon.ico, robots.txt, sitemap.xml (crawler metadata) * - image files */ "/((?!_next/static|_next/image|favicon\\.ico|robots\\.txt|sitemap\\.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", ], };⚠ Heads up: Never treat the proxy as your only authorization check. Verify the user inside each Server Action and Route Handler too — a matcher refactor can quietly drop coverage without any test failing. - Step 9
Create the GitHub OAuth app
This is where most setups break, and the error message is actively misleading. The authorization callback URL must point at Supabase, not at your application. Supabase is the OAuth intermediary: GitHub calls back to Supabase, which then redirects to your app's callback route.
GitHub → Settings → Developer settings → OAuth Apps → New OAuth App Application name: Your App Name Homepage URL: https://www.yourdomain.com Authorization callback URL: https://<your-project-ref>.supabase.co/auth/v1/callback ^^^ Supabase, NOT https://yourdomain.com/auth/callback Then copy the Client ID and generate a Client Secret.⚠ Heads up: Setting the callback to your own domain produces `redirect_uri is not associated with this application`, which reads like a typo in the URL rather than the wrong host entirely. - Step 10
Wire the provider into Supabase
Paste the GitHub credentials into Supabase, then set the URL configuration. The Site URL and Redirect URLs must use your canonical host — if your site serves on
wwwbut you only allowlist the apex, Supabase rejects the exchange and login fails.Supabase Dashboard → Authentication → Providers → GitHub Enabled: yes Client ID: <from GitHub> Client Secret: <from GitHub> Supabase Dashboard → Authentication → URL Configuration Site URL: https://www.yourdomain.com Redirect URLs: https://www.yourdomain.com/auth/callback http://localhost:3000/auth/callback Add both the production and localhost callbacks, or local development breaks.⚠ Heads up: When `redirectTo` is not in the allowlist, Supabase does not error — it silently falls back to the Site URL. The user lands on your homepage with an unused `?code=` parameter and appears logged out, with nothing in the logs. - Step 11
Add the sign-in button
A Client Component, because it needs
window.location.originto build the redirect. Deriving the origin from the browser rather than hardcoding it means the same code works on localhost, preview deployments, and production."use client"; import { createClient } from "@/lib/supabase/client"; export function SignInButton() { const supabase = createClient(); async function signIn() { await supabase.auth.signInWithOAuth({ provider: "github", options: { redirectTo: `${window.location.origin}/auth/callback`, }, }); } return <button onClick={signIn}>Sign in with GitHub</button>; } - Step 12
Handle the callback and exchange the code
The final hop. Supabase redirects here with a
?code=, which you trade for a session. Thenextparameter is attacker-controllable, so validate it before redirecting — an unchecked value here is a textbook open redirect.// app/auth/callback/route.ts import { NextResponse, type NextRequest } from "next/server"; import { createClient } from "@/lib/supabase/server"; // Only allow same-origin paths: must start with a single "/", // not "//" (protocol-relative) and must not contain a scheme. function safeRedirectPath(raw: string | null): string { if (!raw) return "/"; if (!raw.startsWith("/") || raw.startsWith("//")) return "/"; if (/^\/[^/]*:/.test(raw)) return "/"; return raw; } export async function GET(request: NextRequest) { const { searchParams, origin } = new URL(request.url); const code = searchParams.get("code"); const next = safeRedirectPath(searchParams.get("next")); if (code) { const supabase = await createClient(); const { error } = await supabase.auth.exchangeCodeForSession(code); if (!error) { return NextResponse.redirect(`${origin}${next}`); } } return NextResponse.redirect(`${origin}/auth/error`); }⚠ Heads up: Redirecting to a raw `next` parameter lets an attacker send `?next=//evil.example` and bounce your users off-site with your domain in the referrer chain. - Step 13
Read the user on the server — and pick the right method
Three methods look interchangeable and are not.
getSession()reads the session straight from cookie storage and does no verification, so it must never be the basis of a server-side authorization decision.getClaims()validates the JWT signature against your project's published public keys and is the right default for protecting pages.getUser()makes a network call to the Auth server and is what you want when you need a guaranteed-fresh user record.// Any Server Component import { createClient } from "@/lib/supabase/server"; import { redirect } from "next/navigation"; export default async function ProtectedPage() { const supabase = await createClient(); // Verified — safe for authorization const { data, error } = await supabase.auth.getClaims(); if (error || !data?.claims) redirect("/login"); return <p>Signed in as {data.claims.sub}</p>; } // getUser() → network round trip, freshest user record // getClaims() → verifies JWT signature, cheap, use for page protection // getSession() → NO verification. Never trust it in server code.⚠ Heads up: `getSession()` returns a user object assembled from cookies the client can influence. Using it to gate server-rendered content is an authorization bypass, not just a style issue. - Step 14
Let RLS be the real security boundary
Everything above controls what the UI renders. None of it stops someone calling your Supabase project directly with the anon key — which is public by design. Row Level Security is the enforcement layer, so write the policies before you ship.
-- Enable RLS on every table. A table without it is world-readable -- and world-writable through the anon key. alter table profiles enable row level security; -- Anyone may read create policy "profiles are publicly readable" on profiles for select using (true); -- Only the owner may insert their own row create policy "users insert their own profile" on profiles for insert to authenticated with check (auth.uid() = id); -- Only the owner may update it create policy "users update their own profile" on profiles for update to authenticated using (auth.uid() = id) with check (auth.uid() = id);⚠ Heads up: Enabling RLS with no policies denies everything, which at least fails loudly. Forgetting to enable it at all exposes the whole table — verify with `select relname, relrowsecurity from pg_class where relnamespace = 'public'::regnamespace;` - Step 15
Troubleshooting the failures that do not announce themselves
Almost every problem in this stack presents as "login just does not work" with an empty console. Match the symptom to the cause here rather than guessing.
Symptom │ Cause ─────────────────────────────────┼────────────────────────────────────── redirect_uri is not associated │ GitHub callback points at your app. with this application │ It must point at Supabase. ─────────────────────────────────┼────────────────────────────────────── Lands on homepage with ?code= │ redirectTo not in Supabase's Redirect still in the URL, logged out │ URLs. It fell back to Site URL. ─────────────────────────────────┼────────────────────────────────────── Works, then logged out ~1 hour │ proxy.ts not running. Check the later │ filename — middleware.ts is inert │ in Next.js 16. ─────────────────────────────────┼────────────────────────────────────── User visible in Client but not │ Session never reached the server. in Server Components │ Check the matcher covers the route. ─────────────────────────────────┼────────────────────────────────────── Works locally, fails deployed │ Production callback URL missing from │ the allowlist, or canonical host │ mismatch (www vs apex). ─────────────────────────────────┼────────────────────────────────────── Queries return zero rows for a │ RLS enabled with no SELECT policy. signed-in user │
Feature requests
Sign in to suggest features or vote on existing ones.
No feature requests yet.
Discussion
Sign in to join the discussion.
No comments yet.