【Next.js】ミドルウェアで認証ガードを実装する|簡単なToDoアプリの作成
Next.jsのミドルウェアを使い、アクセス制限を行う「認証ガード」の実装方法を解説します。ユーザーのログイン状態を確認し、未ログインの場合はログインページへ自動で移動させます。簡単なToDoアプリを題材に、Webサイトの基本的なセキュリティ対策を初心者にも分かりやすく学べます。
開発環境
- OS: Windows10
- Visual Studio Code: 1.73.0
- node: 22.14.0
- react: 19.0.0
- next: 15.3.2
- tailwindcss: 4
- Prisma: 6.8.2
- NextAuth: 5.8.0
- @auth/prisma-adapter: 2.9.1
- @prisma/client: 6.8.2
- bcrypt: 6.0.0
- @types/bcrypt: 5.0.2
サンプルコード
/src/app/page.tsx
/src/app/page.tsx1-'use client' 2+'use client'; 3 4-import { useEffect, useState } from 'react' 5+import { useEffect, useState } from 'react'; 6 7 type Todo = { 8- id: number 9- text: string 10-} 11+ id: number; 12+ text: string; 13+}; 14 15 export default function Home() { 16- const [todos, setTodos] = useState<Todo[]>([]) 17- const [input, setInput] = useState('') 18- const [editId, setEditId] = useState<number | null>(null) 19- const [editText, setEditText] = useState('') 20+ const [todos, setTodos] = useState<Todo[]>([]); 21+ const [input, setInput] = useState(''); 22+ const [editId, setEditId] = useState<number | null>(null); 23+ const [editText, setEditText] = useState(''); 24 25 const fetchTodos = async () => { 26- const res = await fetch('/api/todos') 27- const data = await res.json() 28- setTodos(data) 29- } 30+ const res = await fetch('/api/todos'); 31+ const data = await res.json(); 32+ setTodos(data); 33+ }; 34 35 const handleAdd = async () => { 36- if (!input.trim()) return 37+ if (!input.trim()) return; 38 await fetch('/api/todos', { 39 method: 'POST', 40 headers: { 'Content-Type': 'application/json' }, 41 body: JSON.stringify({ text: input }), 42- }) 43- setInput('') 44- fetchTodos() 45- } 46+ }); 47+ setInput(''); 48+ fetchTodos(); 49+ }; 50 51 const handleDelete = async (id: number) => { 52 await fetch('/api/todos', { 53 method: 'DELETE', 54 headers: { 'Content-Type': 'application/json' }, 55 body: JSON.stringify({ id }), 56- }) 57- fetchTodos() 58- } 59+ }); 60+ fetchTodos(); 61+ }; 62 63 const handleEdit = (todo: Todo) => { 64- setEditId(todo.id) 65- setEditText(todo.text) 66- } 67+ setEditId(todo.id); 68+ setEditText(todo.text); 69+ }; 70 71 const handleUpdate = async () => { 72- if (editId === null || !editText.trim()) return 73+ if (editId === null || !editText.trim()) return; 74 await fetch('/api/todos', { 75 method: 'PATCH', 76 headers: { 'Content-Type': 'application/json' }, 77 body: JSON.stringify({ id: editId, text: editText }), 78- }) 79- setEditId(null) 80- setEditText('') 81- fetchTodos() 82- } 83+ }); 84+ setEditId(null); 85+ setEditText(''); 86+ fetchTodos(); 87+ }; 88 89 useEffect(() => { 90 fetchTodos() 91 </ul> 92 </div> 93 </main> 94- ) 95+ ); 96 } 97
/src/middleware.ts
/src/middleware.ts1+import { getToken } from "next-auth/jwt"; 2+import { NextResponse } from "next/server"; 3+import type { NextRequest } from "next/server"; 4+ 5+// 認証関連ページ(ログイン・登録) 6+const guestOnlyRoutes = [ 7+ '/auth/signin', 8+ '/auth/signup' 9+]; 10+ 11+// 保護されたページ(ログイン必須) 12+const authOnlyRoutes = [ 13+ '/', 14+]; 15+ 16+export async function middleware(req: NextRequest) { 17+ const token = await getToken({ req }); 18+ const { pathname } = req.nextUrl; 19+ 20+ const isAuthPage = guestOnlyRoutes.some( 21+ route => pathname.startsWith(route) 22+ ); 23+ 24+ const isProtectedPage = authOnlyRoutes.some( 25+ route => pathname === route || pathname.startsWith(`${route}/`) 26+ ); 27+ 28+ // 未ログインで保護ページにアクセス 29+ if (isProtectedPage && !token) { 30+ const loginUrl = new URL('/auth/signin', req.url); 31+ return NextResponse.redirect(loginUrl); 32+ } 33+ 34+ // ログイン済みで認証ページにアクセス 35+ if (isAuthPage && token) { 36+ const loginUrl = new URL('/', req.url); 37+ return NextResponse.redirect(loginUrl); 38+ } 39+ 40+ return NextResponse.next(); 41+} 42+ 43+export const config = { 44+ matcher: ['/:path*'], 45+}; 46
コード解説
変更点: TypeScriptコードのフォーマット修正
/src/app/page.tsx1-'use client' 2+'use client'; 3 4-import { useEffect, useState } from 'react' 5+import { useEffect, useState } from 'react'; 6 7 type Todo = { 8- id: number 9- text: string 10-} 11+ id: number; 12+ text: string; 13+};
これはコードの動作自体を変える変更ではありません。各行の末尾にセミコロン(;)を追加し、コードの見た目を整えるためのフォーマット修正です。プログラムの世界では、コードの書き方をチームやプロジェクトで統一することが多く、このような自動整形ツールによって一貫性が保たれます。セミコロンは文の終わりを示す記号ですが、JavaScriptやTypeScriptでは省略可能な場合もあります。今回は、より明確にするために追加されています。
変更点: ミドルウェアで利用するモジュールのインポート
/src/middleware.ts1+import { getToken } from "next-auth/jwt"; 2+import { NextResponse } from "next/server"; 3+import type { NextRequest } from "next/server";
新しく作成されたミドルウェアファイルで、必要な機能を外部ライブラリから読み込んでいます。
getTokenは、next-auth/jwtライブラリの機能で、リクエスト情報からユーザーの認証トークン(ログインしている証拠)を取得するために使います。NextResponseとNextRequestは、Next.jsのサーバー機能(next/server)の一部です。NextRequestはアクセスしてきたリクエストに関する情報(どのページにアクセスしようとしているかなど)を扱い、NextResponseはサーバーからの応答(別のページに移動させる、など)を制御するために使います。
変更点: 認証状態に応じて制御するページのパスを定義
/src/middleware.ts1+// 認証関連ページ(ログイン・登録) 2+const guestOnlyRoutes = [ 3+ '/auth/signin', 4+ '/auth/signup' 5+]; 6+ 7+// 保護されたページ(ログイン必須) 8+const authOnlyRoutes = [ 9+ '/', 10+];
アクセス制限のルールをわかりやすく管理するために、2つの配列を定義しています。
guestOnlyRoutesには、ログインしていないユーザーだけがアクセスできるページのパス(URLの末尾部分)をまとめています。ログイン済みのユーザーがこれらのページにアクセスしようとした場合、トップページなどに移動させる処理を行います。authOnlyRoutesには、ログインしているユーザーだけがアクセスできる、保護されたページのパスをまとめています。ToDoアプリのメインページであるトップページ(/)がこれに該当します。
変更点: ミドルウェア関数の定義と認証トークンの取得
/src/middleware.ts1+export async function middleware(req: NextRequest) { 2+ const token = await getToken({ req }); 3+ const { pathname } = req.nextUrl;
ここからがミドルウェアの本体となる関数の定義です。middlewareという名前で関数をexport(公開)すると、Next.jsが自動的にリクエストのたびにこの関数を実行してくれます。
const token = await getToken({ req });の部分で、現在のリクエスト(req)から認証トークンを取得しています。ユーザーがログインしていればtokenに情報が入り、ログインしていなければnullになります。const { pathname } = req.nextUrl;の部分では、ユーザーがアクセスしようとしているページのパス(例:/や/auth/signin)を取得し、pathnameという変数に保存しています。
変更点: 現在アクセスされているページが保護対象かどうかの判定
/src/middleware.ts1+ const isAuthPage = guestOnlyRoutes.some( 2+ route => pathname.startsWith(route) 3+ ); 4+ 5+ const isProtectedPage = authOnlyRoutes.some( 6+ route => pathname === route || pathname.startsWith(`${route}/`) 7+ );
アクセスされたページが、先ほど定義した2つの配列のどちらかに該当するかを判定しています。
isAuthPageは、アクセスされたパス(pathname)がguestOnlyRoutes(ログイン・登録ページ)のいずれかで始まる場合にtrueになります。isProtectedPageは、アクセスされたパス(pathname)がauthOnlyRoutes(保護ページ)のいずれかと完全に一致するか、そのパスで始まる場合にtrueになります。 これにより、現在のページが「ログインユーザー用」なのか「未ログインユーザー用」なのかをプログラムが判断できるようになります。
変更点: 未ログインユーザーが保護ページにアクセスした場合の処理
/src/middleware.ts1+ // 未ログインで保護ページにアクセス 2+ if (isProtectedPage && !token) { 3+ const loginUrl = new URL('/auth/signin', req.url); 4+ return NextResponse.redirect(loginUrl); 5+ }
認証ガードの最も重要な部分です。if文を使って条件分岐を行っています。
isProtectedPage && !tokenは、「アクセスされたページが保護ページ(例: ToDoリスト)であり、かつ、ユーザーがログインしていない(トークンがない)場合」という条件です。- この条件に一致した場合、
NextResponse.redirect()を使って、ユーザーを強制的にログインページ(/auth/signin)へ移動(リダイレクト)させます。これにより、ログインしていないユーザーはToDoリストを見ることができなくなります。
変更点: ログイン済みユーザーが認証ページにアクセスした場合の処理
/src/middleware.ts1+ // ログイン済みで認証ページにアクセス 2+ if (isAuthPage && token) { 3+ const loginUrl = new URL('/', req.url); 4+ return NextResponse.redirect(loginUrl); 5+ }
こちらも条件分岐によるアクセス制御です。
isAuthPage && tokenは、「アクセスされたページが認証ページ(例: ログインページ)であり、かつ、ユーザーが既にログインしている(トークンがある)場合」という条件です。- この条件に一致した場合、ユーザーをトップページ(
/)へリダイレクトさせます。これにより、既にログインしているユーザーが、再度ログインページや新規登録ページにアクセスしてしまうのを防ぎ、より快適な操作を提供します。
変更点: リダイレクト条件に合致しない場合のデフォルト処理
/src/middleware.ts1+ return NextResponse.next();
これまでのif文のどの条件にも当てはまらなかった場合に、この処理が実行されます。例えば、「ログイン済みのユーザーが保護ページにアクセスした」場合や「未ログインのユーザーが認証ページにアクセスした」場合など、アクセスが許可されるべき状況がこれに該当します。
NextResponse.next()は、「特に何もしないで、ユーザーが要求したページへのアクセスをそのまま許可する」という命令です。これにより、ミドルウェアによるチェックを通過し、目的のページが表示されます。
変更点: ミドルウェアを適用するパスの指定
/src/middleware.ts1+export const config = { 2+ matcher: ['/:path*'], 3+};
最後に、このミドルウェアをどのページのアクセス時に実行するかを設定しています。
configオブジェクトの中のmatcherプロパティにパスのパターンを指定します。['/:path*']という指定は、「サイトの全てのページ(ルートパス/とその配下の全てのパス)」を対象にすることを意味します。これにより、画像ファイルなどを除く、サイト内の全てのページアクセスに対して、このミドルウェアによる認証チェックが実行されるようになります。
おわりに
今回はNext.jsのミドルウェア機能を使い、認証ガードを実装する方法を学びました。新しく作成したmiddleware.tsファイルが、すべてのページアクセスの手前でユーザーのログイン状態をgetTokenによって確認します。そして、未ログインで保護されたページにアクセスした場合はログインページへ移動させるなど、NextResponseを使ってアクセス制御を行いました。この仕組みは、実際のWebアプリケーションで広く使われている基本的なセキュリティ対策ですので、ぜひ覚えておきましょう。