Flattening Redirect Chains in Edge Middleware

A multi-hop redirect chain forces both browsers and Googlebot to make several round-trips before reaching content, so flattening every chain to a single 301 at the edge recovers latency, link equity, and crawl budget in one pass.

When to Use This Approach

Flatten chains at the edge when your redirect landscape shows any of the following:

  • A crawl audit reveals URLs resolving through two or more hops before returning a 200 (for example /old โ†’ /new โ†’ /final).
  • Successive migrations have stacked redirects, so each replatform added another intermediate rule on top of the last.
  • Structural redirects compound: a www-to-apex rule, a trailing-slash rule, and a locale rule each fire in sequence for a single request.

Redirect chain flattened to a single hop A three-hop chain from an old URL through interim URLs to a final target is collapsed by precomputed edge middleware into one direct 301 to the final destination. Before: three hops /old /interim /renamed /final After: one hop /old /final single 301 (edge lookup)

Implementation Steps

Step 1: Export Every Current Redirect Rule

Consolidate redirect rules from all sources โ€” framework config, CDN rules, _redirects files, and legacy platform exports โ€” into one normalized source-to-destination list. Chains often hide because the hops live in different systems.

# Example: merge redirects from multiple sources into one JSON list
# 1) Framework redirects (Next.js) exported to JSON
node -e "const c=require('./next.config.js'); (async()=>{console.log(JSON.stringify(await c.redirects()))})()" > redirects-next.json

# 2) Cloudflare Pages _redirects converted to the same shape
awk 'NF && $1 !~ /^#/ {printf \"{\\\"source\\\":\\\"%s\\\",\\\"destination\\\":\\\"%s\\\"}\\n\", $1, $2}' _redirects > redirects-cdn.ndjson

# Merge into a single array for processing in Step 2
jq -s 'add' redirects-next.json <(jq -s '.' redirects-cdn.ndjson) > redirects-all.json
wc -l redirects-all.json

Validation: open redirects-all.json and confirm every rule has a source and destination. The count should equal the sum of rules across all systems.


Step 2: Compute the Transitive Closure to the Final Target

For each source, follow its destination through successive rules until you land on a URL that is not itself a redirect source. Store source โ†’ finalTarget directly, and flag any cycle so it never ships.

// scripts/flatten-redirects.ts
type Rule = { source: string; destination: string };

export function flatten(rules: Rule[]): Record<string, string> {
  const map = new Map(rules.map((r) => [r.source, r.destination]));
  const flat: Record<string, string> = {};

  for (const source of map.keys()) {
    const seen = new Set<string>([source]);
    let current = map.get(source)!;
    while (map.has(current)) {
      if (seen.has(current)) {
        throw new Error(`Redirect loop detected at ${current} (from ${source})`);
      }
      seen.add(current);
      current = map.get(current)!;
    }
    flat[source] = current; // final target, not a source of any rule
  }
  return flat;
}

Feeding a normalized slug through this map keeps it aligned with your slug normalization strategies, so a legacy path resolves to the one canonical URL in a single hop.

Validation:

npx tsx -e "
import { flatten } from './scripts/flatten-redirects';
const rules = require('./redirects-all.json');
const flat = flatten(rules);
require('fs').writeFileSync('redirects-flat.json', JSON.stringify(flat, null, 2));
console.log('Flattened', Object.keys(flat).length, 'sources');
"
# A thrown 'Redirect loop detected' error means a cycle must be fixed before shipping

Step 3: Load the Flattened Map at the Edge

Ship redirects-flat.json into edge middleware so every lookup is an in-memory hash lookup with no chained requests. Here the map is bundled with the middleware for a single-region-consistent, zero-round-trip resolution.

// middleware.ts โ€” Next.js Middleware (runs at the edge)
import { NextResponse, type NextRequest } from 'next/server';
import flat from './redirects-flat.json';

const SITE = process.env.NEXT_PUBLIC_SITE_URL!; // e.g. https://yourdomain.com

export function middleware(req: NextRequest) {
  const path = req.nextUrl.pathname;
  const target = (flat as Record<string, string>)[path];
  if (target) {
    const dest = target.startsWith('http') ? target : `${SITE}${target}`;
    return NextResponse.redirect(dest, 301);
  }
  return NextResponse.next();
}

export const config = { matcher: '/((?!_next|api|.*\\..*).*)' };

Validation:

curl -s -o /dev/null -w "size:%{size_download}\n" \
  https://staging.yourdomain.com/old
# Middleware lookups add no measurable body; confirm the redirect fires below

