🤔

【Next.js】App Routerでのリダイレクトを模索した

2023/09/19に公開

Next.jsにおけるリダイレクト方法

公式ドキュメントでは以下の3つのリダイレクト方法を見つけることができました。

  • next.config.jsに書くパターン
  • middlewareに書くパターン
  • redirect APIを使うパターン

それぞれについて簡単に説明しようと思います。

next.config.jsにリダイレクトを書く

これはNext.js13以前から存在し、Version Historyを見ると9.5.0のときにはすでに存在しています。

このパターンの特徴はなんといってもリダイレクト処理を直接next.config.jsに書くことでしょう。

以下のようにredirectsキーを追加し、リダイレクトの設定を記述していきます。

redirects は非同期関数で、source、destination、permanent プロパティのオブジェクトを保持する配列が返されることを期待します(DeepL)

next.config.js
module.exports = {
  async redirects() {
    return [
      {
        source: '/about',
        destination: '/',
        permanent: true,
      },
    ]
  },
}

sourceは受け取るリクエストのパスパターン、destinationにはリダイレクトさせたい先を記述します。
permanentをtrueにするとステータスコードは308となり、リダイレクトがキャッシュに保存されます。falseにするとステータスコードは307となり、リダイレクトはキャッシュされません。

このあたりの仕様に関してはWhy does Next.js use 307 and 308?に載っています。

複雑なパスマッチング

このsourcedestinationはかなり柔軟に指定することができます。

例えば以下のように指定すると/blog/hello-world/blog/a/b/c/d/hello-worldがマッチし、/news/hello-world/news/a/b/c/d/hello-worldにリダイレクトされます。

next.config.js
{
  source: '/blog/:slug*',
  destination: '/news/:slug*',
  permanent: true,
},

そしてパスマッチングに正規表現を使うこともできます。
以下のように指定すると/post/123はマッチしますが、/post/1/post/abcはマッチしません。

next.config.js
{
  source: '/post/:slug(\\d{2,})',
  destination: '/news/:slug',
  permanent: false,
},

Header、Cookieでのマッチング

上記で紹介した複雑なパスマッチングに加え、HeaderやCookieの状態によってもリダイレクトすることができます。
正直next.config.jsへの記述でここまでできるのは驚きました。
今まで出てきたプロパティに加えてhasmissingを用いることができます。

next.config.js
module.exports = {
  async redirects() {
    return [
      {
	source: '/',
	has: [
	  {
	    type: 'header',
	    key: 'x-authorized',
	    value: '(?<authorized>yes|true)',
	  },
	],
	permanent: false,
        destination: '/home?authorized=:authorized',
      },
    ]  
  },
}

上記のコードでは、HeaderにX-Authorizedをkeyとする値がyesまたはtrueのときリダイレクトします。リダイレクト先は/home?authorized=<x-authorizedの値>です。

ここで注目すべきポイントはHeaderにある値をリダイレクトに使用することができるという点です。

さらにhasは配列を受け取るので、複数のパターンを組み合わせてリダイレクトの条件を決めることができます。つまり以下のように指定することもできるということです。

next.config.js
has: [
  {
    type: 'query',
    key: 'page',
    // the page value will not be available in the
    // destination since value is provided and doesn't
    // use a named capture group e.g. (?<page>home)
    value: 'home',
  },
  {
    type: 'cookie',
    key: 'authorized',
    value: 'true',
  },
],

上記の場合、queryパラメータがpage=homeでありCookieにauthorizedがキーで値がtrueのものが含まれているという条件になります。

https://nextjs.org/docs/pages/api-reference/next-config-js/redirects#header-cookie-and-query-matching

middlewareにリダイレクトを書く

middleware自体はNext.jsのv12.0.0からベータ版としてリリースされました。

middlewareについてドキュメントには以下のように書かれています。

ミドルウェアを使うと、リクエストが完了する前にコードを実行することができる。そして、送られてきたリクエストに基づいて、レスポンスを書き換えたり、リダイレクトしたり、リクエストやレスポンスのヘッダーを変更したり、直接レスポンスしたりすることで、レスポンスを変更することができる。
ミドルウェアは、キャッシュされたコンテンツやルートがマッチングされる前に実行される。(DeepL訳)

つまり、middlewareはnext.config.jsに書いたようなリダイレクト処理に加え、他にも様々な処理ができます。しかし、全ての機能を紹介すると非常に長くなってしまうので、ここではリダイレクトに絞っていきたいと思います。

