Redirect Chain Management for Headless Architectures

Multi-hop redirects degrade crawl budget in headless deployments and fragment link equity at every intermediate hop. In decoupled stacks where CDN routing, framework middleware, and origin server rules each operate independently, chains form silently — often the result of overlapping rule sets that were never merged.

Prerequisites

Before applying the workflows on this page, confirm the following are in place:

  • Framework version: Next.js 13.4+ (App Router), Nuxt 3.8+, or SvelteKit 2.0+
  • CDN access: Admin access to Cloudflare Page Rules / Vercel Edge Config or equivalent
  • CLI tooling: curl 7.x+, Node.js 18+, and your framework’s CLI (next, nuxi, vite)
  • Observability: CDN access logs or a log-forwarding integration (Logpush, Datadog, etc.) enabled so you can inspect Location and X-Cache headers post-deployment
  • CI pipeline: A GitHub Actions (or equivalent) workflow where a redirect audit step can be injected before merge

How Redirect Chains Form in Decoupled Stacks

Redirect Chain Execution Path — Headless Architecture Diagram showing how a single user request can trigger three separate redirect hops across CDN page rules, framework middleware, and origin server before reaching the final 200 response. The right side shows the collapsed single-hop version after chain elimination. BEFORE — 3 hops Browser → /old-path 301 CDN Page Rule /old-path → /interim-path 302 Framework Middleware /interim-path → /new-path 301 Origin Server 200 OK /new-path 3 round-trips · equity loss · latency spike AFTER — 1 hop Browser → /old-path 301 Edge Middleware (single rule) /old-path → /new-path 200 OK — /new-path 1 round-trip · equity preserved Zero intermediate hops · full link equity

In a typical decoupled setup, three independent redirect layers each process incoming requests: the CDN’s page rules, the framework’s middleware, and the origin server’s routing config. Chains emerge when rules at different layers each see the request and each issue their own redirect. A /old-path → /interim-path CDN rule stacks on top of an /interim-path → /new-path middleware rule, producing two hops where one sufficed.

The cost is real: each extra hop consumes crawl budget, introduces TTFB latency, and dilutes the link equity that would otherwise flow uninterrupted to the final destination.

Step-by-Step Chain Elimination Workflow

Step 1 — Export Your CDN Redirect Matrix

Pull the complete redirect rule set from your CDN via CLI or dashboard API.

Cloudflare (via API):

curl -s -X GET "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/pagerules?status=active&per_page=100" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" \
  -H "Content-Type: application/json" \
  | jq '[.result[] | select(.actions[].id == "forwarding_url")]'

Vercel Edge Config (via CLI):

vercel env pull .env.local
# then inspect .vercel/output/config.json for "redirects" array
cat .vercel/output/config.json | jq '.redirects'

Save each output to a JSON file. You will diff these against origin-side rules in the next step.

Validation: Confirm the export contains source, destination, and permanent (or statusCode) fields for every rule. Missing entries indicate rules defined outside the exported scope (e.g. inline framework config).

Step 2 — Cross-Reference Origin-Server Redirect Logs

Compare CDN-level rules against what your origin server is actually responding with.

# Pull 3xx responses from the last 24h from your log source (example: Cloudflare Logpush to R2)
curl -s "https://<r2-log-endpoint>/access.log.gz" | gunzip \
  | jq -r 'select(.EdgeResponseStatus >= 300 and .EdgeResponseStatus < 400) | [.ClientRequestURI, .EdgeResponseStatus, .EdgeResponseLocation] | @tsv' \
  | sort | uniq -c | sort -rn

Look for any URL that appears as both a source in one rule and a destination in another — these are the links in your chain.

Validation: Any URL that appears in the destination column of one rule and the source column of another rule is a chain link. Flag these before proceeding.

Step 3 — Flatten Chains to Direct Mappings

Build a collapsed redirect map. For every chain A → B → C, write a single rule A → C and delete the intermediate A → B rule.

// flatten-redirects.mjs
// Input: array of { source, destination } objects
// Output: collapsed map with no intermediate hops

