Back to Blog
Cloud 8 min readJun 10, 2026

Google OAuth in Next.js — And the Build-Time Env Var Trap That Causes 401

Adding Google Sign-In to a Next.js app sounds straightforward until you hitError 401: invalid_clientafter deployment. Here is the full implementation — frontend, backend, DB schema — and the common pitfalls that waste hours of debugging.

The Flow We're Implementing

There are two common approaches for Google OAuth. We use the access token flow:

text
1. Frontend opens Google popup (useGoogleLogin)
2. User approves → Google returns an access_token to the frontend
3. Frontend sends access_token to your backend
4. Backend calls https://www.googleapis.com/oauth2/v3/userinfo with the token
5. Google returns the user's email, name, and Google ID
6. Backend creates or links the account, issues JWT tokens
7. User is logged in

This approach does not require storing the Google Client Secret on the backend — the token is verified by calling Google's userinfo endpoint directly.

Google Cloud Console Setup

Before writing any code, create an OAuth 2.0 Client ID from Google Cloud Console.

text
1. Go to console.cloud.google.com
2. APIs & Services → OAuth consent screen
   → User type: External
   → Fill in app name, support email, developer email
   → Add scopes: email, profile, openid

3. APIs & Services → Credentials → Create Credentials
   → OAuth 2.0 Client ID → Web application
   → Authorised JavaScript origins:
       https://your-domain.com
       http://localhost:3000  (for local dev)
   → Authorised redirect URIs: leave empty for the token flow

4. Copy the Client ID — it ends with .apps.googleusercontent.com
💡

Do not add redirect URIs unless you are using the authorization code flow. The access token flow does not need them.

Frontend: Install and Wrap the App

bash
npm install @react-oauth/google

Wrap your root layout with GoogleOAuthProvider so every page can access the Google context:

tsx
// src/app/ClientLayout.tsx
import { GoogleOAuthProvider } from "@react-oauth/google";

export default function ClientLayout({ children }: { children: React.ReactNode }) {
  return (
    <GoogleOAuthProvider clientId={process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!}>
      {children}
    </GoogleOAuthProvider>
  );
}

Frontend: Wire Up the Google Button

Use the useGoogleLogin hook on your login and signup pages:

tsx
import { useGoogleLogin } from "@react-oauth/google";

const handleGoogleLogin = useGoogleLogin({
  onSuccess: async (tokenResponse) => {
    try {
      const data = await googleAuth(tokenResponse.access_token);
      router.replace("/dashboard");
    } catch (err) {
      setError("Google sign-in failed. Please try again.");
    }
  },
  onError: () => setError("Google sign-in was cancelled or failed."),
});

// In JSX — attach to any button:
<button type="button" onClick={() => handleGoogleLogin()}>
  Continue with Google
</button>

Send the access token to your API:

ts
// auth.service.ts
export const googleAuth = async (accessToken: string): Promise<AuthResponse> => {
  const res = await api.post<AuthResponse>("/auth/google", { accessToken });
  saveTokens(res.data);
  return res.data;
};

Backend: DB Schema Changes

Google accounts have no password, and each Google account needs a unique identifier stored. Two schema changes are needed:

prisma
model User {
  id         String   @id @default(cuid())
  email      String   @unique
  password   String?  // nullable — Google accounts have no password
  googleId   String?  @unique
  isVerified Boolean  @default(false)
  // ...
}
sql
ALTER TABLE "users" ALTER COLUMN "password" DROP NOT NULL;
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "google_id" TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS "users_google_id_key" ON "users"("google_id");

Backend: Verify the Token and Handle the Account

The backend receives the access token, verifies it with Google, then handles three cases: existing Google account, existing email account (link Google to it), or brand new user.

js
export const googleAuth = async (accessToken) => {
  const res = await fetch("https://www.googleapis.com/oauth2/v3/userinfo", {
    headers: { Authorization: `Bearer ${accessToken}` },
  });
  if (!res.ok) throw new Error("Invalid Google token");

  const {
    sub: googleId,
    email,
    given_name: firstName = "",
    family_name: lastName = "",
  } = await res.json();

  const normalizedEmail = email.toLowerCase();

  // Case 1: already signed in with Google before
  let user = await findByGoogleId(googleId);
  if (user) return issueSession(user);

  // Case 2: email exists — link Google to it
  const existing = await findByEmail(normalizedEmail);
  if (existing) {
    user = await linkGoogleId(existing.id, googleId);
    return issueSession(user);
  }

  // Case 3: brand new user
  user = await createGoogleUser({ email: normalizedEmail, googleId, firstName, lastName });
  return issueSession(user);
};
💡

Google accounts are automatically marked as verified since the email is confirmed by Google. No separate email verification step is needed.

Add the Route

js
// routes.js
router.post("/auth/google", googleAuthController);

// controller
export const googleAuthController = async (req, res, next) => {
  try {
    const { accessToken } = req.body;
    const result = await googleAuth(accessToken);
    res.json({ success: true, ...result });
  } catch (err) {
    next(err);
  }
};

The Env Var Trap — Error 401: invalid_client

After deploying, the Google popup opened correctly but every sign-in returnedError 401: invalid_client. Two separate issues caused this.

Trap 1 — Wrong credential in the env var.

The env var was set in the deployment dashboard but the value pasted was the Client Secret, not the Client ID. They look completely different — make sure you copy the right one:

text
Client ID:     1234567890-abcdefgh.apps.googleusercontent.com  ✅ use this in NEXT_PUBLIC_
Client Secret: GOCSPX-xxxxxxxxxxxxxxxxxxxx                      ❌ never goes in the frontend

Trap 2 — NEXT_PUBLIC_ vars are baked at build time, not runtime.

In Next.js, any variable prefixed withNEXT_PUBLIC_is inlined into the JavaScript bundle during next build. If the variable is not present in the build environment, it bakes in as an empty string — no error is thrown, the build succeeds, and you only find out at runtime when Google rejects the empty client ID.

Always verify the value made it into the deployed bundle by checking in the browser console:

js
// Run in browser console after deploy
Array.from(document.querySelectorAll('script'))
  .some(s => s.textContent.includes('your-client-id-prefix'))
// Should return true — if false, the var was empty at build time
💡

If your CI/CD pipeline injects env vars from a secrets manager or parameter store, confirm the build step has permission to read them beforenext build runs. A silent read failure is the most common reason a correctly-configured env var still ends up empty in the bundle.

What Does Not Need to Change

text
Backend .env    → no Google credentials needed for the token flow
Client Secret   → unused in this approach entirely
Redirect URIs   → not needed for the access token flow
isVerified      → set true automatically (Google already verified the email)
All postsGoogle OAuth · Next.js · Node.js · Prisma · Auth

naresh@gowda:~$ built with Next.js + Tailwind + Framer Motion