D
P
0
← All articles Baca dalam Bahasa Indonesia

Technical SEO, Redirects & Migrations

Old URLs 301 to a 404 Even Though the Middleware Redirect Table Is Correct? `redirects()` in `next.config.ts` Runs First

· · 5 min read
Old URLs 301 to a 404 Even Though the Middleware Redirect Table Is Correct? `redirects()` in `next.config.ts` Runs First

This migration started with one list, and it was the second list that made the first rule wrong. The SEO lead on the client side sent 1,558 URLs from the legacy WordPress site that drive traffic or carry backlinks and had to keep resolving on the new site: 1,344 glossary entries under /terms/{slug}/ and 214 blog posts under /posts/{slug}/. The new site is Next.js 16 on the App Router, and for the blog posts the initial decision was simple: a 301 from /posts/[slug] to /stories/[slug] with the slug preserved, placed in next.config.ts, on the reasoning that external backlinks pass roughly 95% of their link equity through a 301.

It looked more or less like this:

// next.config.ts, first version
const nextConfig = {
  async redirects() {
    return [
      { source: "/posts/:slug", destination: "/stories/:slug", permanent: true },
    ];
  },
};

That rule went out in the first three commits, pushed and deployed to staging.

The second CSV flipped the assumption

Then a follow-up CSV arrived from the same SEO lead, 3,733 rows. It stated that every /posts/*, /quote/*, /venue/*, /press/* and similar URL that was not being kept had to 301 to a category or pillar page, not to its slug counterpart. Three columns, URL,Action,Target URL, every Action set to 301, and every target prefixed with the staging domain, which the client asked to be stripped.

The CSV is never read at runtime. A separate build script preprocesses it into two committed JSON artifacts, while the CSV itself stays gitignored. The first artifact is a { pathname: targetPathname } map with 3,725 unique paths, deduplicated per path with query strings stripped so fbclid variants collapse into one entry, and with KEEP-list paths filtered out at build time. Those 3,725 paths fall into just six targets: 1,921 to /beginner-guide, 1,246 to /markets/assets, 544 to /markets/venues, 12 to /, and one each to /trend-analysis and /protocol-basics. The sum is 1,921 + 1,246 + 544 + 12 + 1 + 1 = 3,725, which checks out. The second artifact is a Set of 213 KEEP article slugs, one fewer than the 214 on the first list, because the final scrape came back with 213, and that is what drives the /posts/{slug} to /stories/{slug} redirect for kept articles only, not for the 1,636 being redirected elsewhere.

Middleware then picked up two new responsibilities, in order: if the path is /posts/{slug} and the slug is in the KEEP Set, 301 to /stories/{slug}; otherwise, look the pathname up in the map and 301 to its target. Matching is path-only, with trailing slash and query string normalized first. Stripped down, the order looks roughly like this:

// middleware.ts, in priority order
const path = normalize(req.nextUrl.pathname); // trailing slash and query stripped
 
const keep = path.match(/^\/posts\/([^/]+)$/);
if (keep && keepSlugs.has(keep[1])) {
  return NextResponse.redirect(new URL(`/stories/${keep[1]}`, req.url), 301);
}
 
const target = oldUrlMap[path];
if (target) {
  return NextResponse.redirect(new URL(target, req.url), 301);
}

One target from the client's CSV, /trend-analysis, did not exist as a route yet and had to be added as a pillar. The other two, /beginner-guide and /protocol-basics, already existed.

The table was right, the result was still wrong

On testing, /posts/{slug} for a non-kept slug still redirected to /stories/{slug}, and that page was a 404. It should have landed on /beginner-guide, and the middleware lookup table already mapped it there correctly.

Why this happens

redirects() in next.config.ts runs before middleware in the Next.js order. The blanket /posts/:slug to /stories/:slug rule from the start was still there, and it caught every /posts/* request before middleware ever saw it. The selective lookup over those 3,725 paths never got its turn.

The misleading part is that nothing errored. The middleware was correct, the map was correct, the KEEP Set was correct. What was wrong was that two layers both claimed /posts/*, and the non-selective one happened to stand in front.

The fix

The blanket rule was dropped from next.config.ts entirely, and all /posts/* logic moved into middleware, where it can be selective: KEEP Set check first, then the redirect map. The batch that went into that commit was five files: middleware.ts, next.config.ts, the redirect build script, and its two JSON artifacts. Build hygiene stayed clean throughout the session, tsc zero errors, lint zero problems, 31 of 31 tests passing. The staging auto-deploy succeeded, and I verified the new build on the night of 1 May 2026 through a raw HTML check after purging the CDN cache.

Moving redirects into middleware was also not the first use of middleware in this project. It was already blocking the admin dashboard routes, so no new layer had to be introduced, only one old layer had to stop interfering.

The second trap: browsers remember 301s

While iterating on those /posts/{slug} rules, I kept seeing the old redirect target in the browser even after the server-side logic had changed. It looked like the new code was not deployed, but it was. The browser just never asked the server again. Once a browser has resolved URL_A to URL_B as a 301, it will not re-check the server on subsequent visits to URL_A, and a hard refresh often does not get past that. This tripped the session up multiple times: the old /posts/{slug} to /stories/{slug} redirect stayed cached after the middleware change.

Three ways to verify that I have kept since: an incognito window, DevTools Network with Disable cache checked, or clearing the browser cache for that host. A hard refresh with Ctrl+Shift+R is not reliable for cached 301s.

wrong   Ctrl+Shift+R in a tab that already hit the 301    the browser answers from its own cache
right   incognito window                                  no stored 301s
right   Network -> Disable cache, DevTools kept open
right   clear browser cache for that host

One implication I noted as a consideration, not something done in this migration: real users with stale 301 caches may see the old behavior for days, and if the redirect target changes significantly, a temporary 302 for a brief window before flipping back to 301 could be considered.

Lessons

When the middleware redirect table is correct but the result is still wrong, look for a rule standing in front of it. redirects() in next.config.ts fires before middleware, so a single blanket pattern there is enough to shadow an entire selective lookup without a single error. When a URL prefix needs a per-path decision, only one layer should own it. And before concluding the fix is not deployed, open incognito first.