👁

Next.jsでのログイン機能の実装

この章では、Next.jsでのログイン機能について概要を解説し、ハンズオン形式で実際に動作させながら学習します。これにより、Cookieによるログイン状態の保持・ロール別UIの出し分け・保護ページの実装が身につきます。

1. 本章の概要

1.1 本章の目的

ログイン処理そのものはバックエンドで実装されることが多いですが、ログインフォームからのリクエスト送信・ログイン状態の保持・ログイン済みかどうかによる画面の出し分けといった部分はフロントエンド側で実装する必要があります。この章では、Next.jsでこれらを運用に近い形で実装する手法を扱います。

1.2 ハンズオンの流れ

本章では、フロントエンド側の実装に集中するため、認証APIも同じNext.jsプロジェクト内に用意して進めます(実運用ではAPIサーバを別プロジェクトとして分ける構成が多く見られます。本章のAPIは動作確認用の位置付けで、JWTの発行・検証といった認証サーバ側の内部実装は扱いません)。Cookieに識別情報を持たせるところからスタートし、ログインフォーム・現在のユーザに応じた画面表示・ロール別の管理メニュー・保護ページ・ログアウトまでを順に実装します。

1.3 事前準備

必要なツール

この章では、以下のツールを使用します。まだインストールしていない場合は、リンク先の手順に沿って準備をお願いします。

ツール名 関連箇所 理由
Visual Studio Code Visual Studio Codeのインストール ログイン機能のコードを記述するエディタとして使用する
Node.js Node.jsをインストールしよう Next.jsアプリケーションの実行環境として使用する

2. ログイン情報の持たせ方

ハンズオンに入る前に、ログイン機能を実装するときにフロントエンド側で判断する主要な論点を整理しておきます。認証と認可の違い、ログイン状態を保存する場所の選択肢、そのなかでCookieが担う役割と押さえておくべき属性を先に確認します。

2.1 認証と認可の違い

Webアプリケーションで一緒に語られる「認証」と「認可」は、それぞれ別の役割を持ちます。

認証(Authentication)は、「アクセスしてきた相手が誰であるか」を確かめる仕組みです。ログイン画面でメールアドレスとパスワードを入力し、サーバ側でパスワードを照合して「たしかにこのユーザ本人だ」と識別する処理が該当します。

認可(Authorization)は、「そのユーザが何をしてよいか」を判断する仕組みです。「ログイン済みユーザだけがダッシュボードを見られる」「管理者ロールのユーザだけが管理メニューを開ける」といった判断が認可にあたります。

この2つは順番も役割も違います。まず認証で相手が誰かを特定し、その結果を根拠に認可で操作の可否を判定する、というのが基本の流れです。本章のハンズオンでも、ログインAPI(認証)を先に用意し、その後で「認証済みユーザだけがダッシュボードを見られる」「管理者ロールにだけ管理メニューが見える」という認可を実装します。

2.2 ログイン状態はどこに持たせるか

ログイン中のユーザ情報をフロントエンドで扱うとき、保存場所には複数の選択肢があります。どこに何を持たせるかで、セキュリティ性・利便性・実装の複雑さが変わります。

保存場所 主な用途 認証トークンに向くか
ローカルストレージ ページ間で保持したいユーザ設定など 向かない。JavaScriptから読めるため、XSSでトークンを盗まれるリスクが高い
セッションストレージ タブ内だけで保持したい一時的な情報 向かない。ローカルストレージと同じくJavaScriptから読めてしまう
Cookie(HttpOnly 付き) サーバに自動送信される識別情報 向く。HttpOnly を付けるとJavaScriptから読めなくなり、XSSで盗まれにくくなる
メモリ上の状態 画面表示中のユーザ名・ロールなどの派生情報 トークン自体は Cookie に任せ、派生情報だけをメモリ上の状態で持つ

一般的なパターンは、「識別情報(サーバに送って身分を証明するトークン)はhttpOnly Cookieに任せて、フロント側では派生情報(ユーザ名・ロールなど)だけをメモリ上の状態で持つ」という組み合わせです。本章もこのパターンで進めます。

📝 XSS とは
XSS(Cross-Site Scripting)は、Webページに悪意のあるJavaScriptが埋め込まれてしまう攻撃の総称です。埋め込みが成立すると、そのページのJavaScriptから読める情報はすべて攻撃者に盗まれる可能性があります。ローカルストレージ・セッションストレージに置いた認証トークンはJavaScriptから読めるため、XSSが1箇所でも成立すると認証情報が丸ごと持ち出される事故につながります。

2.3 Cookieとは

Cookie は、ブラウザに保存される小さなデータです。サーバがレスポンスヘッダで Set-Cookie を返すと、ブラウザはそのCookieを保存し、以降同じドメインへのリクエスト時に自動的にリクエストヘッダに載せて送り返します

