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.
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
200responses. - 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
- Auditing Redirect Hops After a Headless Migration โ crawl the legacy inventory and map every hop before you flatten
- Slug Normalization Strategies โ normalize slugs at source so redirect targets resolve to one canonical path
- Canonical URL Enforcement โ keep the flattened redirect target and the advertised canonical in agreement