Migrating from Middleware to Proxy in Next.js 16
Step 22 of 31 — Next.js Tutorial Series | Source code for this step
What You Will Build
If you run pnpm dev (or npm run dev) with Next.js 16, you'll see this warning:
⚠ The "middleware" file convention is deprecated.
Please use "proxy" instead.
Learn more: https://nextjs.org/docs/messages/middleware-to-proxy
In Step 15, we created middleware.ts to protect routes using NextAuth.js. That file convention is now deprecated in Next.js 16. This step follows the official migration guide. You will:
- Understand what Proxy is and how it differs from Middleware
- Learn why Next.js renamed the convention
- Migrate your
middleware.tstoproxy.ts - Verify the deprecation warning is gone and route protection still works
Table of Contents
- What Is Proxy?
- Why Did Next.js Rename Middleware to Proxy?
- The Proxy Concept in JavaScript
- Proxy vs Middleware — What Changed?
- Migrating Our App
- Understanding the Migrated Code
- Verify the Migration
- Summary & Key Takeaways
What Is Proxy?
A proxy is code that sits between the client and your application. When a browser makes a request, the proxy intercepts it before any page or API route runs. It can then decide what to do:
- Allow the request to continue normally
- Redirect to a different URL (e.g., send unauthenticated users to
/login) - Rewrite the request to a different route (e.g., serve
/about-v2when the user visits/about) - Modify headers on the request or response
- Respond directly without ever hitting the application
Browser request
│
▼
┌────────┐
│ Proxy │ ← runs BEFORE any page or API route
└────┬───┘
│
├── redirect → /login
├── rewrite → /different-page
├── modify headers
└── allow → continue to the page/route
This is exactly what our middleware.ts was doing in Step 15 — intercepting requests to /posts/new and redirecting unauthenticated users to /login. The functionality is identical; only the file convention name has changed.
How Proxy Runs
Proxy runs on the server, before routes are rendered. In production on platforms like Vercel, it runs at the Edge — a lightweight environment close to the user, separate from the main application server.
This is why our auth config was split into two files in Step 15:
| File | Purpose | Runtime |
|---|---|---|
auth.config.ts | Lightweight config (session, callbacks) | Edge-safe |
auth.ts | Full config (bcrypt, Prisma, providers) | Node.js only |
The proxy can only import auth.config.ts because the Edge Runtime does not support Node.js modules like bcrypt or prisma. This has not changed — the same restriction applies to proxy.ts.
Why Did Next.js Rename Middleware to Proxy?
The Next.js team renamed the convention for two reasons:
1. The word "middleware" was misleading
"Middleware" in Express.js means a function that runs in the middle of a request pipeline — you can chain many middlewares, each modifying the request. Developers coming from Express expected Next.js middleware to work the same way.
But Next.js middleware is fundamentally different. It's not a chain of functions processing a request through your app. It's a single function that sits in front of your app — intercepting requests before they reach any route. That's what a proxy does.
2. Middleware was being overused
Because "middleware" sounds general-purpose, developers were putting too much logic in it — database queries, heavy computations, complex business logic. Next.js middleware was never designed for that. It's meant for lightweight operations: checking a cookie, redirecting, setting a header.
The rename to "proxy" signals this intent: use it as a thin network layer, not as a place for application logic.
In practice: The Next.js team's recommendation is to avoid relying on proxy unless no other option exists. For most tasks, there are better APIs — Server Components, Server Actions,
auth()checks in pages, etc. Proxy is a last resort for things that must happen before the route renders.
The Proxy Concept in JavaScript
For a complete tutorial on JavaScript's built-in Proxy, see JavaScript Proxy: Intercept and Control Object Operations.
The name "proxy" didn't come out of nowhere. JavaScript itself has a built-in Proxy object that works on the same principle: an intermediary that intercepts operations before they reach the real target.
JavaScript's new Proxy()
new Proxy(target, handler) creates a wrapper around any object or function. You define traps in the handler — functions that intercept specific operations like reading, writing, or deleting properties.
const user = { name: "John", role: "admin" };
const proxy = new Proxy(user, {
get(target, property) {
console.log(`Accessing ${property}`);
return target[property];
},
});
console.log(proxy.name);
// Output:
// Accessing name
// John
The caller interacts with proxy as if it were the real user object — but the get trap intercepts every property access.
Validation with set Trap
const person = { age: 20 };
const proxy = new Proxy(person, {
set(target, property, value) {
if (property === "age" && value < 0) {
throw new Error("Age cannot be negative");
}
target[property] = value;
return true;
},
});
proxy.age = 25; // ✅ works
proxy.age = -5; // ❌ throws Error
The proxy enforces rules before the value reaches the real object.
Access Control
const user = { name: "Admin", password: "secret123" };
const proxy = new Proxy(user, {
get(target, prop) {
if (prop === "password") {
return "Access denied";
}
return target[prop];
},
});
console.log(proxy.password); // "Access denied"
console.log(proxy.name); // "Admin"
Common Proxy Traps
| Trap | Triggered when |
|---|---|
get | Reading a property |
set | Writing a property |
has | Using the in operator |
deleteProperty | Deleting a property |
apply | Calling a function |
construct | Using new |
ownKeys | Listing keys (Object.keys) |
Real-World Usage
JavaScript's Proxy powers reactivity systems in frameworks like Vue 3. When you write const state = reactive({ count: 0 }), Vue internally wraps the object in a Proxy — so it knows when data changes and can update the UI automatically.
The Mental Model
Caller → Proxy → Real Object
The proxy decides what happens before the real object is touched. This is the same mental model for both:
JavaScript new Proxy() | Next.js proxy.ts | |
|---|---|---|
| What it intercepts | Object/function operations (get, set, delete) | HTTP requests (before routes render) |
| Where it sits | Between the caller and the target object | Between the browser and the application |
| What it can do | Validate, log, deny, modify | Redirect, rewrite, set headers, deny |
| Core idea | An intermediary that controls access | An intermediary that controls access |
Important: Next.js's
proxy.tsdoes not use JavaScript'snew Proxy()internally. They are different implementations that share the same concept and name. Next.js borrowed the term because the behavior is analogous — intercepting operations before they reach the real target.
Proxy vs Middleware — What Changed?
The API is nearly identical. Here's what changed and what stayed the same:
What changed
| Before (deprecated) | After |
|---|---|
File: middleware.ts | File: proxy.ts |
Export: export function middleware() or export const middleware = ... | Export: export function proxy() or export default function proxy() |
What stayed the same
- File location — project root (next to
app/), or insidesrc/if using that structure config.matcher— same syntax, same patternsNextRequestandNextResponse— same API- Edge Runtime restrictions — same limitations (no Node.js modules)
- Execution order — still runs before routes are rendered
The migration is essentially: rename the file, rename the export.
Migrating Our App
Here is our current middleware.ts from Step 15:
// middleware.ts (DEPRECATED)
import NextAuth from 'next-auth'
import { authConfig } from './auth.config'
const { auth } = NextAuth(authConfig)
export const middleware = auth
export const config = {
matcher: ['/posts/new'],
}
Step 1 — Rename the file
Rename middleware.ts to proxy.ts:
mv middleware.ts proxy.ts
Step 2 — Update the export
Replace the contents of proxy.ts:
// proxy.ts
import NextAuth from 'next-auth'
import { authConfig } from './auth.config'
const { auth } = NextAuth(authConfig)
export default auth
Step 3 — Add the matcher config
The config.matcher works the same way. Add it back:
// proxy.ts
import NextAuth from 'next-auth'
import { authConfig } from './auth.config'
const { auth } = NextAuth(authConfig)
export default auth
export const config = {
matcher: ['/posts/new'],
}
That's it. The complete migrated file is just 9 lines.
What about auth.config.ts and auth.ts?
No changes needed. The auth config split we set up in Step 15 still serves the same purpose — keeping the Edge-safe config (auth.config.ts) separate from the Node.js-only config (auth.ts). The proxy imports only auth.config.ts, just like the middleware did.
Understanding the Migrated Code
export default auth vs export const middleware = auth
In the old convention, Next.js looked for a named export called middleware. In the new convention, it accepts either:
- A named export called
proxy:export function proxy(request) { ... } - A default export:
export default function proxy(request) { ... }
Since NextAuth's auth function is already a complete request handler (it checks the session, runs the authorized callback, and redirects if needed), we export it as the default export. Next.js picks it up and runs it as the proxy function.
The request flow (unchanged)
The behavior is identical to Step 15:
1. Browser requests /posts/new
2. Proxy runs:
├── auth() reads the JWT session cookie
├── Runs the `authorized` callback from auth.config.ts
│ └── return !!auth?.user
│
├── If user exists → allow request → page renders
└── If no user → redirect to /login
Where the file lives
project-root/
├── app/
│ └── ...
├── auth.config.ts ← Edge-safe auth config (unchanged)
├── auth.ts ← Full auth config (unchanged)
├── proxy.ts ← Renamed from middleware.ts
└── ...
The file must be at the project root (same level as app/). This has not changed.
Verify the Migration
1. The deprecation warning is gone
Restart your dev server:
pnpm dev
The warning ⚠ The "middleware" file convention is deprecated should no longer appear.
2. Route protection still works
Test without a session:
- Open the app in an incognito/private browser window (no session)
- Navigate to
/posts/new - You should be redirected to
/login— the proxy blocked the request
Test with a session:
- Log in with a valid account
- Navigate to
/posts/new - The page should load normally — the proxy allowed the request
The behavior is identical to Step 15. The only difference is the file name.
Summary & Key Takeaways
| Concept | Details |
|---|---|
| What is Proxy | Code that runs between the client and your app — before any page or API route executes |
| Why the rename | "Middleware" was confused with Express.js middleware and encouraged overuse; "Proxy" clarifies its purpose as a thin network layer |
| What changed | File name (middleware.ts → proxy.ts) and export name (middleware → proxy or default export) |
| What stayed the same | File location, config.matcher, NextRequest/NextResponse API, Edge Runtime restrictions |
| Migration steps | Rename file, change export — everything else (auth config split, matcher, callbacks) stays identical |
| Auth config split | auth.config.ts (Edge-safe) and auth.ts (Node.js) — same pattern as Step 15, still required |
| Best practice | Use proxy as a last resort; prefer auth() checks in Server Components or Server Actions for most cases |
Automated migration: Next.js provides a codemod that handles the rename automatically. You can run
npx @next/codemod@canary middleware-to-proxy .instead of doing it manually. We did it manually in this tutorial so you understand what changed and why.
What is Next
In Step 23, we will add error handling using the error.tsx and not-found.tsx file conventions — showing meaningful error pages and custom 404 screens instead of crashing the app.