Supabase + Google OAuth on Next.js 15: The Complete Working Guide (2026)
Developer Guide

Supabase + Google OAuth on Next.js 15: The Complete Working Guide (2026)

A complete Google OAuth setup for Supabase + Next.js 15 (App Router, @supabase/ssr). Covers Cloud Console config, redirect URL allowlists, refresh tokens, scopes, prod vs dev, and the silent failures nobody warns you about.

2026-04-20
28 min read
Supabase + Google OAuth on Next.js 15: The Complete Working Guide (2026)

Supabase + Google OAuth on Next.js 15: The Complete Working Guide (2026)#

Google sign-in is the most-requested auth method in every product I have shipped. It is also the place where Supabase + Next.js setups break the most often.

This guide walks through the entire flow end-to-end on Next.js 15 (App Router) with @supabase/ssr. Every step. Every redirect URL. Every silent failure I have hit in production. By the end you will have a working sign-in button, a server-validated session, and a configuration that does not break when you deploy.

The mental model first#

Most tutorials skip this and the result is hours of confused debugging. Internalize the four-step flow:

  1. Your app → Google. User clicks "Sign in with Google." Your code calls supabase.auth.signInWithOAuth({ provider: 'google' }). Supabase generates a state token, stores it in a cookie, and redirects the browser to Google.
  2. Google → Supabase. User authenticates on Google's domain. Google redirects to Supabase's callback (https://<project>.supabase.co/auth/v1/callback) with an authorization code. Supabase, not your app, exchanges that code for tokens.
  3. Supabase → Your app. Supabase redirects the browser to your callback URL (/auth/callback) with a code query parameter that is a Supabase-issued PKCE code, not the Google one.
  4. Your app finishes the session. Your callback route calls exchangeCodeForSession(code), which writes the session cookies, and then redirects the user wherever you wanted them to land.

If you remember nothing else, remember this: Google's OAuth redirect URL is set to Supabase, not your app. This is the single most common config mistake.

Prerequisites#

  • Supabase project (Free tier is fine for dev)
  • Next.js 15 App Router app
  • @supabase/ssr and @supabase/supabase-js installed
  • Access to a Google Cloud project (free)

Step 1: Create OAuth credentials in Google Cloud#

  1. Go to console.cloud.google.com → APIs & Services → Credentials
  2. Click Create Credentials → OAuth client ID
  3. If prompted, configure the OAuth consent screen first:
    • User Type: External
    • App name, support email, developer email — your real values
    • Scopes: openid, email, profile (add more later if needed)
    • Test users: add your dev email while in Testing mode
  4. Back to Create OAuth client ID:
    • Application type: Web application
    • Authorized JavaScript origins:
      • http://localhost:3000
      • https://yourdomain.com
    • Authorized redirect URIs:
      • https://<your-project-ref>.supabase.co/auth/v1/callback
  5. Save the Client ID and Client Secret.

The redirect URI must match Supabase's callback exactly. Find your project ref in Supabase Dashboard → Project Settings → API → Project URL.

Step 2: Configure Supabase#

In Supabase Dashboard → Authentication → Providers → Google:

  • Enable Google
  • Paste the Client ID and Client Secret from Google
  • Click Save

Then in Authentication → URL Configuration:

  • Site URL: your production URL (e.g. https://yourdomain.com)
  • Redirect URLs (allowlist): add every URL you will redirect to after sign-in:
    • http://localhost:3000/auth/callback
    • https://yourdomain.com/auth/callback
    • Any preview URL pattern: https://*-yourorg.vercel.app/auth/callback

Supabase blocks any redirect not on this list — silently. If redirectTo is not allowlisted, the user lands on Site URL with no error explaining why.

Step 3: Set up the Supabase clients#

Three clients, three contexts. This pattern is from @supabase/ssr and is non-negotiable for App Router.

typescript
// lib/supabase/browser.ts
'use client'
import { createBrowserClient } from '@supabase/ssr'

export function createClient() {
  return createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )
}
typescript
// 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 {
            // Server Component context — ignore
          }
        },
      },
    }
  )
}
typescript
// lib/supabase/middleware.ts
import { createServerClient } 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()
        },
        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)
          )
        },
      },
    }
  )

  await supabase.auth.getUser()
  return response
}
typescript
// middleware.ts (root)
import { updateSession } from '@/lib/supabase/middleware'
import type { NextRequest } from 'next/server'

