From middleware.ts to proxy.ts: Migrating Middleware in Next.js 16

8 min readnextjs

Next.js 16 renamed one of the framework’s oldest conventions: middleware.ts is now proxy.ts, and the exported middleware function is now proxy. Nothing about what the file can do changed. The request and response types are the same, the matcher config is the same, and rewrites, redirects, and header tweaks work exactly as before. It is purely a rename — but a deliberate one, and the reasoning behind it tells you what the Next.js team thinks this code should and should not be doing.

I went through the migration on this site, which runs a small link shortener with a Supabase-backed dashboard — the stack I walked through in Build a URL Shortener with Next.js and Supabase — and its middleware did two jobs: refresh the Supabase session cookie and gate the dashboard behind login. The mechanical rename took about two minutes. Deciding what belongs in that file took longer, and that is the more useful half of this post.

By the end you will know exactly what changed in Next.js 16 and what stayed the same, how to migrate step by step, what a migrated file looks like in a real app that does auth gating, and how to keep the proxy layer thin enough that it never becomes your slowest code path.

Why Next.js renamed middleware to proxy

The word “middleware” carries baggage. In Express and Koa, middleware means a chain of small functions living inside your application — body parsing, logging, sessions, auth — each one doing a piece of work and calling the next. If that is your mental model, then treating middleware.ts as a home for real per-request business logic feels natural. That is exactly what a lot of people did.

But Next.js middleware never worked like Express middleware. There is exactly one per application, not a chain. It runs before the router sees the request, in front of the entire app, and it historically ran in a constrained edge runtime that did not support everything Node.js does. Functionally it sits where a reverse proxy sits: it can inspect an incoming request, rewrite the URL, redirect, adjust headers or cookies — and then it must get out of the way so the application can actually respond.

The mismatch caused real problems. Because the name suggested Express, developers put database queries, full session validation, and analytics writes into middleware — code that then executed in front of every matched request on the site. The rename in the Next.js 16 release is honest labeling: this layer behaves like a proxy, so now it is called one. Same machinery, better name, clearer expectations.

What changed — and what stayed the same

The entire delta fits in a small table:

AspectBefore (Next.js 15)After (Next.js 16)
File namemiddleware.tsproxy.ts
Exported functionmiddlewareproxy
config.matcherUnchangedUnchanged
NextRequest / NextResponseUnchangedUnchanged
Rewrites, redirects, headers, cookiesUnchangedUnchanged
One file per applicationUnchangedUnchanged

middleware.ts still works in Next.js 16 — the old name is deprecated rather than removed — but support is expected to go away in a future major version, so it is worth doing the rename as part of the upgrade rather than leaving it for someday. For the current deprecation status and the full API surface, the proxy file convention page in the Next.js docs is the source of truth.

One adjacent change is worth knowing about: around the same era, Next.js stabilized running this layer on the Node.js runtime rather than only the edge runtime, which lifts the old restrictions on Node APIs. Exactly which runtimes are available depends on your Next.js version and your hosting platform, so check the docs for your setup rather than assuming.

Step-by-step migration

Here is a minimal pre-migration file, the shape most apps have:

// middleware.ts — Next.js 15 and earlier
import { NextResponse, type NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  // inspect, rewrite, redirect...
  return NextResponse.next();
}

export const config = {
  matcher: ["/dashboard/:path*"],
};

The migration is five small steps:

  1. Rename the file. middleware.ts becomes proxy.ts, in the same location — project root, or inside src/ if that is where it lived before.
  2. Rename the export. export function middleware becomes export function proxy. If you used a default export, that still works the same way.
  3. Leave everything else alone. The config export, the matcher patterns, and every NextResponse call are untouched.
  4. Hunt for stragglers. Grep for the old file path in tests, tooling, and docs. Anything importing the middleware module directly — unit tests are the usual suspect — needs the new path.
  5. Verify. Make sure the old file is actually gone — you do not want both names in the tree — then run a production build and click through the routes your matcher covers.

The result, for the minimal example above:

