Google SSO × Next.js 實作手冊

適用環境:Next.js 14+ App Router、Server action、NextAuth.js v4、TypeScript
目標:照本文件從零完成 Google 單一登入(SSO),含登入頁、Session 讀取、未登入導回、登出。
最後更新日期:06/14/2026 | 最後更新人員:陳泓毓


完成後的檔案結構

your-project/
├── .env.local
├── app/
│   ├── layout.tsx                          ← 修改:包 AuthProvider
│   ├── login/page.tsx                      ← 新增
│   └── api/auth/[...nextauth]/route.ts     ← 新增
├── components/
│   ├── login-form.tsx                      ← 新增
│   ├── auth-button.tsx                     ← 新增(可選)
│   └── providers/session-provider.tsx      ← 新增
├── lib/
│   ├── auth-env.ts                         ← 新增
│   ├── auth-options.ts                     ← 新增
│   └── auth.ts                             ← 新增
└── types/
    └── next-auth.d.ts                      ← 新增

先了解流程

從使用者頁面到完成google sso大致可以分為以下幾個步驟:

步驟發生什麼事
使用者造訪受保護頁 → Server 用 getSessionUser() 檢查 → 未登入則 redirect("/login?callbackUrl=...")
登入頁顯示「Google 登入」→ 呼叫 signIn("google", { callbackUrl })
瀏覽器跳至 Google 授權頁
使用者同意 → Google 導向 /api/auth/callback/google
NextAuth 換取 token,寫入 Session Cookie
自動跳回 callbackUrl(登入前要去的頁面)
受保護頁再次執行 getSessionUser() → 有值 → 正常顯示

關鍵 URL(Google Console 必須註冊 redirect URI):
要完成Google S S O,一切的一切最關鍵就是要告訴Google登入完之後要導回我的網站(下章會詳細說明):

http://localhost:3000/api/auth/callback/googleCode language: JavaScript (javascript)

callbackUrl 是什麼?
受保護頁在未登入時,把「登入後要回到哪」寫在 query string:

/login?callbackUrl=/profile