export async function middleware(request: NextRequest) {
  return await updateSession(request)
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)'],
}

Step 4: The sign-in button#

typescript
// components/SignInWithGoogle.tsx
'use client'
import { createClient } from '@/lib/supabase/browser'

export function SignInWithGoogle() {
  const supabase = createClient()

  async function handleSignIn() {
    const { error } = await supabase.auth.signInWithOAuth({
      provider: 'google',
      options: {
        redirectTo: `${window.location.origin}/auth/callback?next=/dashboard`,
        queryParams: {
          access_type: 'offline',
          prompt: 'consent',
        },
      },
    })
    if (error) {
      console.error('OAuth error:', error)
    }
  }

  return (
    <button onClick={handleSignIn} className="btn-google">
      Continue with Google
    </button>
  )
}

Two things to notice:

  • redirectTo points to your Next.js callback route, not to a final destination. The next query param carries the final destination through.
  • access_type: 'offline' + prompt: 'consent' ensures Google issues a refresh token. Without these, you only get an access token that expires in 60 minutes.

Step 5: The callback route#

This is where Supabase tutorials usually go silent. The callback must exchange the code, set cookies, and redirect:

typescript
// app/auth/callback/route.ts
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'

export async function GET(request: Request) {
  const url = new URL(request.url)
  const code = url.searchParams.get('code')
  const next = url.searchParams.get('next') ?? '/'

  // Vercel preview deployment fix — see notes below
  const forwardedHost = request.headers.get('x-forwarded-host')
  const isLocal = process.env.NODE_ENV === 'development'

  if (code) {
    const supabase = await createClient()
    const { error } = await supabase.auth.exchangeCodeForSession(code)

    if (!error) {
      if (isLocal) {
        return NextResponse.redirect(`${url.origin}${next}`)
      } else if (forwardedHost) {
        return NextResponse.redirect(`https://${forwardedHost}${next}`)
      } else {
        return NextResponse.redirect(`${url.origin}${next}`)
      }
    }
  }

  return NextResponse.redirect(`${url.origin}/auth/auth-code-error`)
}

The x-forwarded-host dance is the fix for Vercel preview deployments where url.origin returns the underlying Vercel internal hostname instead of the deployment URL. Skip this and your preview sign-in redirects will land on a 404.

Step 6: An error landing page#

typescript
// app/auth/auth-code-error/page.tsx
export default function AuthCodeError() {
  return (
    <div>
      <h1>Sign-in failed</h1>
      <p>We could not complete your Google sign-in. Try again, or contact support.</p>
      <a href="/login">Back to login</a>
    </div>
  )
}

You want this page to exist. Without it, users land on a 404 when something goes wrong, and you lose them.

Step 7: Read the user on the server#

In any server component or route handler:

typescript
import { createClient } from '@/lib/supabase/server'

export default async function DashboardPage() {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()

  if (!user) {
    redirect('/login')
  }

  return <Dashboard user={user} />
}

Use getUser(), not getSession(), when security matters. getSession() reads the cookie without verifying with Supabase. getUser() calls Supabase to verify the JWT signature and fetch the freshest user record. The cost is one network call. The benefit is no impersonation via stolen session cookies.

The gotchas (the real reason you are reading this)#

"OAuth state mismatch"#

