你在 Next.js App Router 上需要一套認證系統。選項很多——NextAuth、Clerk、Auth0、Firebase Auth——但如果你的後端已經在用 Supabase,答案很明確:直接用 Supabase Auth。
原因不只是少裝一個服務。Supabase Auth 跟 database 共用同一個 JWT,Row Level Security(RLS)可以直接讀 auth.uid(),不需要額外同步使用者資料。認證和授權在同一層解決,架構最乾淨。
為什麼選 Supabase Auth
先快速比較:
| Supabase Auth | NextAuth | Clerk | Auth0 | |
|---|---|---|---|---|
| 免費額度 | 50,000 MAU | 無限(自管) | 10,000 MAU | 25,000 MAU |
| DB 整合 | 原生(RLS) | 需 adapter | 需 webhook sync | 需 webhook sync |
| Social Login | 內建 20+ provider | 內建 | 內建 | 內建 |
| 自訂 UI | 完全自訂 | 完全自訂 | 預製元件 | 預製元件 |
| 架構耦合 | 與 Supabase 生態綁定 | 框架 agnostic | 獨立服務 | 獨立服務 |
Supabase Auth 的優勢在整合深度。如果你已經用 Supabase 做 database 和 storage,auth 不需要額外的服務。使用者登入後,同一個 JWT 可以直接打 database query,RLS policy 自動生效。
缺點是跟 Supabase 生態綁定。如果你未來想搬離 Supabase,auth 也要一起搬。但對大多數專案來說,這不是現階段需要擔心的事。
安裝和初始設定
Step 1:安裝依賴
pnpm add @supabase/supabase-js @supabase/ssr
@supabase/ssr 是關鍵——它取代了舊的 @supabase/auth-helpers-nextjs,專門處理 App Router 下 Server Component 和 middleware 的 cookie 管理。
Step 2:環境變數
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://xxxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJxxxxx
這兩個值在 Supabase dashboard 的 Settings → API 裡找。ANON_KEY 是公開的 client-side key,搭配 RLS 使用是安全的。
Step 3:建立 Supabase client 工具函式
App Router 下你需要兩種 client——server 用和 client 用:
// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
},
},
}
)
}
// lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}
Server client 用 createServerClient,透過 cookies() 讀寫 Next.js 的 cookie store。Client 端用 createBrowserClient,自動處理瀏覽器 cookie。
Middleware:Session 刷新的關鍵
這是最多人漏掉的一步。Supabase 的 JWT 有過期時間,middleware 負責在每次 request 時自動刷新 session:
// middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'
export async function middleware(request: NextRequest) {
let supabaseResponse = NextResponse.next({ request })
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
)
supabaseResponse = NextResponse.next({ request })
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
)
},
},
}
)
// 重要:不要移除這行。它會觸發 session 刷新。
await supabase.auth.getUser()
return supabaseResponse
}
export const config = {
matcher: [
'/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
],
}
await supabase.auth.getUser() 這行看起來沒用到回傳值,但它會觸發 session token 的刷新。如果你拿掉這行,使用者的 session 會在 JWT 過期後靜默失效。
Server Component 取得使用者
在 Server Component 裡取得當前使用者:
// app/dashboard/page.tsx
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
export default async function DashboardPage() {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
redirect('/login')
}
return (
<div>
<h1>Dashboard</h1>
<p>歡迎,{user.email}</p>
</div>
)
}
注意用 getUser() 而不是 getSession()。getUser() 會向 Supabase 驗證 JWT 的有效性,getSession() 只是讀 cookie 裡的 token 但不驗證。在 server 端,永遠用 getUser()。
Client Component 取得使用者
在 Client Component 裡,用 onAuthStateChange 監聽登入狀態:
'use client'
import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import type { User } from '@supabase/supabase-js'
export function AuthStatus() {
const [user, setUser] = useState<User | null>(null)
const supabase = createClient()
useEffect(() => {
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => {
setUser(session?.user ?? null)
}
)
return () => subscription.unsubscribe()
}, [])
if (!user) return <p>未登入</p>
return <p>已登入:{user.email}</p>
}
登入和登出
Email + Password 登入:
// app/login/actions.ts
'use server'
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
export async function login(formData: FormData) {
const supabase = await createClient()
const { error } = await supabase.auth.signInWithPassword({
email: formData.get('email') as string,
password: formData.get('password') as string,
})
if (error) {
redirect('/login?error=Invalid credentials')
}
redirect('/dashboard')
}
export async function logout() {
const supabase = await createClient()
await supabase.auth.signOut()
redirect('/login')
}
用 Server Actions 處理登入登出,form 直接提交,不需要額外的 API route。
OAuth(例如 Google Login)也很直覺:
export async function loginWithGoogle() {
const supabase = await createClient()
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: 'http://localhost:3000/auth/callback',
},
})
if (data.url) {
redirect(data.url)
}
}
OAuth 需要額外建一個 /auth/callback route 來處理 code exchange,Supabase 文件有完整範例。
常見錯誤
1. Cookie 沒有正確傳遞
最常見的問題。如果你在 middleware 裡建立 Supabase client 時沒有正確實作 setAll,session 刷新後的新 cookie 不會寫回 response,使用者會反覆被登出。上面的 middleware 範例已經處理了這個問題。
2. Hydration mismatch
Server Component 拿到的 user 狀態和 Client Component 初始 render 的狀態不一致,會導致 hydration error。解決方式:Client Component 不要在初始 render 時直接讀 session,而是用 onAuthStateChange 在 useEffect 裡更新。
3. 用了 getSession() 而不是 getUser()
getSession() 在 server 端不會驗證 token,只是解析 cookie。如果 token 被竄改,getSession() 不會發現。在所有需要認證的 server 端邏輯裡,用 getUser()。
4. 忘記在 Supabase dashboard 設定 redirect URL
OAuth login 需要在 Authentication → URL Configuration 裡加入你的 redirect URL,否則會拿到 redirect_uri_mismatch 錯誤。本地開發記得加 http://localhost:3000/auth/callback。
5. RLS 沒開
Supabase Auth 登入成功了,但 database query 什麼都拿不到。多半是 table 開了 RLS 但沒有寫 policy。至少要有一條 auth.uid() = user_id 的 SELECT policy。
整體架構
把以上串起來,你的專案結構會是:
app/
├── (auth)/
│ ├── login/
│ │ ├── page.tsx # 登入表單
│ │ └── actions.ts # login / logout Server Actions
│ └── auth/
│ └── callback/
│ └── route.ts # OAuth code exchange
├── dashboard/
│ └── page.tsx # 受保護頁面
├── layout.tsx
└── middleware.ts # Session 刷新
lib/
└── supabase/
├── server.ts # Server client
└── client.ts # Browser client
認證邏輯集中在 lib/supabase/ 和 middleware,頁面只需要呼叫 createClient() 再 getUser() 就能判斷登入狀態。
想深入了解 Supabase + Next.js 的完整架構,包括 RLS、database design 和 subscription 整合?到 Docs 查看系統化的技術指南。