This is a practical, end‑to‑end guide to add “Continue with Google” to a Next.js App Router project using first‑party OAuth 2.0 + OpenID Connect (OIDC). It uses:
oauth_accounts link table.You’ll get dev and prod setups, exact code, and a debugging checklist. I’ll also explain the why in plain language so it’s easy to reason about and maintain.
I wanted a login that users already trust, without the “create another password” friction. I also wanted to keep ownership of my user model (Neon + Drizzle) and my session (simple JWT cookie), instead of pulling in a heavyweight auth toolkit. Google OIDC is standards‑based, well‑documented, and works great with Next.js.
The plan: keep email/password as a baseline, then layer Google with a tiny set of routes and a small link table. Make it secure (PKCE/state/nonce), predictable (HttpOnly cookie), and easy to debug (clear steps).
Think of it like a club entrance:
users) and hand you a wristband (our session cookie). We never store your password from Google—only the link that says “this Google account belongs to this local user”.http://localhost:3000http://localhost:3000/api/auth/callback/googleLater for production, add a second Web client with your domain:
https://yourdomain.com/api/auth/callback/googleIn frontend/.env.local:
PUBLIC_APP_URL=http://localhost:3000
AUTH_SECRET=<strong random 32+ bytes>
GOOGLE_CLIENT_ID=<from console>
GOOGLE_CLIENT_SECRET=<from console>
Restart the dev server whenever you change env.
We already have users for email/password. For Google, we add a tiny table that says “Google user X belongs to local user Y”. No password from Google is stored, just the link and optional tokens if you decide to call Google APIs.
We keep email/password in users and link external providers in a separate table. Add this to frontend/lib/db/schema.ts:
export const oauthAccounts = pgTable('oauth_accounts', {
id: serial('id').primaryKey(),
provider: varchar('provider', { length: 50 }).notNull(),
providerUserId: varchar('provider_user_id', { length: 255 }).notNull(),
userId: integer('user_id').notNull().references(() => users.id),
email: varchar('email', { length: 255 }),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
expiresAt: timestamp('expires_at'),
createdAt: timestamp('created_at').notNull().defaultNow(),
updatedAt: timestamp('updated_at').notNull().defaultNow(),
});
Run migrations (Neon):
cd frontend
npm run db:generate
npm run db:migrate
Add a start route that builds Google’s authorize URL and stores short‑lived cookies:
import { NextResponse } from 'next/server';
export const runtime = 'nodejs';
function base64url(input: ArrayBuffer) { /* … */ }
async function sha256(v: string) { /* … */ }
function rand(n = 32) { /* … */ }
export async function GET() {
const clientId = process.env.GOOGLE_CLIENT_ID;
const base = process.env.NEXT_PUBLIC_BASE_URL || process.env.PUBLIC_APP_URL || '';
if (!clientId || !base) return NextResponse.json({ error: 'Google OAuth not configured' }, { status: 500 });
const redirectUri = `${base.replace(/\/$/, '')}/api/auth/callback/google`;
const state = rand(24); const nonce = rand(24); const verifier = rand(48);
const challenge = await sha256(verifier);
const params = new URLSearchParams({
client_id: clientId, redirect_uri: redirectUri, response_type: 'code',
scope: 'openid email profile', state, nonce,
code_challenge: challenge, code_challenge_method: 'S256',
access_type: 'offline', prompt: 'consent',
});
const url = `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
const res = NextResponse.redirect(url);
const isHttps = base.startsWith('https://');
const opts = { httpOnly: true, sameSite: 'lax' as const, secure: isHttps, path: '/', maxAge: 600 };
res.cookies.set('g_state', state, opts);
res.cookies.set('g_nonce', nonce, opts);
res.cookies.set('g_verifier', verifier, opts);
return res;
}
Handle Google’s callback: verify state/nonce, exchange code, verify ID token, link/create user, set session, redirect:
import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db/drizzle';
import { oauthAccounts, teamMembers, teams, users } from '@/lib/db/schema';
import { and, eq } from 'drizzle-orm';
import { setSession, hashPassword } from '@/lib/auth/session';
import { logActivity } from '@/lib/db/queries';
import { createRemoteJWKSet, jwtVerify, JWTPayload } from 'jose';
export const runtime = 'nodejs';
const GOOGLE_ISS = 'https://accounts.google.com';
const GOOGLE_JWKS = createRemoteJWKSet(new URL('https://www.googleapis.com/oauth2/v3/certs'));
export async function GET(request: NextRequest) {
const base = process.env.NEXT_PUBLIC_BASE_URL || process.env.PUBLIC_APP_URL || '';
const clientId = process.env.GOOGLE_CLIENT_ID;
const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
// 1) Validate config, read code + state, verify cookies (state/nonce/verifier)
// 2) Exchange code → tokens with code_verifier
// 3) Verify ID token signature + audience + issuer
// 4) Link or create user; set session; clear temp cookies; redirect
/* …full implementation in repo… */
}
Why it’s secure:
state binds the response to the initiating browser.nonce prevents ID token replay.ID token vs access token (ELI5): the ID token proves who you are; the access token lets you call Google APIs on the user’s behalf. For simple “Sign in with Google”, we only need the ID token. We store refresh/access tokens only if we later call Google APIs.
<a href="/api/auth/oauth/google/start" className="btn">Continue with Google</a>
<a href="/api/auth/oauth/google/start" className="btn">Continue with Google</a>
Add an icon at public/google.svg (optional).
/account.You can change this to “link‑only” or add a “complete sign‑up” interstitial.
npm run dev (from frontend/)./sign-in → “Continue with Google”./account./api/user (should return your user JSON).Troubleshooting:
oauth_accounts table exists (run migrations).Common errors (and fixes):
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, PUBLIC_APP_URL=https://yourdomain.com).Security checklist for prod:
HttpOnly, Secure (HTTPS), SameSite=Lax.AUTH_SECRET (≥ 32 bytes) and rotation plan.Bonus hardening:
AUTH_SECRET on a schedule; re‑sign sessions at next login..env.local has Google credentials and PUBLIC_APP_URL.oauth_accounts migration applied.Q: Why not store tokens and call Google APIs? A: If you don’t need Google data (Calendar, Drive, etc.), skip it. Less data, fewer secrets.
Q: Why HttpOnly cookie and not localStorage? A: HttpOnly cookies aren’t readable by JS, reducing XSS blast radius. They also work seamlessly with Next middleware.
Q: Can I use only Google sign-in (no passwords)?
A: Yes. Keep users as the source of truth and rely on oauth_accounts linking. You can hide the password form entirely.