Type something to search...
How to Use Middleware in Next.js for Redirects and Request Handling?

How to Use Middleware in Next.js for Redirects and Request Handling?

Some decisions have to happen before a page renders. A visitor without a session should be sent to the login page before the dashboard starts querying the database. An old URL from a site migration should redirect before Next.js returns a 404. A visitor in an A/B test should see variant B consistently, without a flash of variant A. Doing these checks inside each page means duplicating logic across routes and doing the work too late.

Next.js has a single place for this kind of request-level logic. For years it was called Middleware and lived in middleware.ts. In Next.js 16 the same feature was renamed Proxy, and the file is now proxy.ts. The capabilities are the same: run code before a request completes, then redirect, rewrite, change headers, set cookies, or respond directly. Most tutorials and Stack Overflow answers still say "middleware," so this article uses both names and shows the current file convention.

This article covers where the file goes and what it exports, how the matcher config controls which requests it runs on, and working examples for redirects, rewrites, optimistic auth checks, request and response headers, cookies, A/B testing, locale detection, and CORS. It also covers what Proxy should not do, how to migrate from middleware.ts, and the mistakes that most often break a site.

Middleware vs. Proxy: What Changed in Next.js 16

If you are on Next.js 15 or earlier, your file is middleware.ts and exports a function named middleware. In Next.js 16:

ItemNext.js 15 and earlierNext.js 16
File namemiddleware.tsproxy.ts
Exported functionmiddlewareproxy (or a default export)
Default runtimeEdgeNode.js (not configurable)
Config flagskipMiddlewareUrlNormalizeskipProxyUrlNormalize
NextRequest, NextResponseSameSame

The rename is intentional. The Next.js team found that "middleware" made developers think of Express middleware, a chain of handlers for everything. Proxy is a network boundary in front of your app, best used for fast routing decisions, not as a place for business logic.

middleware.ts still works in Next.js 16 but is deprecated. One reason to keep it for now: proxy.ts always runs on the Node.js runtime, so if your setup depends on the Edge runtime for this file, stay on middleware.ts until you are ready to switch. To migrate automatically:

npx @next/codemod@canary middleware-to-proxy .

The codemod renames the file and the exported function. Every example in this article uses proxy.ts. If you are on an older version, rename the file to middleware.ts and the function to middleware, and the code works the same.

Creating proxy.ts

Create proxy.ts in the project root, or inside src/ if your project uses it, at the same level as the app directory. There can be only one per project.

// proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  return NextResponse.redirect(new URL("/home", request.url));
}

export const config = {
  matcher: "/about/:path*",
};

The file has two exports:

  1. The proxy function, named proxy or exported as default. It receives a NextRequest and, optionally, a NextFetchEvent. It can be async.
  2. An optional config object with a matcher that limits which paths the function runs on.

The function can return:

  • NextResponse.next() to continue to the route, optionally with modified headers.
  • NextResponse.redirect(url) to send the browser to another URL.
  • NextResponse.rewrite(url) to serve a different route while keeping the URL in the address bar.
  • A Response or NextResponse.json(...) to answer directly.
  • Nothing, which is the same as NextResponse.next().

Controlling Where It Runs with matcher

Without a matcher, Proxy runs on every request: pages, Route Handlers, _next/static JavaScript and CSS, _next/image requests, and files in public/. An auth check that runs on every request can block your stylesheets and break the whole site. Always set a matcher.

Simple paths

// proxy.ts (config only)
export const config = {
  matcher: ["/dashboard/:path*", "/account/:path*"],
};

Path patterns follow these rules:

  • They must start with /.
  • :param matches one segment, so /blog/:slug matches /blog/hello but not /blog/a/b.
  • :param* matches zero or more segments, :param+ one or more, and :param? zero or one.
  • Regular expressions are allowed in parentheses.
  • Values must be constants. The matcher is read at build time, so variables are ignored.

Excluding static assets

The most common pattern is "run everywhere except static files":

// proxy.ts (config only)
export const config = {
  matcher: [
    /*
     * Match all request paths except:
     * - api routes
     * - _next/static (build output)
     * - _next/image (image optimization)
     * - favicon.ico, sitemap.xml, robots.txt
     * - any file with an extension, such as .png or .svg
     */
    "/((?!api|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|.*\\..*).*)",
  ],
};

Conditional matching with has and missing

Matchers can also depend on headers, query parameters, or cookies:

// proxy.ts (config only)
export const config = {
  matcher: [
    {
      source: "/((?!_next/static|_next/image|favicon.ico).*)",
      missing: [
        { type: "header", key: "next-router-prefetch" },
        { type: "header", key: "purpose", value: "prefetch" },
      ],
    },
  ],
};

This skips prefetch requests, which is useful when your proxy does work you only want on real navigations, such as logging.

