CMS & Framework Routing Changes

Context

When you restructure routes or permalinks during a migration, the redirect logic can live in three very different places depending on the stack: a CMS plugin, a framework config file, or the web server in front of it. WordPress, Next.js, and Nuxt each expose their own redirect mechanism, and each has a server-level fallback that behaves differently for crawl budget, latency, and method preservation. Choosing the wrong layer produces redirect chains, lost query strings, or rules that silently stop firing after a deploy. This section sits under the broader URL Mapping & Redirect Architecture playbook and covers the per-stack decisions that generic server rules cannot make for you.

The teams that hit this hardest are those changing both the routing scheme and the framework at once — for example moving a WordPress blog onto a headless Next.js front end, or migrating a Nuxt 2 site to Nuxt 3 with new route rules. The timing matters: framework-native redirects ship with the application build, so they only take effect after deploy, whereas server rules can be staged ahead of cutover.

Redirect ownership layer per stack Comparison showing where redirects live in WordPress, Next.js, and Nuxt, and the shared server-level fallback below all three. Where Redirects Live Per Stack WordPress Next.js Nuxt Redirection plugin or .htaccess map next.config.js redirects() / middleware nuxt.config routeRules or Nitro middleware Server / edge fallback (Nginx, Apache, CDN) Lowest latency, stageable before deploy, no app rebuild needed Pick one owning layer per rule to avoid double-hop chains
Each framework owns its native redirect mechanism, but all three share a server or edge fallback — keep one owner per rule.

The problem this page addresses is not that frameworks redirect badly — it is that they redirect as well as everything else. A modern stack routinely has four independent layers capable of issuing a redirect for the same path: the CDN, the web server, the framework’s own routing config, and the content system’s slug handling. Each is individually reasonable, none knows about the others, and their composition is what a visitor actually experiences.

That composition is invisible in every configuration file. You can read the Nginx map, the next.config.js redirects, and the WordPress permalink settings in full and still not know how many hops a given legacy URL takes, because the answer depends on which layer answers first and what the next layer does with the result. The only reliable way to know is to request the URL and follow it, which is why every validation step on this page is a runtime check rather than a configuration review.

The remedy is to decide ownership per rule and enforce it. One layer answers for a given path; the others are configured not to. That sounds obvious and is routinely violated, usually because two teams solved the same problem in the two places each of them controls.

Pre-flight Checks

  • Confirm which layer terminates TLS and sees the request first (CDN, Nginx, Apache, or the app server) — this is your lowest-latency redirect point.
  • Export the complete legacy URL list and the new route scheme into a versioned map before writing any rules.
  • Note the current permalink/route format exactly (trailing slashes, case, locale prefixes) so rules match byte-for-byte.
  • Verify deploy cadence: framework-native redirects only go live with the next application build.
  • Check whether query strings, locale prefixes, or pagination parameters must be preserved or rewritten.
  • Have a staging host that mirrors production routing so rules can be tested before cutover.

Execution Steps

1. Decide the Owning Layer Per Rule

For each redirect, pick exactly one layer that owns it — server/edge, framework, or CMS plugin — and never let two layers redirect the same path. Server-level rules win on latency and can be staged before the app deploys; framework-native rules win when the redirect depends on application state. Document the choice in your map so a later deploy does not silently shadow a server rule.

Decide ownership by what the rule needs to know. A rule that depends only on the path belongs at the edge or the web server, where it is cheapest and fastest. A rule that needs to consult the database — does this slug exist, has this post moved, is this user entitled to see it — has to live in the application, because no earlier layer can answer the question. Between those, prefer the earliest layer that has enough context, and write the decision down per rule class rather than per rule.

Then enforce it by removing the alternatives. If the web server owns legacy path mapping, disable the framework’s redirect handling for those paths rather than leaving it configured and hoping it never matches. Configuration that is present but expected not to fire is a chain waiting for someone to change an unrelated rule.

2. Restructure WordPress Permalinks

If WordPress is in scope, change the permalink structure once and generate a deterministic 301 map from old to new slugs rather than relying on the database’s internal guesswork. Decide up front between the Redirection plugin (application layer) and .htaccess/Nginx rules (server layer); see migrating WordPress permalinks without losing rankings for the WP-CLI export and rule generation.

WordPress is the case where the platform actively helps and then quietly works against you. Changing the permalink structure regenerates every URL, and the built-in redirect handling does map many old paths to new ones — but it does so by database lookup on request, at application cost, and only for the patterns it recognises. Anything it does not recognise falls through to a 404, and anything it does recognise adds a hop if a server-level rule already handled it.