登入成功後 NextAuth 會自動導向 /profile
在開始之前
確保tsconfig.json 已設定 @/* 路徑別名(預設 create-next-app 即有)


步驟 1:Google Cloud Console

目的:向 Google 註冊你的網站,取得 OAuth 憑證。
本步驟不寫程式。

  1. 開啟 Google Cloud Console
  2. 建立或選擇專案
  3. APIs & Services → OAuth consent screen
  • User Type:External(或 Workspace 選 Internal)
  • 填寫應用程式名稱、支援 Email
  • 若 External 且未發布:到 Test users 加入你的 Google 帳號
  1. APIs & Services → Credentials → Create Credentials → OAuth client ID
  • Application type:Web application
  • Authorized JavaScript origins
    http://localhost:3000
  • Authorized redirect URIs
    http://localhost:3000/api/auth/callback/google
  1. 建立後複製 Client IDClient Secret

驗收:

  • [ ] 手邊有 Client ID(結尾 apps.googleusercontent.com
  • [ ] 手邊有 Client Secret(通常以 GOCSPX- 開頭)
  • [ ] Redirect URI 列表中有 /api/auth/callback/google

步驟 2:環境變數

目的:把 Google 憑證與 NextAuth 設定寫進專案。

在專案根目錄建立 .env.local(若已存在則追加):

AUTH_GOOGLE_ID=貼上你的 Client ID
AUTH_GOOGLE_SECRET=貼上你的 Client Secret

AUTH_SECRET=隨機字串至少32字元
NEXTAUTH_SECRET=同上,與 AUTH_SECRET 相同即可

NEXTAUTH_URL=http://localhost:3000Code language: JavaScript (javascript)

變數別名(擇一組即可)

用途名稱 A名稱 B
Client IDAUTH_GOOGLE_IDGOOGLE_CLIENT_ID
Client SecretAUTH_GOOGLE_SECRETGOOGLE_CLIENT_SECRET
SecretAUTH_SECRETNEXTAUTH_SECRET
網站 URLNEXTAUTH_URLAUTH_URL

驗收:

  • [ ] .env.local 四項皆有值
  • [ ] 重啟 dev server 後,終端機沒有 [next-auth][warn][NO_SECRET]

步驟 3:安裝 next-auth

npm install next-auth

驗收:

  • [ ] package.jsondependencies 出現 "next-auth"

步驟 4:建立 lib/auth-env.ts

目的:集中讀取環境變數,避免在各處直接存取 process.env

建立 lib/auth-env.ts,貼上以下完整內容:

export function getGoogleClientId(): string {
  return (
    process.env.GOOGLE_CLIENT_ID?.trim() ||
    process.env.AUTH_GOOGLE_ID?.trim() ||
    ""
  );
}

export function getGoogleClientSecret(): string {
  return (
    process.env.GOOGLE_CLIENT_SECRET?.trim() ||
    process.env.AUTH_GOOGLE_SECRET?.trim() ||
    ""
  );
}

export function getAuthSecret(): string {
  return (
    process.env.NEXTAUTH_SECRET?.trim() ||
    process.env.AUTH_SECRET?.trim() ||
    ""
  );
}

export function getAuthUrl(): string {
  return (
    process.env.NEXTAUTH_URL?.trim() ||
    process.env.AUTH_URL?.trim() ||
    "http://localhost:3000"
  );
}

export function isAuthConfigured(): boolean {
  return Boolean(
    getGoogleClientId() && getGoogleClientSecret() && getAuthSecret()
  );
}

export function getAuthConfigError(): string | null {
  if (!getGoogleClientId()) {
    return "缺少 GOOGLE_CLIENT_ID 或 AUTH_GOOGLE_ID";
  }
  if (!getGoogleClientSecret()) {
    return "缺少 GOOGLE_CLIENT_SECRET 或 AUTH_GOOGLE_SECRET";
  }
  if (!getAuthSecret()) {
    return "缺少 NEXTAUTH_SECRET 或 AUTH_SECRET";
  }
  return null;
}Code language: JavaScript (javascript)

驗收:

  • [ ] 檔案存在,專案可正常編譯(npm run dev 無 import 錯誤)

步驟 5:建立 lib/auth-options.ts

目的:NextAuth 核心設定——Google Provider、Session callback、自訂登入頁路徑。

建立 lib/auth-options.ts

import type { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";

import {
  getAuthSecret,
  getAuthUrl,
  getGoogleClientId,
  getGoogleClientSecret,
} from "@/lib/auth-env";

export const authOptions: NextAuthOptions = {
  providers: [
    GoogleProvider({
      clientId: getGoogleClientId(),
      clientSecret: getGoogleClientSecret(),
    }),
  ],
  pages: {
    signIn: "/login",
  },
  callbacks: {
    session({ session, token }) {
      if (session.user && token.sub) {
        session.user.id = token.sub;
      }
      return session;
    },
  },
  secret: getAuthSecret(),
  ...(getAuthUrl() ? { url: getAuthUrl() } : {}),
};Code language: JavaScript (javascript)

重點說明:

  • pages.signIn: "/login" — NextAuth 預設登入頁改為你的 /login
  • session callback — 把 Google 使用者唯一 ID(token.sub)寫入 session.user.id,後續當資料庫 key 使用

驗收:

  • [ ] 檔案存在,無 TypeScript 錯誤

步驟 6:建立 API 路由

目的:提供 NextAuth 所需的 HTTP 端點,含 Google OAuth 回呼。

建立目錄與檔案 app/api/auth/[...nextauth]/route.ts

import NextAuth from "next-auth";

import { authOptions } from "@/lib/auth-options";

const handler = NextAuth(authOptions);

export { handler as GET, handler as POST };Code language: JavaScript (javascript)

此路由自動處理:

路徑用途
/api/auth/signin/google開始 Google 登入
/api/auth/callback/googleGoogle 授權後回呼
/api/auth/session讀取目前 Session
/api/auth/signout登出

驗收:

  • [ ] 瀏覽器開啟 http://localhost:3000/api/auth/signin/google
  • [ ] 能跳轉到 Google 登入頁(或 Google 帳號選擇頁)
  • [ ] 若出現 redirect_uri_mismatch → 回到步驟 1 檢查 Console 的 redirect URI

步驟 7:建立 types/next-auth.d.ts

目的:TypeScript 預設的 session.user 沒有 id,需擴充型別。

建立 types/next-auth.d.ts

import type { DefaultSession } from "next-auth";

declare module "next-auth" {
  interface Session {
    user: {
      id: string;
    } & DefaultSession["user"];
  }
}

declare module "next-auth/jwt" {
  interface JWT {
    sub: string;
  }
}

export {};Code language: PHP (php)

驗收:

  • [ ] 後續使用 session.user.id 時 TypeScript 不報錯

步驟 8:建立 lib/auth.ts

目的:Server 端統一讀取登入使用者。所有 Server Component 與 API Route 都應呼叫此檔的函式。

建立 lib/auth.ts

import { getServerSession } from "next-auth";

import { authOptions } from "@/lib/auth-options";

export async function getSessionUser() {
  const session = await getServerSession(authOptions);
  if (!session?.user?.id) return null;

  return {
    id: session.user.id,
    name: session.user.name ?? "",
    email: session.user.email ?? "",
    image: session.user.image ?? "",
  };
}

export function isAdminEmail(email: string): boolean {
  const admins = (process.env.ADMIN_EMAILS ?? "")
    .split(",")
    .map((item) => item.trim().toLowerCase())
    .filter(Boolean);

  return admins.includes(email.toLowerCase());
}Code language: JavaScript (javascript)

回傳值:

欄位來源用途
idGoogle sub使用者唯一 key,存資料庫 / JSON
nameGoogle 姓名顯示用
emailGoogle Email顯示、管理員白名單
imageGoogle 頭像 URL可選顯示

未登入時 getSessionUser() 回傳 null

驗收:

  • [ ] 檔案存在,可被其他模組 import

步驟 9:SessionProvider 與 layout

目的:Client Component 要使用 signIn / signOut / useSession,根 layout 必須包一層 Provider。

9.1 建立 Provider

建立 components/providers/session-provider.tsx

"use client";

import { SessionProvider } from "next-auth/react";

export function AuthProvider({ children }: { children: React.ReactNode }) {
  return <SessionProvider>{children}</SessionProvider>;
}Code language: JavaScript (javascript)

9.2 修改 app/layout.tsx

<body> 內用 AuthProvider 包住所有內容:

import { AuthProvider } from "@/components/providers/session-provider";

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="zh-TW">
      <body>
        <AuthProvider>
          {children}
        </AuthProvider>
      </body>
    </html>
  );
}Code language: JavaScript (javascript)

若你已有 header / main 結構,放在 Provider 裡面即可:

<AuthProvider>
  <SiteHeader />
  <main>{children}</main>
</AuthProvider>Code language: HTML, XML (xml)

驗收:

  • [ ] 頁面正常渲染,無 useSession must be wrapped in SessionProvider 錯誤

步驟 10:登入頁

目的:自訂登入 UI,並正確傳遞 callbackUrl 給 Google 登入流程。

10.1 建立 components/login-form.tsx

"use client";

import { signIn } from "next-auth/react";

type LoginFormProps = {
  callbackUrl: string;
  authConfigured: boolean;
  configError: string | null;
  oauthError?: string;
};

export function LoginForm({
  callbackUrl,
  authConfigured,
  configError,
  oauthError,
}: LoginFormProps) {
  return (
    <div>
      <h1>登入</h1>
      <p>請使用 Google 帳號登入</p>

      {!authConfigured && configError ? (
        <p>Google SSO 尚未設定:{configError}</p>
      ) : null}

      {oauthError ? (
        <p>登入失敗,請確認 Google OAuth 設定與 NEXTAUTH_URL。</p>
      ) : null}

      <button
        type="button"
        disabled={!authConfigured}
        onClick={() => signIn("google", { callbackUrl })}
      >
        使用 Google 登入
      </button>
    </div>
  );
}Code language: JavaScript (javascript)

10.2 建立 app/login/page.tsx

import { redirect } from "next/navigation";

import { LoginForm } from "@/components/login-form";
import { getAuthConfigError, isAuthConfigured } from "@/lib/auth-env";
import { getSessionUser } from "@/lib/auth";

type LoginPageProps = {
  searchParams: Promise<{ callbackUrl?: string; error?: string }>;
};

function getSafeCallbackUrl(raw?: string): string {
  if (!raw) return "/";

  try {
    const decoded = decodeURIComponent(raw);
    const url = decoded.startsWith("http")
      ? new URL(decoded)
      : new URL(decoded, "http://localhost");

    if (url.pathname.startsWith("/login")) return "/";
    return `${url.pathname}${url.search}`;
  } catch {
    return "/";
  }
}

export default async function LoginPage({ searchParams }: LoginPageProps) {
  const params = await searchParams;
  const callbackUrl = getSafeCallbackUrl(params.callbackUrl);

  const user = await getSessionUser();
  if (user) {
    redirect(callbackUrl);
  }

  return (
    <LoginForm
      callbackUrl={callbackUrl}
      authConfigured={isAuthConfigured()}
      configError={getAuthConfigError()}
      oauthError={params.error}
    />
  );
}Code language: JavaScript (javascript)

getSafeCallbackUrl 做了什麼:

  • 解析 ?callbackUrl= 參數
  • 只允許站內路徑,防止 open redirect 攻擊
  • 避免 callbackUrl=/login 造成無限迴圈
  • 已登入使用者造訪 /login 時,直接 redirect(callbackUrl)

驗收:

  • [ ] 開啟 http://localhost:3000/login 能看到登入按鈕
  • [ ] 點「Google 登入」→ Google 授權 → 成功後回到首頁 /
  • [ ] 開啟 http://localhost:3000/login?callbackUrl=/profile → 登入後跳到 /profile

步驟 11:導覽列登入/登出

加上Layout後就可以實現全站任意頁面可登入、登出、顯示 Email。

建立 components/auth-button.tsx

"use client";

import { signIn, signOut, useSession } from "next-auth/react";
import Link from "next/link";

export function AuthButton() {
  const { data: session, status } = useSession();

  if (status === "loading") {
    return <span>載入中...</span>;
  }

  if (!session?.user) {
    return (
      <button type="button" onClick={() => signIn("google")}>
        Google 登入
      </button>
    );
  }

  return (
    <div>
      <Link href="/profile">個人資料</Link>
      <span>{session.user.email}</span>
      <button type="button" onClick={() => signOut({ callbackUrl: "/" })}>
        登出
      </button>
    </div>
  );
}Code language: JavaScript (javascript)

在 header 元件中引入(範例):

import { AuthButton } from "@/components/auth-button";

export function SiteHeader() {
  return (
    <header>
      <nav>
        <AuthButton />
      </nav>
    </header>
  );
}Code language: JavaScript (javascript)

Server vs Client 讀取 Session

執行位置用法場景
Server Component / APIawait getSessionUser()頁面保護、後端邏輯
Client ComponentuseSession()按鈕、即時 UI

不要混用:Server 端不要用 useSession();Client 端不要用 getServerSession()

驗收:

  • [ ] 未登入時 header 顯示「Google 登入」
  • [ ] 登入後顯示 Email 與「登出」
  • [ ] 登出後回到首頁,Session 清除

步驟 12:保護頁面(未登入導回)

目的:特定頁面只允許登入使用者存取;未登入者送去登入頁,並記住原本要去的路。

在任何需要登入的 Server Component 頁面 最上方加入:

import { redirect } from "next/navigation";
import { getSessionUser } from "@/lib/auth";

export default async function ProfilePage() {
  const user = await getSessionUser();
  if (!user) {
    redirect("/login?callbackUrl=/profile");
  }

  return (
    <div>
      <h1>個人資料</h1>
      <p>{user.name}</p>
      <p>{user.email}</p>
    </div>
  );
}Code language: JavaScript (javascript)

規則:

  • callbackUrl此頁面的路徑(以 / 開頭)
  • 登入成功後使用者會自動回到這裡
  • user.id 作為資料庫 / 儲存的使用者 key

進階:登入後還需補資料

若已登入但個人資料未完成,用 returnUrl(不是 callbackUrl):

const user = await getSessionUser();
if (!user) redirect("/login?callbackUrl=/3d-printer/apply");

const profile = await getUserProfile(user.id);
if (!isProfileComplete(profile)) {
  redirect("/profile?returnUrl=/3d-printer/apply");
}Code language: JavaScript (javascript)
參數時機
callbackUrl未登入 → 登入後回去
returnUrl已登入但資料不足 → 填完回去

驗收:

  • [ ] 未登入直接開啟受保護頁 → 自動跳到 /login?callbackUrl=...
  • [ ] 登入後自動回到受保護頁,能看到內容

步驟 13:保護 API Route

目的:API 不接受未登入請求,回傳 401 JSON(API 無法使用 redirect())。

import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/auth";

export async function GET() {
  const user = await getSessionUser();
  if (!user) {
    return NextResponse.json({ error: "請先登入" }, { status: 401 });
  }

  return NextResponse.json({
    userId: user.id,
    email: user.email,
  });
}Code language: JavaScript (javascript)

Client 端 fetch 若收到 401,可導向登入:

const res = await fetch("/api/your-endpoint");
if (res.status === 401) {
  window.location.href = `/login?callbackUrl=${encodeURIComponent(window.location.pathname)}`;
}Code language: JavaScript (javascript)

驗收:

  • [ ] 未登入呼叫 API → HTTP 401
  • [ ] 登入後呼叫 API → 正常回傳資料

步驟 14:端到端驗收

全部完成後,依序執行以下測試:

測試 A:基本登入
  1. 開無痕視窗 → http://localhost:3000/login
  2. 點 Google 登入 → 選帳號 → 同意
  3. 預期:跳回首頁,header 顯示 Email

測試 B:callbackUrl
  1. 無痕視窗 → http://localhost:3000/login?callbackUrl=/profile
  2. 登入
  3. 預期:登入後在 /profile,而非首頁

測試 C:受保護頁面
  1. 無痕視窗 → 直接開啟受保護頁(如 /profile)
  2. 預期:自動跳到 /login?callbackUrl=/profile
  3. 登入後回到 /profile

測試 D:登出
  1. 已登入狀態點「登出」
  2. 預期:回首頁,再開受保護頁需重新登入

測試 E:Session 持久
  1. 登入後重新整理頁面
  2. 預期:仍保持登入狀態

測試 F:API
  1. 未登入 fetch 受保護 API → 401
  2. 登入後 fetch → 200Code language: PHP (php)

全部通過 → Google SSO 整合完成。


正式環境部署

  1. Google Console 追加正式網域:
   https://your-domain.com                          ← JavaScript origins
   https://your-domain.com/api/auth/callback/google ← redirect URIsCode language: JavaScript (javascript)
  1. 主機環境變數
   NEXTAUTH_URL=https://your-domain.com
   AUTH_GOOGLE_ID=...
   AUTH_GOOGLE_SECRET=...
   NEXTAUTH_SECRET=...Code language: JavaScript (javascript)
  1. OAuth 同意畫面若要對外公開 → 提交 Google 審核
  2. 重新部署後執行「步驟 14」測試(改用正式網址)

疑難排解

現象原因解法
client_id is required環境變數未載入確認 .env.local 存在;重啟 dev server;不要同時放兩組衝突的 Client ID
redirect_uri_mismatchConsole 未登記 URI加入 http://localhost:3000/api/auth/callback/google
Access blockedOAuth 測試模式Consent screen → Test users 加入你的 Gmail
[next-auth][warn][NO_SECRET]缺少 secret設定 NEXTAUTH_SECRETAUTH_SECRET
[next-auth][warn][NEXTAUTH_URL]缺少或設錯 URL設為 http://localhost:3000(不含 callback 路徑)
登入成功但回首頁未傳 callbackUrl受保護頁 redirect 時帶 ?callbackUrl=/your-path
登入後無 user.idcallback / 型別未設確認 auth-options.ts session callback 與 next-auth.d.ts
useSession must be wrapped...缺 Provider確認 layout.tsx 有包 <AuthProvider>

參考連結

Leave a Reply

Your email address will not be published. Required fields are marked *