function flattenRedirects(rules) {
  const map = new Map(rules.map(r => [r.source, r.destination]));

  for (const [src, dest] of map) {
    let target = dest;
    const visited = new Set([src]);
    while (map.has(target)) {
      if (visited.has(target)) {
        console.error(`Redirect loop detected at: ${target}`);
        break;
      }
      visited.add(target);
      target = map.get(target);
    }
    if (target !== dest) {
      console.log(`Collapsed: ${src}${dest} → ... → ${target}`);
      map.set(src, target);
    }
  }
  return [...map.entries()].map(([source, destination]) => ({ source, destination }));
}

const rules = JSON.parse(process.argv[2]);
console.log(JSON.stringify(flattenRedirects(rules), null, 2));
node flatten-redirects.mjs "$(cat cdn-rules.json)" > collapsed-rules.json

Validation: Run the output through the same script a second time — if any entry still maps to a URL that is itself a source, the flatten did not complete. The output should be stable (idempotent) on a second pass.

Step 4 — Deploy the Collapsed Rules

Push the flattened rule set to the edge. Remove intermediate CDN page rules and update framework config in the same deployment to avoid a race where the old and new rules briefly coexist.

# Cloudflare: delete old page rules and upload new bulk redirects
curl -s -X DELETE "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/pagerules/${OLD_RULE_ID}" \
  -H "Authorization: Bearer ${CF_API_TOKEN}"

# Then create the new bulk redirect list
curl -s -X POST "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/rules/lists" \
  -H "Authorization: Bearer ${CF_API_TOKEN}" \
  -H "Content-Type: application/json" \
  --data-binary @collapsed-rules.json

Validation: After deployment, run curl -I -L https://yoursite.com/old-path and count the HTTP/ lines in the response. There must be exactly two: one 301 and one 200.

Step 5 — Set Immutable Cache Headers on Permanent Redirects

A 301 that is not cached by the CDN can be re-fetched on every crawl, wasting crawl budget identically to a chain.

# nginx snippet for origin fallback (if applicable)
location /old-path {
  return 301 /new-path;
  add_header Cache-Control "public, max-age=31536000, immutable";
}

For CDN-level redirects (Cloudflare Workers, Vercel middleware), attach the header in the response object — see the framework examples below.

Framework-Specific Implementation

Next.js App Router Middleware

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';

// Centralised flat redirect map — no chains allowed
const REDIRECTS: Record<string, string> = {
  '/legacy-path': '/new-path',
  '/blog/2022/old-post': '/blog/old-post',
  '/docs/v1': '/docs/v2',
};

export function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;
  const destination = REDIRECTS[pathname];
  if (destination) {
    const url = req.nextUrl.clone();
    url.pathname = destination;
    const res = NextResponse.redirect(url, { status: 301 });
    res.headers.set('Cache-Control', 'public, max-age=31536000, immutable');
    return res;
  }
  return NextResponse.next();
}

export const config = {
  matcher: Object.keys(REDIRECTS),
};

SEO impact: Executes before the Next.js router resolves, so no intermediate framework hops can form. The Cache-Control header ensures the CDN caches the 301 and does not forward the same request to origin on repeat crawls.

Validation: Check Vercel Function logs for NextResponse.redirect invocations. Confirm no downstream 302 responses appear for paths listed in REDIRECTS.

SvelteKit hooks.server.ts

// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';

const REDIRECTS: Record<string, string> = {
  '/old-slug': '/new-slug',
  '/archive/post-a': '/blog/post-a',
};

export const handle: Handle = async ({ event, resolve }) => {
  const dest = REDIRECTS[event.url.pathname];
  if (dest) {
    return new Response(null, {
      status: 301,
      headers: {
        Location: dest,
        'Cache-Control': 'public, max-age=31536000, immutable',
      },
    });
  }
  return resolve(event);
};

SEO impact: The handle hook intercepts at the server entry point, before any page load function runs. This prevents SvelteKit’s data-loading layer from issuing a secondary redirect.

