"I want to keep unauthenticated users out of /dashboard," "I want to change currency display per country," "I want to show the new design to only 5% of users" — what realizes these before the page and while keeping the static cache is Routing Middleware.
This article, faithful to the official spec of Vercel Routing Middleware, collects the implementation of auth gates, personalization, A/B, and redirects. For the big picture, see the Vercel production-operations guide.
What Routing Middleware is
The official definition is simple.
Routing Middleware executes code before a request is processed on a site, and are built on top of fluid compute. (— Routing Middleware)
Important properties:
- It runs globally before request processing and before the CDN cache. So you can add personalization to statically generated content (keep the cache effective, and only do the differentiation upfront).
- It runs on Fluid Compute (Functions).
- It's framework-agnostic — place
middleware.tsat the project root and it runs in any framework. Same filename as Next.js's middleware, but as a Vercel product it's framework-independent.
// middleware.ts(プロジェクトルート)
export default function middleware(request: Request) {
const url = new URL(request.url);
// 旧パスをリダイレクト
if (url.pathname === "/old-page") {
return new Response(null, {
status: 302,
headers: { Location: "/new-page" },
});
}
// 次のハンドラへ続行
return new Response("Hello from your Middleware!");
}
Choose the runtime (Edge / Node.js / Bun)
The default is the Edge runtime. If you need the full Node.js API, switch to nodejs with config.
// middleware.ts — Node.js ランタイムに切り替え
export const config = {
runtime: "nodejs", // 既定は 'edge'
};
export default function middleware(request: Request) {
return new Response("Hello from Node.js Middleware!");
}
| Runtime | When to use | Note |
|---|---|---|
| Edge (default) | Lightweight, ultra-low-latency redirects/header manipulation | env is up to 5KB per variable |
| Node.js | Full Node.js API / existing libraries needed | Runs on Fluid Compute and incurs Functions billing |
| Bun | Bun-native | bunVersion in vercel.json + runtime nodejs |
To use Node.js middleware in Next.js, enable
experimental.nodeMiddleware: trueinnext.config.tsand setconfig.runtimeinmiddleware.tstonodejs(a matcher can also be used together).
Use case ①: auth gate
Send unauthenticated users from protected routes to login. Since you reject them before the page, there's no rendering cost for the protected page either.
// middleware.ts
const PROTECTED = ["/dashboard", "/settings", "/billing"];
export default function middleware(request: Request) {
const url = new URL(request.url);
const needsAuth = PROTECTED.some((p) => url.pathname.startsWith(p));
if (!needsAuth) return; // 続行
const session = request.headers.get("cookie")?.includes("session=");
if (!session) {
const login = new URL("/login", request.url);
login.searchParams.set("next", url.pathname); // 戻り先を保持
return Response.redirect(login, 307);
}
}
Important (trust boundary): the auth check in middleware is a UX optimization, not the final authorization. Don't finalize authorization on a cookie-existence check alone — always verify the token on the server side of the page/API. Considering MITM and replay, the iron rule is to enforce authorization on the server/DB. For cookie tampering, use a signed cookie.
Use case ②: personalization (region, device, language)
While keeping the static page cached, differentiate by country, language, and device. Vercel provides geo headers (x-vercel-ip-country, etc.).
// middleware.ts — 国に応じてロケールへ rewrite(キャッシュは活きる)
export default function middleware(request: Request) {
const url = new URL(request.url);
if (url.pathname !== "/") return;
const country = request.headers.get("x-vercel-ip-country") ?? "US";
const locale = country === "JP" ? "ja" : "en";
// rewrite(URLは変えずに内部的に別ページを返す)
const rewritten = new URL(`/${locale}`, request.url);
return new Response(null, {
status: 200,
headers: { "x-middleware-rewrite": rewritten.toString() },
});
}
If you want to include attributes in the cache key, use Vary on the response side (caching strategy).
Use case ③: A/B test bucket assignment
Show the new design to some users. Fix the bucket with a cookie and return a consistent experience to the same user.
// middleware.ts
export default function middleware(request: Request) {
const url = new URL(request.url);
if (url.pathname !== "/") return;
const cookie = request.headers.get("cookie") ?? "";
let bucket = cookie.match(/ab=(a|b)/)?.[1];
const res = new Response(null, { status: 200 });
if (!bucket) {
bucket = Math.random() < 0.5 ? "a" : "b";
res.headers.append("set-cookie", `ab=${bucket}; Path=/; Max-Age=2592000`);
}
res.headers.set(
"x-middleware-rewrite",
new URL(bucket === "b" ? "/home-variant" : "/", request.url).toString(),
);
return res;
}
For staged production rollout itself, Rolling Releases (with Skew Protection, metric comparison, and instant rollback) is safer than a self-built A/B in middleware. Middleware A/B suits "feature-flag-like permanent differentiation."
Use case ④: emergency redirects, IP blocking (Edge Config integration)
Hitting a DB in middleware adds latency. The standard is to put flags, redirect tables, and IP blocklists in Edge Config (P99 under 15ms).
// middleware.ts — Edge Config で「再デプロイなしの」制御
import { get } from "@vercel/edge-config";
export default async function middleware(request: Request) {
// メンテモード(コードを触らず即ON/OFF)
if (await get<boolean>("maintenance_mode")) {
return Response.redirect(new URL("/maintenance", request.url), 307);
}
// 悪性IPブロック(アップストリームを呼ばずに弾く)
const ip = request.headers.get("x-forwarded-for")?.split(",")[0];
const blocked = (await get<string[]>("blocked_ips")) ?? [];
if (ip && blocked.includes(ip)) {
return new Response("Forbidden", { status: 403 });
}
}
For large-scale IP blocking, rate limiting, and bot countermeasures, Vercel WAF / BotID (which works at the entrance before calling the function) is more appropriate than middleware. Divide roles: middleware is "app-side logic," and the WAF is "the platform entrance."
Limits and cost
| Item | Limit |
|---|---|
| Max URL length | 14 KB |
| Request body | 4 MB |
| Number of request headers | 64 |
| Request header length | 16 KB |
| Edge env variable | 5KB per variable |
- Cost is the Fluid Compute model (Active CPU billing). Node.js-runtime middleware incurs Functions billing.
- Since middleware runs before every request, don't put heavy processing in it. Keep judgments light and pull data from Edge Config.
Observability
In Observability you can check middleware's per-path calls, action breakdown (redirect/rewrite), and the frequency of rewrite targets (more detailed with Observability Plus). The console.* API is fully supported, so necessary logs appear in Runtime Logs (observability).
Production checklist (Middleware)
- Middleware does only light judgments (don't put heavy processing/secret logic)
- The auth gate is a UX optimization, and the final authorization is verified on the server/DB
- Flags/redirects/IP lists in Edge Config (no redeploy needed, low latency)
- Choose the runtime by use (Edge default / Node.js incurs Functions billing)
- Delegate large-scale bot/rate-limiting to WAF/BotID
- Don't exceed the URL 14KB, body 4MB, and header limits
- Monitor per-path behavior with Observability
Conclusion
Routing Middleware can implement auth, personalization, A/B, and redirects from the prime seat of "before the page, before the cache."
- The greatest value is being able to personalize while keeping the cache effective
- The runtime is Edge default / Node.js incurs Functions billing
- Data is in Edge Config (low latency, no redeploy needed)
- Finalize authorization on the server side, and bot/rate-limiting with the WAF
- Keep it light and monitor with Observability
I take on the design and implementation of auth gates, multi-region personalization, and staged releases as a project.
This article is based on the official documentation of Routing Middleware / Edge Config (as of June 2026). The spec and limits get updated, so confirm the latest values in the official docs when adopting in production.