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;
}
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();
}
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();
}
}
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();
}
}
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);
}
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);
}
}
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);
});
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:
Freeze current behavior in table-driven tests.
Extract one responsibility into a proxy rule.
Keep rule order visible in one place.
Run the URL table after every extraction.
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)