DEV Community

Cover image for Simplify Next.js Proxy: from one large file to NEMO
Mateusz Janota for ZanReal

Posted on • Originally published at zanreal.com

Simplify Next.js Proxy: from one large file to NEMO

Next.js Proxy usually starts small. You add one redirect from an old path. Then an exception for draft mode. Later come locale handling, security headers, admin protection, legacy URLs after a content migration, and a special case for a crawler.

After a few months, one file becomes a place nobody wants to touch. Every change feels risky: will the redirect run before locale detection? Will Sanity preview still work? Will the private section guard catch static assets? Will a new redirect create a loop?

This article shows a practical way out. The goal is not to switch libraries for the sake of it. The goal is to make edge routing readable, testable and safe to extend. NEMO helps treat Proxy as an explicit set of proxy rules instead of one growing chain of conditions.

Proxy that grew faster than the app

The typical problem is not just file length. It is mixed responsibility. SEO redirects sit next to authentication. Locale detection sits next to draft mode. Security headers sit next to historical paths from a blog migration.

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

  if (pathname.startsWith("/_next")) {
    return NextResponse.next();
  }

  if (pathname.startsWith("/old-blog")) {
    return NextResponse.redirect(new URL("/blog", request.url));
  }

  if (!pathname.startsWith("/pl") && prefersPolish(request)) {
    return NextResponse.redirect(new URL(`/pl${pathname}`, request.url));
  }

  if (pathname.startsWith("/dashboard") && !hasSession(request)) {
    return NextResponse.redirect(new URL("/login", request.url));
  }

  const response = NextResponse.next();
  response.headers.set("x-frame-options", "DENY");
  return response;
}
Enter fullscreen mode Exit fullscreen mode

The code may work, but it is hard to evolve safely. The missing piece is a clear model: which rules run first, which rules are exceptions, which only decorate the response, and which stop request handling completely.

Name responsibilities before changing code

Before introducing NEMO, split the problem into responsibilities. Most applications need a similar list:

  • technical paths Proxy should not touch,

  • legacy redirects after migrations,

  • URL canonicalization,

  • locale prefixes and language routing,

  • draft mode and preview exceptions,

  • session-protected sections,

  • security headers,

  • request diagnostics and metrics.

Only after this list is explicit can you decide the order. That order is more important than the library itself. A good order prevents most accidental regressions.

A rule should do one thing

The simplest model is a function that receives a request and optionally returns a response. If it returns nothing, the next rule continues.

type ProxyRule = (request: NextRequest) => NextResponse | undefined;

const rules: ProxyRule[] = [
  skipInternalPaths,
  legacyRedirects,
  draftModeBypass,
  localeRedirects,
  authGate,
  securityHeaders,
];

export function Proxy(request: NextRequest) {
  for (const rule of rules) {
    const response = rule(request);
    if (response) {
      return response;
    }
  }

  return NextResponse.next();
}
Enter fullscreen mode Exit fullscreen mode

NEMO helps formalize this pattern as a proxy composition. The important rule remains the same: one rule, one responsibility, explicit order.

Put technical exceptions first

Proxy should not accidentally process Next.js files, images, favicons, health checks or endpoints that need to remain neutral. Keep those exceptions at the top. They reduce work and prevent surprising side effects.

const INTERNAL_PREFIXES = ["/_next", "/favicon.ico", "/robots.txt", "/sitemap.xml"];

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

  if (INTERNAL_PREFIXES.some((prefix) => pathname.startsWith(prefix))) {
    return NextResponse.next();
  }
}
Enter fullscreen mode Exit fullscreen mode

This looks basic, but these conditions often end up in random places. Then a new rule can start redirecting assets or breaking a preview endpoint.

Treat draft mode as a separate contract

Draft mode is easy to break because it behaves differently from normal traffic. Preview can see draft documents, unpublished slugs and technical query parameters. You do not want a locale rule or SEO redirect to run before preview logic.

function draftModeBypass(request: NextRequest) {
  if (request.nextUrl.searchParams.has("__vercel_draft")) {
    return NextResponse.next();
  }

  if (request.cookies.has("__prerender_bypass")) {
    return NextResponse.next();
  }
}
Enter fullscreen mode Exit fullscreen mode

This does not mean draft mode should bypass every protection in every application. It means the rule should be named, tested and placed deliberately.

SEO redirects without loops

Legacy redirects are easier to maintain as data, not as a long series of conditions. That makes them easier to review, remove later and test in a table.

const redirects = new Map([
  ["/old-blog/docker", "/blog/docker-for-beginners-practical-guide"],
  ["/pl/old-blog/docker", "/pl/blog/docker-dla-poczatkujacych"],
]);

function legacyRedirects(request: NextRequest) {
  const target = redirects.get(request.nextUrl.pathname);
  if (!target) {
    return;
  }

  return NextResponse.redirect(new URL(target, request.url), 308);
}
Enter fullscreen mode Exit fullscreen mode

For a larger redirect set, add validation that detects redirects to the same path. A loop in Proxy can block the page before the app has a chance to render an error.

Locale routing is not content fallback

A locale rule should decide the URL shape, not whether an article exists in a given language. Those are different problems. Proxy can enforce a /pl prefix, but the blog page still has to check whether the document has slug.pl or slug.en.

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

  if (pathname.startsWith("/pl")) {
    return;
  }

  if (prefersPolish(request)) {
    const url = request.nextUrl.clone();
    url.pathname = `/pl${pathname}`;
    return NextResponse.redirect(url, 307);
  }
}
Enter fullscreen mode Exit fullscreen mode

This distinction matters for drafts. A missing English slug should not make a Polish article render under an English URL. That belongs in the query and detail page logic, not in Proxy.

Test URLs, not implementation details

Good Proxy tests speak in URLs. Do not only check that an internal helper was called. Check what a user, crawler or Sanity preview will see.

it.each([
  ["/old-blog/docker", 308, "/blog/docker-for-beginners-practical-guide"],
  ["/pl/blog/docker-dla-poczatkujacych", 200, null],
  ["/blog/post?__vercel_draft=1", 200, null],
  ["/_next/static/chunk.js", 200, null],
])("handles %s", (path, status, location) => {
  const response = Proxy(makeRequest(path));
  expect(response.status).toBe(status);
  expect(response.headers.get("location")).toBe(location);
});
Enter fullscreen mode Exit fullscreen mode

Keep separate cases for production traffic and draft mode. That is where conflicts between language, preview and redirects usually appear.

Migrate without a big-bang rewrite

Do not rewrite all Proxy in one commit. A safe migration is intentionally boring:

  1. Freeze current behavior in table-driven tests.

  2. Extract one responsibility into a proxy rule.

  3. Keep rule order visible in one place.

  4. Run the URL table after every extraction.

  5. Only then remove the old condition from the large proxy file.

If a rule needs five parameters and knowledge of three other rules, the boundary is probably wrong.

What you gain

Good Proxy does not have to be short. It has to be predictable. After the refactor you should be able to answer:

  • which rule runs first,

  • which rule stops request handling,

  • which paths are excluded from Proxy,

  • how draft mode avoids normal redirects,

  • where to add a new redirect without breaking auth and i18n.

That is the real value of using NEMO here: less hidden logic, fewer accidental loops, and less stress every time routing changes.

Top comments (0)