proxy.ts (Successor to Next.js Middleware)
In short: In Next.js 16, middleware.ts was renamed to proxy.ts — only one of the two files may exist in the project, otherwise the build breaks.
In more detail: Like the former middleware, it runs before the actual rendering/routing and can rewrite or redirect requests, or abort early with its own response (e.g. 429 when a rate limit is exceeded). The matcher (config.matcher) determines for which paths the function is called at all.
Our context: At Emzett, proxy.ts deliberately handles TWO tasks in one file: global rate limiting (broad matcher, almost every request, except pure read-only pages without forms) and a lightweight auth redirect for protected areas (only a “cookie present” check — the full session verification against the DB still happens on the server in getCurrentUser()). According to AGENTS.md in the project root, a deliberately documented Next.js 16 pitfall, because training knowledge still assumes middleware.ts here.
In Depth
The matcher as a performance lever
The matcher in the exported config is crucial for performance: if the function runs on EVERY request (including all static assets), it costs unnecessary edge function invocations — each invocation means additional latency and, on most hosting platforms, additional cost. A typical pattern specifically excludes what never needs to go through the middleware:
export const config = {
matcher: [
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)",
],
};Internally, the matcher is compiled into a regular expression and checked BEFORE every request — so it runs even if the actual middleware function isn’t executed as a result. Static matcher strings (like the one above) are more performant than ones assembled dynamically with array methods, since Next.js can analyse them fully at build time.
Rewriting, redirecting or aborting
Inside the middleware function there are essentially three ways to respond: NextResponse.next() lets the request continue unchanged, NextResponse.redirect(url) sends a real HTTP redirect to the browser (the URL in the address bar visibly changes), and NextResponse.rewrite(url) serves content from a different internal URL WITHOUT the browser noticing (the address bar stays unchanged). For auth redirects, redirect() is usually right (the user should see that they’re landing on /login, for example), while rewrite() is more suitable for internally mapping to static exports or A/B-testing variants.
Interaction with next.config.ts’s rewrites()
An important, easily overlooked point: next.config.ts’s rewrites() and the middleware run in different phases of request processing — if a path points via a rewrite to completely different, standalone static content (e.g. a pre-built static site export), but the middleware matcher still covers it, ALL of that page’s subsequently loaded assets (CSS, JS, JSON) run through the same middleware logic individually — with globally applied rate limiting, this can quickly blow the limit for a single “page view”, because dozens of individual requests are counted instead of one. An explicit exception in the matcher or in your own middleware logic is then needed for such paths.
Limits of the edge runtime
Middleware/proxy.ts runs by default in an edge runtime, not a full Node.js environment — many Node APIs (e.g. native fs access to the file system, some database drivers) aren’t available there. This is one of the reasons why auth checks in proxy.ts typically only do a lightweight “cookie present” check (instead of a real database query), while the actual, complete session verification still happens in a regular Server Component or route handler with a full Node.js environment.
See also: Next.js App Router, Rate Limiting, Open Redirect