💸

Next.jsやVercelでMiddlewareを使うときに知っておくとよさそうな挙動やコスト面の話

2024/09/19に公開

案件でMiddlewareを初めて使うことがあり、コスト面や挙動について調べたので、その情報をまとめています。
把握しきれていないことや仕様が変更されている可能性があるので、案件で使う場合は動作検証や公式ドキュメントを参照することをおすすめします。

ちなみにプランはProを想定しています。

VercelはMiddlewareの起動回数によって課金されるが、CPU時間は課金対象に含まれない

Edge Middleware is priced based on the number of times your middleware is invoked.
Edge Middlewareの価格は、ミドルウェアの起動回数に応じて決まります。
Manage and optimize usage for Edge Middleware

記事執筆時点で、起動回数(Invocations)に対して課金されますが、CPU時間(CPU Time)には課金されません。起動回数は単純にMiddlewareが起動された回数で、CPU時間はリクエストに対するレスポンスを計算した時間のことです(実行時間ではありません)。

This is the time your Middleware has spent computing responses to requests. The compute time refers to the actual net CPU time used, not the execution time. Operations such as network access do not count towards the CPU time.
これは、ミドルウェアがリクエストに対する応答の計算に費やした時間である。計算時間は、実行時間ではなく、実際に使用された正味のCPU時間を指します。ネットワークアクセスなどの操作はCPU時間にはカウントされません。
Manage and optimize usage for Edge Middleware

単価は次のとおりです。

Resource Included Additional Price
Edge Middleware Invocations
Edge Middleware Invocations occur when a request is made to an Edge Middleware function. They are charged per unique invocation.
First 1,000,000 invocations 1,000,000 Invocations 0.65

最初の100万回は無料で、超過した100万回ごとに0.65ドルです(執筆時点で$表記がなかった)。2024年8月時点ではリージョンごとに単価が違い、東京リージョンで1.95ドルだったので、かなり値下げされていますね。

Middleware()内で複数の処理が実行されても起動回数は1回とみなされる

CPU時間は課金対象に含まれませんが「middleware()内に複数の処理があった場合も起動回数は1回なのか?」をVercelのサポート担当に質問してみると、その認識で問題ないようでした。

ただし、複数の処理によってCPU時間が大幅に長くなってしまうとUXが悪化したり、CPU時間が平均50msを超えるとフェアユースガイドラインに抵触して解消が必要になります。
起動回数とCPU時間はVercelのUsageにあるEdge Middlewareから確認できます。

Edge Middleware CPU Limits - Edge Middleware can use no more than 50ms of CPU time on average. This limitation refers to the actual net CPU time, not the execution time. For example, when you are blocked from talking to the network, the time spent waiting for a response does not count toward CPU time limitations.
Edge MiddlewareのCPU制限 - Edge Middlewareは、平均で50msを超えるCPU時間を使用することはできません。この制限は、実行時間ではなく、実際のネット CPU 時間を指します。たとえば、ネットワークとの通信がブロックされている場合、応答を待っている時間はCPU時間の制限にカウントされません。
Fair use Guidelines

As a guideline for our community, we expect most users to fall within the below ranges for each plan. We will notify you if your usage is an outlier. Our goal is to be as permissive as possible while not allowing an unreasonable burden on our infrastructure. Where possible, we'll reach out to you ahead of any action we take to address unreasonable usage and work with you to correct it.
当コミュニティのガイドラインとして、ほとんどのユーザーが各プランの以下の範囲内に収まることを想定しています。あなたの使用量が異常値である場合、私たちはあなたに通知します。私たちの目標は、インフラに不当な負担をかけないようにしながらも、可能な限り寛容であることです。可能な限り、不合理な使用量に対処するために私たちが取る措置の前にあなたに連絡し、それを修正するためにあなたと協力します。
Fair use Guidelines

MiddlewareはすべてのHTTPリクエストに対して実行される

Edge Middleware Invocations occur when a request is made to an Edge Middleware function. They are charged per unique invocation.
エッジミドルウェアの呼び出しは、エッジミドルウェアの機能に対してリクエストが行われたときに発生します。一意の呼び出しごとに課金されます。
Pricing on Vercel

Middlewareはリクエストが処理される前に実行される関数なので、次のようなNext.jsサーバーに向けられたHTTPリクエストに対してMiddlewareの呼び出しが発生します。

matcherにマッチしなければMiddlewareは起動されない

By default, Middleware is invoked for every route in your project. You should take advantage of the config matcher property to limit the routes where your Middleware is invoked. This can help reduce the number of invocations and optimize your usage. See Config object for more information
デフォルトでは、プロジェクト内のすべてのルートに対して Middleware が起動されます。config matcherプロパティを利用して、Middlewareが呼び出されるルートを限定する必要があります。これにより、起動回数を減らし、使用量を最適化することができます。詳細は Config オブジェクトを参照してください。
Manage and optimize usage for Edge Middleware

「config matcherプロパティにマッチしなかった場合はMiddlewareの起動回数に含まれないか」をVercelのサポート担当に確認してみたところ、やはり起動回数に含まれないということでした。Middlewareの最適化をするときにmatcherは最優先で検討しておく必要がありそうです。
matcherは正規表現や配列、missinghasといった条件を配列で指定できるので、かなり柔軟に指定できますね。

意図せずMiddlewareが起動してしまうのを避ける

ここからはMiddlewareの最適化を考える際の具体的な例をいくつか見ていきます。

Next.jsのStatic Assetsでの起動に対応する

Next.jsサーバーに向けられた静的ファイル(CSS、JS、画像など)のHTTPリクエストもMiddlewareの呼び出し対象になると述べました。
これについてはNext.jsでの初心者向け Middleware.ts入門 (v13.1.0) + 公式マニュアル 解説 + 複数のMiddlewareの実装方法という記事の中で、matcherを指定せずrequest.nextUrl.pathnameconsole.log()した挙動が参考になりました。

middleware.ts: request.nextUrl.pathname /
middleware.ts: request.nextUrl.pathname /_next/static/webpack/934f8f934d3a6f9d.webpack.hot-update.json
middleware.ts: request.nextUrl.pathname /
middleware.ts: request.nextUrl.pathname /_next/static/chunks/webpack.js
middleware.ts: request.nextUrl.pathname /_next/static/chunks/react-refresh.js
middleware.ts: request.nextUrl.pathname /_next/static/chunks/main.js
middleware.ts: request.nextUrl.pathname /_next/static/chunks/pages/_app.js
middleware.ts: request.nextUrl.pathname /_next/static/development/_buildManifest.js
middleware.ts: request.nextUrl.pathname /_next/static/development/_ssgManifest.js
middleware.ts: request.nextUrl.pathname /_next/static/chunks/pages/index.js
middleware.ts: request.nextUrl.pathname /_next/static/development/_devMiddlewareManifest.json
middleware.ts: request.nextUrl.pathname /_next/static/development/_devPagesManifest.json
middleware.ts: request.nextUrl.pathname /favicon.ico

上記のように /_next/static/から始まるファイルで何回もMiddlewareが起動しているのがわかります。少なくともmatcherの未指定状態は避けたほうがよさそうです。

公式ドキュメントにも紹介されているmatcherを指定すると、これらの意図しないMiddlewareの起動は避けられそうです。

'/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)'

また、Next.jsでMiddlewareが大量に実行される場合の対処法という記事の中で、「コンポーネントのファイル内に画像があると、画像の呼び出しごとにMiddlewareが実行される」という挙動が紹介されています。

そのときのmatcherの指定が書かれていなかったのでなんとも言えませんが、Middlewareで画像を処理する用途はそれほど多くないと思われる(ファイルアップロードなど?)ので、記事で書かれていたように早期に終了させておくのもいいかもしれません。

middleware.ts
export async function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;

  /** 画像ファイルは処理をしない(画像の再呼び出し対応) */
  if (
    pathname.endsWith('.webp') ||
    pathname.endsWith('.avif') ||
    pathname.endsWith('.svg') ||
    pathname.endsWith('.ico') ||
    pathname.endsWith('.jpg') ||
    pathname.endsWith('.jpeg') ||
    pathname.endsWith('.png') ||
    pathname.endsWith('.gif')
  ) {
    return NextResponse.next();
  }

  // これ以降に処理を書く
}

Middlewareとは少し離れますが、画像が再描画されるとEdge Requestが発生して課金対象になる可能性がある点にも注意が必要です。画像のCDNをCloudFront などのVercel外に置いている場合は対象外ですが、next/imageの<Image>を使用しているとキャッシュの有無に関わらずEdgeにリクエストが走るため課金対象になります。

https://zenn.dev/chot/articles/a1df49a07ca979

https://zenn.dev/chot/articles/next-image-cache-control

prefetchでの起動に対応する

Next.jsでMiddlewareが大量に実行される場合の対処法という記事のなかで「next/linkの<Link>でprefetchを起点としてMiddlewareが実行される」という挙動が紹介されています。

ISRの場合もprefetchで再検証が実行されることがあったのですが(12.2.0で解消済み)、自動で動作する機能なだけに考慮もれが起こりやすいので注意が必要ですね。

記事内で解決策として書かれていて、公式ドキュメントでも紹介されているmatcherを指定すると、これらの意図しないMiddlewareの起動は避けられそうです。

middleware.ts
export const config = {
  matcher: [
    {
      /*
       * Match all request paths except for the ones starting with:
       * - api (API routes)
       * - _next/static (static files)
       * - _next/image (image optimization files)
       * - favicon.ico, sitemap.xml, robots.txt (metadata files)
       */
      source:
        '/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
      /** `<Link>`のprefetchでの実行を防止する */
      missing: [
        { type: 'header', key: 'next-router-prefetch' },
        { type: 'header', key: 'purpose', value: 'prefetch' },
      ],
    },
  ],
};

最終的なコード

これらを踏まえると、次のようなコードで意図しない起動をブロックしつつ、処理を書いていくのがよさそうです。

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

export const config = {
  matcher: [
    {
      /*
       * Match all request paths except for the ones starting with:
       * - api (API routes)
       * - _next/static (static files)
       * - _next/image (image optimization files)
       * - favicon.ico, sitemap.xml, robots.txt (metadata files)
       */
      source:
        '/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt).*)',
      /** `<Link>`のprefetchでの実行を防止する */
      missing: [
        { type: 'header', key: 'next-router-prefetch' },
        { type: 'header', key: 'purpose', value: 'prefetch' },
      ],
    },
  ],
};

export async function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;

  /** 画像ファイルは処理をしない(画像の再呼び出し対応) */
  if (
    pathname.endsWith('.webp') ||
    pathname.endsWith('.avif') ||
    pathname.endsWith('.svg') ||
    pathname.endsWith('.ico') ||
    pathname.endsWith('.jpg') ||
    pathname.endsWith('.jpeg') ||
    pathname.endsWith('.png') ||
    pathname.endsWith('.gif')
  ) {
    return NextResponse.next();
  }

  // これ以降に処理を書く
}

リダイレクトするたびにMiddlewareも呼び出される

設定では防止できないですが、リダイレクトはネットワークリクエストが発生するので、リダイレクトするたびにMiddlewareの呼び出しが発生するようです。
SEO的にリダイレクトチェーンが問題なる場合もありますし、コスト面でも多少の影響がありそうですね。

似たような機能ですが、リライトは内部的に別のページにルーティングするだけなので、ネットワークリクエストは発生しないようです。