Validation: Run npx svelte-kit sync && npm run build. Deploy to staging and confirm curl -I /old-slug returns 301 directly to /new-slug with zero intermediate hops.

Nuxt 3 Route Rules

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    // Direct permanent redirects — no intermediate paths as keys
    '/old-slug/**': { redirect: { to: '/new-slug/**', statusCode: 301 } },
    '/docs/v1/**': { redirect: { to: '/docs/v2/**', statusCode: 301 } },
  },
});

SEO impact: Nuxt compiles routeRules into Nitro server routes at build time, placing the redirect logic at the CDN layer on supported platforms (Cloudflare, Vercel). No framework hydration occurs before the 301 is issued.

Validation: After nuxi build, inspect .output/server/chunks/nitro/node-server.mjs for the compiled redirect entries. Run curl -I /old-slug/example against the preview deployment and confirm statusCode: 301 with the correct Location value.

HTTP Headers & CDN Directives Reference

Header Required Value Rationale
Location Absolute or root-relative final destination URL Must point to the final 200 target — no intermediate URLs
Cache-Control public, max-age=31536000, immutable Caches the 301 at the CDN edge; prevents repeat origin fetches by crawlers
X-Robots-Tag Omit on redirect responses Robots directives on 3xx responses are not honoured; apply only on the final 200
Vary Accept-Encoding (on 200 responses only) Prevents cache poisoning on compressed variants; irrelevant on redirect responses
Strict-Transport-Security max-age=31536000; includeSubDomains Eliminates http://https:// as a forced first hop in every chain

Validation Protocol

curl Chain Audit

# Verify single-hop resolution
curl -I -L --max-redirs 10 https://yoursite.com/old-path 2>&1 | grep -E "^HTTP|^location:"

Expected output (two lines only):

HTTP/2 301
location: https://yoursite.com/new-path
HTTP/2 200

Any additional HTTP/2 30x lines indicate a surviving chain.

GSC URL Inspection

  1. Open Google Search Console → URL Inspection → paste the legacy URL.
  2. Under Page fetchHTTP response, confirm the crawler saw exactly 301 with the final destination in Location.
  3. If GSC shows Redirect error or a chain warning, the issue is live in production — re-run the flatten workflow.

Lighthouse CI Threshold

Add a redirect audit to your Lighthouse CI budget file:

{
  "ci": {
    "assert": {
      "assertions": {
        "redirects": ["error", { "maxLength": 1 }]
      }
    }
  }
}

A maxLength of 1 means a maximum of one redirect per URL before reaching a 200. Set this in .lighthouserc.json and wire it into your PR gate.

Troubleshooting

Symptom Root Cause Fix
curl -I -L shows two or more 301 responses CDN page rule and framework middleware both redirect the same path Remove the CDN rule for that path; let middleware handle it exclusively, or vice versa
302 appears before the final 200 A CMS auto-redirect plugin is issuing temporary redirects before framework rules fire Disable the CMS redirect plugin; route all mappings through the framework’s centralized redirect map
Trailing slash produces a chain: /path/path/ → final Framework trailingSlash config conflicts with CDN normalisation rule Set trailingSlash: false in framework config and remove the CDN trailing-slash rewrite; enforce one canonical form
window.location redirect in client JS stacks on top of server-side 301 Client-side navigation logic re-redirecting after the server redirect resolved Replace window.location.href = ... and router.push(...) with server-side NextResponse.redirect() or SvelteKit redirect()
Chain reappears after deploying the flattened rules Old CDN rule was cached at the PoP; new rules not yet propagated Purge the CDN cache for affected URLs immediately after rule deployment; use curl -H "Cache-Control: no-cache" to bypass edge cache during verification
GSC reports Redirect error for URLs that curl resolves correctly GSC fetches from a different region where CDN propagation is incomplete Wait for global CDN propagation (typically 5–10 minutes for Cloudflare), then re-run GSC URL Inspection

Slug & Path Normalization Integration

Redirect chains frequently originate from slug normalization inconsistencies — a trailing slash redirect stacks on a case-normalisation redirect before the final destination is reached. Enforce the following at the edge before your redirect map is evaluated:

