Supabase Google Auth in Next.js: One Server Action, Three Consoles
The Server Action, the shared PKCE callback and the three dashboard surfaces that actually break Google sign-in — including the trailing slash that cost us a redirect.
Supabase Google auth is OAuth 2.0 with Google as the provider and Supabase holding the client secret: your app calls signInWithOAuth({ provider: "google" }), the browser visits Google, and Google returns a code to a callback route that exchanges it for a session. The code is fifteen lines. The configuration is in three places, none of them your repository.
That ratio — fifteen lines against three consoles — is the whole story of this feature, and it is why "it doesn't work" is almost never a code problem. Below is the exact implementation from this storefront's repo, followed by the three configuration surfaces and the one defect that actually cost us time.
The code half: one Server Action
// src/lib/actions/auth.ts
/** Bound directly to a `<form action={signInWithGoogle}>` — always redirects. */
export async function signInWithGoogle(formData: FormData): Promise<void> {
const next = safeNext(formData.get("next")?.toString());
const supabase = await createClient();
const { data, error } = await supabase.auth.signInWithOAuth({
provider: "google",
options: {
redirectTo: `${SITE_URL}/auth/callback?next=${encodeURIComponent(next)}`,
},
});
if (error || !data?.url) {
console.error("auth: signInWithOAuth error", error);
redirect("/login?error=auth");
}
redirect(data.url);
}
Two details in there are not obvious from the method name.
signInWithOAuth does not sign anyone in, and it does not redirect. It builds a URL and hands it back in data.url. On the server you have to redirect to it yourself; in a Client Component the browser client performs the navigation for you, which is why half the tutorials you find have no redirect() call and the other half do. Forgetting it on the server produces the most confusing possible symptom: the button works, nothing happens, no error.
It takes a FormData and returns void. That is what lets it bind straight to a form with no client-side JavaScript of its own:
// src/components/molecules/AuthCard.tsx
<form action={signInWithGoogle}>
<input type="hidden" name="next" value={googleNext} />
<button type="submit">…Continue with Google</button>
</form>
The next field is a hidden input rather than a closure argument so the post-login destination survives a full page navigation. It is also attacker-controlled, so it goes through safeNext before it is ever handed to redirect() — two lines in src/lib/validation.ts that reduce anything not matching a strict path pattern to /dashboard. An unguarded ?next= here is an open redirect on your own domain; the six Server Actions that make up this auth layer covers that rule and the account-enumeration one alongside it.
One callback route, three flows
The callback is the part worth copying, because it is smaller than people expect and it is shared:
// src/app/auth/callback/route.ts
/**
* PKCE code-exchange callback, shared by: email confirmation (signUp),
* Google OAuth (signInWithOAuth), and password recovery (the reset-password
* page forwards its `?code` here, since only a Route Handler/Server Action
* can persist the session cookie — a Server Component render cannot).
*/
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get("code");
const next = safeNext(searchParams.get("next"));
if (code) {
const supabase = await createClient();
const { error } = await supabase.auth.exchangeCodeForSession(code);
if (!error) {
redirect(`${origin}${next}`);
}
console.error("auth/callback: exchangeCodeForSession error", error);
}
redirect(`${origin}/login?error=auth`);
}
Adding Google to an app that already confirms emails cost no new routes, because all three flows hand back a code and all three need the same exchange. If you are planning the work, that is the useful estimate: the Google provider is a button and a provider string, not a subsystem.
It has to be a Route Handler or a Server Action, not a page. exchangeCodeForSession writes the session cookie, and a Server Component render cannot set cookies in Next.js — the same constraint that makes this repo's server client swallow its own setAll failure and leave the refresh to src/proxy.ts.
The three places you configure it, none of them code
| Where | What you set | Fails as |
|---|---|---|
| Google Cloud Console → Credentials | An OAuth 2.0 Web application client; authorized redirect URI = https://<project-ref>.supabase.co/auth/v1/callback | Google's own redirect_uri_mismatch screen, before Supabase is involved |
| Supabase dashboard → Authentication → Providers → Google | Enable the provider; paste the client ID and client secret | Supabase returns "Unsupported provider: provider is not enabled" |
| Supabase dashboard → Authentication → URL Configuration | Site URL plus every redirectTo origin in the allow list | Login succeeds, then you land on localhost:3000 in production, or the redirect is rejected outright |
The first row is the one that reads backwards the first time. The redirect URI you give Google is Supabase's callback, not your app's — Google talks to Supabase, Supabase talks to your /auth/callback. Putting your own route in Google's console is the single most common cause of redirect_uri_mismatch.
And the whole table is dashboard state. It does not live in supabase/migrations/, it does not travel with a db push, and it is per-project — so a staging project and a production project need it entered twice. This repo's eight migration files and 271 lines of SQL move between projects unchanged; the three rows above do not move at all.
The defect: an extra slash broke the redirect allow list
redirectTo is compared against Supabase's allow list exactly. This codebase built that URL by concatenating an env var with a leading-slash path, and NEXT_PUBLIC_SITE_URL was at one point set with a trailing slash. The result was https://host//auth/callback — which is a different string, and therefore not on the list.
The fix is now one function every caller goes through:
// src/lib/siteUrl.ts
export function siteUrl(fallback = "https://asoctemplates.com"): string {
return (process.env.NEXT_PUBLIC_SITE_URL || fallback).replace(/\/+$/, "");
}
Three things in six lines are deliberate. The strip is /\/+$/, not a single-character trim, because two trailing slashes are as easy to paste as one. The fallback is a parameter: auth redirects pass "http://localhost:3000" so development stays local, while public metadata defaults to the production origin — one constant could not serve both. And the check is ||, not ??, so an env var set to the empty string counts as unset rather than producing root-relative URLs.
The doubled slash was actually first spotted in JSON-LD, where it made image and url values stop matching the canonical URLs Google crawls. It was breaking two unrelated things, which is the usual argument for centralising a string like this before it is copied into a fifth call site.
What it costs the browser
@supabase/ssr plus auth-js is 68 KiB over the wire, 255 KiB parsed, and a "Continue with Google" button does not need any of it — the Server Action does the work. This matters because the import is easy to leak site-wide: a Header that renders on every page and an ownership lookup behind every product grid both imported the browser client at module scope here, which put the entire auth stack in the initial bundle of /, /blog, /docs and /pricing — pages with no account UI at all.
The fix is in src/lib/supabase/lazyClient.ts: a dynamic import inside the effect that needs it, plus a cookie probe that skips it entirely for signed-out visitors.
/** Whether this browser is carrying a Supabase session cookie. */
export function hasAuthCookie(): boolean {
if (typeof document === "undefined") return false;
return /(?:^|;\s*)sb-.+?-auth-token/.test(document.cookie);
}
That regex works because @supabase/ssr deliberately does not set those cookies HttpOnly — createBrowserClient reads them from document.cookie itself. The probe is a rendering shortcut and can never grant anything: every real gate stays server-side.
Honest status, since this is our own code
The Google provider on this storefront is implemented and not yet provisioned. The Server Action, the button and the callback are in the repository and type-check in the build; the Google Cloud OAuth client and the dashboard provider toggle are go-live tasks still owed. That is a fair illustration of the split this post is about: the code half was finished in an afternoon, and the half that makes it work is a console someone has to log into.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
Google shows Error 400: redirect_uri_mismatch | Your app's route is in Google's console instead of Supabase's | Authorized redirect URI = https://<ref>.supabase.co/auth/v1/callback |
Unsupported provider: provider is not enabled | Provider toggled off in the Supabase dashboard | Authentication → Providers → Google → enable, with ID and secret |
| Button submits, nothing happens | Server-side signInWithOAuth without redirect(data.url) | It returns a URL; navigate to it yourself |
Redirected to localhost from production | NEXT_PUBLIC_SITE_URL unset in the deploy, falling back | Set it per environment; never hardcode the origin |
| Redirect rejected despite being in the list | A trailing slash produced //auth/callback | Normalise the origin once, as above |
Callback returns ?error=auth every time | exchangeCodeForSession ran twice, or on a page not a handler | Exchange once, in a Route Handler or Server Action |
| Session appears, then vanishes on reload | Nothing refreshes it between requests | Refresh in the proxy/middleware with getClaims() |
Signing in works but getSession() is empty server-side | getSession() reads unverified storage | Use getClaims() or getUser() for any decision |
Frequently asked questions
Do I need a Google Cloud project to use Supabase Google auth? Yes. Supabase holds the secret and performs the exchange, but the OAuth client itself is yours — created in Google Cloud Console as a Web application credential. There is no Supabase-managed shortcut that skips it.
What is the difference between signInWithOAuth and signInWithIdToken?
signInWithOAuth is the browser redirect flow described here: the user leaves for Google and comes back with a code. signInWithIdToken takes an ID token you already obtained — from Google One Tap, or a native iOS/Android SDK — and skips the redirect entirely. Web apps want the first; native mobile usually wants the second.
Can I use Google auth with row-level security?
Yes, and nothing changes. A Google sign-in produces an ordinary row in auth.users with an id, so policies written as (select auth.uid()) = user_id work identically for Google and password users. This repo's six tables never ask how a session was created.
Does adding Google sign-in replace email and password? It does not have to. This codebase keeps both on the same card — the password form, a divider, then the Google button — because they produce the same session. Worth knowing: a user who signs up with a password and later uses Google at the same address will, depending on your project's identity-linking setting, either land in the same account or create a second one. Decide that deliberately before launch rather than after the first support email.
Templates that keep auth off the first paint
The deferral above is the pattern behind the Next.js landing page templates and Tailwind landing page templates: the marketing surface renders statically, so adding Google sign-in costs you a button rather than 68 KiB on every page.
Templates in this post
ASoc Guard, ASoc Haven and ASoc Hearth are Next.js + Tailwind landing page templates with static marketing routes, so a Supabase auth stack stays scoped to the pages that actually need a session.