ブラウザとサーバがCookieをやり取りする流れは、以下のようになっています。

sequenceDiagram
    participant B as ブラウザ
    participant S as サーバ

    B->>S: POST /api/login (email, password)
    S-->>B: 200 OK + Set-Cookie: token=xxxxx
    Note over B: Cookieを保存

    B->>S: GET /api/me<br/>Cookie: token=xxxxx
    S-->>B: 200 OK { user: "..." }

    B->>S: GET /dashboard<br/>Cookie: token=xxxxx
    S-->>B: 200 OK (ダッシュボードHTML)

Cookieは「サーバがブラウザに識別情報を持たせておき、次回以降のリクエストで自動的に返してもらう仕組み」と捉えると理解しやすくなります。ログイン状態を維持する仕組みは、この「Cookieが毎回自動で送られてくる」性質を土台にしています。

本章のハンズオンでも、ログインAPI(/api/login)が発行した認証トークンをCookieに保存し、以降の現在のユーザ取得API(/api/me)や保護ページへのリクエスト時にブラウザから自動送信させることで、ログイン状態を維持します。フロントエンド側のコードでCookieを直接読み書きする処理は書きません。

2.4 Cookieの属性

Cookieに認証情報を入れる場合、そのCookieには適切な属性を必ず付けます。属性を付けないと、CookieはJavaScriptから読み書きでき、HTTP通信でも平文で送られる状態になり、盗まれるリスクが跳ね上がります。

代表的なCookie属性は以下の3つです。

属性 役割
HttpOnly JavaScriptの document.cookie からCookieに触れなくする。XSSで認証情報を盗まれるのを防ぐ
Secure HTTPS通信のときにだけCookieを送る。HTTP通信で平文のまま送られてCookieが盗聴されるのを防ぐ
SameSite 別ドメインからのリクエスト時にCookieを送るかを制御する。Lax(既定)・StrictNone の3値がある。CSRF(他サイトから勝手にリクエストを飛ばされる攻撃)を防ぐ

本章のハンズオンで発行するログインCookieは、HttpOnly + Secure + SameSite=Lax を付けた状態でセットします。これで、認証情報がフロントのJavaScriptから読めない状態を保てます。

3. テスト用APIサーバの用意

ここからハンズオン本編に入ります。本章はフロントエンド側の実装に集中したいので、認証API側は動作確認用の最小構成で用意します。実運用ではJWTなどでトークン発行・検証を行う専用のバックエンドサーバがある想定で、本章のAPIはそのサーバの代わりとして振る舞います。JWTの発行・検証といった認証サーバ側の内部実装は本章のスコープ外なので、APIコードはコピー&ペーストで動く形にとどめて、詳細解説は最小限にします。

3.1 サンプルプロジェクトの準備

本章専用のNext.jsプロジェクトを新規作成します。前章のプロジェクトに追記する方式ではなく、ログイン機能の実装だけに集中できる新しいプロジェクトを用意します。

任意の場所に frontend-login-demo フォルダを作成し、Visual Studio Codeの「ファイル」→「フォルダーを開く」から、作成した frontend-login-demo フォルダを開きます。以降の操作は、Visual Studio Codeのターミナルから行います。

frontend-login-demo  ← このフォルダを作成

以下のコマンドを実行して、npm がインストールされていることを確認します。npx は npm に同梱されているため、npm のバージョンが確認できれば npx も利用できます。

npm --version

以下のように npm のバージョンが表示されれば、インストールは確認できています。

11.x.x

バージョンが表示されない場合は、Node.jsをインストールしよう を先に実施してください。

インストールが確認できたら、以下のコマンドを実行して、Next.jsプロジェクトの雛形をいま開いているカレントフォルダに生成します。

npx create-next-app@latest .

npx はローカルにインストールされていないコマンドをその場でダウンロードして実行するNode.js標準のツールで、create-next-app@latest の部分でNext.js公式スキャフォールディングツールの最新版を実行しています。直後の . は「カレントフォルダ(いま開いている frontend-login-demo)に生成する」ことを指定しています。

実行すると、create-next-app パッケージをその場で取得するために、以下のような確認が表示されることがあります。y を入力して Enter を押します。

Need to install the following packages:
create-next-app@16.x.x
Ok to proceed? (y)

続けて、以下の質問が表示されます。

  • Would you like to use the recommended Next.js defaults?Yes, use recommended defaults を選択して Enter

Yes を選ぶと、TypeScript / ESLint / Tailwind CSS / App Router / Turbopack など、本章のハンズオンに必要な構成が一括で揃った状態で雛形が生成されます。

回答が終わると、依存パッケージのインストール・型生成・Gitリポジトリの初期化が自動で行われ、以下のような実行結果が表示されれば成功です(途中のインストールログは省略しています)。