// proxy.ts — Next.js 16
import { NextResponse, type NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  return NextResponse.next();
}

export const config = {
  matcher: ["/dashboard/:path*"],
};

The Next.js upgrade tooling around @next/codemod can handle renames like this as part of a version bump. I did it by hand, because it is two edits.

A real example: Supabase session refresh and auth gating

Minimal examples undersell what this file is for, so here is the actual proxy.ts running this site, lightly trimmed. It refreshes the Supabase auth session and gates two route groups: the dashboard pages and the link-management API.

// src/proxy.ts
import { NextResponse, type NextRequest } from "next/server";
import { updateSupabaseSession } from "@/lib/supabase/session";

export async function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // Only refresh the session + auth-gate on protected routes.
  if (
    !pathname.startsWith("/dashboard") &&
    !pathname.startsWith("/api/links")
  ) {
    return NextResponse.next();
  }

  const { response, user } = await updateSupabaseSession(request);

  if (!user && pathname.startsWith("/dashboard")) {
    const url = request.nextUrl.clone();
    url.pathname = "/login";
    url.searchParams.set("next", pathname);
    return NextResponse.redirect(url);
  }

  if (!user && pathname.startsWith("/api/links")) {
    return NextResponse.json({ error: "unauthorized" }, { status: 401 });
  }

  return response;
}

export const config = {
  matcher: ["/dashboard/:path*", "/api/links/:path*"],
};

Three behaviors, in order:

The session refresh helper

The interesting work hides inside updateSupabaseSession, which follows the pattern from the Supabase server-side auth guide. Condensed:

// lib/supabase/session.ts (condensed)
export async function updateSupabaseSession(request: NextRequest) {
  let response = NextResponse.next({ request });

  const supabase = createServerClient(SUPABASE_URL, ANON_KEY, {
    cookies: {
      getAll: () => request.cookies.getAll(),
      setAll(cookiesToSet) {
        cookiesToSet.forEach(({ name, value }) =>
          request.cookies.set(name, value),
        );
        response = NextResponse.next({ request });
        cookiesToSet.forEach(({ name, value, options }) =>
          response.cookies.set(name, value, options),
        );
      },
    },
  });

  const {
    data: { user },
  } = await supabase.auth.getUser();

  return { response, user };
}

The double cookie write looks odd but is load-bearing: refreshed auth tokens have to reach both the browser (via the response) and the server components rendering later in this same request (via the request). And the check uses supabase.auth.getUser(), which validates the token against Supabase, rather than trusting whatever the cookie claims. The full flow — callback route, redirect handling, and the gotchas — is in Magic Link Auth in Next.js with Supabase.

Keep the proxy thin

The rename is a design hint, and it is worth taking. Everything in this file runs before anything else can, on every matched request, so latency added here is added everywhere. Work that belongs in a proxy layer:

Work that does not:

The Supabase session refresh above is close to the ceiling of what I would accept: getUser() is a network round-trip. That is precisely why the matcher confines it to the dashboard and its API — the public site and the redirect path never pay for it.

One more framing that keeps proxies honest: the gate is a user experience feature, not your security boundary. A redirect to /login keeps signed-out users from seeing a broken dashboard, but actual data protection has to live where the data is — route handlers re-checking auth, and in Supabase’s case row level security, which I covered in Supabase Row Level Security: Practical Patterns That Scale. If the proxy were deleted tomorrow, nobody should gain access to anything.

Post-migration checklist

Bottom line

The migration itself is trivial: rename the file, rename the export, change nothing else. NextRequest, NextResponse, and config.matcher all carry over untouched, and the old name keeps working in Next.js 16 while deprecated. Do the rename during the upgrade and it costs you minutes.

The lasting value is the mental model shift the name enforces. Treat proxy.ts the way you would treat rules on a reverse proxy: decide fast, redirect or rewrite or annotate, and hand off. Auth gating with a tight matcher is squarely inside that job description. Heavy logic is not — it belongs in the routes, next to the data it needs, behind a proxy that stays out of the way.