Export the old and new URL pairs before changing the structure, generate server-level rules from that export, and then let the platform’s own handling act only as a backstop for what the rules miss. Watch for the plugin ecosystem too: redirect plugins store their rules in the database, outside version control, and are a common source of a hop nobody can find in any config file.

3. Configure Next.js Redirects

For Next.js, prefer the static redirects() array in next.config.js for predictable path changes, and reserve middleware for redirects that need request inspection (cookies, headers, geo). The full breakdown of permanent vs temporary flags and has/missing matchers is in configuring Next.js redirects during domain migration.

Next.js draws a sharp line between redirects() in the config and logic in middleware, and the line is about what the decision depends on. Config entries are static, evaluated before the application does any work, and are the right home for anything expressible as a path transformation. Middleware runs per request with access to headers, cookies and geography, which makes it the only option for a genuinely conditional redirect — and a poor choice for a bulk legacy map, because it pays that cost on every request including the ones that never redirect.

The practical failure is putting a large static map in middleware because it was easier to express in code. It works, and it adds a per-request cost to the entire site in exchange for handling a set of paths that a server rule would have answered for free.

4. Handle Nuxt Route Changes

For Nuxt 3, route changes are expressed as routeRules in nuxt.config, executed by the Nitro server, with server middleware available for dynamic cases. See handling Nuxt route changes during site migration for the redirect rule syntax and Nitro middleware patterns.

Framework redirect configuration is evaluated in declaration order and is easy to get subtly wrong at scale, because the config is code and code invites cleverness. Keep the generated entries mechanical — one entry per mapping, sorted so exact matches precede patterns — and generate them from the same inventory that produces the server rules rather than maintaining a parallel list. A framework config and a server config describing overlapping but non-identical mappings is the single most common origin of a cross-layer chain.

Be careful with the distinction between a redirect and a rewrite. A rewrite serves different content at the same URL and issues no status code; a redirect tells the client to go elsewhere. Frameworks expose both through similar-looking configuration, and using a rewrite where a redirect was intended produces a page that works perfectly and never transfers any signal to the new URL, because as far as anything outside the server is concerned the old URL is still the live one.

5. Flatten and Validate Cross-Layer Chains

This is the step that catches what the previous four could not, because it is the only one that observes the layers working together rather than in isolation. After wiring framework or plugin rules, re-crawl to catch chains created when an old server rule and a new framework rule both fire on one URL. Apply Redirect Chain Elimination to collapse any multi-hop sequence into a single 301, then confirm with curl -IL.

Four layers can answer a request, and the one that answers first decides what the others ever see.

Which layer should own a redirect rule Four layers — CDN edge, web server, framework config and CMS slug handling — compared by the context each can inspect, its cost per request, and the rule class that belongs there. Earliest layer with enough context — and only that layer Layer Can inspect Cost Owns rules that are CDN edge path, host, headers lowest host and protocol moves Web server the full request one origin hop bulk legacy path mapping Framework config the request, at boot framework boot route-shape changes CMS slug handling the database a query per request per-item slug history Configuration left in place at a layer that should not own a path is not inert — it is a chain waiting for an unrelated change.
Only the bottom row genuinely needs database context; most legacy mapping belongs two rows above it, where it costs nothing per request.

Configs / Commands

Each fragment below implements the same mapping at a different layer, and they are alternatives rather than a set to apply together. Pick the one whose layer owns the rule class in question, and make sure the others are configured not to match those paths — the point of the ownership decision is lost if the losing layers are left with their rules in place.

WordPress — flush rewrite rules after a permalink change (WP-CLI):

# Re-write .htaccess / rewrite rules after changing permalink structure in the admin
wp rewrite structure '/%category%/%postname%/' --hard
wp rewrite flush --hard

Next.js — static redirects array (next.config.js):

// Static redirects evaluated at the edge before rendering
module.exports = {
  async redirects() {
    return [
      { source: '/blog/:slug', destination: '/articles/:slug', permanent: true }, // 308
    ];
  },
};

Nuxt — route-level redirect (nuxt.config):

// Nitro serves these redirects without running the page component
export default defineNuxtConfig({
  routeRules: {
    '/blog/**': { redirect: { to: '/articles/**', statusCode: 301 } },
  },
});

Server fallback — Nginx catch-all for the legacy prefix:

# Lowest-latency layer; stage this before the app deploy if possible
location ^~ /blog/ {
    return 301 /articles/$uri$is_args$args;
}

When two layers both answer, the result is a chain that appears in none of the configuration files involved.