matcherには変数が使えないのでスクリプトで対応する

matcherには変数のような動的な値を指定しても無視されます。

Good to know: The matcher values need to be constants so they can be statically analyzed at build-time. Dynamic values such as variables will be ignored.
知っておいて損はない: マッチャーの値は、ビルド時に静的に解析できるように、定数である必要がある。変数のような動的な値は無視されます。
Routing: Middleware | Next.js

たとえば、Basic認証を開発環境だけで実行したい場合です。matcherに環境変数を使っていると無視されてしまい、意図通りの挙動になりません(Middlewareの処理自体には環境変数が使えます)。
そういった処理内容での調整が難しい場合は、スクリプト(シェルかnpm scripts)でファイルを調整する方法が考えられます。

  1. 開発環境用にmiddleware.tsを、本番環境用にmiddleware.prod.tsを作成する
  2. 本番用のビルド時にmiddleware.tsを削除し、middleware.prod.tsをmiddleware.tsにリネームする

その他Next.jsやVercel特有の情報

ここからはコスト面ではないですが、知っておくとよさそうなことを追記しています。

Node.jsランタイムは使用できず、Edgeランタイムのみサポートされている

Middleware currently only supports the Edge runtime. The Node.js runtime can not be used.
ミドルウェアは現在、Edgeランタイムのみをサポートしています。Node.jsランタイムは使用できません。
Routing: Middleware | Next.js

Node.jsラインタイムはその名の通り、Node.jsと互換性を持たせているようですが、EdgeランタイムはAPIが制限されているので、サポートされているAPIでMiddlewareの処理を書く必要があります。

API Reference: Edge Runtime | Next.js

Middlewareが実行されるタイミング

Middleware will be invoked for every route in your project. Given this, it's crucial to use matchers to precisely target or exclude specific routes. The following is the execution order:
ミドルウェアは、プロジェクト内のすべてのルートに対して呼び出されます。そのため、マッチャーを使って、特定のルートを正確にターゲットにしたり、除外したりすることが重要になります。以下に実行順序を示します:

  1. headers from next.config.js
  2. redirects from next.config.js
  3. Middleware (rewritesredirects, etc.)
  4. beforeFiles (rewrites) from next.config.js
  5. Filesystem routes (public/_next/static/pages/app/, etc.)
  6. afterFiles (rewrites) from next.config.js
  7. Dynamic Routes (/blog/[slug])
  8. fallback (rewrites) from next.config.js
    Routing: Middleware | Next.js

StorybookでもMiddlewareを使う場合は@vercel/edgeに書き換える

NextResponse.next();がStorybookでは動かないので、"@vercel/edge"next()に変更する必要があるようです。

Next.jsではMiddlewareを1つの関数としてエクスポートする必要がある

The file must export a single function, either as a default export or named middleware. Note that multiple middleware from the same file are not supported.
ファイルは、デフォルト・エクスポートまたは名前付きミドルウェアとして、単一の関数をエクスポートする必要があります。同じファイルからの複数のミドルウェアはサポートされていないことに注意してください。
File Conventions: middleware.js | Next.js

Next.js 12.2.0でMiddlewareが正式版になったのですが、_middleware.tsを階層ごとに配置できるNested Middlewareが廃止になっているので、エラーとして検出されます。
middleware.tsは1つに制限されていますが、関数や変数を別ファイルに切り出しておいて、importして使うことはできます。

ちなみにMiddlewareファイルは、プロジェクトのルートかsrcの中に配置する必要があります。

Use the file middleware.ts (or .js) in the root of your project to define Middleware. For example, at the same level as pages or app, or inside src if applicable.
ミドルウェアを定義するには、プロジェクトのルートにあるmiddleware.ts(または.js)ファイルを使用します。例えば、pagesやappと同じレベルか、該当する場合はsrcの中です。
Routing: Middleware | Next.js

chot Inc. tech blog

Discussion