Next.jsで認証
フロントエンドでの認証処理を実装します。ログインフォーム、トークン管理、認証状態に応じた表示切り替えを行います。
認証Contextの作成
contexts/AuthContext.tsx
"use client";
import { createContext, useContext, useState, useEffect, ReactNode } from "react";
type User = {
id: number;
email: string;
name: string;
};
type AuthContextType = {
user: User | null;
token: string | null;
login: (email: string, password: string) => Promise<void>;
logout: () => void;
isLoading: boolean;
};
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);
// 初期化時にlocalStorageからトークンを復元
useEffect(() => {
const storedToken = localStorage.getItem("token");
if (storedToken) {
setToken(storedToken);
fetchUser(storedToken);
} else {
setIsLoading(false);
}
}, []);
// ユーザー情報取得
const fetchUser = async (accessToken: string) => {
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/me`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (res.ok) {
const userData = await res.json();
setUser(userData);
} else {
// トークン無効
localStorage.removeItem("token");
setToken(null);
}
} catch (error) {
console.error("Failed to fetch user:", error);
} finally {
setIsLoading(false);
}
};
// ログイン
const login = async (email: string, password: string) => {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ username: email, password }),
});
if (!res.ok) {
throw new Error("Login failed");
}
const data = await res.json();
localStorage.setItem("token", data.access_token);
setToken(data.access_token);
await fetchUser(data.access_token);
};
// ログアウト
const logout = () => {
localStorage.removeItem("token");
setToken(null);
setUser(null);
};
return (
<AuthContext.Provider value={{ user, token, login, logout, isLoading }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}
プロバイダー設定
app/layout.tsx
import { AuthProvider } from "@/contexts/AuthContext";
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="ja">
<body>
<AuthProvider>
{children}
</AuthProvider>
</body>
</html>
);
}
ログインフォーム
app/login/page.tsx
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/contexts/AuthContext";
import Link from "next/link";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);
const { login } = useAuth();
const router = useRouter();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setIsLoading(true);
try {
await login(email, password);
router.push("/");
} catch (err) {
setError("メールアドレスまたはパスワードが正しくありません");
} finally {
setIsLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full bg-white rounded-lg shadow p-8">
<h1 className="text-2xl font-bold text-center mb-6">ログイン</h1>
{error && (
<div className="bg-red-100 text-red-700 p-3 rounded mb-4">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-bold 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 font-bold 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>
<button
type="submit"
disabled={isLoading}
className="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700 disabled:opacity-50"
>
{isLoading ? "ログイン中..." : "ログイン"}
</button>
</form>
<p className="text-center mt-4 text-sm text-gray-600">
アカウントをお持ちでない方は
<Link href="/register" className="text-blue-600 hover:underline">
新規登録
</Link>
</p>
</div>
</div>
);
}
ヘッダーでの認証表示
components/Header.tsx
"use client";
import Link from "next/link";
import { useAuth } from "@/contexts/AuthContext";
export function Header() {
const { user, logout, isLoading } = useAuth();
return (
<header className="bg-white shadow">
<nav className="container mx-auto px-6 py-4 flex justify-between items-center">
<Link href="/" className="text-xl font-bold">
Blog App
</Link>
<div className="flex items-center gap-4">
{isLoading ? (
<span>読み込み中...</span>
) : user ? (
<>
<span className="text-gray-600">{user.name}</span>
<Link
href="/posts/new"
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
記事を書く
</Link>
<button
onClick={logout}
className="text-gray-600 hover:text-gray-900"
>
ログアウト
</button>
</>
) : (
<>
<Link href="/login" className="text-gray-600 hover:text-gray-900">
ログイン
</Link>
<Link
href="/register"
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700"
>
新規登録
</Link>
</>
)}
</div>
</nav>
</header>
);
}
認証必須ページの保護
components/RequireAuth.tsx
"use client";
import { useAuth } from "@/contexts/AuthContext";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export function RequireAuth({ children }: { children: React.ReactNode }) {
const { user, isLoading } = useAuth();
const router = useRouter();
useEffect(() => {
if (!isLoading && !user) {
router.push("/login");
}
}, [user, isLoading, router]);
if (isLoading) {
return <div className="text-center py-8">読み込み中...</div>;
}
if (!user) {
return null;
}
return <>{children}</>;
}
使用例: app/posts/new/page.tsx
"use client";
import { RequireAuth } from "@/components/RequireAuth";
import { PostForm } from "@/components/PostForm";
export default function NewPostPage() {
return (
<RequireAuth>
<div className="container mx-auto px-6 py-8">
<h1 className="text-2xl font-bold mb-6">新規記事作成</h1>
<PostForm />
</div>
</RequireAuth>
);
}
認証付きAPI呼び出し
lib/api.ts
const API_URL = process.env.NEXT_PUBLIC_API_URL;
export async function fetchWithAuth(
endpoint: string,
options: RequestInit = {}
) {
const token = localStorage.getItem("token");
const headers: HeadersInit = {
"Content-Type": "application/json",
...options.headers,
};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const res = await fetch(`${API_URL}${endpoint}`, {
...options,
headers,
});
if (res.status === 401) {
// トークン期限切れ
localStorage.removeItem("token");
window.location.href = "/login";
throw new Error("Unauthorized");
}
return res;
}
// 使用例
export async function createPost(title: string, content: string) {
const res = await fetchWithAuth("/posts", {
method: "POST",
body: JSON.stringify({ title, content }),
});
if (!res.ok) {
throw new Error("Failed to create post");
}
return res.json();
}
まとめ
- ✓ AuthContextでアプリ全体の認証状態を管理
- ✓ localStorageにトークンを保存(ページリロード対応)
- ✓ RequireAuthコンポーネントで認証必須ページを保護
- ✓ API呼び出し時にAuthorizationヘッダーを付与