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 憑證。
本步驟不寫程式。
- 開啟 Google Cloud Console
- 建立或選擇專案
- APIs & Services → OAuth consent screen
- User Type:External(或 Workspace 選 Internal)
- 填寫應用程式名稱、支援 Email
- 若 External 且未發布:到 Test users 加入你的 Google 帳號
- 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
- 建立後複製 Client ID 與 Client 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 ID | AUTH_GOOGLE_ID | GOOGLE_CLIENT_ID |
| Client Secret | AUTH_GOOGLE_SECRET | GOOGLE_CLIENT_SECRET |
| Secret | AUTH_SECRET | NEXTAUTH_SECRET |
| 網站 URL | NEXTAUTH_URL | AUTH_URL |
驗收:
- [ ]
.env.local四項皆有值 - [ ] 重啟 dev server 後,終端機沒有
[next-auth][warn][NO_SECRET]
步驟 3:安裝 next-auth
npm install next-auth
驗收:
- [ ]
package.json的dependencies出現"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 預設登入頁改為你的/loginsessioncallback — 把 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/google | Google 授權後回呼 |
/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)
回傳值:
| 欄位 | 來源 | 用途 |
|---|---|---|
id | Google sub | 使用者唯一 key,存資料庫 / JSON |
name | Google 姓名 | 顯示用 |
email | Google Email | 顯示、管理員白名單 |
image | Google 頭像 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 / API | await getSessionUser() | 頁面保護、後端邏輯 |
| Client Component | useSession() | 按鈕、即時 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 整合完成。
正式環境部署
- Google Console 追加正式網域:
https://your-domain.com ← JavaScript origins
https://your-domain.com/api/auth/callback/google ← redirect URIsCode language: JavaScript (javascript)
- 主機環境變數:
NEXTAUTH_URL=https://your-domain.com
AUTH_GOOGLE_ID=...
AUTH_GOOGLE_SECRET=...
NEXTAUTH_SECRET=...Code language: JavaScript (javascript)
- OAuth 同意畫面若要對外公開 → 提交 Google 審核
- 重新部署後執行「步驟 14」測試(改用正式網址)
疑難排解
| 現象 | 原因 | 解法 |
|---|---|---|
client_id is required | 環境變數未載入 | 確認 .env.local 存在;重啟 dev server;不要同時放兩組衝突的 Client ID |
redirect_uri_mismatch | Console 未登記 URI | 加入 http://localhost:3000/api/auth/callback/google |
Access blocked | OAuth 測試模式 | Consent screen → Test users 加入你的 Gmail |
[next-auth][warn][NO_SECRET] | 缺少 secret | 設定 NEXTAUTH_SECRET 或 AUTH_SECRET |
[next-auth][warn][NEXTAUTH_URL] | 缺少或設錯 URL | 設為 http://localhost:3000(不含 callback 路徑) |
| 登入成功但回首頁 | 未傳 callbackUrl | 受保護頁 redirect 時帶 ?callbackUrl=/your-path |
登入後無 user.id | callback / 型別未設 | 確認 auth-options.ts session callback 與 next-auth.d.ts |
useSession must be wrapped... | 缺 Provider | 確認 layout.tsx 有包 <AuthProvider> |
