nextjs · tutorial · agents

Middleware Is Dead. proxy.ts Ate It.

Next 16 replaces middleware.ts with proxy.ts: matcher audit, single-router rule, edge behavior parity. Migrate in twenty minutes inside.

A soldier on horseback leaping over an open touring car carrying two passengers on a parade ground, 1924
A soldier on horseback leaping over an open touring car carrying two passengers on a parade ground, 1924 · Photo: Harris & Ewing, public domain, via Wikimedia Commons

The middleware stopped existing

Upgrade morning. Auth checks, locale redirects, bot filtering — all lived in middleware.ts, all worked for years. Post-upgrade: logged-out users strolling into dashboards, locales unredirected, bots unfiltered. No errors anywhere. The middleware simply... wasn't running.

Because in Next 16 it's called proxy.ts. The old file sat there, valid TypeScript, fully ignored — a security guard who retired without telling anyone, uniform still on, post abandoned.

Middleware proxy.ts migration isn't a rename. It's a funeral with paperwork: matchers re-audited, behaviors re-verified, the old file deleted (not deprecated — dead).

Ignored middleware vs migrated proxy

Behavior middleware.ts (ignored) proxy.ts (live)
Auth gating Silently absent (open doors!) Enforced, verified
Locale redirects Missing (SEO drift) Working, tested
Bot filtering Off (meters spike) On, edge-cheap
Matcher coverage Assumed (stale) Audited (current)
Developer awareness Zero (no errors!) Ritual (this post)

Three details make silent retirement dangerous:

  1. Security evaporates without symptoms. Auth middleware ignored means open routes serving normally — no errors, no logs, no alerts. The breach looks exactly like the app working. Silent security removal is the worst failure class: everything green, doors open, discovery by stranger.
  2. Matchers rot across renames. Old matcher patterns (written for middleware semantics) may misbehave under proxy edge semantics — paths matched differently, exclusions shifted, static assets caught. Rename-plus-audit beats rename-alone; unreviewed matchers are wishes, not rules.
  3. Coexistence is the trap. Old file present plus new file partial equals requests falling through both (the blank-page post's ghost). Migration means moved (old deleted, new proven) — never doubled, never gradual-at-the-file-level. Two routers means no router, full stop.

The blank-page post covered the void. This one buries the guard properly.

Middleware proxy.ts migration ritual

Audit, migrate, delete — twenty minutes, zero open doors after.

Step 1 — Inventory what the old guard actually did.

# read middleware.ts fully (probably small, probably critical):
# list: auth checks? redirects? rewrites? headers? bot rules? logging?
# each behavior becomes a checklist row (migrated + verified, or deliberately dropped)

Inventory before migration (behaviors documented, not assumed) — most middleware files hold 3–5 jobs nobody remembers choosing. Each job needs a verdict (migrate verbatim, improve in transit, drop deliberately). Undocumented behaviors migrate as surprises; inventoried ones migrate as tasks. List first, move second.

Step 2 — Write proxy.ts with audited matchers.

proxy.ts (sketch — verify current API per minor)
import { NextResponse } from "next/server";
export default function proxy(req) { /* ported behaviors, same order */ }
export const config = { matcher: ["/dashboard/:path*", "/admin/:path*"] };
// matchers: explicit paths (audited), static assets excluded (deliberate), tested per path

Port behaviors in order (same sequence, same semantics), audit matchers against actual routes (old patterns re-examined, orphans adopted or dropped), exclude statics deliberately (edge cheap, origin spared). Matcher review is security review wearing config clothes — every path either guarded or knowingly open. Explicit or vulnerable, no third option.

Step 3 — Delete the old file, verify edge parity.

# exactly one router file exists afterward:
ls middleware.* proxy.* 2>/dev/null   # expect: proxy.ts ONLY
# verify: logged-out dashboard → login redirect ✓ | locale → redirect ✓
# bots → filtered ✓ | static assets → untouched, fast ✓

Deletion is the migration (coexistence was the bug). Verify each inventoried behavior live (not "config looks right" — actual requests, actual redirects, actual blocks). Edge parity proven per behavior closes the ritual; anything unverified is assumed broken until witnessed. Delete, verify, sleep.

And that's the whole picture — guards retired with honors, successors verified.

The migration ledger

Behavior (old file) Migrated? Verified live
Dashboard auth gate Yes, verbatim 401→login redirect ✓
Locale redirects Yes, improved / → /en, tested ✓
Bot user-agent filter Yes Bot curl blocked ✓
Security headers Yes Headers present ✓
Old logging hook Dropped (dead code) N/A (deliberate)
Open-door window ~2 days (unknowing) 0 after ritual

BoringPush guards retire with paperwork.

Honesty first: migration afternoons feel wasteful (renaming working config) until the alternative prices itself (open auth for days, SEO drift for weeks, bot meters for a month). Priced against twenty ritual minutes: funerals with paperwork beat retirements without notice, every major, forever. Bury guards properly; successors start clean.

FAQ

Is middleware proxy.ts migration just a rename I can sed?

No — semantics shift underneath (edge runtime nuances, matcher interpretation, export shapes), and sed renames syntax while preserving assumptions. Migrate by inventory (behaviors listed), port (semantics verified), delete (old gone), verify (live requests). Four steps where sed does half of one. Renames are easy; migrations are careful. Be careful.

What happens to middleware tests during migration?

Port them alongside (same behaviors, new entry point): auth-gate tests hit proxy paths, redirect tests assert new responses, bot tests curl blocked agents. Untested migrations are hopes; ported tests are proof. The pre-push gate runs them; CI enforces them. Tests travel with behavior, never stay with filenames.

Should edge logic move into layouts/routes instead of proxy?

Case by case: auth-at-edge stays (early rejection saves origin work), locale redirects stay (edge-cheap, correct layer), complex business rules move inward (routes have full context, testability, DB access). Proxy handles perimeter (who enters, where redirected); routes handle meaning (what's allowed, what's served). Perimeter at edge, meaning inside — layers with jobs, not opinions.

How do I know the old middleware isn't still running somewhere?

Delete it (git rm, committed, deployed) plus grep the repo (zero middleware.* references) plus edge behavior test (remove proxy temporarily on staging → expect open doors → restores certainty the proxy was guarding). Deletion plus negative testing proves the new guard owns the perimeter. Trust, but verify by removing.

Do monorepos or turborepos change proxy migration?

Multiplied paths need per-app inventory (each app's behaviors listed separately) plus shared-package updates (common guards versioned, consumed uniformly). Migrate per app, share via versioned package, verify each perimeter independently. Monorepo migrations are N small rituals, never one big one. Slice the funeral too.

When is staying on middleware (old major) legitimate?

Pinned majors during revenue weeks (launches, holidays) or deep entanglement (custom edge runtime forks depending on middleware internals): pin with calendar date, migrate next quiet week, never indefinitely. Pinning is scheduling (explicit, dated), not strategy (permanent, comfortable). Calendar the funeral; unmigrated middleware rots from supported to haunted.

Keep reading

+ all the boring stuff (analytics, auth, SQLite ↔ Supabase, full marketplace, webhooks)

The Agent-First Shipping Kit

Command from your phone.

Built so autonomous AI agents build what you want without breaking what's already paying you.

$199

Get BoringPush

One-time payment · Lifetime updates · Unlimited projects.

Keep reading