Next.jsのmiddlewareを用いてメンテナンスモードを導入する

Next.js, Vercel ではメンテナンスモードが提供されていないため、自前で用意する必要があります。
Next.js の middleware を使って、メンテナンスを実装してみます。

完成後のコードは以下のリポジトリです。
https://github.com/takumibv/nextjs-maintenance-example

## 達成したいこと
1. Next.js の middleware でメンテナンスモードを導入する
1. 特定のページは、メンテナンスモードでも表示できるようにする
1. 特定のIPアドレスの場合は、メンテナンスモードを除外できるようにする

## 環境
- Node.js 16.8以降
- Next 13.4

## 0. 準備
### Next.jsの導入

```shell
$ npx create-next-app@latest
# 執筆時点で 13.4.4 が最新

# 設定は以下の通り
Would you like to use TypeScript with this project? Yes
Would you like to use ESLint with this project? Yes
Would you like to use Tailwind CSS with this project? Yes
Would you like to use `src/` directory with this project? No
Use App Router (recommended)? Yes
Would you like to customize the default import alias? No
```

- 今回は Next.js 13 の[App Router](https://nextjs.org/docs/app) を選択しますが、 `middleware.ts` の書き方は Next.js 12 以降であれば同じ記述で実現できます。(Next.js 12での動作は未確認)

### 画面の用意
今回は以下の3画面を用意します。
```shell
./
├── app
│   ├── page.tsx
│   ├── layout.tsx
│   ├── dashboard
│   │   └── page.tsx
│   ├── maintenance
│   │   └── page.tsx
│   └── ...
:
```

- `app/page.tsx` (`/`): トップ画面. メンテナンスモード時でも表示できるようにする.
- `app/dashboard/page.tsx` (`/dashboard`): メンテナンスモード時に、メンテナンス画面を表示する画面.
- `app/maintenance/page.tsx` (`/maintenance`): メンテナンス画面.


<details><summary>app/layout.tsxのソースコード</summary><div>

```tsx:app/layout.tsx
import './globals.css'
import { Inter } from 'next/font/google'

const inter = Inter({ subsets: ['latin'] })

export const metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body className={inter.className}>{children}</body>
    </html>
  )
}

```

</div></details>

<details><summary>app/page.tsxのソースコード(補足あり)</summary><div>

```tsx:app/page.tsx
import Image from "next/image";
import Link from "next/link";
import logo from '@/public/next.svg';

export default function Home() {
  return (
    <main className="p-24">
      <Image
        className="relative dark:drop-shadow-[0_0_0.3rem_#ffffff70] dark:invert"
        src={logo}
        alt="Next.js Logo"
        width={180}
        height={37}
        priority
      />

      <Image
        className="relative dark:drop-shadow-[0_0_0.3rem_#ffffff70] dark:invert"
        src="/next.svg"
        alt="この画像はメンテナンスモードの時には表示できない"
        width={180}
        height={37}
        priority
      />

      <p className="mt-6">この画面はメンテナンスモードを適用しない</p>

      <ul className="mt-2">
        <li>
          <Link href="/dashboard" className="underline">
            Dashboard
          </Link>
        </li>
        <li>
          <Link href="/maintenance" className="underline">
            Maintenance
          </Link>
        </li>
      </ul>
    </main>
  );
}

```

#### 画像ファイルについて
`<Image>` を2例載せており、下のパターンはsrcを画像パスを文字列で渡しており、メンテナンスモード時は表示できなくなります。
これは `middleware.ts` で画像リンクがリダイレクト対象になってしまうためです。メンテナンスモード時で画像などのアセットを使用する場合は、 上パターンのように import したファイルをsrcに指定するようにしましょう。
![画像アセットについて.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/75986/aabed2dd-fbee-c456-6c2f-228661d45f35.png)

</div></details>

<details><summary>app/dashboard/page.tsxのソースコード</summary><div>

```tsx:app/dashboard/page.tsx
import Link from "next/link";

export default function Dashboard() {
  return (
    <main className="p-24">
      <h1 className="text-2xl font-bold">Dashboard</h1>

      <div className="mt-2">
        <Link href="/" className="underline">
          Top
        </Link>
      </div>
    </main>
  );
}
```

</div></details>

<details><summary>app/maintenance/page.tsxのソースコード</summary><div>

```tsx:app/maintenance/page.tsx
import Image from "next/image";
import Link from "next/link";
import logo from "@/public/next.svg";

export const metadata = {
  title: "Maintenance",
  robots: "noindex",
};

export default function Maintenance() {
  return (
    <main className="p-24">
      <Image
        className="relative dark:drop-shadow-[0_0_0.3rem_#ffffff70] dark:invert w-20"
        src={logo}
        alt="Next.js Logo"
        width={180}
        height={37}
        priority
      />

      <h1 className="text-2xl font-bold mt-2">Maintenance</h1>

      <div className="mt-2">
        <Link href="/" className="underline">
          Top
        </Link>
      </div>
    </main>
  );
}
```

</div></details>

## 1. Next.js の middleware でメンテナンスモードを導入する
### 方針
1. `.env` ファイルの `NEXT_PUBLIC_MAINTENANCE_MODE` でメンテナンスモードを切り替えるようにする
1. メンテナンスモードの時に、 任意の画面にアクセスした際に `/maintenance` にリダイレクトするようにする

### `.env` ファイルに環境変数を用意する

```.env:.env
NEXT_PUBLIC_APP_HOST=http://localhost:3000

NEXT_PUBLIC_MAINTENANCE_MODE=true
```

- `NEXT_PUBLIC_MAINTENANCE_MODE`: `true` を指定してメンテナンスモードに切り替えます。
- リダイレクト先は絶対パスでしか指定できないため、`NEXT_PUBLIC_APP_HOST` にホスト名を用意します。

### `middleware.ts` ファイルを作成する

`middleware.ts`の最低限のコードです。 メンテナンスモード時に、全ての画面に対して `/maintenance` にリダイレクトするようになります。


```ts:middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

const isMaintenanceMode = Boolean(process.env.NEXT_PUBLIC_MAINTENANCE_MODE);

export function middleware(request: NextRequest) {
  if (!isMaintenanceMode) {
    // メンテナンスモードでない時は、/maintenance を404にする
    if (request.nextUrl.pathname === "/maintenance") {
      request.nextUrl.pathname = "/404";
      return NextResponse.rewrite(request.nextUrl);
    }

    return;
  }

  // メンテナンスモード時の処理
  if (isMaintenanceMode) {
    // 繰り返しリダイレクトを防ぐ
    if (request.nextUrl.pathname === "/maintenance") return;

    // メンテナンス画面へリダイレクト
    return NextResponse.redirect(`${process.env.NEXT_PUBLIC_APP_HOST}/maintenance`);
  }
}

export const config = {
  matcher: [
    // 画面遷移以外のリクエストを除外する
    "/((?!api|_next/static|_next/image|favicon.ico).*)",
  ],
};
```

- `middleware` 関数がリクエストが完了する前に実行されることで、レスポンスを操作することができます。([参考](https://nextjs.org/docs/app/building-your-application/routing/middleware#advanced-middleware-flags))
- `config`変数 で`middleware` 関数を実行するパスを指定できます。初期状態だとアセット系も含めすべてのリクエストに対して実行されるため、画面遷移以外のリクエストを除外します。
- メンテナンスモードでない時に`/maintenance` にアクセスすると、 `request.nextUrl.pathname = "/404"` を指定し、404画面を表示するようにします。
- `NextResponse.redirect(url)` には絶対パスを指定します。([参考](https://nextjs.org/docs/messages/middleware-relative-urls)) 

## 2. 特定のページは、メンテナンスモードでも表示できるようにする

メンテナンスモード時でも、 トップ画面 `app/page.tsx` (`/`) は表示できるように修正します。

### `middleware.ts` を修正する


```diff_tsx:middleware.ts
+ // メンテナンスモードの除外ページ
+ const maintenanceExclusionPaths = ["/"];

const isMaintenanceMode = Boolean(process.env.NEXT_PUBLIC_MAINTENANCE_MODE);

export function middleware(request: NextRequest) {
  if (!isMaintenanceMode) {
    // メンテナンスモードでない時は、/maintenance を404にする
    if (request.nextUrl.pathname === "/maintenance") {
      request.nextUrl.pathname = "/404";
      return NextResponse.rewrite(request.nextUrl);
    }
    return;
  }

+  const { pathname } = request.nextUrl;
+
+  // メンテナンスモードの対象かどうか
+  const isMaintenanceTargetPath = !maintenanceExclusionPaths.some((path) => pathname === path);

-  if (isMaintenanceMode) {
+  if (isMaintenanceMode && isMaintenanceTargetPath) {
    // 繰り返しリダイレクトを防ぐ
    if (request.nextUrl.pathname === "/maintenance") return;

    // メンテナンス画面へリダイレクト
    return NextResponse.redirect(`${process.env.NEXT_PUBLIC_APP_HOST}/maintenance`);
  }
}
```

- `maintenanceExclusionPaths`: メンテナンスモードの除外ページのパスを配列で格納します。
- `request.nextUrl.path` (アクセスしたページのパス) が`maintenanceExclusionPaths`に含まれていれば、リダイレクトを回避するように修正します。

## 3. 特定のIPアドレスの場合は、メンテナンスモードを除外できるようにする

### 方針
- `.env` ファイルの `NEXT_PUBLIC_MAINTENANCE_WHITELIST` にメンテナンスモードを除外するIPアドレスを記載する
- リクエストヘッダーの `x-forwarded-for` ヘッダーからIPを取得し、除外IPアドレスと比較する

### `.env` に変数を追加
```diff:.env
NEXT_PUBLIC_APP_HOST=http://localhost:3000

NEXT_PUBLIC_MAINTENANCE_MODE=true
+ NEXT_PUBLIC_MAINTENANCE_WHITELIST=1.0.0.1,1.0.0.2
```

### `middleware.ts` を修正する

```diff_tsx:middleware.ts
// メンテナンスモードの除外ページ
const maintenanceExclusionPaths = ["/"];

const isMaintenanceMode = Boolean(process.env.NEXT_PUBLIC_MAINTENANCE_MODE);
+ const maintenanceWhiteListIPs = process.env.NEXT_PUBLIC_MAINTENANCE_WHITELIST?.split(",") ?? [];

export function middleware(request: NextRequest) {
  if (!isMaintenanceMode) {
    // メンテナンスモードでない時は、/maintenance を404にする
    if (request.nextUrl.pathname === "/maintenance") {
      request.nextUrl.pathname = "/404";
      return NextResponse.rewrite(request.nextUrl);
    }
    return;
  }

  const { pathname } = request.nextUrl;
+ const ip = getIP(request);

  // メンテナンスモードの対象かどうか
  const isMaintenanceTargetPath = !maintenanceExclusionPaths.some((path) => pathname === path);
+ const isMaintenanceTargetIP = !maintenanceWhiteListIPs.includes(ip);

- if (isMaintenanceMode && isMaintenanceTargetPath) {
+ if (isMaintenanceMode && isMaintenanceTargetPath && isMaintenanceTargetIP) {
    // 繰り返しリダイレクトを防ぐ
    if (request.nextUrl.pathname === "/maintenance") return;

    // メンテナンス画面へリダイレクト
    return NextResponse.redirect(`${process.env.NEXT_PUBLIC_APP_HOST}/maintenance`);
  }
}

+ // IPアドレスを取得する
+ function getIP(request: NextRequest) {
+   // x-forwarded-for ヘッダーからIPを取得する
+   const xff = request.headers.get("x-forwarded-for");
+ 
+   return xff ? (Array.isArray(xff) ? xff[0] : xff.split(",")[0]) : "127.0.0.1";
+ }
```

- `getIP()`関数でIPアドレスを取得します。
- `maintenanceWhiteListIPs` (メンテナンスを除外するIPアドレス配列)に、`getIP()` で取得されるIPアドレスが含まれていれば、リダイレクトを回避するように修正します。



<details><summary>最終的な middleware.ts のソースコード</summary><div>

```ts:middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

// メンテナンスモードの除外ページ
const maintenanceExclusionPaths = ["/"];

const isMaintenanceMode = Boolean(process.env.NEXT_PUBLIC_MAINTENANCE_MODE);
const maintenanceWhiteListIPs = process.env.NEXT_PUBLIC_MAINTENANCE_WHITELIST?.split(",") ?? [];

export function middleware(request: NextRequest) {
  if (!isMaintenanceMode) {
    // メンテナンスモードでない時は、/maintenance を404にする
    if (request.nextUrl.pathname === "/maintenance") {
      request.nextUrl.pathname = "/404";
      return NextResponse.rewrite(request.nextUrl);
    }
    return;
  }

  const { pathname } = request.nextUrl;
  const ip = getIP(request);

  // メンテナンスモードの対象かどうか
  const isMaintenanceTargetPath = !maintenanceExclusionPaths.some((path) => pathname === path);
  const isMaintenanceTargetIP = !maintenanceWhiteListIPs.includes(ip);

  if (isMaintenanceMode && isMaintenanceTargetPath && isMaintenanceTargetIP) {
    // 繰り返しリダイレクトを防ぐ
    if (request.nextUrl.pathname === "/maintenance") return;

    // メンテナンス画面へリダイレクト
    return NextResponse.redirect(`${process.env.NEXT_PUBLIC_APP_HOST}/maintenance`);
  }
}

export const config = {
  matcher: [
    // 画面遷移以外のリクエストを除外する
    "/((?!api|_next/static|_next/image|favicon.ico).*)",
  ],
};

// IPアドレスを取得する
function getIP(request: NextRequest) {
  // x-forwarded-for ヘッダーからIPを取得する
  const xff = request.headers.get("x-forwarded-for");

  return xff ? (Array.isArray(xff) ? xff[0] : xff.split(",")[0]) : "127.0.0.1";
}
```

</div></details>

------

以上、middlewareを用いたメンテナンスモードの導入でした。


## 参考
- [Next.jsのMiddlewareを使ってメンテナンスモードを表示したメモ](https://zenn.dev/ksmzn/articles/c753853eb3533b)
- [Maintenance page usage example | Vercel Examples](https://edge-maintenance-page.vercel.app/)