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.
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:
- 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. - 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. - Supabase → Your app. Supabase redirects the browser to your callback URL (
/auth/callback) with acodequery parameter that is a Supabase-issued PKCE code, not the Google one. - 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/ssrand@supabase/supabase-jsinstalled- Access to a Google Cloud project (free)
Step 1: Create OAuth credentials in Google Cloud#
- Go to console.cloud.google.com → APIs & Services → Credentials
- Click Create Credentials → OAuth client ID
- 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
- Back to Create OAuth client ID:
- Application type: Web application
- Authorized JavaScript origins:
http://localhost:3000https://yourdomain.com
- Authorized redirect URIs:
https://<your-project-ref>.supabase.co/auth/v1/callback
- 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/callbackhttps://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.
// 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!
)
}
// 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
}
},
},
}
)
}
// 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
}
// 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#
// 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:
redirectTopoints to your Next.js callback route, not to a final destination. Thenextquery 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:
// 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#
// 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:
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-Cookiewith the wrong domain — never setDomain=.yourdomain.comif your callback is onapp.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:
- Production domain in Google → Authorized JavaScript origins
- Production callback URL in Supabase → Redirect URLs allowlist
- 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:
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:
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:
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.
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/callbackis in Supabase Redirect URLs allowlist - [ ]
NEXT_PUBLIC_SITE_URLis set in production env vars - [ ]
prompt: 'consent'andaccess_type: 'offline'are passed if you need refresh tokens - [ ] Middleware does not strip
sb-*cookies - [ ] Auth callback route handles the Vercel
x-forwarded-hostcase - [ ] Profile-creation trigger exists if you mirror auth data to a
profilestable
See Also#
- Supabase Auth + Middleware: The Complete Session Management Guide for Next.js 15 — full session and middleware deep-dive
- Next.js App Router + Supabase SSR Session Management Deep Dive — SSR session patterns
- Supabase Auth Redirect Not Working Next.js App Router — debugging redirect failures
- Handle Supabase Auth Errors in Next.js Middleware — middleware error handling
- Advanced Authentication Patterns with Next.js and Supabase — RBAC, magic links, MFA
- Next.js 15 Middleware: Complete Guide to Auth, Rate Limiting, A/B Testing, and Edge Logic — middleware composition
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
One email a month — no fluff
RLS gotchas, Next.js cache debugging, and the one Supabase setting that bit me last month.
Related Guides
Next.js App Router + Supabase SSR Session Management Deep Dive
Deep dive into Supabase SSR session management in Next.js App Router. Learn how cookies, middleware, and Server Components interact to keep users authenticated.
Supabase Authentication with Next.js 15 Complete Production Guide 2026
Master Supabase Auth in Next.js 15 with this complete production guide. Email/password, OAuth, magic links, middleware protection, RLS integration, and advanced patterns for multi-tenant SaaS.
Advanced Authentication Patterns with Next.js and Supabase
Master advanced authentication patterns including OAuth, magic links, passwordless auth, custom JWT, multi-tenant authentication, and enterprise SSO integration with Next.js and Supabase.