Firebase 驗證 儲存到useContext
Firebase Auth + React Context
TL;DRonAuthStateChanged listener 監聽登入狀態,放進 Context 讓全 app 元件透過 useAuth() 取用。loading state 必加,否則初次載入會閃過「未登入」狀態。Next.js 13+ App Router 寫法略不同(client component + server side cookie session)。
主要參考LogRocket — Implementing authentication in Next.js with Firebase
完整實作(TypeScript + Pages Router)
1. AuthContext
// contexts/AuthContext.tsx
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { onAuthStateChanged, User } from 'firebase/auth';
import { auth } from '../lib/firebase';
interface AuthContextType {
user: User | null;
loading: boolean;
}
const AuthContext = createContext<AuthContextType>({
user: null,
loading: true,
});
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// ⭐ 訂閱 auth 狀態變化(登入 / 登出 / token refresh)
const unsubscribe = onAuthStateChanged(auth, (firebaseUser) => {
setUser(firebaseUser);
setLoading(false);
});
return unsubscribe;
}, []);
return (
<AuthContext.Provider value={{ user, loading }}>
{!loading && children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);
{!loading && children} 重要不檢查 loading 直接渲染 children,SSR 時 user 是 null → 顯示「請登入」→ Firebase 拿到 token 後切換成已登入 → 畫面閃爍。用
!loading && children確保 Firebase Auth ready 才渲染,避免閃爍。代價是 initial loading 多 100-500ms,可以加個 skeleton 緩解。
2. 包進 App
// pages/_app.tsx
import { AuthProvider } from '../contexts/AuthContext';
export default function App({ Component, pageProps }: AppProps) {
return (
<AuthProvider>
<Component {...pageProps} />
</AuthProvider>
);
}
3. 在元件中使用
import { useAuth } from '../contexts/AuthContext';
function Profile() {
const { user, loading } = useAuth();
if (loading) return <Skeleton />;
if (!user) return <p>請先登入</p>;
return (
<div>
<img src={user.photoURL} alt="" />
<p>歡迎, {user.displayName}</p>
<p>email: {user.email}</p>
</div>
);
}
登入 / 登出函式
// lib/auth-actions.ts
import {
signInWithEmailAndPassword,
signInWithPopup,
GoogleAuthProvider,
signOut as fbSignOut,
} from 'firebase/auth';
import { auth } from './firebase';
// Email + Password
export async function signInEmail(email: string, password: string) {
return signInWithEmailAndPassword(auth, email, password);
}
// Google OAuth
const googleProvider = new GoogleAuthProvider();
export async function signInWithGoogle() {
return signInWithPopup(auth, googleProvider);
}
// 登出
export async function signOut() {
return fbSignOut(auth);
}
重點:登入 / 登出後 不需要手動更新 state,onAuthStateChanged 會自動觸發 → context 自動 re-render → 全 app 同步。
保護路由(只有登入才能看)
方案 1:HOC
// hocs/withAuth.tsx
import { useRouter } from 'next/router';
import { useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext';
export function withAuth<P extends object>(Component: React.ComponentType<P>) {
return function WithAuthComponent(props: P) {
const { user, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!loading && !user) {
router.push('/login');
}
}, [user, loading, router]);
if (loading || !user) return <Skeleton />;
return <Component {...props} />;
};
}
// 使用
import { withAuth } from '../hocs/withAuth';
function Dashboard() { /* ... */ }
export default withAuth(Dashboard);
方案 2:Custom Hook
function useRequireAuth(redirectTo = '/login') {
const { user, loading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!loading && !user) router.push(redirectTo);
}, [user, loading, router, redirectTo]);
return { user, loading };
}
// 使用
function Dashboard() {
const { user, loading } = useRequireAuth();
if (loading) return <Skeleton />;
return <div>歡迎 {user!.displayName}</div>;
}
方案 2 比較現代,沒有 HOC nesting 複雜度。
App Router(Next.js 13+)注意事項
Server Components 不能用 Context,所以 AuthContext 要在 client component:
// app/providers.tsx
'use client';
import { AuthProvider } from '../contexts/AuthContext';
export function Providers({ children }: { children: React.ReactNode }) {
return <AuthProvider>{children}</AuthProvider>;
}
// app/layout.tsx
import { Providers } from './providers';
export default function RootLayout({ children }) {
return (
<html>
<body>
<Providers>{children}</Providers>
</body>
</html>
);
}
Server Side Auth(進階)
Client-side useAuth() 只在 browser 運作。Server Components 想知道 user 狀態要走 cookie-based session:
// 用 firebase-admin 在 server 驗證 cookie
// app/dashboard/page.tsx
import { cookies } from 'next/headers';
import { adminAuth } from '../lib/firebase-admin';
export default async function Dashboard() {
const sessionCookie = cookies().get('session')?.value;
if (!sessionCookie) redirect('/login');
try {
const decoded = await adminAuth.verifySessionCookie(sessionCookie);
return <div>歡迎 {decoded.name}</div>;
} catch {
redirect('/login');
}
}
這需要設定 Firebase Admin SDK== + 自訂 cookie 流程,比 client-only 複雜。如果 SEO 沒那麼重要,純 client-side 就夠用。
常見地雷
1. 多分頁 sync 問題同個 user 開多個分頁,在 A 分頁登出 → B 分頁不會自動知道。解法:
onAuthStateChanged會自動跨分頁同步(因為 Firebase 用 IndexedDB / localStorage 存 token,被改動會觸發其他分頁的 listener)。
2. SSR Hydration mismatchServer 渲染時 user 是 null,client 立即知道是登入 → React 抱怨 hydration mismatch。解法:確保
loadingstate 處理。或者用useState + useEffect延遲渲染,first paint 不依賴 user 狀態。
3. token 過期Firebase ID token 每小時自動 refresh,通常無感。但如果你用 token 呼叫自家後端,要在 server 驗證 + handle 過期。
// 在前端取最新 token
const token = await user.getIdToken(true); // true = force refresh
fetch('/api/protected', {
headers: { Authorization: `Bearer ${token}` },
});
Auth 流程實際範例
// app/login/page.tsx
'use client';
import { useState } from 'react';
import { signInEmail, signInWithGoogle } from '@/lib/auth-actions';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
import { useEffect } from 'react';
export default function LoginPage() {
const { user } = useAuth();
const router = useRouter();
const [email, setEmail] = useState('');
const [pwd, setPwd] = useState('');
const [err, setErr] = useState('');
// 已登入直接導走
useEffect(() => {
if (user) router.push('/dashboard');
}, [user, router]);
const handleEmailLogin = async () => {
try {
await signInEmail(email, pwd);
// ⭐ 不用手動 router.push,上面 useEffect 會處理
} catch (e: any) {
setErr(e.message);
}
};
return (
<form onSubmit={(e) => { e.preventDefault(); handleEmailLogin(); }}>
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="email" />
<input type="password" value={pwd} onChange={(e) => setPwd(e.target.value)} placeholder="password" />
<button type="submit">Login</button>
<button type="button" onClick={signInWithGoogle}>Google</button>
{err && <p style={{ color: 'red' }}>{err}</p>}
</form>
);
}