You can also filter inside the function with request.nextUrl.pathname. The matcher is cheaper, because a request that does not match never invokes your code.

Redirects

Bulk redirects from a migration

For a handful of fixed redirects, the redirects option in next.config.ts is simpler and should be your first choice. Proxy is the better fit when the list is large, comes from data, or depends on the request:

// proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

const legacyRedirects: Record<string, string> = {
  "/old-blog/getting-started":
    "/blog/setting-up-a-new-next-js-project-step-by-step-guide",
  "/services/web": "/services",
  "/contact-us": "/contact",
};

export function proxy(request: NextRequest) {
  const destination = legacyRedirects[request.nextUrl.pathname];

  if (destination) {
    const url = request.nextUrl.clone();
    url.pathname = destination;
    return NextResponse.redirect(url, 308);
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/old-blog/:path*", "/services/web", "/contact-us"],
};

NextResponse.redirect defaults to a 307 temporary redirect. Pass 308 for permanent moves so search engines transfer ranking to the new URL. Cloning request.nextUrl keeps the query string, base path, and host intact.

Forcing a canonical host

// proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  const host = request.headers.get("host");

  if (host === "www.example.com") {
    const url = request.nextUrl.clone();
    url.host = "example.com";
    url.port = "";
    return NextResponse.redirect(url, 308);
  }
}

In many setups this redirect is better handled at the DNS, CDN, or web server level, but Proxy works when you do not control those layers.

Rewrites

A rewrite serves content from a different route while the address bar keeps the original URL. This is useful for multi-tenant apps, feature flags, and maintenance pages:

// proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  if (process.env.MAINTENANCE_MODE === "true") {
    return NextResponse.rewrite(new URL("/maintenance", request.url));
  }
}

export const config = {
  matcher: ["/((?!maintenance|_next/static|_next/image|favicon.ico).*)"],
};

Notice that the matcher excludes /maintenance itself, otherwise the rewrite would apply to the maintenance page too.

For subdomain-based multi-tenancy, rewrite acme.example.com/settings to an internal route such as /tenants/acme/settings:

// proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

const ROOT_DOMAIN = "example.com";

