Next.js middleware rules that stay readable as apps grow
Next.js middleware rules stay easier to manage when you sort checks by purpose, keep one clear order, and stop mixing redirects, locale, bots, and auth.

Why middleware gets messy fast
A middleware file often starts with one harmless check. Maybe you redirect signed-out users away from "/dashboard". A week later, you add locale detection. Then a bot block. Then a special case for preview mode, old URLs, or a login callback. The file still looks small, but the behavior stops being obvious.
That is the real problem with Next.js middleware rules. The code may fit on one screen, yet each new condition changes the meaning of the ones above and below it. One early return can cancel a locale redirect. One bot rule can block a route that auth should handle first. One tiny patch can turn a clean request flow into a pile of guesses.
Small fixes cause most of the mess. Someone sees a bug, adds one more if, and moves on. The app works for that case, but the file now has a hidden priority system no one wrote down. Months later, a user gets sent to the wrong language, or a logged-in user lands in a redirect loop, and the team has to trace the request step by step to learn what happened.
The overlap makes it worse. Redirects, locale checks, bot filtering in Next.js, and auth flow cleanup rarely stay separate for long. A login route may need a locale prefix. A bot rule may skip public marketing pages but block account pages. A redirect from an old path may run before auth and send users somewhere that no longer matches their session state.
A simple example shows how fast this happens. You add a rule that sends / to /en. Later, you add a rule that sends signed-in users from /login to /app. Then support asks for bot filtering on /app. If the order is sloppy, a bot can hit /login, get redirected to /app, then trigger auth logic you never meant it to reach.
Readable middleware usually comes down to one thing: every request passes through the same fixed order. When that order is clear, bugs stay local. When it is not, the file grows by surprise.
What belongs in middleware and what does not
Middleware should make fast gate decisions on the request itself. It is a good place to check whether the URL needs a redirect, whether a locale prefix is missing, whether a bot should be blocked, or whether a user has a session cookie at all.
That last part matters. A cookie check fits in middleware. Looking up the user, loading account data, pricing plans, or team permissions does not. Once middleware starts pulling page data, it stops being a traffic guard and turns into hidden app logic.
A simple rule helps: if the decision depends only on the request and a small amount of cheap context, middleware is fine. If it needs database reads, complex permission logic, or page-specific state, move it to a route handler, server action, or the page itself.
Good middleware work usually fits into a short list:
- normalize URLs and apply redirects
- add or validate locale prefixes
- block obvious bad bots or abusive patterns
- check whether a session token exists
- attach simple request flags for later use
Business rules belong somewhere else. If a premium feature should unlock only for paid teams on active contracts, that rule should live close to the feature, not in middleware. If a checkout route needs to verify stock, coupon status, and tax rules, put that logic in the handler that owns checkout.
This split keeps bugs easier to find. When a request gets redirected before the page loads, you know to inspect middleware. When a page shows the wrong plan or role, you know to inspect the feature code. Teams lose a lot of time when both kinds of logic sit in one file.
The best Next.js middleware rules stay boring. They run early, decide fast, and get out of the way. If a rule needs a paragraph to explain, it probably does not belong there.
A small example makes the boundary clear. Redirect "/dashboard" to "/en/dashboard" when the locale is missing? Middleware. Load the user dashboard widgets based on billing tier and saved preferences? Page or route code. That line keeps auth flow cleanup manageable instead of scattering it across redirects, rewrites, and hidden checks.
Put the rules in one fixed order
Middleware gets confusing when every new rule can redirect, rewrite, or stop the request. A fixed order cuts down surprises. When someone reads the file, they should know why a request changed path and which rule touched it first.
A good default for Next.js middleware rules is boring on purpose. Keep the same sequence every time, even when the app grows.
- Start with hard skips for assets, static files, and framework internals.
- Run bot and abuse checks before anything user-facing.
- Normalize the URL with locale and cleanup rules.
- Apply auth near the end, as one separate stage.
The first step should exit fast. If the request is for an image, font, script, favicon, or internal Next.js asset, let it pass and do nothing else. That saves work and keeps logs cleaner. It also stops odd cases where a CSS file gets caught by a redirect rule that was written for pages.
Bot checks should happen early because they do not need user context. If a scraper, bad crawler, or obvious abuse pattern hits the app, block it before locale work or session checks. Otherwise you waste time on requests that were never real page visits.
After that, clean up the URL. This is where locale checks, trailing slash rules, and other path fixes belong. Do this before auth so every later rule sees one stable path shape. If /dashboard should become /en/dashboard, make that change first.
That order avoids messy redirect chains. A common mistake goes like this: the user opens /dashboard, auth sends them to /login, then locale logic changes it again to /en/login. The browser still gets there, but the flow is harder to debug, and edge cases pile up.
Leave auth as one clear stage near the end. By then you already know the request is for a real page, not a file, not a bot, and not a messy URL variant. Auth can make one clean decision: allow the request, or send the user to the right sign-in page.
If a new rule does not fit somewhere in that order, that is usually a warning sign. The file is starting to hide behavior instead of showing it.
Build the rule flow step by step
Start away from the editor. Middleware gets messy when people add one more condition every week and never stop to name the rules.
Write every rule on paper first, even the small ones. That usually means redirects, locale checks in middleware, bot filtering in Next.js, auth guards, and a short list of public paths. When you can see the full set at once, overlap shows up fast.
Then group rules by purpose, not by route name. A file stays readable longer when it asks plain questions like "should this request be blocked?" or "does this URL need a locale?" Route names can live inside those checks, but they should not shape the whole file.
A clean order often looks like this:
- Ignore requests you never want to touch, such as static files or internal assets.
- Stop bad bots or traffic you do not want to serve.
- Fix the URL shape, such as locale prefixes or trailing slash rules.
- Apply auth checks and redirects.
- Fall through and continue the request.
Turn each group into a small named function. Names do a lot of work here. skipAsset, blockBot, addLocale, and protectDashboard read better than one long if chain with comments that go stale.
Each function should do one thing and return early when it matches. That matters more than most teams think. If a bot rule matches, stop there. If the request needs a locale redirect, send it and stop there. Do not let later auth logic rewrite the same request and hide what happened.
The top level flow should read from top to bottom like a checklist. Someone new to the app should understand the redirect order in Next.js in under a minute.
A small example makes this clearer. Say /dashboard needs sign in, /pricing is public, every page needs a locale like /en, and some scrapers should get blocked. If a request hits /dashboard without a locale, the middleware should add the locale first, then let the next request decide auth. If the scraper hits the same path, the bot rule should stop it before any auth code runs.
That order saves time in debugging. When one request can only take one path, odd behavior gets easier to spot.
A simple app example
A small store app makes the order easy to see. It has public pages like /, /search, and product pages, account pages like /account and /orders, and admin pages under /admin. Next.js middleware rules stay readable when each request passes through the same checks in the same order.
Start with the URL, not the user session. If someone lands on /, the app should pick the right locale first and redirect to /en, /fr, or another supported path. A signed out visitor should not jump to /login from / just because the app saw no session cookie. The app has to decide which page the user asked for before it decides whether that page needs login.
Search pages need a different rule. A store search route often attracts noisy scrapers and cheap bot traffic. If the request goes to /en/search?q=boots, the middleware can inspect the user agent and block the bot only on that route. Real shoppers still get the page. Public product pages still load. Good crawlers you want to allow can pass through untouched.
Auth should run after those URL checks. If a signed out user requests /en/admin, that request should go to /en/login?next=/en/admin. If the same person opens /en/product/red-boots, the middleware should do nothing after the locale check because that page is public.
One request shows the full flow:
- A user types
/adminin the browser. - Middleware sees that the locale is missing and redirects to
/en/admin. - The next request hits
/en/admin. - Middleware checks whether the route is a search page. It is not, so the bot rule does nothing.
- Middleware checks the session. There is no valid login, so it redirects to
/en/login?next=/en/admin.
That flow is easy to read in code and easy to debug in logs. Each rule has one job, and no rule tries to guess what a later rule will do.
How to test the flow
Start with real URLs, not abstract rules. Middleware bugs usually show up when one request can match two or three conditions, and nobody agrees which one should win.
Write a short test sheet before you touch the code. One line per request is enough, as long as each line says the path, the user state, and the exact result you expect.
/pricingas a guest -> allow/dashboardas a guest -> redirect to/login/dashboardwith no locale -> redirect to/en/dashboard/en/dashboardas a signed-in user -> allow/signupfrom a known bot -> block or return a limited response
That list does two useful things. It catches hidden rule conflicts, and it forces you to decide the redirect order in Next.js before the file turns into guesswork.
Locale handling needs extra attention. Test the same page twice: once with a locale in the URL and once without it. If your app sends a guest from /dashboard to /login, but also adds /en/ first, make sure the result matches your chosen order every time.
Do the same for public pages and private pages. A public page should stay public, even when locale logic runs first. A private page should never bounce through two or three redirects unless you planned that path on purpose.
Bot checks deserve their own pass. Use a bot user agent or a simple test header and confirm the request stops where you expect. If a bot can still reach login, signup, or expensive routes, your rule order is probably wrong.
A tiny log helps more than a long comment. Log the pathname and the rule that fired so you can see the flow instead of guessing.
console.log(`[middleware] ${request.nextUrl.pathname} -> locale_redirect`)
Keep the rule name short and stable. When you test Next.js middleware rules this way, you can spot bad behavior fast: wrong locale first, auth check in the wrong place, or a bot rule that never runs. If one request surprises you twice, the flow still needs work.
Mistakes that hide bad behavior
Most middleware bugs do not come from one bad line. They come from small shortcuts that pile up until nobody can tell why a request moved, rewrote, or failed.
Bad Next.js middleware rules often look fine in review. Then a user hits a private page with the wrong locale, a bot hits the same path, and a marketing redirect joins the party. If those checks live in one tangled block, the result feels random.
A common mistake is mixing routing and auth in the same if chain. A redirect for /pricing should not sit beside a session check for /app. Those rules answer different questions. One decides where a URL should go. The other decides who may enter. When they share one block, a later edit can change both without anyone noticing.
Another problem is hiding route lists in too many helpers. If public paths live in one file, locale exceptions in another, and bot-only paths in a third, nobody sees the full picture. Keep the lists close to the middleware or collect them in one clear config file. If a teammate needs ten minutes to find why /fr/login skips auth, the setup is already too scattered.
Rewrites also cause trouble when they run too early. Finish the cheap guards first. Check obvious redirects, blocked bots, and locale rules before you rewrite the URL into something harder to read. Once the path changes, debugging gets slower because logs no longer match the original request.
Side effects make things worse. One rule sets a header, another reads it. One helper mutates the URL, another assumes the old value. That style saves a few lines and costs hours later. Each stage should read the request, decide, and return. It should not quietly prepare state for the next stage.
When exceptions keep growing, the names usually stop matching reality. "Public routes" turns into "public routes except invited users and password reset flows." That is the moment to split the stage and rename it.
A readable flow usually keeps these stages separate:
- direct redirects
- bot checks
- locale handling
- auth checks
- final rewrite logic
If a rule does not fit its stage, do not squeeze it in. Give it a proper place before it turns into hidden behavior.
A quick check before you ship
Good Next.js middleware rules read like a short route map, not a pile of surprises. Before you merge, hand the file to a teammate who did not write it. If they cannot explain the flow for three sample requests in about a minute, the logic is too tangled.
Use real requests, not abstract talk. Try one public page, one private page, and one odd case such as a bot hitting a localized route. A request like /fr/dashboard should be easy to trace from top to bottom: locale check first, bot rule next, auth decision after that, then a clear exit.
Each rule should do one thing and stop. A locale rule should not also decide auth. A bot rule should not quietly rewrite a marketing page. When one block handles too much, you stop seeing where behavior starts, and bugs hide in the gaps.
A short pre-ship check helps more than another comment in the file:
- A new teammate can describe the order without guessing.
- Every rule has one job and one obvious exit.
- You can follow any request line by line without jumping between helpers.
- Logs tell you which stage handled the request.
- Removing one rule does not change unrelated paths.
Logs matter more than many teams admit. When a redirect fires, the log should say which stage did it and why. "auth -> redirect /login" is enough. "middleware redirect" is too vague and wastes time when users report loops or missing pages.
One small test often reveals messy design: comment out a single rule and run the same requests again. If a locale check breaks bot handling, or a bot filter changes auth behavior, your boundaries are weak. Clean Next.js middleware rules survive small removals because each part owns one decision.
If this check feels strict, keep it strict. Middleware runs before the rest of the app gets a chance to help. Hidden behavior there spreads fast, and it gets expensive to untangle later.
What to do when the file keeps growing
A growing middleware file usually means the app learned new rules faster than the team cleaned up old ones. That is normal for a while. It stops being fine when nobody can tell, at a glance, why a user got redirected, why a locale changed, or why a bot got blocked.
If the file feels hard to scan in under a minute, split the data first, not the flow. Keep one main middleware file that shows the order of decisions. Move the noisy parts into small modules with plain names.
- routeMaps.ts for public paths, protected paths, and redirect targets
- localeRules.ts for supported locales, default locale, and path checks
- botRules.ts for user-agent checks and allow or deny rules
- authRules.ts for session checks and protected route logic
The main file should read almost like a short checklist. Read the request. Check locale. Check bot rules. Check auth. Return the response. That shape is much easier to trust than a file full of scattered condition blocks and helper calls.
This matters even more when you work with Next.js middleware rules across several teams. One person adds preview mode, another adds country redirects, and someone else patches a crawler issue. The file still runs, but the behavior gets harder to predict.
When new product rules appear, review the full order right away. A partner landing page, a region block, or a special campaign route can change redirect order in Next.js in subtle ways. If you just attach the new rule at the bottom, you often create behavior that only shows up in production.
A simple team habit helps a lot: every new middleware condition should answer two questions in the pull request. Where does this rule belong in the order? Which earlier rule can override it? That takes two minutes and prevents hours of debugging later.
If your team keeps patching this area and the same bugs return, a short architecture review from Oleg Sotnikov can help. He works with startup teams on app architecture, AI-first development, and lean production systems, so he can help split route logic cleanly and keep auth flow from leaking into every corner of the app.