🫑

Next.jsのMiddlewareについて学んでみる

2024/12/06に公開

What Middleware?

https://nextjs.org/docs/app/building-your-application/routing/middleware

公式の解説を翻訳してみた👇

ミドルウェア

ミドルウェアを使用すると、リクエストが完了する前にコードを実行できます。その後、受信したリクエストに基づいて、リクエスト ヘッダーまたはレスポンス ヘッダーを書き換え、リダイレクト、変更したり、直接応答したりして、レスポンスを変更できます。

ミドルウェアは、キャッシュされたコンテンツとルートが一致する前に実行されます。詳細については、「一致するパス」を参照してください。

ユースケース

アプリケーションにミドルウェアを統合すると、パフォーマンス、セキュリティ、ユーザー エクスペリエンスが大幅に向上します。ミドルウェアが特に効果的な一般的なシナリオは次のとおりです。

認証と承認: 特定のページまたは API ルートへのアクセスを許可する前に、ユーザーの ID を確認し、セッション クッキーを確認します。
サーバー側リダイレクト: 特定の条件 (ロケール、ユーザー ロールなど) に基づいて、サーバー レベルでユーザーをリダイレクトします。
パスの書き換え: リクエストのプロパティに基づいて API ルートまたはページへのパスを動的に書き換えることで、A/B テスト、機能のロールアウト、またはレガシー パスをサポートします。
ボット検出: ボット トラフィックを検出してブロックすることでリソースを保護します。
ログ記録と分析: ページまたは API で処理する前に、リクエスト データをキャプチャして分析し、洞察を得ます。
機能フラグ設定: シームレスな機能のロールアウトやテストのために、機能を動的に有効または無効にします。
ミドルウェアが最適なアプローチではない可能性がある状況を認識することも同様に重要です。注意すべきシナリオをいくつか示します。

複雑なデータの取得と操作: ミドルウェアは直接的なデータの取得や操作用に設計されていないため、代わりにルート ハンドラーまたはサーバー側ユーティリティ内で実行する必要があります。
負荷の高い計算タスク: ミドルウェアは軽量で応答が速い必要があります。そうでないと、ページの読み込みに遅延が生じる可能性があります。負荷の高い計算タスクや長時間実行されるプロセスは、専用のルート ハンドラー内で実行する必要があります。
広範なセッション管理: ミドルウェアは基本的なセッション タスクを管理できますが、広範なセッション管理は専用の認証サービスまたはルート ハンドラー内で管理する必要があります。
直接データベース操作: ミドルウェア内で直接データベース操作を実行することは推奨されません。データベースのやり取りは、ルート ハンドラーまたはサーバー側ユーティリティ内で実行する必要があります。

https://youtu.be/KrEksZdB9Pw

Next.js Middleware Authentication Demo

このプロジェクトは、Next.jsのミドルウェア機能を使用した認証システムのデモンストレーションです。

機能概要

  • ミドルウェアを使用した認証制御
  • 保護されたルート(/blog)へのアクセス制御
  • クッキーベースの認証状態管理
  • ログイン/ログアウト機能

セットアップ

必要なパッケージをインストールします:

bun add js-cookie react-hot-toast @heroicons/react
bun add -d @types/js-cookie

ミドルウェアの実装について

このデモでは、middleware.tsを使用して以下の認証制御を実装しています:

1. 認証チェック

  • すべての保護されたルート(/blog)へのアクセスは認証が必要
  • 未認証ユーザーは自動的にログインページにリダイレクト
  • 認証済みユーザーがログインページにアクセスした場合は、ブログページにリダイレクト

2. 実装の詳細

// middleware.tsの主要な機能
- クッキーから認証トークンを確認
- パスに基づいた条件分岐
- 適切なリダイレクト処理

3. ミドルウェアの設定

export const config = {
  matcher: ['/blog/:path*', '/login']
}

このmatcher設定により、ミドルウェアは以下のパスでのみ実行されます:

  • /blogとその配下のすべてのパス
  • /loginページ

認証フロー

  1. 未認証ユーザーが保護されたページ(/blog)にアクセス → ログインページへリダイレクト
  2. ログイン成功時 → auth-tokenクッキーが設定され、ブログページへリダイレクト
  3. 認証済みユーザーがログインページにアクセス → ブログページへ自動リダイレクト
  4. ログアウト時 → クッキーを削除し、ログインページへリダイレクト