// Edge normalisation that runs BEFORE the redirect map (Next.js middleware example)
export function middleware(req: NextRequest) {
  const url = req.nextUrl.clone();
  let { pathname } = url;

  // 1. Lowercase
  const lowered = pathname.toLowerCase();
  // 2. Strip trailing slash (except root)
  const stripped = lowered.length > 1 ? lowered.replace(/\/$/, '') : lowered;

  if (pathname !== stripped) {
    url.pathname = stripped;
    return NextResponse.redirect(url, { status: 301 });
  }

  // Then evaluate the redirect map
  const destination = REDIRECTS[stripped];
  if (destination) {
    url.pathname = destination;
    const res = NextResponse.redirect(url, { status: 301 });
    res.headers.set('Cache-Control', 'public, max-age=31536000, immutable');
    return res;
  }

  return NextResponse.next();
}

By collapsing normalisation and redirect-map lookup into a single function, you guarantee that a URL can never trigger more than one redirect regardless of its initial form.

Integrating Chain Audits into CI/CD

Prevent chains from reaching production by adding an automated audit step to your deployment pipeline. The check fetches every URL in the CMS sitemap with redirect-following disabled, then fails the build if any response status is 3xx.

# .github/workflows/redirect-audit.yml
name: Redirect Chain Audit
on: [pull_request]

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Fetch CMS slugs
        id: slugs
        run: |
          curl -s "https://your-cms.io/api/pages?fields=slug&limit=500" \
            | jq -r '.data[].slug' > slugs.txt
          echo "Total slugs: $(wc -l < slugs.txt)"

      - name: Check for redirect chains
        run: |
          FAILED=0
          while IFS= read -r slug; do
            STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-redirs 0 "https://staging.yoursite.com${slug}")
            if [[ "$STATUS" == "302" || "$STATUS" == "307" || "$STATUS" == "308" ]]; then
              echo "FAIL: unexpected temporary redirect at ${slug} (${STATUS})"
              FAILED=1
            fi
            CHAIN=$(curl -s -o /dev/null -w "%{num_redirects}" -L --max-redirs 10 "https://staging.yoursite.com${slug}")
            if [[ "$CHAIN" -gt 1 ]]; then
              echo "FAIL: chain of length ${CHAIN} at ${slug}"
              FAILED=1
            fi
          done < slugs.txt
          exit $FAILED

This integrates naturally with the broader Dynamic Routing & Indexation Workflows CI strategy: the same pipeline that validates XML sitemap generation and canonical URL enforcement can run this redirect audit in a parallel job.

FAQ

{: .faq }

How many redirect hops are acceptable before impacting SEO? Zero chains — every destination should be reachable in a single 301. Crawlers follow multiple hops but each hop costs crawl budget and dilutes link equity; chains longer than three hops may not be followed at all by major search engines.

Can edge middleware replace traditional .htaccess redirects in headless setups? Yes. Edge middleware executes at the CDN layer, delivering 301 responses without hitting the origin server. This eliminates the latency introduced by Apache/Nginx processing and prevents origin-level chaining entirely.

How do I audit redirect chains in a decoupled CMS architecture? Combine synthetic crawling with framework route introspection. Run curl -I -L <url> and count intermediate 3xx responses before the final 200. Automate this in CI with redirect-following disabled so any chain with length greater than one fails the build.

What is the difference between a 301 and a 302 in a redirect chain context? For chain management purposes, both status codes consume a hop. The distinction matters for link equity: 301 passes equity to the destination; 302 does not (in most implementations). A chain that mixes 302 and 301 hops loses equity at the 302 step, so even a two-hop chain with one 302 wastes the link equity that the 301 would have transferred.

How do I handle preview environments without creating chains in production? Set an environment variable (e.g. NEXT_PUBLIC_ENV=preview) and skip the redirect map entirely in preview deployments. Route preview traffic directly to the current draft URL without any redirect layer, then restore the full redirect map on production deployment.


Part of: Dynamic Routing & Indexation Workflows

Related: