【Next.js】App Routerでのリダイレクトを模索した
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)
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?に載っています。
複雑なパスマッチング
このsource
やdestination
はかなり柔軟に指定することができます。
例えば以下のように指定すると/blog/hello-world
や/blog/a/b/c/d/hello-world
がマッチし、/news/hello-world
や/news/a/b/c/d/hello-world
にリダイレクトされます。
{
source: '/blog/:slug*',
destination: '/news/:slug*',
permanent: true,
},
そしてパスマッチングに正規表現を使うこともできます。
以下のように指定すると/post/123
はマッチしますが、/post/1
や/post/abc
はマッチしません。
{
source: '/post/:slug(\\d{2,})',
destination: '/news/:slug',
permanent: false,
},
Header、Cookieでのマッチング
上記で紹介した複雑なパスマッチングに加え、HeaderやCookieの状態によってもリダイレクトすることができます。
正直next.config.jsへの記述でここまでできるのは驚きました。
今まで出てきたプロパティに加えてhas
とmissing
を用いることができます。
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
は配列を受け取るので、複数のパターンを組み合わせてリダイレクトの条件を決めることができます。つまり以下のように指定することもできるということです。
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
のものが含まれているという条件になります。
middlewareにリダイレクトを書く
middleware自体はNext.jsのv12.0.0
からベータ版としてリリースされました。
middlewareについてドキュメントには以下のように書かれています。
ミドルウェアを使うと、リクエストが完了する前にコードを実行することができる。そして、送られてきたリクエストに基づいて、レスポンスを書き換えたり、リダイレクトしたり、リクエストやレスポンスのヘッダーを変更したり、直接レスポンスしたりすることで、レスポンスを変更することができる。
ミドルウェアは、キャッシュされたコンテンツやルートがマッチングされる前に実行される。(DeepL訳)
つまり、middlewareはnext.config.js
に書いたようなリダイレクト処理に加え、他にも様々な処理ができます。しかし、全ての機能を紹介すると非常に長くなってしまうので、ここではリダイレクトに絞っていきたいと思います。
Cookieを用いたリダイレクト
next.config.js
による設定でのリダイレクト処理と比較しmiddlewareでは以下のことを実現できます。
- Cookieを取得して使用する
- Cookieの削除
- Cookieの追加
上記を全て使った公式のサンプルが以下になります。
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 usingawait
inside
直接レスポンスを返せる
さらにv13.1.0
からレスポンスを直接返せるようになりました。JSONやHTMLをステータスコード付きで返却することができます。
最低限の認証や、ページをブロックする処理などを書くのにちょうど良いかもしれません。
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
でデータフェッチをしその結果によってリダイレクトするというようにとても柔軟に使えます。
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つのリダイレクト方法を紹介しました。
- next.config.jsに書くパターン
- middlewareに書くパターン
- redirect APIを使うパターン
ではどれを使ってリダイレクトを実装するのが良いでしょうか?現時点の僕の中での答えは2と3の組み合わせです。理由は以下です。
- middlewareはnext.config.jsへの記述よりも柔軟に実装できる
- middlewareは単体テストを書くことができる
- middlewareだけで実現できないリダイレクトを
redirecrt
を使ってカバーする
現時点でredirect
を使う要件は発生していないですが、今後使用を検討していこうと思っています。
ただまだこのあたりは模索中なので、これから試していこうと思います。
middewareを書いてみた
実際にmiddlewareでリダイレクトを実装するとどのようになるかイメージするために、検証のコードを書いてみました。
middleware.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
/**
* @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から出た新機能もあり、これからリダイレクト処理を実装する際の選択肢となるのではないでしょうか。
またこれは現時点で調べた限りであり、これから新しい機能がリリースされる可能性もあり、ぜひ皆さんのリダイレクトライフを共有してほしいです!
参考
Discussion