Step 4: Issue a Single 301 Straight to the Destination

The middleware returns exactly one permanent redirect to the final target on the first request. Because the map already holds the resolved destination, the intermediate hops (/interim, /renamed) are never touched.

# Follow every hop and print the chain for a legacy URL
curl -sIL https://staging.yourdomain.com/old \
  | grep -iE '^(HTTP|location):'
# Expected โ€” exactly one 301 then the 200:
#   HTTP/2 301
#   location: https://yourdomain.com/final
#   HTTP/2 200

Landing the redirect in one hop also protects your canonical signals: the destination is the URL your canonical URL enforcement layer already advertises, so redirect target and canonical agree.

Validation: the curl -sIL output above must contain a single 301 line. Two or more indicates an unflattened chain still present in another system.


Step 5: Add a Loop Guard and Validate Hop Count in CI

Fail the build if any source resolves in more than one hop or if the flatten step detected a cycle. Wire this into CI so a future redirect edit cannot silently reintroduce a chain.

# scripts/assert-single-hop.sh โ€” run against a list of legacy URLs
fail=0
while IFS= read -r url; do
  hops=$(curl -sIL "https://staging.yourdomain.com${url}" \
    | grep -ci '^location:')
  if [ "$hops" -gt 1 ]; then
    echo "CHAIN (${hops} hops): ${url}"
    fail=1
  fi
done < legacy-urls.txt
exit $fail

Validation: the script exits 0 with no CHAIN lines. Any listed URL is a chain that must be flattened before promoting the deploy.

SEO Impact Summary

Signal What improves What breaks if misconfigured
Crawl efficiency One hop per legacy URL means one fetch to resolve it Multi-hop chains multiply Googlebot fetches per URL
Link equity Signals pass through a single 301 to the final target Each extra hop risks diluting or dropping consolidated equity
Latency Edge lookup resolves in memory with no chained round-trips Sequential server redirects stack TTFB for users and crawlers
Reliability A loop guard prevents cycles from ever shipping An undetected cycle returns an infinite redirect and a broken URL

Measurable signals to watch:

  • Crawl audit: URLs resolving in more than one hop should drop to zero after deployment.
  • GSC Crawl stats: fetches spent on redirect responses should decline relative to 200 responses.
  • Server timing: redirect-response TTFB should fall now that hops resolve in a single edge lookup.

Edge Cases and Gotchas

Rules living outside the flattened map If a redirect still lives only in the CDN dashboard or a legacy origin, the edge map cannot flatten it and a chain survives. Treat the exported map as the single source of truth and disable ad-hoc redirects elsewhere, or export them into Step 1 first.

Structural redirects that should stay as rules Host-level normalization (www to apex, protocol upgrade) is best handled by the platform before middleware runs, not baked into the path map. Ensure those fire first so the middleware only ever sees the normalized host, otherwise a path lookup can miss.

Query strings and fragments The flattened map keys on pathname. Decide explicitly whether query parameters should be preserved on redirect or stripped. Preserving them can be correct for tracking, but forwarding stale parameters to the final target can create parameter variants that need canonical handling.

Regenerating the map on every redirect change The flattened map is a build artifact. If someone edits a raw redirect rule without rerunning the flatten step, the edge map goes stale and a new chain appears. Make flatten a required build step and keep the loop guard in CI. For a full post-migration sweep, pair this with auditing redirect hops after a headless migration.

Frequently Asked Questions

How many redirect hops is too many? Aim for exactly one hop from any old URL to its final destination. Google will follow up to roughly five hops in a single crawl attempt before it pauses and retries later, but every extra hop adds latency, risks diluting link signals, and wastes crawl budget. Treat any URL that resolves in two or more hops as a defect to flatten.

How do I flatten a chain automatically? Compute the transitive closure of your redirect map. For each source, follow its destination through successive rules until you reach a URL that is not itself a source, then store the source-to-final-target mapping directly. Load that flattened map into edge middleware so the very first request lands a single 301 on the final destination.

Do chains waste crawl budget? Yes. Each hop is a separate request Googlebot must fetch before it reaches real content, so a three-hop chain costs three crawls to resolve one URL. On large sites those wasted fetches accumulate and slow the discovery of new and updated pages, which is why flattening chains directly benefits crawl efficiency.

Should redirect middleware use 301 or 302? Use 301 for permanent moves so search engines consolidate signals onto the destination and eventually swap the indexed URL. Reserve 302 for genuinely temporary situations, since a temporary redirect tells Google to keep the original URL indexed and will not transfer ranking to the target.


Part of: Redirect Chain Management

Related