The PKCE state cookie was lost between sign-in and callback. Causes:

  • Safari with strict tracking prevention — fix by ensuring no third-party redirects
  • A Set-Cookie with the wrong domain — never set Domain=.yourdomain.com if your callback is on app.yourdomain.com
  • A user who took longer than the cookie lifetime to complete sign-in
  • A custom middleware that strips cookies on /auth/* routes

The middleware step above explicitly preserves auth cookies. If you run a custom middleware on top, double-check it does not remove sb-* cookies.

"OAuth provider error: invalid client"#

The Client ID in Supabase does not match Google's. Re-paste it, save, and try again. Yes, even if it looks identical — invisible whitespace from copy-paste is real.

Sign-in works locally, fails in production#

Three checklist items:

  1. Production domain in Google → Authorized JavaScript origins
  2. Production callback URL in Supabase → Redirect URLs allowlist
  3. OAuth consent screen status — if Testing, only test users can sign in. Switch to In production before launching. (External, "In production" is fine for most apps; only sensitive scopes need verification.)

User's email shows but no name or picture#

Google's profile scope returns the full profile, but Supabase only stores email in auth.users by default. The richer profile data lives in user.user_metadata:

typescript
const { data: { user } } = await supabase.auth.getUser()
const name = user?.user_metadata?.full_name
const avatar = user?.user_metadata?.avatar_url

If you need this in your profiles table, write a Postgres trigger to copy it on insert:

sql
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
  INSERT INTO public.profiles (id, full_name, avatar_url)
  VALUES (
    NEW.id,
    NEW.raw_user_meta_data->>'full_name',
    NEW.raw_user_meta_data->>'avatar_url'
  );
  RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();

Refresh token disappears after first sign-in#

Google only returns a refresh token when prompt=consent forces re-consent. Without it, the second sign-in attempt returns no refresh token — and Supabase will not have one stored.

If you forgot prompt: 'consent' on your first deploy, users who already authorized your app will not get a refresh token until they revoke and re-grant access at myaccount.google.com/permissions. Adding the param now and forcing existing users to re-sign-in is usually less annoying than asking them to revoke.

Linking to additional providers#

If a user signs in with Google but also wants to link their GitHub account:

typescript
const { error } = await supabase.auth.linkIdentity({ provider: 'github' })

The user must currently have a session. Linking adds a row to auth.identities keyed on the same auth.users.id.

Production callback redirects to localhost#

Almost always your redirectTo is hardcoded somewhere as http://localhost:3000/.... Use window.location.origin (client) or process.env.NEXT_PUBLIC_SITE_URL (server) instead of hardcoded strings.

typescript
redirectTo: `${process.env.NEXT_PUBLIC_SITE_URL ?? window.location.origin}/auth/callback`

Production checklist#

Before launch, verify:

  • [ ] Google OAuth consent screen status is In production (not Testing)
  • [ ] Production domain is in Google Authorized JavaScript origins
  • [ ] Supabase callback URL (https://<project>.supabase.co/auth/v1/callback) is in Google Authorized redirect URIs
  • [ ] Production app URL /auth/callback is in Supabase Redirect URLs allowlist
  • [ ] NEXT_PUBLIC_SITE_URL is set in production env vars
  • [ ] prompt: 'consent' and access_type: 'offline' are passed if you need refresh tokens
  • [ ] Middleware does not strip sb-* cookies
  • [ ] Auth callback route handles the Vercel x-forwarded-host case
  • [ ] Profile-creation trigger exists if you mirror auth data to a profiles table

See Also#

Closing#

Google OAuth on Supabase + Next.js is not hard. It is fragile. Three URLs, three configs, three places one wrong character ruins everything. Once you have it working once, the pattern is the same for every other provider.

Save this guide. The next time someone files a bug saying "Google sign-in is broken," you will fix it in five minutes instead of five hours.

Production Notes#

  • Root cause to verify: compare local, preview, and production callback URLs, cookies, and server-side auth.getUser() results.
  • Production fix pattern: keep the server client as the source of truth and avoid client-only session assumptions in protected routes.
  • Verification step: sign in, hard-refresh a protected page, and confirm the server still sees the user.

Frequently Asked Questions

|

Have more questions? Contact us

One email a month — no fluff

RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.