Cookieを用いたリダイレクト

next.config.jsによる設定でのリダイレクト処理と比較しmiddlewareでは以下のことを実現できます。

  • Cookieを取得して使用する
  • Cookieの削除
  • Cookieの追加

上記を全て使った公式のサンプルが以下になります。

middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
 
export function middleware(request: NextRequest) {
  // Assume a "Cookie:nextjs=fast" header to be present on the incoming request
  // Getting cookies from the request using the `RequestCookies` API
  let cookie = request.cookies.get('nextjs')
  console.log(cookie) // => { name: 'nextjs', value: 'fast', Path: '/' }
  const allCookies = request.cookies.getAll()
  console.log(allCookies) // => [{ name: 'nextjs', value: 'fast' }]
 
  request.cookies.has('nextjs') // => true
  request.cookies.delete('nextjs')
  request.cookies.has('nextjs') // => false
 
  // Setting cookies on the response using the `ResponseCookies` API
  const response = NextResponse.next()
  response.cookies.set('vercel', 'fast')
  response.cookies.set({
    name: 'vercel',
    value: 'fast',
    path: '/',
  })
  cookie = response.cookies.get('vercel')
  console.log(cookie) // => { name: 'vercel', value: 'fast', Path: '/' }
  // The outgoing response will have a `Set-Cookie:vercel=fast;path=/test` header.
 
  return response
}

middelwareはasync関数にすることができるので、例えばmiddewareの中でCookieの値を元にデータフェッチをすることもできます。

This function can be marked async if using await inside

直接レスポンスを返せる

さらにv13.1.0からレスポンスを直接返せるようになりました。JSONやHTMLをステータスコード付きで返却することができます。
最低限の認証や、ページをブロックする処理などを書くのにちょうど良いかもしれません。

middleware.ts
if (!isAuthenticated(request)) {
  // Respond with JSON indicating an error message
  return new NextResponse(
    JSON.stringify({ success: false, message: 'authentication failed' }),
    { status: 401, headers: { 'content-type': 'application/json' } }
  )
}

redirect functionを使う

v13.0.0からredirect functionという関数がNext.jsから提供されました。redirectはServer Components, Client Componentsの両方で使えます。
また注目されているServer Actionsでも使用することができます。

redirectはシンプルにリダイレクト処理を提供しているだけなので、たとえば各page.tsxでデータフェッチをしその結果によってリダイレクトするというようにとても柔軟に使えます。

page.tsx
import { redirect } from 'next/navigation'
 
async function fetchTeam(id) {
  const res = await fetch('https://...')
  if (!res.ok) return undefined
  return res.json()
}
 
export default async function Profile({ params }) {
  const team = await fetchTeam(params.id)
  if (!team) {
    redirect('/login')
  }
 
  // ...
}

どれを使うのが良いか

以上、以下の3つのリダイレクト方法を紹介しました。

  1. next.config.jsに書くパターン
  2. middlewareに書くパターン
  3. redirect APIを使うパターン

ではどれを使ってリダイレクトを実装するのが良いでしょうか?現時点の僕の中での答えは2と3の組み合わせです。理由は以下です。

  1. middlewareはnext.config.jsへの記述よりも柔軟に実装できる
  2. middlewareは単体テストを書くことができる
  3. middlewareだけで実現できないリダイレクトをredirecrtを使ってカバーする

現時点でredirectを使う要件は発生していないですが、今後使用を検討していこうと思っています。
ただまだこのあたりは模索中なので、これから試していこうと思います。

middewareを書いてみた

実際にmiddlewareでリダイレクトを実装するとどのようになるかイメージするために、検証のコードを書いてみました。

middleware.ts

middeware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

const sleep = (ms: number): Promise<void> => {
  return new Promise((resolve) => setTimeout(resolve, ms));
};

