設計の核心:2 つの関数を分ける
Session 取得関数は用途によって 2 種類に分けます。
| 関数 | 戻り値 | 未認証時 | 向いている場面 |
|---|---|---|---|
getSession() | Session | null | null を返す | 認証状態で表示を出し分けたいページ |
requireSession() | Session(non-null) | redirect("/login") | そのページにはログインが必須 |
getSession() に redirect を内包させると、未認証でもアクセスできるページで使えなくなります。null を返す汎用関数とredirect するラッパーを分けることが設計の起点です。
Session 型の定義
// lib/session.ts
export type Session = {
userId: string;
email: string;
role: "user" | "admin";
expiresAt: Date;
};
ロールは Union 型にすることで、後述の requireRole() で型安全なチェックができます。
getSession() — null を返す汎用関数
// lib/session.ts
import { cache } from "react";
import { cookies } from "next/headers";
// React cache() でリクエスト単位にメモ化(DB クエリの重複を防ぐ)
export const getSession = cache(async (): Promise<Session | null> => {
const cookieStore = await cookies();
const sessionId = cookieStore.get("session_id")?.value;
if (!sessionId) return null;
const session = await db.session.findUnique({
where: { id: sessionId },
select: { userId: true, email: true, role: true, expiresAt: true },
});
// 期限切れチェック
if (!session || session.expiresAt < new Date()) return null;
return session;
});
react の cache() を使う理由: Server Component は同一リクエスト内で複数のコンポーネントから getSession() を呼ぶことがあります。cache() でメモ化すると、リクエスト単位で 1 回だけ DB クエリが実行されます。
requireSession() — 認証必須ラッパー
// lib/session.ts
import { redirect } from "next/navigation";
export async function requireSession(): Promise<Session> {
const session = await getSession();
if (!session) {
redirect("/login");
}
return session; // TypeScript: Session (null でない)
}
呼び出し側は null チェック不要になります。
// app/dashboard/page.tsx
export default async function DashboardPage() {
const session = await requireSession(); // 未認証なら redirect される
// session は Session 型(null でない)
return <Dashboard userId={session.userId} />;
}
requireRole() — ロールチェックラッパー
権限が必要なページ向けのラッパーです。
// lib/session.ts
export async function requireRole(role: Session["role"]): Promise<Session> {
const session = await requireSession();
if (session.role !== role) {
notFound(); // 存在を隠したい場合
// または redirect("/forbidden") で権限不足を明示
}
return session;
}
// app/admin/page.tsx
export default async function AdminPage() {
const session = await requireRole("admin"); // admin 以外は notFound
return <AdminDashboard session={session} />;
}
認証状態で表示を出し分けるパターン
// app/page.tsx — ログイン状態で表示が変わるトップページ
export default async function TopPage() {
const session = await getSession(); // null でもエラーにならない
return (
<main>
<h1>TOOLS BOX</h1>
{session ? (
<p>{session.email} でログイン中</p>
) : (
<a href="/login">ログイン</a>
)}
</main>
);
}
責務分離のまとめ
getSession()
├── null を返す(redirect しない)
├── React cache() でメモ化
└── 使う場面: 表示の出し分け、optional な認証
requireSession()
├── getSession() を呼んで null なら redirect("/login")
├── 戻り値は Session(TypeScript が non-null と認識)
└── 使う場面: ログイン必須ページ
requireRole(role)
├── requireSession() を呼んでロールチェック
├── 不一致なら notFound() または redirect("/forbidden")
└── 使う場面: 管理者専用・ロール限定ページ
呼び出し側のページ
└── redirect / notFound の判断は呼び出し側に持たせない
(requireSession / requireRole が引き受ける)
よくある誤用
getSession() に redirect を内包させる
// ❌ getSession() が redirect を持つと、未認証でも使えるページで呼べなくなる
export async function getSession() {
const session = await fetchSession();
if (!session) redirect("/login"); // 呼び出し元を強制する
return session;
}
// ✅ redirect は requireSession() に分離する
export async function getSession(): Promise<Session | null> { ... }
export async function requireSession(): Promise<Session> {
const s = await getSession();
if (!s) redirect("/login");
return s;
}
同一リクエストで getSession() を何度も呼ぶ
// ❌ レイアウト・ページ・コンポーネントでそれぞれ呼ぶと DB クエリが重複する
// Layout: await getSession()
// Page: await getSession() ← 同じリクエスト内で再実行される
// ✅ React cache() でメモ化すると 1 リクエスト = 1 クエリになる
export const getSession = cache(async () => { ... });
未認証・権限不足での失敗分岐 → 認証・権限チェックの失敗分岐
認証判定をどこに置くか → Next.js 認証方式比較