export function proxy(request: NextRequest) {
  const host = request.headers.get("host")?.split(":")[0] ?? "";

  if (host.endsWith(`.${ROOT_DOMAIN}`) && host !== `www.${ROOT_DOMAIN}`) {
    const tenant = host.replace(`.${ROOT_DOMAIN}`, "");
    const url = request.nextUrl.clone();
    url.pathname = `/tenants/${tenant}${request.nextUrl.pathname}`;
    return NextResponse.rewrite(url);
  }
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

The page at app/tenants/[tenant]/[[...slug]]/page.tsx then reads the tenant from its params.

Optimistic Authentication Checks

Proxy is a good place to send signed-out users to the login page quickly, before any rendering happens. It is not a complete authorization layer. The docs call these optimistic checks: read the session cookie, verify it cheaply, redirect if it is clearly missing or invalid, and leave real authorization to the pages, data access functions, and Server Actions.

This example verifies a JWT session cookie with jose (npm install jose):

// proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { jwtVerify } from "jose";

const secret = new TextEncoder().encode(process.env.SESSION_SECRET);

const protectedPrefixes = ["/dashboard", "/account"];
const authPages = ["/login", "/signup"];

async function isValidSession(token: string | undefined) {
  if (!token) return false;
  try {
    await jwtVerify(token, secret, { algorithms: ["HS256"] });
    return true;
  } catch {
    return false;
  }
}

export async function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const signedIn = await isValidSession(request.cookies.get("session")?.value);

  const isProtected = protectedPrefixes.some((p) => pathname.startsWith(p));
  const isAuthPage = authPages.includes(pathname);

  if (isProtected && !signedIn) {
    const loginUrl = new URL("/login", request.url);
    loginUrl.searchParams.set("callbackUrl", pathname);
    return NextResponse.redirect(loginUrl);
  }

  if (isAuthPage && signedIn) {
    return NextResponse.redirect(new URL("/dashboard", request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/dashboard/:path*", "/account/:path*", "/login", "/signup"],
};

Why not stop here? Because Server Actions are POST requests to whatever page uses them. If a matcher changes, or an action moves to a route the matcher does not cover, the proxy check silently disappears for that action. Keep the real check close to the data: verify the session inside each Server Action and in your data access layer. The earlier guides on implementing authentication in a Next.js app and handling authentication tokens cover the token side in more depth.

Request Headers, Response Headers, and Cookies

Passing data to your app with request headers

Proxy cannot pass props to pages. To hand information downstream, set a request header and read it with headers() in a Server Component or Route Handler:

// proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  const requestId = crypto.randomUUID();

  const requestHeaders = new Headers(request.headers);
  requestHeaders.set("x-request-id", requestId);

  const response = NextResponse.next({
    request: { headers: requestHeaders },
  });

  // Also expose it to the browser for debugging.
  response.headers.set("x-request-id", requestId);
  return response;
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
// app/debug/page.tsx
import { headers } from "next/headers";

export default async function DebugPage() {
  const headerList = await headers();
  return <p>Request ID: {headerList.get("x-request-id")}</p>;
}

The distinction matters: NextResponse.next({ request: { headers } }) sends headers upstream to your app. NextResponse.next({ headers }) sends them to the browser. Mixing these up is a common way to leak internal values. Also keep headers small; very large headers can trigger 431 Request Header Fields Too Large from your server.

Reading and setting cookies

request.cookies and response.cookies provide get, getAll, set, delete, and has:

// proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function proxy(request: NextRequest) {
  const response = NextResponse.next();

  if (!request.cookies.has("first_visit")) {
    response.cookies.set("first_visit", new Date().toISOString(), {
      path: "/",
      maxAge: 60 * 60 * 24 * 365,
      sameSite: "lax",
    });
  }

  return response;
}

For a broader look at cookie handling across the App Router, see how Next.js handles cookies and sessions.

A/B Testing with Rewrites and a Cookie

Proxy runs before rendering, so it can assign a variant and serve it without any client-side flicker. Assign the bucket once, store it in a cookie, and rewrite to a variant route:

// proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

const COOKIE = "ab-pricing";
const VARIANTS = ["a", "b"] as const;

export function proxy(request: NextRequest) {
  let variant = request.cookies.get(COOKIE)?.value;

  if (!variant || !VARIANTS.includes(variant as (typeof VARIANTS)[number])) {
    variant = Math.random() < 0.5 ? "a" : "b";
  }

  const url = request.nextUrl.clone();
  url.pathname = `/pricing/${variant}`;

  const response = NextResponse.rewrite(url);
  response.cookies.set(COOKIE, variant, {
    path: "/",
    maxAge: 60 * 60 * 24 * 30,
  });
  return response;
}

export const config = {
  matcher: "/pricing",
};

Create app/pricing/a/page.tsx and app/pricing/b/page.tsx. Visitors always see /pricing in the address bar, and the cookie keeps them in the same group on return visits.

Locale Detection

Proxy is the standard place to redirect visitors to a localized path based on Accept-Language:

// proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

const locales = ["en", "bn", "fr"];
const defaultLocale = "en";

function getLocale(request: NextRequest) {
  const header = request.headers.get("accept-language") ?? "";
  const preferred = header
    .split(",")
    .map((part) => part.split(";")[0].trim().toLowerCase().split("-")[0]);
  return preferred.find((lang) => locales.includes(lang)) ?? defaultLocale;
}

export function proxy(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const hasLocale = locales.some(
    (l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`),
  );
  if (hasLocale) return;

  const url = request.nextUrl.clone();
  url.pathname = `/${getLocale(request)}${pathname}`;
  return NextResponse.redirect(url);
}

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)"],
};

Your pages then live under app/[lang]/. The full setup, including dictionaries, is covered in how to create a multi-language website with Next.js.

CORS for API Routes

If browsers on other origins call your Route Handlers, Proxy can answer preflight requests and add CORS headers in one place:

// proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

const allowedOrigins = ["https://app.example.com", "https://admin.example.com"];

const corsHeaders = {
  "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
  "Access-Control-Allow-Headers": "Content-Type, Authorization",
};

export function proxy(request: NextRequest) {
  const origin = request.headers.get("origin") ?? "";
  const isAllowed = allowedOrigins.includes(origin);

  if (request.method === "OPTIONS") {
    return NextResponse.json(
      {},
      {
        headers: {
          ...(isAllowed && { "Access-Control-Allow-Origin": origin }),
          ...corsHeaders,
        },
      },
    );
  }

  const response = NextResponse.next();
  if (isAllowed) response.headers.set("Access-Control-Allow-Origin", origin);
  Object.entries(corsHeaders).forEach(([key, value]) =>
    response.headers.set(key, value),
  );
  return response;
}

export const config = {
  matcher: "/api/:path*",
};

Background Work with waitUntil

Logging or analytics calls should not delay the response. The second argument, NextFetchEvent, has a waitUntil method that keeps the work alive after the response is sent:

// proxy.ts
import { NextResponse } from "next/server";
import type { NextFetchEvent, NextRequest } from "next/server";

export function proxy(request: NextRequest, event: NextFetchEvent) {
  event.waitUntil(
    fetch("https://logs.example.com/ingest", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ path: request.nextUrl.pathname, at: Date.now() }),
    }),
  );

  return NextResponse.next();
}

Organizing Logic in One File

Only one proxy.ts is allowed, but you can split logic into modules and compose them:

// proxy.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { handleLegacyRedirects } from "@/lib/proxy/redirects";
import { handleAuth } from "@/lib/proxy/auth";

export async function proxy(request: NextRequest) {
  return (
    handleLegacyRedirects(request) ??
    (await handleAuth(request)) ??
    NextResponse.next()
  );
}

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)"],
};

Each handler returns a NextResponse when it wants to act, or undefined to pass the request along.

What Not to Do in Proxy

  • Slow data fetching. Proxy runs on the hot path of matching requests. Database queries and slow APIs here add latency to every page view. fetch caching options such as next.revalidate and next.tags have no effect in Proxy.
  • Full authorization. Use it for optimistic redirects, then enforce permissions where the data is read or changed.
  • Shared global state. Proxy may run separately from your render code, and in some deployments on a CDN. Do not rely on module-level variables shared with your pages. Pass data with headers, cookies, rewrites, or the URL.
  • Static redirects. If the list is fixed, use redirects in next.config.ts. It runs before Proxy and needs no code.

Proxy is not supported with output: "export" static builds, since there is no server to run it.

Common Problems and Fixes

  • CSS, JavaScript, or images stop loading. The proxy runs on _next/static or public/ files and redirects them. Exclude static paths in the matcher.
  • Redirect loop. The proxy redirects to a path that also matches and redirects again, such as /login redirecting to /login. Exclude the destination from the matcher or check for it in code.
  • Headers set in proxy are missing in the page. You used NextResponse.next({ headers }), which targets the browser. Use NextResponse.next({ request: { headers } }).
  • "The runtime config option is not available in Proxy." proxy.ts always uses Node.js. Remove export const runtime, or keep middleware.ts if you need Edge.
  • Matcher seems ignored. The matcher used a variable or template string. It must be a literal, because it is analyzed at build time.
  • Proxy does not run on a static export. Static exports have no server. Move the logic to your host's redirect rules or deploy with a Node.js server.
  • The file is not picked up. It is in the wrong place. It must sit next to app/, in the project root or in src/.

Next.js Middleware and Proxy FAQ

The middleware file name is deprecated in Next.js 16 and the feature is now called Proxy. Rename middleware.ts to proxy.ts and the exported function to proxy. The old name still works for now, and the official codemod performs the rename for you.

No. In Next.js 16 proxy.ts always runs on the Node.js runtime and the runtime cannot be configured. If you need the Edge runtime for this file, continue using middleware.ts until you can switch.

No. A project supports a single proxy file in the root or in the src folder. You can split the logic into separate modules and call them from that one file.

Use it for fast, optimistic checks such as redirecting users without a valid session cookie. Do not rely on it alone. Verify the session and permissions again inside Server Actions, Route Handlers, and data access functions.

Use the redirects option for fixed rules that never depend on the request, such as renamed pages. Use proxy when the decision needs cookies, headers, a large data-driven list, or other request information.

Server Actions are POST requests to the page that uses them, so proxy runs on them only if that page path matches your matcher. That is one reason authorization must also live inside each action.

Conclusion

Next.js middleware, now called Proxy, is the single entry point for decisions that must happen before a route renders. With one proxy.ts file and a precise matcher, you can redirect legacy URLs, rewrite to tenant or variant routes, send signed-out users to the login page, pass request IDs and other context to your pages, set cookies, detect locales, and handle CORS.

Keep it fast and focused. Exclude static assets in the matcher, prefer next.config.ts redirects for fixed rules, use Proxy for optimistic checks rather than full authorization, and pass data downstream with request headers instead of shared state. If you are upgrading, run the codemod to rename middleware.ts to proxy.ts, and remember that the new file always runs on Node.js.

Here are some useful references for going deeper on Next.js Proxy:

  1. Next.js Docs: Proxy — the getting-started guide and recommended use cases.
  2. Next.js Docs: proxy.js API reference — matcher syntax, execution order, and migration from middleware.
  3. Next.js Docs: NextResponse — redirect, rewrite, next, and cookie helpers.
  4. Next.js Docs: Upgrading to Version 16 — the middleware to proxy rename and runtime changes.
  5. MDN Web Docs: Cross-Origin Resource Sharing (CORS) — how preflight requests and CORS headers work.
Tags :
Share :

Related Posts

Building Powerful Desktop Applications with Next.js

Building Powerful Desktop Applications with Next.js

Next.js is a popular React framework known for its capabilities in building server-side rendered (S

Continue Reading
Can use custom server logic with Next.js?

Can use custom server logic with Next.js?

Next.js, a popular React framework for building web applications, has gained widespread adoption for its simplicity, performance, and developer-frien

Continue Reading
TypeScript with Next.js?

TypeScript with Next.js?

Next.js has emerged as a popular React framework for building robust web applications, offering developers a powerful set of features to enhance thei

Continue Reading