Orchestrating Requests in Proxy
Proxy (formerly Middleware) as a single checkpoint that runs before a route renders — auth redirects, region personalization, and security headers decided once instead of scattered across every page.
View the live demo →Concept: proxy
One checkpoint, before routing and rendering decide anything
Proxy acts as a centralized checkpoint before a request reaches your application's route rendering logic. It is code that runs before a request's routing and rendering decisions are finalized — early enough to inspect a request, redirect it, rewrite it, or attach headers, and late enough to already know the pathname, cookies, and headers the browser sent. It defaults to the Node.js runtime, so it has the same APIs a Route Handler does, just running one step earlier.
This file convention was named middleware.ts through Next.js 15. Next.js 16 renamed it to proxy.ts — same request/response API, exported as proxy instead of middleware — because "middleware" kept getting confused with the Express.js kind, which runs handlers in a chain rather than making a single early routing decision.
Typical responsibilities:
- Authentication: redirect unauthenticated users away from protected routes.
- Region personalization: rewrite URLs or pass a decided region downstream, based on country or locale.
- Security headers: apply a consistent set of HTTP response headers.
- Request routing: redirect legacy URLs or run A/B experiments.
A project has exactly one Proxy file. Everything it should and shouldn't touch is decided by one thing: its matcher.
Concept: the problem
Without it, the same check gets copy-pasted into every route
A session check that lives inside every protected page is a check that has to be remembered every time a new protected page gets added — and forgotten exactly once for a route to quietly ship without it. Proxy turns "does every route that needs auth check for it" into "does the one file at the project root check for it," run once per matched request instead of once per route.
request.cookies.get("auth-token")
→ no valid session, pathname under /dashboard-demo
→ redirect to /login-demo?returnTo=/dashboard-demoThe same request also carries a country code and a browser that will render whatever headers the response sends back — two more decisions ("which region's content" and "which security headers apply") that don't need the destination route's own logic to make.
Example: proxy.ts
All three decisions in one file
Reduced to its shape, a Proxy file makes its decisions in order: redirect, then personalize, then decorate the response.
// src/proxy.ts (condensed)
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
// 1. Authentication redirect
const claims = await verifyAuthToken(request.cookies.get(AUTH_COOKIE)?.value);
if (isDashboardDemoPath(pathname) && !claims) {
const loginUrl = new URL(LOGIN_DEMO_PATH, request.url);
loginUrl.searchParams.set("returnTo", pathname);
return withSecurityHeaders(NextResponse.redirect(loginUrl));
}
// 2. Region personalization
const country = request.cookies.get(DEMO_COUNTRY_COOKIE)?.value ?? "US";
const requestHeaders = new Headers(request.headers);
requestHeaders.set(CONTENT_REGION_HEADER, regionForCountry(country));
// 3. Security headers
return withSecurityHeaders(
NextResponse.next({ request: { headers: requestHeaders } }),
);
}
export const config = {
matcher: ["/patterns/request-orchestration/:path*"],
};A few details in this example are easy to get wrong:
- A cookie existing is not authentication. Checking
cookies.get("session")only proves a cookie was sent. Production code should validate a signed session or token, which is whyverifyAuthTokenchecks a JWT's signature and claims. - The region header is provider-specific and may be absent.
x-vercel-ip-countryonly exists behind Vercel, so always fall back to a default (the demo uses"US"). - Headers only land on responses you set them on. Setting them on the
NextResponse.next()result alone would skip redirects and rewrites, which is why the demo wraps every return inwithSecurityHeaders. - The matcher decides which routes run Proxy at all. Anything outside it gets no redirect, no region, and no headers.
Concept: scope
Keep it to the decisions that don't need a database
Auth/token validation, geolocation-based personalization, feature flags, redirects, and security headers are the recurring list of things worth centralizing here. Proxy is a poor fit for database operations, fetching a full user profile, expensive API calls, or detailed resource-level authorization. That work belongs in the Route Handler or Server Component the request eventually reaches, not in the checkpoint every request passes through first.
This demo's check is deliberately shallow for that reason: it verifies a signed JWT's claims, which is CPU work with no I/O, and nothing heavier. A real app might additionally confirm the session hasn't been revoked — that's the kind of check that belongs downstream instead, once, in whatever already talks to the database.
Proxy makes the request-level decision. It doesn't do the application's work — it hands the request to whatever does.
Concept: where things belong
Proxy vs. Server Components vs. Route Handlers
| Responsibility | Best location |
|---|---|
| Redirect unauthenticated users | Proxy |
| Verify permissions for sensitive data | Server service / data layer |
| Region-based URL rewrites | Proxy |
| Fetch product data | Server Component |
| Database operations | Server service / repository |
| Handle API requests | Route Handler |
| Cache server data | cache() / "use cache" where appropriate |
Concept: defense in depth
Proxy redirects away. It doesn't protect what runs after.
A matcher change, or a route that moves, can silently drop Proxy's coverage without anything erroring — the route just starts rendering for requests Proxy used to redirect. The dashboard demo re-verifies its own session cookie on render rather than trusting that Proxy already handled it, which is what the demo's two auth checks — one in Proxy, one in the page — are actually showing.
For example, Proxy can redirect a user without a session from /dashboard to /login. The dashboard's server-side data layer must still verify the session and permissions before returning private information. That gives each layer one job:
Proxy = request-level decisions
Server Components = rendering and data requirements
Services = authorization and business logic
Repositories = persistenceProxy should be a lightweight request orchestrator, not your application's business logic layer, and never a security boundary the rest of the app blindly trusts.
Concept: the flow
The whole lifecycle, in one picture
Everything above described one piece at a time. Here's how they fit into a single request's trip through the demo — the exception path (dashed, looping back through login-demo) versus the path a request with a session actually takes.
login-demo before returning to the same checkpoint, rather than dead-ending.Implementation example
The dashboard demo's structure
Visit the live demo without a session and Proxy redirects you to login-demo before anything renders. Sign in as one of two demo personas there and it hands back a signed JWT in an auth-token cookie, which is what gets you back into dashboard-demo on the next request.
src/proxy.ts ← the one Proxy file, matcher-scoped
to /patterns/request-orchestration/*
app/patterns/request-orchestration/
├── data.ts ← JWT sign/verify, demo personas,
│ country → region mapping
├── dashboard-demo/
│ └── page.tsx ← protected; re-checks its own session
└── login-demo/
├── page.tsx ← persona + simulated-country picker
└── actions.ts ← signs the JWT, sets/clears cookies
components/patterns/request-orchestration/
├── PersonaPicker.tsx
├── RegionPicker.tsx
├── SecurityHeadersPanel.tsx ← re-fetches the page, reads its own
│ response headers back
└── SignOutButton.tsxThe region picker stands in for a CDN header. Production deployments get a geolocation signal for free on every request (Vercel's x-vercel-ip-country, for instance) — NextRequest.geo itself was removed in Next.js 15. This demo has no CDN in front of it, so picking a country writes a cookie that Proxy reads the same way it would read that header.
The personalization header is set on the request, not the response. NextResponse.next({ request: { headers } }) forwards the decided region to whatever renders next, so the dashboard page can read it back with headers(). Setting it on the response instead would only ever reach the browser's network tab, not any server code downstream.
The security headers panel on the live demo re-fetches the current page and reads its own response headers back out — proof that Proxy's headers landed on the actual response, not just in the source.