Developer Guide

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

Complete Google OAuth setup for Supabase + Next.js 15 (App Router, @supabase/ssr): Cloud Console config, redirect allowlists, refresh tokens, scopes.

Mahdi Ben RhoumaMahdi Ben Rhouma
Updated
28 min read

Google sign-in is one of the most commonly requested auth methods, and one of the places where Supabase + Next.js setups break most often — usually on a redirect URL or an env var, not the OAuth flow itself.

This guide walks through the entire flow end-to-end on Next.js 15 (App Router) with @supabase/ssr: every step, every redirect URL, and the silent-failure modes documented below with their real causes. 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 setup below follows Supabase's official Login with Google guide and Google's OAuth 2.0 for Web Server Applications documentation; where this guide adds anything, it is the failure modes Google and Supabase's docs do not cover.

The mental model first#

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

  1. Your app → Supabase → Google. User clicks "Sign in with Google." Your code calls supabase.auth.signInWithOAuth({ provider: 'google' }). In the PKCE flow, supabase-js generates a code verifier, stores it under a key ending in -code-verifier (a cookie, with @supabase/ssr), and sends only its hash (code_challenge) to Supabase Auth, which keeps the OAuth state on its side 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 and the state parameter. Supabase, not your app, validates that state and exchanges the 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 sends the code together with the stored code verifier to Supabase Auth, 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#

Per Google's guide to setting up OAuth 2.0, every OAuth client needs a consent screen configured before Google will issue credentials — this is the step most tutorials gloss over and the one that breaks production sign-in if left in "Testing" mode (see Step 1.3 and the production checklist below).

  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. See Supabase's Redirect URLs guide for the allowlist mechanics and wildcard patterns for preview deployments.

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, per Google's Using OAuth 2.0 for Web Server Applications guide. 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')
  let next = url.searchParams.get('next') ?? '/'
  if (!next.startsWith('/')) {
    // only allow relative paths, never an external URL
    next = '/'
  }
 
  // original host before a proxy or load balancer — 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`)
}

This is the callback from Supabase's Login with Google guide. The next.startsWith('/') check keeps the route from redirecting to an arbitrary host supplied in the query string. The x-forwarded-host branch exists because, behind a proxy or load balancer, the host in request.url is not necessarily the public host the user visited; Supabase's own example labels that header "original origin before load balancer".

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 { redirect } from 'next/navigation'
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 <p>Signed in as {user.email}</p>
}

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. This distinction is called out directly in Supabase's Server-Side Auth guide: server code should always revalidate the token with getUser() rather than trust the cookie-derived session.

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

"both auth code and code verifier should be non-empty"#

This is the message Supabase Auth returns when exchangeCodeForSession arrives without a PKCE code verifier. In auth-js, signInWithOAuth stores the verifier under <storageKey>-code-verifier and exchangeCodeForSession reads it back from the same storage; with @supabase/ssr that storage is a cookie on your domain. The OAuth state itself is checked by Supabase Auth on its own callback, not by a cookie in your app. Causes of a missing verifier:

  • Sign-in started on one origin and the callback ran on another (localhost vs 127.0.0.1, apex vs www), so the cookie is not sent
  • signInWithOAuth was called from a client that does not write cookies (a plain createClient from @supabase/supabase-js instead of createBrowserClient)
  • 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/restricted scopes need Google's verification review.)

User's email shows but no name or picture#

Google's name and picture are not separate columns of auth.users. Supabase copies them into the user's metadata (the raw_user_meta_data column, exposed as 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. Supabase does not keep one for you either: per Supabase's social login docs, provider tokens are intentionally not stored in your project's database, so read provider_refresh_token from the session returned after sign-in and store it yourself on a server you control.

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

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.

Further reading: Supabase SSR Sessions in Next.js App Router (2026 Guide), Supabase Auth Callback Redirect Not Working? Next.js Fix.

Frequently Asked Questions

|

Have more questions? Contact us

Still stuck? Paste your error.

Paste the error message or stack trace — we check it against every fix and tool on the site.

Matching happens in your browser. When you search, a cleaned error signature is sent so we can spot errors we do not cover yet — never the raw text, your IP address or your e-mail. Do not paste passwords or keys. Details

One email a month — the latest explained errors

What the site published that month — mostly Next.js, Supabase and Node errors, explained. You confirm by email; unsubscribe any time in one click.