TL;DR
middleware.tsis deprecated → renamed toproxy.tswith function nameproxy.- Proxy can only rewrite, redirect, or modify headers — no response bodies.
- React 19.2 brings
<Activity>for state-preserving visibility,useEffectEventfor stable callbacks. - Dynamic APIs (
cookies(),headers(),params) must be awaited in Next.js 16.
Step 1: proxy.ts — The New Middleware
Next.js replaced middleware.ts with proxy.ts because the original middleware had a fundamental limitation: it ran in Edge Runtime, which doesn't support Node.js APIs (no fs, no native modules, limited crypto). Most real-world middleware needs (auth checks, database lookups, A/B testing) required Node.js APIs, forcing developers into awkward workarounds. proxy.ts runs in the Node.js runtime with full API access, can proxy to different origins, and supports streaming — making it a true reverse proxy layer rather than a limited edge function.
Migration
# Automatic codemod
npx @next/codemod@canary upgrade latest
# Output: Migrated middleware.ts to proxy.ts
# Or manual rename
mv middleware.ts proxy.ts
Before (Next.js 15)
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
if (!request.cookies.get("session")) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*"],
};
After (Next.js 16)
// proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function proxy(request: NextRequest) {
if (!request.cookies.get("session")) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/settings/:path*"],
};
What Changed
| Feature | middleware.ts (old) | proxy.ts (new) |
|---|---|---|
| Function name | middleware |
proxy (or default export) |
| Response bodies | Allowed | Not allowed |
| Purpose | General middleware | Network boundary only |
| Rewrites/redirects | ✅ | ✅ |
| Header modification | ✅ | ✅ |
| Return full response | ✅ | ❌ Use Route Handlers |
Common Proxy Patterns
// proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
// Pattern 1: Auth redirect
const session = request.cookies.get("session");
if (pathname.startsWith("/dashboard") && !session) {
return NextResponse.redirect(new URL("/login", request.url));
}
// Pattern 2: Geo-based routing
const country = request.headers.get("x-vercel-ip-country") ?? "US";
if (pathname === "/" && country === "DE") {
return NextResponse.rewrite(new URL("/de", request.url));
}
// Pattern 3: A/B testing
const bucket = request.cookies.get("ab-bucket")?.value ?? "control";
if (pathname === "/pricing") {
return NextResponse.rewrite(new URL(`/pricing/${bucket}`, request.url));
}
// Pattern 4: Add headers
const response = NextResponse.next();
response.headers.set("x-request-id", crypto.randomUUID());
return response;
}
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};
Step 2: Async Dynamic APIs
Next.js 16 made headers(), cookies(), params, and searchParams async because the synchronous versions forced the entire route to be dynamically rendered. With async versions, Next.js can statically analyze which parts of your page actually need request-time data and only make those parts dynamic. This enables more aggressive static optimization — a page can be mostly cached with only the cookie-reading part being dynamic. The codemod handles migration automatically, but understanding why helps you design better component boundaries.
In Next.js 16, dynamic APIs are async by default:
// ❌ Before (Next.js 15) — synchronous
import { cookies, headers } from "next/headers";
export default function Page({ params }) {
const cookieStore = cookies();
const headerList = headers();
const { slug } = params;
}
// ✅ After (Next.js 16) — must await
import { cookies, headers } from "next/headers";
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const cookieStore = await cookies();
const headerList = await headers();
const { slug } = await params;
return <div>{slug}</div>;
}
All Affected APIs
// All of these must be awaited in Next.js 16:
const cookieStore = await cookies();
const headerList = await headers();
const { id } = await params;
const { page } = await searchParams;
Step 3: React 19.2 Features
React 19.2 (bundled with Next.js 16) introduces features that solve long-standing UX problems: the Activity component preserves state of hidden tabs (no more losing form data when switching tabs), and improved Suspense boundaries enable more granular streaming. These features were developed in partnership between the React and Next.js teams specifically for the Server Components architecture — they work best when the framework can coordinate server rendering, streaming, and client hydration together.
Activity Component — State-Preserving Visibility
<Activity> hides/shows components without destroying state:
"use client";
import { Activity } from "react";
import { useState } from "react";
function TabPanel({ activeTab }: { activeTab: string }) {
return (
<>
<Activity mode={activeTab === "home" ? "visible" : "hidden"}>
<HomePage /> {/* State preserved when hidden! */}
</Activity>
<Activity mode={activeTab === "settings" ? "visible" : "hidden"}>
<SettingsPage /> {/* Form inputs, scroll position — all kept */}
</Activity>
</>
);
}
Use cases:
- Tab interfaces (keep form state when switching tabs)
- Pre-rendering content user will likely visit
- Offscreen preparation (like mobile back gesture)
useEffectEvent — Stable Event Callbacks
Solves the "stale closure" problem in effects:
"use client";
import { useEffect, useEffectEvent } from "react";
function ChatRoom({ roomId, onMessage }) {
// This callback always has latest props — but doesn't trigger effect re-run
const onMsg = useEffectEvent((message) => {
onMessage(roomId, message); // Always latest roomId and onMessage
});
useEffect(() => {
const conn = connect(roomId);
conn.on("message", onMsg);
return () => conn.disconnect();
}, [roomId]); // onMessage NOT in deps — no infinite loops!
}
View Transitions
"use client";
import { useRouter } from "next/navigation";
import { startTransition } from "react";
function NavigationLink({ href, children }) {
const router = useRouter();
const handleClick = (e: React.MouseEvent) => {
e.preventDefault();
// Smooth animated transition between pages
document.startViewTransition(() => {
startTransition(() => {
router.push(href);
});
});
};
return <a href={href} onClick={handleClick}>{children}</a>;
}
Step 4: Route Structure (Updated for 16)
Next.js's file-based routing was inspired by PHP's simplicity (file = URL) but enhanced with React's component model. The App Router (introduced in Next.js 13, refined through 16) adds conventions for layouts, loading states, error boundaries, and parallel routes — all expressed through file naming. Understanding the file conventions (page.tsx, layout.tsx, loading.tsx, error.tsx, not-found.tsx) is essential because they map directly to React's rendering model: layouts don't re-render on navigation, loading files are Suspense boundaries, and error files are error boundaries.
app/
├── proxy.ts → Network boundary (was middleware.ts)
├── layout.tsx → Root layout (can use "use cache")
├── page.tsx → / route
├── loading.tsx → Loading UI (Suspense boundary)
├── error.tsx → Error boundary
├── not-found.tsx → 404
├── blog/
│ ├── page.tsx → /blog
│ └── [slug]/
│ └── page.tsx → /blog/:slug
├── (marketing)/ → Route group (no URL segment)
│ ├── about/page.tsx → /about
│ └── pricing/page.tsx → /pricing
├── @modal/ → Parallel route (slot)
│ └── default.tsx → Required in Next.js 16
└── api/
└── users/
└── route.ts → API Route Handler
Breaking: All parallel route slots need default.tsx
// app/@modal/default.tsx — REQUIRED in Next.js 16
export default function Default() {
return null;
}
Step 5: Breaking Changes Summary
Major version upgrades always have breaking changes, and Next.js 16 has several significant ones. The team tries to provide codemods for mechanical migrations (middleware → proxy, sync → async APIs), but architectural changes (PPR → use cache, new caching semantics) require understanding the new mental model. This summary gives you a quick reference for what needs to change and what's automated, making upgrades from 14/15 manageable.
| Change | Action Required |
|---|---|
middleware.ts → proxy.ts |
Rename file + function |
| Dynamic APIs are async | Add await to cookies(), headers(), params |
| Node.js 18 dropped | Upgrade to Node.js 20.9+ |
| AMP removed | Remove all AMP APIs |
next lint removed |
Use Biome or ESLint directly |
Parallel routes need default.tsx |
Add default.tsx to all @ slots |
| Image cache TTL | Now 4 hours (was 60s) |
| Turbopack is default | No action (Webpack still available via flag) |
Interview Questions
-
Why was middleware renamed to proxy?
- To clarify its purpose: it handles network boundary concerns (rewrites, redirects, headers) — not application logic. No response bodies allowed.
-
Why are dynamic APIs async in Next.js 16?
- Enables better streaming and concurrent rendering. The runtime can start sending the response before all cookies/headers are resolved.
-
What's
<Activity>in React 19.2?- A component that controls visibility while preserving state. Hidden components keep their DOM and React state, unlike conditional rendering which destroys them.
-
What's the difference between
useEffectEventanduseCallback?useEffectEventcreates a stable reference that always sees latest values without being a dependency.useCallbackrequires deps and causes effect re-runs when those deps change.