テスト用アカウント

デモ用のログイン情報:

  • メールアドレス: hoge@co.jp
  • 認証トークン: 10分で失効

技術スタック

  • Next.js (App Router)
  • TypeScript
  • js-cookie (クッキー管理)
  • react-hot-toast (通知UI)
  • Tailwind CSS (スタイリング)

デモアプリのソースコードはこちらになります。

ログイン後のblog/を作成

'use client'

import { useRouter } from 'next/navigation'
import Cookies from 'js-cookie'
import toast from 'react-hot-toast'

export default function Blog() {
  const router = useRouter()

  const handleLogout = () => {
    Cookies.remove('auth-token')
    toast.success('ログアウトしました')
    router.push('/login')
  }

  return (
    <div className="min-h-screen bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
      <div className="max-w-4xl mx-auto">
        <div className="bg-white shadow-lg rounded-lg p-6">
          <div className="flex justify-between items-center mb-8">
            <h1 className="text-3xl font-bold text-gray-900">ブログページ</h1>
            <button
              onClick={handleLogout}
              className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
            >
              ログアウト
            </button>
          </div>
          <div className="prose max-w-none">
            <p className="text-lg text-gray-700">
              ここは認証が必要なブログページです。ログインしているユーザーのみがアクセスできます。
            </p>
            {/* ここにブログコンテンツを追加できます */}
          </div>
        </div>
      </div>
    </div>
  )
}

ログインページのlogin/のソースコード

'use client'

import { useState } from 'react'
import { useRouter } from 'next/navigation'
import Cookies from 'js-cookie'
import toast from 'react-hot-toast'
import { CheckCircleIcon } from '@heroicons/react/24/solid'

export default function Login() {
  const [email, setEmail] = useState('')
  const [loading, setLoading] = useState(false)
  const router = useRouter()

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    setLoading(true)

    if (email === 'hoge@co.jp') {
      // 10分後に失効するクッキーを設定
      Cookies.set('auth-token', 'dummy-token', { expires: 1/144 })
      
      // ローディング表示を少し見せるため
      await new Promise(resolve => setTimeout(resolve, 1000))
      
      toast.success('ログインしました', {
        icon: <CheckCircleIcon className="w-6 h-6 text-green-500" />,
        duration: 3000
      })
      
      router.push('/blog')
    } else {
      toast.error('メールアドレスが正しくありません')
      setLoading(false)
    }
  }

  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50">
      <div className="max-w-md w-full space-y-8 p-8 bg-white rounded-lg shadow-lg">
        <div>
          <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
            ログイン
          </h2>
        </div>
        <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
          <div>
            <label htmlFor="email" className="block text-sm font-medium text-gray-700">
              メールアドレス
            </label>
            <input
              id="email"
              type="email"
              required
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500"
              placeholder="hoge@co.jp"
            />
          </div>
          <button
            type="submit"
            disabled={loading}
            className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50"
          >
            {loading ? (
              <div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
            ) : (
              'ログイン'
            )}
          </button>
        </form>
      </div>
    </div>
  )
}

最初に表示されるページのコードを修正。クリックしたらログインページへ遷移します。

import Link from "next/link";

export default function Home() {
  return (
    <div>
      <h1>middleware demo</h1>
      <p><Link href="/login">Login</Link></p>
    </div>
  );
}

middleware.tsを作成して認証が通っているかいないかでページ遷移する設定をする。本来であればボタンをクリックしたらページ遷移するコードを書くより特定のページへは遷移できないようにしたり、認証がされていればページ遷移するようにするのが望ましい。

最近だとAuth.jsなるものを使うのも流行っているとか。

import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  const authToken = request.cookies.get('auth-token')
  const isLoginPage = request.nextUrl.pathname === '/login'
  
  // ログインページ以外へのアクセスで認証が必要
  if (!authToken && !isLoginPage) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  // すでにログインしている場合、ログインページにアクセスするとブログページにリダイレクト
  if (authToken && isLoginPage) {
    return NextResponse.redirect(new URL('/blog', request.url))
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/blog/:path*', '/login']
}

まとめ

簡単ではなかったけど、Middlewareの使い方について学んでみました。公式の情報だけだと使い方を理解できなかったので、ダミーのデータを使って認証機能を仮実装してみました。

Discussion