✔ Would you like to use the recommended Next.js defaults? › Yes, use recommended defaults
Creating a new Next.js app in /path/to/frontend-login-demo.

Using npm.

Initializing project with template: app-tw


Installing dependencies:
- next
- react
- react-dom

...

Success! Created frontend-login-demo at /path/to/frontend-login-demo

Visual Studio Codeのサイドバーには、以下のような構成が表示されます。

frontend-login-demo/
├── app/
│   ├── favicon.ico
│   ├── globals.css
│   ├── layout.tsx
│   └── page.tsx
├── public/
├── .gitignore
├── eslint.config.mjs
├── next-env.d.ts
├── next.config.ts
├── package-lock.json
├── package.json
├── postcss.config.mjs
├── README.md
└── tsconfig.json

続けて、以下のコマンドを実行して、開発サーバを起動し、初期状態の画面を確認します。

npm run dev

ブラウザで http://localhost:3000 を開き、以下のようなNext.jsの初期ページ(page.tsx の編集を促す案内が表示された画面)が確認できれば準備完了です。

開発サーバは以降のハンズオンの間も起動したままにしておきます。

3.2 認証APIの実装

本章のフロントエンド実装から呼び出す先として、/api/login(ログイン)、/api/logout(ログアウト)、/api/me(現在のユーザ取得)の3つのRoute Handlerを用意します。冒頭で述べたとおり、このAPI側のコードは本章の学習対象ではありません。認証サーバがすでにある想定でフロント側の実装に入るための土台なので、以下のコードはそのままコピーして動作させれば問題ありません。

ユーザ情報とトークンを保持するモジュールを、app/lib/session-store.ts として作成します。Visual Studio Codeのエクスプローラーで app フォルダを右クリックして「新しいフォルダー」で lib を作り、その中に session-store.ts を作ります。

frontend-login-demo/
├── app/
│   ├── lib/                       ← このフォルダを作成
│   │   └── session-store.ts       ← このファイルを作成
│   └── ...
└── ...

作成した app/lib/session-store.ts に以下の内容を記述して保存します。

import { randomUUID } from "crypto";

export type User = {
  id: string;
  email: string;
  role: "user" | "admin";
};

type StoredUser = User & { password: string };

const USERS: StoredUser[] = [
  { id: "1", email: "alice@example.com", password: "password123", role: "user" },
  { id: "2", email: "admin@example.com", password: "adminpass", role: "admin" },
];

const globalForSessions = globalThis as unknown as {
  __sessions?: Map<string, User>;
};

const sessions =
  globalForSessions.__sessions ?? new Map<string, User>();

if (process.env.NODE_ENV !== "production") {
  globalForSessions.__sessions = sessions;
}

export function login(email: string, password: string): { token: string; user: User } | null {
  const found = USERS.find((u) => u.email === email && u.password === password);
  if (!found) return null;
  const { password: _pw, ...user } = found;
  const token = randomUUID();
  sessions.set(token, user);
  return { token, user };
}

export function getSession(token: string): User | null {
  return sessions.get(token) ?? null;
}

export function destroySession(token: string): void {
  sessions.delete(token);
}

このモジュールは、ユーザ一覧(USERS)とトークン→ユーザの対応表(sessions)をメモリ上に持ちます。認証サーバ側の内部実装(本来はJWTの発行・検証を行うところ)を、動作確認用に最小のロジックで再現しています。

sessionsglobalThis に紐付けているのは、開発サーバ(npm run dev)ではファイル保存のたびにRoute HandlerとServer Componentそれぞれのモジュールが別々に再評価されて sessions の中身が消え、ログイン後に /dashboard へアクセスしても getSession が空を返してログイン画面に戻される事象が起きるのを防ぐためです。Next.jsの開発モードでメモリ上の状態を保持したいときによく使う書き方で、本番ビルド(NODE_ENV=production)ではこの分岐は無効になり、通常の Map として動きます。開発サーバを再起動すると sessions の中身は初期化される点は、後の動作確認時に頭に入れておきましょう。

続いてログインAPI(/api/login)を作ります。エクスプローラーで app フォルダを右クリックして「新しいフォルダー」で api/login を作り、その中に route.ts を作ります。

frontend-login-demo/
├── app/
│   ├── api/
│   │   └── login/         ← このフォルダを作成
│   │       └── route.ts   ← このファイルを作成
│   ├── lib/
│   │   └── session-store.ts
│   └── ...
└── ...

作成した app/api/login/route.ts に以下の内容を記述して保存します。

import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { login } from "@/app/lib/session-store";

