Migrating Vercel Edge Middleware to Cloudflare Workers
This guide is part of Edge Runtime Fundamentals & Platform Constraints. It walks a real middleware.ts from Vercel’s framework-shaped API down to the plain fetch handler a Worker exposes, and then gets traffic across without a flag day.
The two runtimes are far more alike than the migration suggests. Both are V8 isolates, both speak the same Web APIs, and most of your logic will move unchanged. What does not move is the framework layer: NextResponse is not a runtime primitive, it is a convention Vercel’s proxy understands. Every awkward part of this migration is a place where you were relying on that convention without noticing.
The problem
You copy middleware.ts into a Worker, fix the import errors, and deploy. Requests return 200 with an empty body. NextResponse.next() compiled fine, but nothing downstream knows what to do with it. Then process.env.API_KEY is undefined. Then request.geo is undefined and your country redirect sends everyone to the default locale. Then the config.matcher export is silently ignored and your middleware runs on every image request.
None of these are bugs. They are all the same fact surfacing four times: you were writing against a platform contract, not against the runtime.
Root cause: NextResponse is a protocol, not a response
NextResponse.next() does not continue anything. It returns a Response carrying an x-middleware-next: 1 header that Vercel’s routing layer interprets as “no interception, proceed to the origin route”. NextResponse.rewrite(url) sets x-middleware-rewrite. NextResponse.redirect(url) is the only one that is a real HTTP response on its own, which is why it is the only one that appears to work when you paste it into a Worker.
A Worker has no such routing layer. Its fetch handler is the routing layer: whatever Response you return is what the client receives. “Continue to origin” is not a signal you emit, it is a fetch you perform. Once that inversion is clear, the rest of the port is mechanical.
The same inversion explains the other three surprises. process.env is a build-time substitution on Vercel and does not exist in the Workers runtime, where bindings arrive as an env argument to the handler. request.geo is a property Vercel’s proxy attaches; Cloudflare attaches request.cf instead, with different field names. And config.matcher is read by Vercel’s build to decide whether to invoke your function at all, a decision that on Cloudflare belongs to route patterns in wrangler.toml — or, if you skip that, to an if statement at the top of your handler that runs after you have already been billed for the invocation.
Step 1 — Inventory the platform surface you actually use
Before porting anything, grep for the four things that will not compile or will silently misbehave. This takes five minutes and determines the shape of the whole migration.
# Every platform-coupled symbol in the middleware and anything it imports.
rg -n "NextResponse|NextRequest|process\.env|\.geo\b|waitUntil|export const config" \
src middleware.ts --type ts
# Node built-ins that Vercel's bundler may have been shimming for you.
rg -n "from ['\"]node:|require\(['\"](fs|path|crypto|stream|buffer)" src middleware.ts --type ts
The second command matters more than it looks. Vercel’s edge build rejects most Node built-ins too, but the two platforms differ on the margins, and a dependency that resolved to a browser build under Vercel’s conditions may resolve differently under Wrangler’s. Resolve those before you start, using the approach in tree-shaking dependencies for edge middleware.
Step 2 — Port the response primitives
Here is a representative Vercel middleware: it short-circuits bots, rewrites a locale prefix, redirects unauthenticated users, and fires an analytics beacon.
// BEFORE — middleware.ts on Vercel
import { NextResponse, type NextRequest } from "next/server";
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
export default async function middleware(request: NextRequest) {
const url = request.nextUrl;
const country = request.geo?.country ?? "US";
if (/bot|crawler/i.test(request.headers.get("user-agent") ?? "")) {
return new NextResponse("Forbidden", { status: 403 });
}
if (!request.cookies.get("session") && url.pathname.startsWith("/app")) {
const login = new URL("/login", request.url);
login.searchParams.set("next", url.pathname);
return NextResponse.redirect(login, 307);
}
if (url.pathname === "/") {
const target = new URL(`/${country.toLowerCase()}/home`, request.url);
return NextResponse.rewrite(target);
}
const res = NextResponse.next();
res.headers.set("x-country", country);
fetch(`${process.env.BEACON_URL}/hit`, { method: "POST" });
return res;
}
And the same behaviour as a Worker. Every framework helper has become an explicit Response or an explicit fetch.
// AFTER — src/worker.ts on Cloudflare Workers
export interface Env {
BEACON_URL: string;
ORIGIN: string;
}
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
const url = new URL(request.url);
const country = (request as { cf?: { country?: string } }).cf?.country ?? "US";
if (/bot|crawler/i.test(request.headers.get("user-agent") ?? "")) {
return new Response("Forbidden", { status: 403 });
}
const cookies = request.headers.get("cookie") ?? "";
const hasSession = /(?:^|;\s*)session=/.test(cookies);
if (!hasSession && url.pathname.startsWith("/app")) {
const login = new URL("/login", url);
login.searchParams.set("next", url.pathname);
return Response.redirect(login.toString(), 307);
}
// rewrite: fetch a different path, return its bytes under the original URL.
const target = new URL(url);
if (url.pathname === "/") {
target.pathname = `/${country.toLowerCase()}/home`;
}
target.hostname = env.ORIGIN;
const upstream = await fetch(new Request(target.toString(), request));
// next(): there is no next. You performed the fetch; now decorate it.
const response = new Response(upstream.body, upstream);
response.headers.set("x-country", country);
ctx.waitUntil(
fetch(`${env.BEACON_URL}/hit`, { method: "POST" }).catch(() => undefined),
);
return response;
},
} satisfies ExportedHandler<Env>;
Four translations are doing the work. NextResponse.next() became “fetch the origin and decorate the result”, because continuation is an action rather than a signal. NextResponse.rewrite(target) became a fetch of the rewritten URL while the client’s address bar keeps the original — which is exactly what a rewrite always was, just performed by you. NextResponse.redirect became Response.redirect, the one near-identical mapping. And new Response(upstream.body, upstream) is the idiom for “same response, mutable headers”; constructing from the upstream response copies status and headers, and passing the body as a stream keeps it streaming rather than buffering it into memory.
One trap worth naming: on Vercel, NextResponse.next() with modified request headers passes those headers onward to your route. In a Worker you must build the modified Request yourself before fetching, because there is nothing downstream reading a header-mutation protocol.
Step 3 — Move process.env to the env parameter
On Vercel, process.env.API_KEY is replaced at build time with a literal, which is why it works in an isolate that has no process. Workers do not do that substitution; configuration arrives as the second argument to fetch, typed by you.
// src/env.ts — one typed surface, validated once at the edge of the handler.
export interface Env {
ORIGIN: string;
BEACON_URL: string;
SESSION_SECRET: string; // set with: wrangler secret put SESSION_SECRET
FLAGS: KVNamespace;
}
export function requireEnv(env: Env): Env {
for (const key of ["ORIGIN", "BEACON_URL", "SESSION_SECRET"] as const) {
if (!env[key]) throw new Error(`Missing binding: ${key}`);
}
return env;
}
The structural consequence is that anything reading configuration must now receive env, which means module-scoped singletons built from secrets no longer work the way they did. A JWKS client or an API client constructed at module scope has no access to bindings. The usual fix is a lazily initialized module-level variable that the first request populates:
let client: ApiClient | undefined;
function getClient(env: Env): ApiClient {
// Built once per isolate, but only once a request has supplied bindings.
client ??= new ApiClient(env.BEACON_URL, env.SESSION_SECRET);
return client;
}
Non-secret values go in [vars] in wrangler.toml; secrets go through wrangler secret put and never into the file. The distinction is covered in managing secrets in Cloudflare Workers with Wrangler.
Step 4 — Translate config.matcher into routes
export const config = { matcher: [...] } is a build-time instruction telling Vercel which paths invoke the function. Deleting it and adding a path check inside the handler is a correctness-preserving but cost-increasing change: the Worker still runs, still boots an isolate, and is still billed. Express the exclusion in wrangler.toml so requests you do not care about never reach the Worker at all.
name = "edge-middleware"
main = "src/worker.ts"
compatibility_date = "2026-01-15"
compatibility_flags = ["nodejs_compat_v2"]
# Positive routes: the Worker runs only on these.
routes = [
{ pattern = "example.com/app/*", zone_name = "example.com" },
{ pattern = "example.com/login", zone_name = "example.com" },
{ pattern = "example.com/", zone_name = "example.com" },
]
[vars]
ORIGIN = "origin.example.com"
BEACON_URL = "https://beacon.example.com"
[observability]
enabled = true
Route patterns are positive matches with limited wildcards; they cannot express the negative lookahead a Next.js matcher uses. That is usually an improvement — enumerate the handful of prefixes that need middleware instead of excluding the sprawl of asset paths — but it does change how you think about coverage. Where a negative pattern is genuinely required, keep a cheap guard as the first statement in the handler and accept the invocation cost for those paths.
const SKIP = /^\/(?:_next\/static|_next\/image|favicon\.ico|assets)\//;
if (SKIP.test(new URL(request.url).pathname)) return fetch(request);
Step 5 — Replace request.geo with request.cf
Both platforms attach geolocation to the request; the field names and the availability differ.
interface CfProperties {
country?: string; // ISO 3166-1 alpha-2, or "T1" for Tor
city?: string;
region?: string; // subdivision name, not a code
regionCode?: string;
postalCode?: string;
timezone?: string;
colo?: string; // the PoP that served the request
latitude?: string; // string, not number
longitude?: string;
}
export function geoOf(request: Request): { country: string; city?: string; timezone?: string } {
const cf = (request as Request & { cf?: CfProperties }).cf;
return {
// cf is undefined in local dev and for requests that bypass the edge.
country: cf?.country ?? "US",
city: cf?.city,
timezone: cf?.timezone,
};
}
Three differences bite in practice. latitude and longitude arrive as strings on Cloudflare and need parsing. region is a human-readable name where Vercel’s region was a deployment region, not a subdivision — the closest equivalent is regionCode. And cf is undefined during local development unless you run Wrangler in remote mode, so every read needs a default or your local behaviour diverges from production. Country-based routing patterns are covered in country-based redirects in edge middleware.
Step 6 — Reconcile waitUntil semantics
Both platforms expose waitUntil, and the naive port compiles. The semantics differ enough to matter.
On Vercel, waitUntil extends the lifetime of the invocation and is subject to that function’s overall budget; work queued there competes with the same wall-clock and CPU limits. On Cloudflare, ctx.waitUntil extends the request context after the response is returned, with its own allowance, and CPU time is accounted separately from wall-clock time spent awaiting I/O — so a background fetch that takes 400 ms of network waiting costs almost no CPU.
Three rules make the port safe regardless of platform. Always attach a .catch(); an unhandled rejection inside waitUntil can terminate the context and, on some runtimes, surface as a 500 for a response you already returned. Never place work the response depends on inside it — if you waitUntil a cache write and immediately read that cache, you have a race. And bound it: an unbounded fetch with no AbortSignal can pin the context open until the platform kills it.
function background(ctx: ExecutionContext, work: () => Promise<unknown>, budgetMs = 2000): void {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), budgetMs);
ctx.waitUntil(
work()
.catch((err) => console.log(JSON.stringify({ event: "bg.error", message: String(err) })))
.finally(() => clearTimeout(timer)),
);
}
Step 7 — Cut over with weighted DNS
Do not switch a hostname in one move. Weighted DNS lets you move a percentage of real traffic and revert in seconds by changing one number, without a redeploy on either side.
Point the production hostname at a weighted record set: one target is the existing Vercel deployment, the other is the Cloudflare zone running the Worker. Start at five percent, hold for a full traffic cycle including a peak, and compare error rate, p95 TTFB, and — the metric people forget — the rate of authenticated requests, because session handling is the most common thing to break silently in a port.
Two practical constraints. DNS TTL sets your rollback latency, so drop it to 60 seconds a day before you start and restore it after; a 3600-second TTL means an hour of broken traffic you cannot recall. And sticky behaviour is not guaranteed — a client can resolve differently between requests, so both stacks must accept each other’s session cookies for the whole ramp. If the two sign sessions with different secrets, users will be randomly logged out at exactly the moment you are trying to read a clean error rate.
Local versus production divergence
| Behaviour | Local (wrangler dev) |
Cloudflare production |
|---|---|---|
request.cf |
undefined unless run with remote mode |
Fully populated by the PoP |
| Bindings | From .dev.vars and wrangler.toml |
From the dashboard and wrangler secret |
| Routes | Ignored; every path hits the Worker | Only configured route patterns invoke it |
ctx.waitUntil |
Runs, but the process may exit first | Runs with a real post-response allowance |
| KV reads | Local simulated store, strongly consistent | Eventually consistent across PoPs |
| Isolate lifetime | Reset on every file change | Minutes to hours |
Origin fetch |
Reaches your machine’s network directly | Subject to zone routing and possible loops |
| Subrequest limits | Unenforced | Capped per invocation |
The routes row causes the most confusion during a migration: locally the Worker handles everything, so the guard clause in Step 4 appears unnecessary, and then in production a path you expected to be intercepted is not — or vice versa. Verify route coverage against the deployed Worker, not against wrangler dev. The broader comparison lives in local emulation with wrangler dev vs production.
Provider mapping
| Concern | Vercel Edge Middleware | Cloudflare Workers | Netlify Edge Functions |
|---|---|---|---|
| Handler shape | export default function middleware(req) |
export default { fetch(req, env, ctx) } |
export default (req, context) => Response |
| Continue to origin | NextResponse.next() |
await fetch(request) |
context.next() |
| Rewrite | NextResponse.rewrite(url) |
fetch(new Request(url, request)) |
new URL rewrite plus context.rewrite(url) |
| Configuration | process.env, build-time inlined |
env argument, runtime bindings |
Netlify.env.get("KEY") |
| Geolocation | request.geo |
request.cf |
context.geo |
| Background work | waitUntil within the invocation budget |
ctx.waitUntil with a post-response allowance |
context.waitUntil |
| Path selection | config.matcher regex array |
routes patterns in wrangler.toml |
config.path / excludedPath per function |
| Runtime | V8 isolate, Web APIs | V8 isolate, Web APIs plus bindings | Deno, Web APIs plus Deno std |
Netlify sits between the two: context.next() preserves the continuation model you are leaving behind, while environment access and path config look more like the Workers approach. If you are choosing rather than migrating, the trade-offs are laid out in Vercel Edge Runtime vs Cloudflare Workers.
Validating with Vitest
A ported Worker is testable without any platform emulation, because the handler is a plain function of (Request, Env, ExecutionContext). Build fakes for the three arguments and assert behaviour, not implementation.
// worker.test.ts
import { describe, expect, it, vi } from "vitest";
import worker, { type Env } from "./src/worker";
function ctx(): ExecutionContext & { pending: Promise<unknown>[] } {
const pending: Promise<unknown>[] = [];
return { pending, waitUntil: (p) => void pending.push(p), passThroughOnException: () => {} } as never;
}
const env: Env = { ORIGIN: "origin.example.com", BEACON_URL: "https://beacon.test" };
function req(path: string, init: RequestInit & { country?: string } = {}): Request {
const r = new Request(`https://example.com${path}`, init);
Object.defineProperty(r, "cf", { value: { country: init.country ?? "DE" } });
return r;
}
describe("ported worker", () => {
it("rewrites the root to the country home without changing the client URL", async () => {
const seen: string[] = [];
vi.stubGlobal("fetch", async (input: Request) => {
seen.push(new URL(input.url).pathname);
return new Response("ok", { status: 200 });
});
const res = await worker.fetch(req("/"), env, ctx());
expect(seen).toEqual(["/de/home"]);
expect(res.status).toBe(200);
expect(res.headers.get("x-country")).toBe("DE");
});
it("redirects unauthenticated app traffic to login with a next parameter", async () => {
const res = await worker.fetch(req("/app/billing"), env, ctx());
expect(res.status).toBe(307);
expect(new URL(res.headers.get("location")!).searchParams.get("next")).toBe("/app/billing");
});
it("short-circuits bots before any origin fetch", async () => {
const spy = vi.fn(async () => new Response("ok"));
vi.stubGlobal("fetch", spy);
const res = await worker.fetch(req("/", { headers: { "user-agent": "evilbot/1" } }), env, ctx());
expect(res.status).toBe(403);
expect(spy).not.toHaveBeenCalled();
});
it("queues the beacon in waitUntil rather than awaiting it", async () => {
vi.stubGlobal("fetch", async () => new Response("ok"));
const c = ctx();
await worker.fetch(req("/"), env, c);
expect(c.pending).toHaveLength(1);
});
});
The last test is the one that catches a real regression class. A port that awaits the beacon instead of queuing it passes every functional test and adds the beacon’s latency to every user request. Asserting on the shape of the concurrency, not just the output, is what keeps that honest. The same technique is expanded in unit testing edge middleware with Vitest.
Common pitfalls and resolutions
Empty 200 responses after the port — a NextResponse.next() survived the migration. There is nothing to continue to; perform the origin fetch yourself.
Infinite request loop, then a 1000-series error — the Worker fetched a URL that routes back through the same Worker. Point the upstream fetch at an origin hostname not covered by the route pattern.
env.SECRET is undefined in production but fine locally — the value is in .dev.vars and was never set with wrangler secret put. Validate bindings at the top of the handler so this fails loudly.
Locale routing sends everyone to the default — request.cf is undefined locally and for some request paths. Always supply a default, and verify against a deployed preview rather than wrangler dev.
Response body already used — you read upstream.text() for logging and then returned upstream.body. Clone before reading, or restructure to avoid reading at all.
Header mutations vanish — you mutated upstream.headers directly on an immutable response. Construct new Response(upstream.body, upstream) first; its headers are mutable.
Traffic split behaves inconsistently during cutover — clients re-resolve DNS between requests. Both stacks must validate the same session cookies for the whole ramp.
Production deployment checklist
Frequently Asked Questions
Why does NextResponse.next() not work in a Worker?
Because it never continued anything. It returns a response carrying an x-middleware-next header that Vercel’s routing layer interprets as an instruction to proceed to the application route. A Worker has no such layer, so whatever you return is what the client receives. Continuation becomes an explicit origin fetch that you perform.
How do I replace process.env in a Worker?
Configuration arrives as the second argument to the fetch handler rather than as a build-time substitution. Define a typed Env interface, validate the required keys at the top of the handler, put non-secret values in the vars section of wrangler.toml, and set secrets with wrangler secret put. Anything that used to build a client at module scope must be lazily initialized on the first request instead.
Can route patterns express a Next.js negative matcher?
No. Route patterns are positive matches with limited wildcards and cannot express a negative lookahead. Enumerate the handful of prefixes that need the Worker instead, and where a negative pattern is genuinely required, keep a cheap guard clause as the first statement in the handler and accept the invocation cost on those paths.
What is different about waitUntil on Cloudflare?
On Vercel the queued work shares the invocation’s overall budget. On Cloudflare it extends the request context after the response has been returned, with its own allowance, and CPU time is accounted separately from time spent waiting on network I/O. In both cases attach a catch handler, bound the work with an abort signal, and never put anything the response depends on inside it.
Why does my Worker loop back into itself?
Because the origin fetch targets a URL covered by the Worker’s own route patterns, so the request re-enters the same Worker until the platform stops it. Point upstream fetches at an origin hostname that no route pattern matches, and verify that against the deployed configuration rather than local development.
How long should the weighted DNS ramp take?
Long enough to cover at least one full traffic cycle including a peak at each weight, which in practice means roughly two weeks from five percent to steady state. Lower the DNS TTL to sixty seconds a day beforehand so rollback is fast, keep the old deployment reachable throughout, and make sure both stacks accept the same session cookies for the whole ramp.