Your team wrote middleware.ts carefully — Edge-safe imports only, jose instead of jsonwebtoken, no direct database calls — because that's what Edge middleware demanded. Then you upgraded to Next.js 16, skimmed the release notes, and moved on. Nothing broke. Which is exactly the problem: middleware.ts still runs, but it's now the deprecated way to do the one job every non-trivial app needs — checking a request before a single line of your app runs. The framework renamed the file, moved the runtime under it, and left the old name working just long enough for teams to miss the change entirely.
What you'll learn
By the end of this article you'll be able to:
- Explain what
proxy.tsis, why Next.js 16 renamedmiddleware.tsto it, and what actually changed under the hood. - State exactly which runtime
proxy.tsruns on — and why you can no longer choose. - Migrate an existing
middleware.tsfile with the official codemod, including the config options that renamed alongside it. - Recognize the one capability trade Next.js made, and decide whether it affects your app.
- Write a
proxy.tsthat checks auth, sets a header, and rewrites a request — the shape that covers most real uses.
Who this is for
You've shipped a Next.js App Router app and have (or have used) a middleware.ts file for things like auth checks or redirects. You don't need prior Edge-runtime experience — this article explains what that runtime was and why it mattered.
Table of contents
- The problem: a boundary with two names and a hidden runtime
- The mental model: proxy.ts is the network boundary, not a request handler
- Migrating middleware.ts to proxy.ts, step by step
- Edge cases and gotchas
- Best practices
- FAQ
- Cheat sheet
- Key takeaways
The problem: a boundary with two names and a hidden runtime
This article is written against Next.js 16.3 (the current Active LTS release, verified against the framework's own file-convention and upgrade docs, and its GitHub releases, in September 2026). If you're reading this from a much later version, re-check the docs linked below before trusting a specific detail — that's the honest habit this series keeps asking of you, and this topic is exactly why.
Here's the wrong-way-first version, because it's what most teams actually did. A Next.js 15 app has this middleware.ts:
// middleware.ts — Next.js 15, Edge runtime (the only option)
import { NextResponse } from "next/server";
import { jwtVerify } from "jose"; // Edge-safe; jsonwebtoken would not run here
export async function middleware(request: Request) {
const token = request.headers.get("cookie")?.match(/session=([^;]+)/)?.[1];
if (!token) return NextResponse.redirect(new URL("/login", request.url));
try {
await jwtVerify(token, secretKey); // must be Edge-runtime-compatible
return NextResponse.next();
} catch {
return NextResponse.redirect(new URL("/login", request.url));
}
}
export const config = { matcher: ["/dashboard/:path*"] };
Every choice in that file — jose over jsonwebtoken, no direct Postgres client, no fs — exists because Edge middleware ran on a restricted, non-Node runtime. That constraint was real and it shaped how an entire generation of Next.js auth code got written.
Then Next.js 16 ships, and the docs start talking about proxy.ts instead. The team upgrades. middleware.ts still runs — Next.js kept it working on purpose — so nothing visibly breaks, and the rename gets filed under "not our problem yet." Two things go quietly wrong from there:
- New code in the same repo starts appearing as
proxy.ts(copied from a blog post, a teammate's other project, or the docs), and now the app has both amiddleware.tsand a mental model split between two names for the same job. - Someone "helpfully" migrates the file and copies the runtime opt-in along with it:
// proxy.ts — this line is now meaningless
export const runtime = "edge"; // ❌ ignored — proxy always runs on Node.js
proxy.ts doesn't fail loudly here — it just runs on the Node.js runtime regardless, because that runtime cannot be configured. The Edge runtime isn't an option for proxy.ts at all. If your mental model is still "Edge middleware, just renamed," you'll misjudge what you can and can't do inside it.
The mental model: proxy.ts is the network boundary, not a request handler
The mental model: proxy.ts is the one file that sits in front of your entire app, on every request that matches its matcher, and runs before the App Router resolves a route — before any layout, page, Server Component, or Server Action executes. Next.js 16 renamed it from middleware.ts specifically to stop you from thinking of it as a request handler in the Express sense (a function in a chain, alongside your route logic). It's a network boundary: the place where you decide whether a request is even allowed to reach the app, and what it's allowed to carry in with it (a header, a rewritten path, a redirect).
The rename came with a runtime decision, not just new vocabulary: proxy.ts runs exclusively on the Node.js runtime. There is no export const runtime = "edge" for it — the option doesn't exist, because a proxy that always runs the same way, in the same environment, is the entire point. middleware.ts is still there for teams that specifically need Edge behavior, but it's documented as deprecated, scheduled for removal in a future major version. You're not choosing between two files going forward; you're on a deprecation clock.
What that buys you: proxy.ts can use anything the Node.js runtime supports — Node's built-in crypto, a real database driver for a session lookup, any npm package that assumes Node — without auditing it for Edge compatibility first. What it costs you: if your app specifically wanted Edge's global, low-latency execution for this boundary, that option is gone for new code. For the overwhelming majority of auth/redirect/rewrite logic, that trade is invisible; for a handful of latency-critical, globally-distributed checks, it's worth knowing about before you commit.
Migrating middleware.ts to proxy.ts, step by step
Step 1 — run the codemod, don't hand-edit. Next.js ships an automated migration:
npx @next/codemod@canary middleware-to-proxy
This renames middleware.ts → proxy.ts, renames the exported middleware function to proxy, and updates the config keys that renamed alongside it (for example skipMiddlewareUrlNormalize → skipProxyUrlNormalize, and experimental.middlewareClientMaxBodySize → experimental.proxyClientMaxBodySize in next.config.js). Run it, then read the diff — a codemod is a strong first draft, not a substitute for review.
Key concept: the codemod changes names, not behavior. Whatever your middleware did, your proxy does identically — the boundary's job hasn't moved, only its label and its guaranteed runtime.
Step 2 — delete any runtime opt-in. If your old file had export const config = { runtime: "edge" } or similar, remove it. It has no effect on proxy.ts, and leaving it in is the kind of thing that confuses the next engineer more than it confuses the framework.
Step 3 — keep the matcher, unmodified. The matcher config that scopes which paths trigger the boundary is unchanged:
// proxy.ts
export const config = {
matcher: ["/dashboard/:path*", "/api/protected/:path*"],
};
Step 4 — now you can simplify, if it helps. Because you're guaranteed Node.js, you can replace an Edge-safe workaround with the straightforward version, if one exists:
// proxy.ts — Next.js 16, Node.js runtime (the only option, and now a guarantee)
import { NextResponse } from "next/server";
import { jwtVerify } from "jose"; // still works fine — no need to rip it out
import type { NextRequest } from "next/server";
export function proxy(request: NextRequest) {
const token = request.cookies.get("session")?.value;
if (!token) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("from", request.nextUrl.pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = { matcher: ["/dashboard/:path*"] };
Key concept: nothing here had to change — jose runs fine on Node.js too. The point isn't "rewrite everything," it's that you're no longer required to reach for an Edge-safe library when a plain Node one would do, and you won't hit a surprise if a dependency assumes Buffer or crypto.createHmac exists.
Edge cases and gotchas
-
middleware.tsstill works — for now. Next.js 16 didn't remove it; it's deprecated and slated for removal in a future major version. If you need the Edge runtime specifically (for its global execution model), keep usingmiddleware.tswith itsruntimeopt-in and track the deprecation notice for when that stops being an option. -
The runtime is not configurable, in either direction. You can't opt
proxy.tsinto Edge, and there's no flag to forcemiddleware.tsonto Node.js. The two files map to two fixed runtimes; migrating means accepting the new one. -
Config keys renamed, not just the file. If your
next.config.jssetsskipMiddlewareUrlNormalizeorexperimental.middlewareClientMaxBodySize, those need theproxy-prefixed equivalents after migration — the codemod handles this, a manual rename easily misses it. - This doesn't remove the Edge runtime from Next.js. Route Handlers and pages can still opt into the Edge runtime where it's supported. The one place Edge specifically disappeared is the network-boundary file — don't over-generalize the change to the rest of the framework.
-
The rewrite/redirect logic itself hasn't changed.
NextResponse.next(),.redirect(),.rewrite(), and reading/writing cookies and headers all work the same way inproxy.tsas they did inmiddleware.ts. The migration is about the file's name, its exported function's name, and its runtime — not its API.
Best practices
-
Reach for
proxy.tsfor boundary decisions, not business logic: auth gating, locale/region redirects, A/B routing, header injection, and blocking bad requests before they cost you a route render. If a check needs your app's Server Components or database models to decide, it usually belongs past the boundary, not inside it. -
Run the codemod on every
middleware.tsyou own, even ones that "still work fine." The deprecation clock is real, and doing it now — while you can compare the diff against a file you understand — is cheaper than doing it later under a removal deadline. -
Keep the
matcheras narrow as the job needs. A boundary that runs on every request, including static assets it doesn't care about, is pure overhead; scope it to the paths that actually need the check. -
Don't move Edge-specific code into
proxy.tsunexamined. If a library was chosen specifically for Edge compatibility, it's fine to leave it — but don't assume you now need a different library, either. Change what the runtime actually requires you to change, nothing more.
FAQ
Is proxy.ts a completely new file, or a rename?
It's a rename with a runtime attached. Same conceptual job as middleware.ts — code that runs before the App Router resolves a route — but the exported function is now called proxy, the file is proxy.ts, and it always runs on the Node.js runtime.
Do I have to migrate right now?
No — middleware.ts still works in Next.js 16. But it's documented as deprecated and due for removal in a future major version, so treat this as scheduled work, not optional cleanup.
Can I run proxy.ts on the Edge runtime if I really want to?
No. The runtime for proxy.ts is fixed to Node.js and isn't configurable. If your use case specifically needs Edge, that's what middleware.ts remains for, while it's still available.
Will the migration change what my auth/redirect logic does?
It shouldn't. The codemod renames the file, the function, and the handful of config keys that renamed with it. The request/response API — NextResponse.next(), .redirect(), .rewrite(), cookies, headers — is unchanged.
Does this affect Route Handlers or pages that use the Edge runtime?
No. The Edge-runtime removal is specific to the network-boundary file. Route Handlers and pages can still opt into Edge where Next.js supports it there.
Cheat sheet
| Task | Next.js 16 way | Notes |
|---|---|---|
| File name |
proxy.ts (was middleware.ts) |
Old name still works, deprecated |
| Exported function | export function proxy(...) |
Was export function middleware(...)
|
| Runtime | Node.js only, not configurable | No Edge option for proxy.ts
|
| Scope which paths run it | export const config = { matcher: [...] } |
Unchanged from middleware.ts
|
| Migrate automatically | npx @next/codemod@canary middleware-to-proxy |
Renames file, function, and config keys |
| Renamed config keys |
skipMiddlewareUrlNormalize → skipProxyUrlNormalize; experimental.middlewareClientMaxBodySize → experimental.proxyClientMaxBodySize
|
Codemod handles these |
| Need Edge runtime specifically | Keep middleware.ts for now |
Tracked for future removal |
| Redirect / rewrite / headers API |
NextResponse.next() / .redirect() / .rewrite()
|
Identical to middleware.ts
|
// The canonical proxy.ts shape: check, then let through or redirect
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function proxy(request: NextRequest) {
const isAllowed = /* your boundary check — auth, locale, A/B, etc. */ true;
if (!isAllowed) return NextResponse.redirect(new URL("/login", request.url));
return NextResponse.next();
}
export const config = { matcher: ["/dashboard/:path*"] };
🎮 Try it yourself
▶️ Open the interactive playground →
Runs right in your browser — poke at it and watch the concept react live.
Key takeaways
-
proxy.tsis Next.js 16's rename ofmiddleware.ts— same job (the network boundary in front of your app), new name, and a fixed Node.js runtime that can't be configured. -
middleware.tsstill runs today, but it's deprecated; the official codemod (npx @next/codemod@canary middleware-to-proxy) migrates the file, the function name, and the config keys together. - The Edge runtime isn't gone from Next.js — it's gone specifically from this one boundary file, so don't over-apply the change to Route Handlers or pages.
- Because the boundary now runs on Node.js unconditionally, you can use ordinary Node-only libraries there without an Edge-compatibility audit — but you don't have to change code that already worked.
This series has already covered two things proxy.ts sits in front of: the request eventually reaches Server Actions and the mutation flow they run, and whatever renders downstream is shaped by Cache Components and what streams versus what's cached. Neither is required reading here, but both make more sense once you know what already ran before them.
🧠 Test yourself
Think it clicked? Take the 7-question quiz →
Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.
Your middleware.ts still works today — but it's running on borrowed time and an assumption about the Edge runtime that no longer holds for new code. Migrate it this week, while the diff is small and the reasoning is fresh, rather than in a rush when the removal notice finally lands. What's the messiest thing your boundary file currently does — and would you trust it to run on Node.js without a second look?
🚀 Want more like this? Every guide, playground, and quiz lives on bestpractic.org — open it and sign up free so the next one finds you.
Thanks for reading! Let's stay connected:
- ⭐ GitHub — follow me and star the projects: github.com/parsajiravand
- 💬 Discord — join the frontend best-practices community: discord.gg/d9KRhuAwQ
- 📸 Instagram — frontend best practices, daily: @bestpractice___
Top comments (0)