export async function POST(request: Request) {
  const { email, password } = await request.json();

  const result = login(email, password);
  if (!result) {
    return NextResponse.json(
      { error: "メールアドレスまたはパスワードが正しくありません" },
      { status: 401 }
    );
  }

  const cookieStore = await cookies();
  cookieStore.set("token", result.token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "lax",
    path: "/",
    maxAge: 60 * 60,
  });

  return NextResponse.json({ ok: true });
}

このAPIは、session-storelogin にメールアドレスとパスワードを渡し、成功すれば新しいトークンを httpOnly Cookie としてレスポンスに載せます。Cookie属性の意味は「Cookieの属性」で整理したとおりで、httpOnly: true でJavaScriptから触れなくし、sameSite: "lax" で他サイトからのPOSTには送らないようにし、secure は本番環境(HTTPS)でのみ有効にしています。

次に、現在ログイン中のユーザを返すAPI(/api/me)を作ります。エクスプローラーで app/api フォルダを右クリックして「新しいフォルダー」で me を作り、その中に route.ts を作ります。

frontend-login-demo/
├── app/
│   ├── api/
│   │   ├── login/
│   │   │   └── route.ts
│   │   └── me/           ← このフォルダを作成
│   │       └── route.ts  ← このファイルを作成
│   └── ...
└── ...

作成した app/api/me/route.ts に以下の内容を記述して保存します。

import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { getSession } from "@/app/lib/session-store";

export async function GET() {
  const cookieStore = await cookies();
  const token = cookieStore.get("token")?.value;

  if (!token) {
    return NextResponse.json({ error: "unauthenticated" }, { status: 401 });
  }

  const user = getSession(token);
  if (!user) {
    return NextResponse.json({ error: "invalid session" }, { status: 401 });
  }

  return NextResponse.json({ user });
}

Cookieから token を取り出し、session-store の対応表にあれば { user: { id, email, role } } を返し、なければ 401 を返します。フロント側では、この 401 を「未ログイン」と判定して扱います。

最後にログアウトAPI(/api/logout)を作ります。エクスプローラーで app/api フォルダを右クリックして「新しいフォルダー」で logout を作り、その中に route.ts を作ります。

frontend-login-demo/
├── app/
│   ├── api/
│   │   ├── login/
│   │   │   └── route.ts
│   │   ├── logout/       ← このフォルダを作成
│   │   │   └── route.ts  ← このファイルを作成
│   │   └── me/
│   │       └── route.ts
│   └── ...
└── ...

作成した app/api/logout/route.ts に以下の内容を記述して保存します。

import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { destroySession } from "@/app/lib/session-store";

export async function POST() {
  const cookieStore = await cookies();
  const token = cookieStore.get("token")?.value;
  if (token) {
    destroySession(token);
  }
  cookieStore.delete("token");
  return NextResponse.json({ ok: true });
}

Cookieに入っているトークンを対応表から消し、続けてブラウザ側のCookieも削除します。これで完全にログアウト状態になります。

3つのAPIが揃ったので、フロントを繋ぐ前に curl で動作を確認しておきます。Visual Studio Codeで新しいターミナルを開き、まずログインを試します。

curl -i -X POST http://localhost:3000/api/login \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@example.com","password":"password123"}'

以下のような実行結果が表示されます。

HTTP/1.1 200 OK
Set-Cookie: token=xxxx-xxxx-xxxx-xxxx; Path=/; Expires=...; HttpOnly; SameSite=lax
Content-Type: application/json

{"ok":true}

レスポンスヘッダに Set-Cookie が付き、その属性に HttpOnlySameSite=lax が含まれていれば、ログインAPIが正しく動いています。{"ok":true} が返っていればログイン処理も成功しています。

4. ログイン画面とログイン処理

API側の準備ができたので、ここからフロントエンド側の実装に入ります。ログイン画面を作り、フォームの送信・成功時のダッシュボードへのリダイレクト・失敗時のエラー表示までを1つのコンポーネントにまとめます。

4.1 ログイン画面の実装

フォームは入力状態の管理・送信中フラグ・エラー表示などクライアント側の状態を持つため、Client Component として実装します。

ログインページの作成

エクスプローラーで app フォルダを右クリックして「新しいフォルダー」で login を作り、その中に page.tsx を作ります。

frontend-login-demo/
├── app/
│   ├── api/
│   ├── lib/
│   ├── login/               ← このフォルダを作成
│   │   └── page.tsx         ← このファイルを作成
│   └── ...
└── ...

作成した app/login/page.tsx に以下の内容を記述して保存します。

"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";

