Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 031 / 100

Middleware is now proxy: real-world lessons and examples

Next.js 16 renamed middleware.ts to proxy.ts.

Expona Admin9 min read

Next.js 16 has renamed middleware.ts to proxy.ts, marking a subtle but meaningful shift in how we think about request interception in our applications. If you've been building with Next.js for a while, you might wonder what changed beyond the filename. In this article, we'll explore what middleware is now proxy means, why it matters, and how to use it safely in production systems.

What is Proxy and Why the Name Change?

The renaming from middleware to proxy reflects the actual role this feature plays in your Next.js application. A proxy sits between the client request and your routes, intercepting and potentially modifying the request or response before it reaches your handler. This is exactly what the old middleware did, but calling it proxy makes the mental model clearer.

In my experience building e-commerce platforms at scale, having clarity around naming saves debugging time later. When a junior developer sees proxy.ts, they immediately understand: this code runs early, it can redirect traffic, rewrite URLs, or inject headers. Compare that to middleware, which could mean anything from authentication to logging to request transformation.

The proxy file still lives at the root of your src directory (or project root if you're not using src), and it still exports a middleware function. The filename change is what's new, but the core functionality remains the same.

tsx
// src/proxy.ts
import { NextRequest, NextResponse } from 'next/server';

export function middleware(request: NextRequest) {
  // Your logic here
  return NextResponse.next();
}

export const config = {
  matcher: ['/api/:path*', '/admin/:path*'],
};

Runs Before Routes: Redirects, Rewrites, Headers

Proxy code executes on the Edge runtime, which means it runs before your route handlers or server components. This gives you a powerful place to make decisions about what happens next.

Consider a real-world scenario: you're running a multi-tenant SaaS platform. Different customers access the same app via different subdomains (customer1.app.com, customer2.app.com). Your proxy can detect the subdomain and rewrite the request to include the tenant ID before it reaches your route handler.

tsx
// src/proxy.ts
import { NextRequest, NextResponse } from 'next/server';

export function middleware(request: NextRequest) {
  const hostname = request.headers.get('host');
  const subdomain = hostname?.split('.')[0];

  if (subdomain && subdomain !== 'www' && subdomain !== 'app') {
    // Rewrite to /api/tenant/[id]/... while keeping the URL clean
    const newUrl = new URL(request.url);
    newUrl.pathname = `/api/tenant/${subdomain}${request.pathname}`;
    return NextResponse.rewrite(newUrl);
  }

  return NextResponse.next();
}

You can also inject headers that your route handlers rely on. I've used this pattern in ride-hailing platforms to pass a correlation ID for distributed tracing:

tsx
export function middleware(request: NextRequest) {
  const correlationId = request.headers.get('x-correlation-id') || 
                        crypto.randomUUID();

  const response = NextResponse.next();
  response.headers.set('x-correlation-id', correlationId);
  
  return response;
}

Redirects are equally useful. You might redirect traffic based on the user's location, browser, or device type. On a travel booking platform, I redirected mobile users to a mobile-optimized flow before they hit the main route:

tsx
export function middleware(request: NextRequest) {
  const ua = request.headers.get('user-agent');
  const isMobile = /mobile|android|iphone/i.test(ua || '');

  if (isMobile && request.nextUrl.pathname === '/') {
    return NextResponse.redirect(new URL('/mobile-home', request.url));
  }

  return NextResponse.next();
}

Keep It Fast — It Runs on Every Matched Request

This is the critical constraint. Your proxy code executes on every single request that matches your matcher pattern. If you have a slow operation in there, every user feels the impact immediately.

I once made the mistake of querying a database inside middleware to check feature flags. The latency was brutal: every request added 50-100ms because the database was in a different region. Users noticed. Dashboards lit up red. We refactored in an emergency.

The Edge runtime has limitations by design. You can't use Node.js APIs like fs or spawning processes. You're limited to what the Edge runtime provides: fetch, Web APIs, and some Vercel-specific utilities. This forces you to keep middleware lean.

tsx
// ✗ DON'T: Database queries in middleware
import { db } from '@/lib/db';

export async function middleware(request: NextRequest) {
  const user = await db.user.findUnique({ where: { id: userId } });
  // Slow and blocks the request
}

// ✓ DO: Use edge-compatible operations
export function middleware(request: NextRequest) {
  const token = request.cookies.get('auth_token')?.value;
  // Fast, synchronous, no I/O
  return NextResponse.next();
}

If you need to enrich requests with data, consider these patterns:

  • Store simple data in cookies or JWT tokens that the middleware can decode
  • Use Vercel KV (Redis) for millisecond-fast lookups of feature flags or user settings
  • Defer heavy operations to your route handler, not the middleware

The rule I follow: if an operation takes longer than a few milliseconds, it doesn't belong in your proxy.

Use Matcher to Limit Which Paths It Handles

The matcher configuration is your way of saying: "only run this proxy code for these specific routes." Without it, your code could run on every single request, including static assets, which is wasteful.

Matchers accept patterns using Next.js's built-in syntax. You can match specific paths, wildcards, and regex patterns.

tsx
export const config = {
  matcher: [
    // Exact paths
    '/admin',
    // Path segments
    '/api/:path*',
    // Multiple patterns
    '/((?!static|.*\\.png).*)',
  ],
};

I typically use matchers to exclude static assets and public resources, then apply specific logic to protected routes. Here's a pattern that's worked well across multiple projects:

tsx
// src/proxy.ts
import { NextRequest, NextResponse } from 'next/server';

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

  // Add request ID header for all matched requests
  const response = NextResponse.next();
  response.headers.set('x-request-id', crypto.randomUUID());

  return response;
}

