Google SSO × Next.js 實作手冊
適用環境:Next.js 14+ App Router、Server action、NextAuth.js v4、TypeScript
目標:照本文件從零完成 Google 單一登入(SSO),含登入頁、Session 讀取、未登入導回、登出。
第一版日期:06/14/2026 |最後更新日期:10/02/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 ← 新增
因為本文件未涉及到資料庫儲存部分,所以此專案中「登入與否」的狀態是存在Cookie。
先了解流程
從使用者頁面到完成google sso大致可以分為以下幾個步驟:
sequenceDiagram
autonumber
actor User as 使用者
participant App as 你的 App / 網站
participant IdP as 身份提供者 (Google)
participant Backend as 你的後端伺服器
User->>App: 點擊「第三方登入」
App->>IdP: 重新導向 / 呼叫 SDK 進入登入頁面
IdP->>User: 顯示登入與授權畫面
User->>IdP: 輸入帳密 / 生物辨識並同意授權
IdP-->>App: 回傳授權碼 (Authorization Code) 或 Token
App->>Backend: 將 Token 傳給後端驗證
Backend->>IdP: (伺服器對伺服器) 向 IdP 確認 Token 真偽
IdP-->>Backend: 回傳使用者基本資料 (Email、ID 等)
Backend->>Backend: 檢查本地資料庫 (有則登入,無則註冊)
Backend-->>App: 回傳自家系統的登入憑證 (Session / JWT)
App->>User: 登入成功關鍵 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 |
步驟 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)
步驟 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 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;
}
}
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 | 登出 |
步驟 7:建立 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。
步驟 8:SessionProvider 與 layout
目的:Client Component 要使用
signIn/signOut/useSession,根 layout 必須包一層 Provider。
8.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)
8.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)
步驟 9:登入頁
目的:自訂登入 UI,並正確傳遞
callbackUrl給 Google 登入流程。
9.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)
9.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 授權 → 成功後回到首頁
/
步驟 10:導覽列登入/登出
加上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 元件中引入,建立 components/site-header.tsx:
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 清除
步驟 11:保護頁面(未登入導回)
目的:特定頁面只允許登入使用者存取;未登入者送去登入頁,並記住原本要去的路。
在任何需要登入的 Server Component 頁面 最上方加入,
建立 app/profile/page.tsx:
import { redirect } from "next/navigation";
import { SiteHeader } from "@/components/site-header";
import { getSessionUser } from "@/lib/auth";
export default async function ProfilePage() {
const user = await getSessionUser();
if (!user) {
redirect("/login?callbackUrl=/profile");
}
return (
<div>
<SiteHeader />
<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 | 已登入但資料不足 → 填完回去 |
驗收:
- [ ] 未登入狀態下開啟
http://localhost:3000/會被導向http://localhost:3000/login?callbackUrl=/profile - [ ] 在
http://localhost:3000/login?callbackUrl=/profile登入後會自動導回profile頁,能看到內容
步驟 12:保護 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 → 正常回傳資料