export async function middleware(request: NextRequest) {
  if (request.nextUrl.pathname.startsWith('/auth/callback')) {
    const requestHeaders = new Headers(request.headers);
    // Headerに値を追加する
    requestHeaders.set('x-for-server-headers', 'server-team');

    const response = NextResponse.next({
      request: {
        headers: requestHeaders,
      },
    });

    response.headers.set('x-for-client-headers', 'client-team');
    return response;
  }
  
  const redirectUrl = request.nextUrl.searchParams.get('redirectUrl');
  if (redirectUrl && redirectUrl.length > 0) {
    // リダイレクトする
    return NextResponse.redirect(redirectUrl);
  }

  if (request.nextUrl.pathname.startsWith('/api/v1')) {
    await sleep(1000);
    console.log('You can fetch from API in middleware');
    return NextResponse.redirect(new URL('/api/v2', request.url));
  }

  if (request.nextUrl.pathname.startsWith('/notfound')) {
    const html = `
    <!DOCTYPE html>
    <html>
      <head>
        <title>Not Found</title>
      </head>
      <body>
        <h1>404 - Not Found</h1>
      </body>
    </html>
  `;

    // 直接HTMLを返す
    return new NextResponse(html, {
      status: 404,
      headers: { 'content-type': 'text/html' },
    });
  }
}

上のコードでは

  • Headerの値を操作
  • URLパラメータをもとにリダイレクト
  • middlewareでawait処理
  • 直接HTMLを返す

というパターンを紹介しています。
このあたりの機能があればある程度のリダイレクトを実現できるのではないでしょうか。
続いて上記コードのテストも紹介していきます。

middleware.test.ts

middleware.test.ts
/**
 * @jest-environment node
 */

import { NextResponse, NextRequest } from 'next/server';
import { middleware } from './middleware';

const redirectSpy = jest.spyOn(NextResponse, 'redirect');
const nextSpy = jest.spyOn(NextResponse, 'next');
const domain = 'https://www.example.com';

describe('middleware', () => {
  afterEach(() => {
    jest.clearAllMocks();
  });

  it('should handle custom headers', async () => {
    const requestHeaders = new Headers();
    requestHeaders.set('x-for-server-headers', 'server-team');

    const req = new NextRequest(`${domain}/auth`);

    const response = await middleware(req);

    expect(nextSpy).toHaveBeenCalledTimes(1);
    expect(response?.headers.get('x-for-client-headers')).toBe('client-team');
  });

  it('should handle redirectUrl and redirect accordingly', async () => {
    const redirectUrl = 'https://fujiyamaorange.vercel.app/';
    const req = new NextRequest(
      new Request(`${domain}?redirectUrl=${redirectUrl}`)
    );

    await middleware(req);

    expect(redirectSpy).toHaveBeenCalledTimes(1);
    expect(redirectSpy).toHaveBeenCalledWith(redirectUrl);
  });

  it('should handle /api and delay the response by 1000ms', async () => {
    const req = new NextRequest(new Request(`${domain}/api/v1/some-endpoint`));

    const startTime = Date.now();
    await middleware(req);
    const endTime = Date.now();

    expect(redirectSpy).toHaveBeenCalledTimes(1);
    expect(redirectSpy).toHaveBeenCalledWith(new URL('/', req.url));
    expect(endTime - startTime).toBeGreaterThanOrEqual(1000);
  });

  it('should handle /notfound and return a 404 HTML response', async () => {
    const req = new NextRequest(new Request(`${domain}/notfound`));

    const response = await middleware(req);

    expect(response?.status).toBe(404);
    expect(response?.headers.get('content-type')).toBe('text/html');
    expect(await response?.text()).toContain('404 - Not Found');
  });
});

middlewareのテストコードをかくにあたりつまづきポイントがあったので紹介します。

jest-environment: nodeを書く必要がある

/**
 * @jest-environment node
 */

の部分を見て、不思議に思った方もいるかも知れませんが、middlewareのテストを書くときにはこれが重要です。Jestのデフォルトのテスト環境はjsdomとなっており、これは雑にいうとNode.jsでDOMを扱うための環境です。

jsdom is a pure-JavaScript implementation of many web standards, notably the WHATWG DOM and HTML Standards, for use with Node.js. In general, the goal of the project is to emulate enough of a subset of a web browser to be useful for testing and scraping real-world web applications.

しかしmiddewareはサーバーサイドで実行されるためJestの実行環境をNode.jsに設定する必要があります。

まとめ

Next.js App Routerでのリダイレクトの調査を紹介してきました。
すでに存在した機能もありましたが、バージョン13から出た新機能もあり、これからリダイレクト処理を実装する際の選択肢となるのではないでしょうか。

またこれは現時点で調べた限りであり、これから新しい機能がリリースされる可能性もあり、ぜひ皆さんのリダイレクトライフを共有してほしいです!

参考

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

https://nextjs.org/docs/pages/api-reference/next-config-js/redirects

https://github.com/vercel/next.js/discussions/32797

https://github.com/vercel/next.js/discussions/38809

Discussion