export default function LoginPage() {
  const router = useRouter();
  const [email, setEmail] = useState("alice@example.com");
  const [password, setPassword] = useState("password123");
  const [error, setError] = useState<string | null>(null);
  const [submitting, setSubmitting] = useState(false);

  const handleSubmit = async (event: React.FormEvent) => {
    event.preventDefault();
    setSubmitting(true);
    setError(null);

    const res = await fetch("/api/login", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ email, password }),
    });

    if (!res.ok) {
      const data = await res.json();
      setError(data.error ?? "ログインに失敗しました");
      setSubmitting(false);
      return;
    }

    router.push("/dashboard");
  };

  return (
    <main className="mx-auto max-w-md p-8">
      <h1 className="text-2xl font-bold mb-6">ログイン</h1>
      <form onSubmit={handleSubmit} className="space-y-4">
        <div>
          <label className="block text-sm mb-1">メールアドレス</label>
          <input
            type="email"
            value={email}
            onChange={(e) => setEmail(e.target.value)}
            className="w-full border rounded px-3 py-2"
            required
          />
        </div>
        <div>
          <label className="block text-sm mb-1">パスワード</label>
          <input
            type="password"
            value={password}
            onChange={(e) => setPassword(e.target.value)}
            className="w-full border rounded px-3 py-2"
            required
          />
        </div>
        {error && <p className="text-red-600 text-sm">{error}</p>}
        <button
          type="submit"
          disabled={submitting}
          className="w-full bg-black text-white rounded py-2 disabled:opacity-50"
        >
          {submitting ? "ログイン中..." : "ログイン"}
        </button>
      </form>
    </main>
  );
}

コードの解説

コードを解説します。

"use client";

このファイルをClient Componentとして扱う宣言です。フォームの入力状態やクリックイベントを扱うためには、ブラウザ側で実行されるコンポーネントである必要があります。

const [email, setEmail] = useState("alice@example.com");
const [password, setPassword] = useState("password123");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);

フォームの入力値・エラーメッセージ・送信中フラグを、Reactの状態として管理します。emailpassword の初期値には、認証APIでハードコードした alice@example.com / password123 を入れてあり、フォームを開いてすぐに「ログイン」ボタンを押せば動作確認できるようにしてあります。submitting は連打防止と送信中の表示切り替えに使います。

const res = await fetch("/api/login", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ email, password }),
});

作成した認証APIに、入力されたメールアドレス・パスワードをPOSTします。Next.jsのRoute Handlerと同じオリジン(localhost:3000)へのリクエストのため、レスポンスの Set-Cookie は自動的にブラウザに保存されます。

if (!res.ok) {
  const data = await res.json();
  setError(data.error ?? "ログインに失敗しました");
  setSubmitting(false);
  return;
}

router.push("/dashboard");

res.okfalse401)なら、APIから返ってきたエラーメッセージを画面に表示します。成功時は useRouter().push("/dashboard") でダッシュボード画面に遷移します。

動作確認

ログイン画面と認証APIが連携して動くことを、ここで確認します。開発サーバを npm run dev で起動したまま、ブラウザで http://localhost:3000/login を開きます。

「ログイン」の見出し・メールアドレス欄・パスワード欄・「ログイン」ボタンからなる、以下のようなフォームが表示されれば、ログイン画面のレンダリングは動いています。

初期値のまま(alice@example.com / password123)で「ログイン」ボタンを押します。/dashboard に遷移し、以下のように「404」と「This page could not be found.」が表示されれば、フォーム送信 → 認証APIへのPOST → 成功時のリダイレクトまでが動いています。/dashboard はまだ作っていないので、この時点で404になるのが正しい挙動です。

続けて、Cookieがブラウザに正しく保存されているかを確認します。ブラウザのDevTools(macOSは Cmd + Option + I、Windowsは F12)を開き、上部タブから「Application」を選択します。左のサイドバーの「Storage」セクションから「Cookies」→「http://localhost:3000」をクリックし、以下のように token という名前のCookieが表示されていて HttpOnly 列(列幅が狭い場合は Ht... と省略表示されます)にチェックが入っていれば、認証APIから発行されたCookieがブラウザ側でも正しく受け取れています。

最後に、失敗時のエラー表示を確認します。ブラウザで http://localhost:3000/login に戻り、パスワードを wrongpass などの誤った値に書き換えて「ログイン」ボタンを押します。以下のようにフォームの下に赤字で「メールアドレスまたはパスワードが正しくありません」と表示されれば、エラーハンドリングも動いています。

⚠️ ブラウザ拡張機能に起因するエラーが出た場合
動作確認中に、以下のようなエラーがブラウザの開発者コンソールに表示されることがあります。

- A tree hydrated but some attributes of the server rendered HTML didn't match the client properties. (差分に data-google-analytics-opt-outdata-redeviation-bs-uid など、見覚えのない属性が含まれるハイドレーションエラー)
- Uncaught TypeError: Failed to execute 'measure' on 'Performance': 'ページ名' cannot have a negative time stamp. (Next.jsのパフォーマンス計測APIに拡張機能の処理タイミングが干渉して起きるエラー)