A cross-layer chain formed by two correct configurations A request answered first by a web server rule mapping an old path to a new one, then by the CMS slug handler mapping that to a further URL, producing two hops that neither configuration file describes. Two owners, one path, a hop nobody configured /2019/03/post the requested URL /blog/post server rule says so /articles/post CMS slug history says so Nginx WordPress curl -IL shows two 301s; nginx.conf and the CMS settings each show one, and both look correct Fix it by making the server rule point at the terminal URL, then disabling the CMS handler for paths the server owns — not by adding a third rule somewhere to compensate.
This is why validation here is always `curl -IL` against the running stack rather than a review of the configuration that produced it.

Validation

Run every check below against the deployed stack rather than against configuration, since the behaviour that matters here is the composition of several layers and no single file describes it.

  • Confirm each migrated path returns a single hop: curl -sIL https://www.example.com/blog/old-post | grep -iE '^HTTP|^location' should show one 301 then the final 200.
  • Check no path is redirected by two layers — a double 301 in the curl -IL trace means server and framework rules overlap.
  • Verify query strings survive: request /blog/post?utm_source=x and confirm utm_source appears on the Location target.
  • After deploy, watch server logs for 404 spikes on the old route pattern indicating a missing map entry.

Nuxt’s routeRules sit closer to the server than a framework config usually does, because Nitro can apply them at the edge when deployed to a platform that supports it — which makes them genuinely fast, and also makes their deployment target something you need to confirm rather than assume. The same configuration behaves differently depending on where the build is deployed, so verify the actual response headers on the target platform rather than relying on local development, where everything is served by one process and layering questions never arise.

Rollback Triggers

  1. Redirect error rate on migrated paths exceeds 2% of requests over a 15-minute window — revert framework config and re-enable the prior server rules.
  2. Any redirect chain longer than 1 hop appears on a top-traffic path — disable the conflicting layer immediately.
  3. 404 rate on the legacy route prefix climbs above 0.5% of total traffic — restore the previous permalink/route scheme and regenerate the map.
  4. Median redirect latency exceeds 80 ms (indicating app-layer redirects that belong at the edge) — move the affected rules to the server/CDN layer.

FAQ

Should redirects live in the framework or at the web server? Put static, request-independent redirects at the server or edge for lowest latency and so they can be staged before the app deploys. Reserve framework or plugin redirects for rules that need application state such as auth, session, or feature flags.

Will framework redirects work before the new build is live? No. Redirects defined in next.config.js, nuxt.config, or a WordPress plugin only take effect once that application code is deployed. If you need cutover-day coverage, stage the equivalent rules in Nginx or Apache ahead of the deploy.

How do I stop two layers redirecting the same URL? Assign a single owning layer per rule in your map and remove the duplicate. A double 301 in a curl -IL trace is the signature of an old server rule and a new framework rule both firing on one path.

Why does a framework redirect stop firing after a deploy? Usually because it never fired in the first place and something else was doing the work. Framework-native redirects ship with the application build, so a rule added to next.config.js or nuxt.config does nothing until the next deploy — which means a rule that appeared to work during testing was probably being handled by a server rule, and the deploy simply changed which layer answered. The other common cause is ordering: framework configs evaluate in declaration order, and a broad entry added above a specific one silently shadows it. Confirm behaviour with curl -IL against the deployed environment rather than inferring it from the config.

Should redirects move to the server before or after the framework migration? Before, wherever the rule does not need application state. Server rules can be staged and tested ahead of the cutover, they survive the application being rebuilt or briefly unavailable, and they take effect the moment the config is reloaded rather than on the next deploy. That ordering also means the legacy paths are already handled when the new application first goes live, so the framework’s own routing is never the only thing standing between a visitor and a 404.

How do I handle locale prefixes and trailing slashes across layers? Normalise both at the earliest layer and enforce a single convention everywhere after it. These are the two details most likely to differ between a server rule and a framework’s internal routing, and a disagreement produces an endless redirect: the server strips a trailing slash, the framework adds it back, and the client gives up after twenty hops. Decide the canonical form, implement it once at the edge or web server, and configure the framework’s own trailing-slash and locale handling to agree rather than to compete.

What about redirects stored in a database by a plugin? Treat them as configuration that has escaped version control, because that is what they are. Redirect plugins store rules in the database, outside any repository, invisible to code review, and surviving deploys that would reset a config file — which makes them a frequent source of a hop nobody can locate. Export them, fold the ones worth keeping into the mapping inventory, and disable the plugin’s handling for paths the server now owns. If the plugin must stay, at least export its rules into the repository on a schedule so they are reviewable.

Related

← Back to URL Mapping & Redirect Architecture

Explore Sub-topics