TechSetupGuides
Intermediatenextjssupabaseauthoauthgithubssrrls

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.

  1. 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.ts file 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
  2. Step 2

    Scaffold the project and install the SSR helpers

    @supabase/ssr is the package that matters — it wraps supabase-js with cookie storage that works in Server Components, Route Handlers, and the proxy. The older @supabase/auth-helpers-nextjs package 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
  3. 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 it ANON_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.
  4. 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!,
      );
    }
  5. Step 5

    Create the server client

    This one is used from Server Components, Server Actions, and Route Handlers. The setAll call 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.
              }
            },
          },
        },
      );
    }
  6. Step 6

    proxy.ts — the Next.js 16 rename that breaks every older tutorial

    Next.js 16 deprecated middleware.ts and renamed the convention to proxy.ts. The file must sit at the project root, next to app/. The exported function is renamed too: middleware() becomes proxy(), and the NextMiddleware type becomes NextProxy. A file still named middleware.ts simply 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.
  7. 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 response is reassigned inside setAll — 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.
  8. 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/data runs 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.
  9. 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.
  10. 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 www but 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.
  11. Step 11

    Add the sign-in button

    A Client Component, because it needs window.location.origin to 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>;
    }
  12. Step 12

    Handle the callback and exchange the code

    The final hop. Supabase redirects here with a ?code=, which you trade for a session. The next parameter 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.
  13. 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.
  14. 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;`
  15. 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

0 people marked this as worked·Sign in to mark your own.

Sign in to join the discussion.

No comments yet.