John Aleman

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-demo

The 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 why verifyAuthToken checks a JWT's signature and claims.
  • The region header is provider-specific and may be absent. x-vercel-ip-country only 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 in withSecurityHeaders.
  • 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

ResponsibilityBest location
Redirect unauthenticated usersProxy
Verify permissions for sensitive dataServer service / data layer
Region-based URL rewritesProxy
Fetch product dataServer Component
Database operationsServer service / repository
Handle API requestsRoute Handler
Cache server datacache() / "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      = persistence

Proxy 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.

BrowserGET dashboard-demoProxysrc/proxy.tsmatcher: /patterns/request-orchestration/*reads auth-token cookieverifyAuthToken(token) → claims | nullvalid session?noyeslogin-demoPersonaPicker→ signInAsDemoPersona()sets auth-token cookiehttpOnly · sameSite=laxredirect · retryx-content-region4 security headersdashboard-demore-verifies sessioncookies().get('auth-token')headers().get('x-content-region')renders Welcome + region + headers proofSecurityHeadersPanel confirms them client-side
The demo's actual control flow — the no-session branch loops back through 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.tsx

The 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.