いずれも、ブラウザ拡張機能が <html> 要素に独自の属性を差し込んだり、Next.jsの内部処理に割り込んだりすることが原因で、ハンズオンで書いたコード側の問題ではありません。

回避するには、シークレットウィンドウ(macOSは Cmd + Shift + N、Windowsは Ctrl + Shift + N)で http://localhost:3000 を開くと、拡張機能が読み込まれない状態で動作確認できます。

5. ログイン後の画面と状態管理

ログイン画面ができたので、次はログイン後に表示するダッシュボード画面を作ります。現在ログインしているユーザ情報をサーバから取り出して表示するところから始めて、ロールに応じた管理メニューの出し分けを加え、最後にログアウトの導線を追加します。

5.1 ダッシュボードページの作成

ダッシュボードは「ログイン中のユーザしか見られない」保護ページです。ページを開いた時点で「Cookieを見て、未認証なら即座にログイン画面に飛ばす」処理が必要です。この判定はサーバ側(=ページ描画前)にやらないと、未認証ユーザに一瞬でも中身を見せてしまいます。そのため、Server Component として実装します。

未認証チェックの書き方

Server ComponentからCookieを読んで未認証ならリダイレクトする書き方は、以下のとおりです。redirect() は例外を投げる関数で、呼ばれた時点で以降のJSXレンダリングは実行されません。

import { cookies } from "next/headers";
import { redirect } from "next/navigation";

export default async function ページコンポーネント() {
  const cookieStore = await cookies();
  const token = cookieStore.get("Cookie名")?.value;
  const user = token ? 対応表で引く関数(token) : null;
  if (!user) {
    redirect("リダイレクト先パス");
  }

  // 認証済みユーザ向けのJSXを返す
}

保護ページの作成

以下は、これを使って /dashboard を保護するServer Componentの実装です。エクスプローラーで app フォルダを右クリックして「新しいフォルダー」で dashboard を作り、その中に page.tsx を作ります。

frontend-login-demo/
├── app/
│   ├── api/
│   ├── dashboard/         ← このフォルダを作成
│   │   └── page.tsx       ← このファイルを作成
│   ├── lib/
│   ├── login/
│   │   └── page.tsx
│   └── ...
└── ...

作成した app/dashboard/page.tsx に以下の内容を記述して保存します。

import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getSession } from "@/app/lib/session-store";

export default async function DashboardPage() {
  const cookieStore = await cookies();
  const token = cookieStore.get("token")?.value;

  const user = token ? getSession(token) : null;
  if (!user) {
    redirect("/login");
  }

  return (
    <main className="mx-auto max-w-md p-8">
      <h1 className="text-2xl font-bold mb-4">ダッシュボード</h1>
      <p className="mb-2">
        ようこそ、<strong>{user.email}</strong> さん
      </p>
      <p className="text-sm text-gray-600 mb-6">
        ロール: {user.role}
      </p>
    </main>
  );
}

コードの解説

コードを解説します。

const cookieStore = await cookies();
const token = cookieStore.get("token")?.value;

const user = token ? getSession(token) : null;
if (!user) {
  redirect("/login");
}

Server Componentなので、cookies()session-store の関数を直接呼び出せます。Cookieが無い・トークンが対応表にない場合は、Next.jsの redirect("/login") でログイン画面に遷移します。redirect は例外を投げる仕組みで、以降のJSXは描画されません。そのため、この行より下では user は必ず有効な値であるとTypeScriptも判断してくれます。

return (
  <main className="mx-auto max-w-md p-8">
    <h1 className="text-2xl font-bold mb-4">ダッシュボード</h1>
    <p className="mb-2">
      ようこそ、<strong>{user.email}</strong> さん
    </p>
    <p className="text-sm text-gray-600 mb-6">
      ロール: {user.role}
    </p>
  </main>
);

認証済みユーザ向けの中身です。認証APIでCookieに詰めたトークンから引き当てたユーザ情報が、Server Componentから読み取れていることを画面で確認できます。

動作確認

まず、未認証時の挙動を確認します。開発者ツールの Application → Cookies から token を削除してから、http://localhost:3000/dashboard にアクセスします。ログインしていない状態なので /login にリダイレクトされれば、未認証チェックは動いています。

続けて、ログインからダッシュボード表示までの一連の流れを確認します。フォームに初期値(alice@example.com / password123)のまま「ログイン」を押すと /dashboard に遷移し、以下のように「ダッシュボード」の見出しと「ようこそ、alice@example.com さん」「ロール: user」が表示されれば、認証フロー全体が動いています。

5.2 ロール別の画面出し分け

ダッシュボードにログインできましたが、現状は一般ユーザ(user)と管理者(admin)で見える内容が同じです。実運用では、管理者だけが見られる管理メニューを画面に出し分けることが多いため、その典型的なパターンを実装します。

条件付きレンダリングの書き方

Reactでは、JSXの中で {条件 && <要素/>} と書くと、条件が真のときだけ要素が描画されます。ダッシュボードに管理メニューのブロックを追加し、user.role === "admin" のときだけ表示します。書き方は以下のとおりです。

{条件 && (
  <セクション>
    管理者だけが見られる内容
  </セクション>
)}

管理メニューの追加

app/dashboard/page.tsx を以下の内容に置き換えます。

import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getSession } from "@/app/lib/session-store";

export default async function DashboardPage() {
  const cookieStore = await cookies();
  const token = cookieStore.get("token")?.value;

  const user = token ? getSession(token) : null;
  if (!user) {
    redirect("/login");
  }

  return (
    <main className="mx-auto max-w-md p-8">
      <h1 className="text-2xl font-bold mb-4">ダッシュボード</h1>
      <p className="mb-2">
        ようこそ、<strong>{user.email}</strong> さん
      </p>
      <p className="text-sm text-gray-600 mb-6">
        ロール: {user.role}
      </p>

      {user.role === "admin" && (
        <section className="mt-8 p-4 border rounded">
          <h2 className="text-lg font-bold mb-2">管理メニュー</h2>
          <ul className="text-sm space-y-1 list-disc pl-5">
            <li>ユーザ管理</li>
            <li>システム設定</li>
          </ul>
        </section>
      )}
    </main>
  );
}

追加した部分を解説します。

{user.role === "admin" && (
  <section className="mt-8 p-4 border rounded">
    <h2 className="text-lg font-bold mb-2">管理メニュー</h2>
    <ul className="text-sm space-y-1 list-disc pl-5">
      <li>ユーザ管理</li>
      <li>システム設定</li>
    </ul>
  </section>
)}

{条件 && <要素/>} は、条件が真なら <要素/> を描画し、偽なら何も描画しない書き方です。ここでは user.role === "admin" が真のときだけ「管理メニュー」の <section> が現れます。認可判定をこの1行で表現できるので、ロールごとの表示切り替えは実質この形が基本になります。

動作確認

まず、管理者ロールで表示を確認します。開発者ツールの Application → Cookies から token を削除し、管理者アカウント(admin@example.com / adminpass)でログインし直します。以下のように、「ようこそ、admin@example.com さん」「ロール: admin」の下に「管理メニュー」(ユーザ管理 / システム設定)の枠が表示されれば、管理者向けの出し分けは動いています。

続けて、一般ユーザで再度ログインし直します(同じ手順で token Cookieを削除してから、alice@example.com / password123 でログイン)。以下のように「ようこそ、alice@example.com さん」「ロール: user」だけが表示され、「管理メニュー」の枠が消えていれば、条件付きレンダリングによる出し分けが正しく動いています。

📝 フロント側の出し分けの位置付け
「管理メニュー」を非表示にするだけでは、悪意のあるユーザがブラウザ側のJavaScriptを書き換えて表示させる可能性があります。管理者しか実行できない操作を提供する場合は、必ずサーバ側でもロールを再チェックしてください。フロントの出し分けは「ユーザ体験を整えるためのもの」であり、認可の最終判断はサーバ側で行うのが原則です。

6. ログアウトの実装

ログインができても、Cookieを消す手段(=ログアウト)を用意しないと、ブラウザを閉じるまでログイン状態が残り続けます。ユーザが自分の意思でログアウトできる導線を追加します。

6.1 ログアウトボタンの作成

ボタンはクリック時にAPIを呼び出してルーティングを切り替えるため、Client Componentで用意します。エクスプローラーで app/dashboard フォルダを右クリックし「新しいファイル」から logout-button.tsx を作成します。

frontend-login-demo/
├── app/
│   ├── dashboard/
│   │   ├── logout-button.tsx   ← このファイルを作成
│   │   └── page.tsx
│   └── ...
└── ...

作成した app/dashboard/logout-button.tsx に以下の内容を記述して保存します。

"use client";

import { useRouter } from "next/navigation";
import { useState } from "react";

export function LogoutButton() {
  const router = useRouter();
  const [submitting, setSubmitting] = useState(false);

  const handleClick = async () => {
    setSubmitting(true);
    await fetch("/api/logout", { method: "POST" });
    router.push("/login");
    router.refresh();
  };

  return (
    <button
      onClick={handleClick}
      disabled={submitting}
      className="mt-6 text-sm text-red-600 underline disabled:opacity-50"
    >
      {submitting ? "ログアウト中..." : "ログアウト"}
    </button>
  );
}

コードを解説します。

await fetch("/api/logout", { method: "POST" });
router.push("/login");
router.refresh();

ログアウトAPIを呼び出したあと、ログイン画面に遷移します。router.refresh() を呼ぶことで、Server Componentのキャッシュがクリアされ、次回 /dashboard に戻ってきたときに最新のCookie(消えている状態)を再評価してくれます。

このボタンはこの時点ではダッシュボードから呼び出されていないため、動作確認は次の「ダッシュボードへの組み込み」で表示までまとめて行います。

6.2 ダッシュボードへの組み込み

続いて、このボタンを dashboard/page.tsx に組み込みます。app/dashboard/page.tsx を以下の内容に置き換えます。

import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { getSession } from "@/app/lib/session-store";
import { LogoutButton } from "./logout-button";

export default async function DashboardPage() {
  const cookieStore = await cookies();
  const token = cookieStore.get("token")?.value;

  const user = token ? getSession(token) : null;
  if (!user) {
    redirect("/login");
  }

  return (
    <main className="mx-auto max-w-md p-8">
      <h1 className="text-2xl font-bold mb-4">ダッシュボード</h1>
      <p className="mb-2">
        ようこそ、<strong>{user.email}</strong> さん
      </p>
      <p className="text-sm text-gray-600 mb-6">
        ロール: {user.role}
      </p>

      {user.role === "admin" && (
        <section className="mt-8 p-4 border rounded">
          <h2 className="text-lg font-bold mb-2">管理メニュー</h2>
          <ul className="text-sm space-y-1 list-disc pl-5">
            <li>ユーザ管理</li>
            <li>システム設定</li>
          </ul>
        </section>
      )}

      <LogoutButton />
    </main>
  );
}

追加した部分を解説します。

import { LogoutButton } from "./logout-button";

いま作成した LogoutButton コンポーネントを読み込みます。./logout-button は同じフォルダ(app/dashboard/)にある logout-button.tsx を指しています。

<LogoutButton />

JSXの末尾に LogoutButton を配置します。Server Componentである DashboardPage の内部にClient Componentである LogoutButton を差し込む形になりますが、Next.jsが「ここから先はブラウザで動く」という境界をビルド時に自動で扱ってくれるため、書き手側で特別な指定は必要ありません。

LogoutButton を末尾に追加しただけで、認証ロジック側は変わりません。「Server Componentで認証チェック」「Client Componentでユーザ操作」というNext.jsらしい役割分担ができています。

組み込みができたので、ここで表示とログアウトの動作を確認します。ブラウザで /dashboard を開くと、以下のようにダッシュボードの下部に赤字の「ログアウト」ボタンが表示されます。

「ログアウト」を押すと、以下のようにログイン画面(/login)に遷移します。あわせて、開発者ツールの Application → Cookies から token Cookieが消えていることを確認できれば、ログアウト処理も動いています。

💡 ポイント
本章では、認証APIをNext.jsのRoute Handlersで用意しましたが、実運用ではJWT発行・検証を担うバックエンドサーバが別途あり、フロントはそのAPIに対して同じようなCookieベースのフローで繋ぐことが多くなります。Cookie経由でトークンをやり取りする形は共通なので、本章で身につけた「フロント側での付き合い方」はそのまま応用できます。

7. まとめ

この章では、Next.jsでのログイン機能について概要を整理しつつ、実際にログインからログアウトまでの認証フローを体験しました。

  • 認証(誰であるか)と認可(何ができるか)の違いを整理できる
  • ログイン用のトークンはローカルストレージではなくhttpOnly Cookieに持たせるのが安全である
  • Cookieがドメインごとに保存され、リクエストヘッダに自動で載る仕組みを理解できる
  • Cookieの HttpOnlySecureSameSite 属性がそれぞれ何を防ぐかを整理できる
  • Next.jsのRoute Handlersで動作確認用の認証API(ログイン/ログアウト/現在のユーザ取得)を用意できる
  • ログインフォームから /api/login にPOSTしてhttpOnly Cookieを受け取り、/dashboard に遷移する導線を実装できる
  • Server ComponentでCookieを検証し、未認証なら /login にリダイレクトする保護ページを実装できる
  • Reactの条件付きレンダリングで、ロールに応じた管理メニューの出し分けを実装できる
  • ログアウトAPIとボタンにより、Cookieを削除してログイン状態を解除できる

次の章では、Next.js App RouterとTypeScriptを組み合わせて、ログイン機能付きノートアプリケーションを構築する流れをハンズオン形式で体験します。

この教材は役に立ちましたか?

いいねをたくさんいただけると、制作者の励みになり、より多くのセクションが作れるようになります。

感想を一言(任意)

いただいたコメントは次の制作のヒントになります。ぜひお気軽にご投稿ください。

このコメントは他の受講生には公開されません。DevOps Camp運営が、教材改善のために確認します。

0 / 2000