export const config = {
  matcher: [
    // Protect API routes
    '/api/:path*',
    // Protect admin routes
    '/admin/:path*',
    // Protect dashboard
    '/dashboard/:path*',
    // But skip static files and images
    '/((?!_next/static|_next/image|favicon.ico).*)',
  ],
};

One thing I learned the hard way: overly complex regex patterns in matchers can cause unexpected behavior. Keep them simple and test them thoroughly in development. Use a tool to visualize the regex if you're doing anything beyond basic wildcards.

Do Not Rely on It as Your Only Auth Check

This is the critical security principle that separates production-ready code from code that looks secure but isn't. Proxy code runs on the Edge, which means it can be bypassed or bypassed in ways you don't expect. If you're protecting sensitive operations, you need authentication checks in your actual route handler as well.

Here's what I mean. A proxy can redirect unauthenticated users away from a page, but a determined attacker could directly call your API endpoint, bypassing the proxy. Or someone could craft a request that doesn't match your matcher pattern. The proxy is a first-line defense, not the fortress itself.

Think of it like a security checkpoint at a building entrance. It might turn away most people, but the valuables in the back still need their own locks.

tsx
// src/proxy.ts
export function middleware(request: NextRequest) {
  const token = request.cookies.get('auth_token')?.value;

  if (!token && request.nextUrl.pathname.startsWith('/admin')) {
    return NextResponse.redirect(new URL('/login', request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/admin/:path*'],
};

That's good for preventing accidental access or casual browsing. But in your actual route handler, you must validate the token again:

tsx
// app/admin/page.tsx
import { verifyAuth } from '@/lib/auth';
import { cookies } from 'next/headers';

export default async function AdminPage() {
  const cookieStore = await cookies();
  const token = cookieStore.get('auth_token')?.value;

  // Verify the token again here
  const user = await verifyAuth(token);

  if (!user) {
    // This will never happen if proxy is working, but it's your safety net
    throw new Error('Unauthorized');
  }

  return <div>Admin content</div>;
}

// Or in an API route
export async function GET(request: NextRequest) {
  const token = request.headers.get('authorization')?.replace('Bearer ', '');
  const user = await verifyAuth(token);

  if (!user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // Your business logic
}

In the trading platform I worked on, we had a proxy that checked if a user was verified. But we also had a second check in the actual trade execution handler. When we discovered a bug in the proxy's verification logic, the second check caught it before any bad trades went through. That second check saved the company thousands.

The principle is defense in depth. Each layer should be independent and able to stand on its own.

Common Mistakes

  • Doing expensive operations in proxy: Querying databases, calling external APIs, or heavy computations belong in your route handler, not middleware. Keep proxy code in the milliseconds range.
  • Assuming proxy can't be bypassed: Direct API calls can skip your proxy if the matcher doesn't cover them. Always validate in your route handler too.
  • Not testing matcher patterns: A regex that looks right often isn't. Test edge cases like routes with query params, trailing slashes, and unusual characters.
  • Storing state in proxy: The Edge runtime is stateless. Don't try to cache things in variables. Use cookies, headers, or external services like Redis.
  • Forgetting performance implications: Proxy runs on every matched request. A feature that works fine for 10 users might tank at 10,000. Profile and optimize early.
  • Relying on proxy for security alone: It's a convenience layer, not a fortress. Always validate in your actual route handlers and server actions.

Key Takeaways

  • Middleware is now proxy in Next.js 16, reflecting its role as a request interception layer that runs on the Edge
  • Use proxy for redirects, rewrites, and header injection—operations that need to happen before your routes are evaluated
  • Keep proxy code fast. The Edge runtime has limitations for good reason. Defer expensive operations to your route handlers
  • Use matcher to scope which routes trigger your proxy code. Excluding static assets and public routes prevents unnecessary overhead
  • Proxy is a first-line defense, not your only defense. Always validate authentication and authorization in your actual route handlers and server actions
  • Test your proxy in development and staging. Edge runtime behavior can surprise you if you're used to Node.js runtime
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles