Developer Demo

URL Redirects

In a traditional CMS, the platform creates 301 redirects automatically when a page moves. In a headless setup that responsibility shifts to the app layer - here is how this project handles it.

The problem: URL changes in a headless CMS#

In Optimizely CMS 12/13, WordPress, and Sitecore the platform owns HTTP routing - rename a page and the CMS creates a redirect automatically. In a headless setup the CMS only provides content data via API; Next.js owns routing. Change a routeSegment and the old URL silently 404s - breaking bookmarks, backlinks, and SEO equity. There is also no built-in way to point a short marketing URL at an existing page.

Traditional CMS

  • +Platform owns URL routing
  • +Page rename triggers automatic 301
  • +No developer involvement needed

Headless CMS

  • !CMS provides content data only
  • !Next.js owns routing - no auto-redirect
  • !App layer must manage redirects explicitly

Option A: CMS-managed redirect rules#

Store redirect rules as content. A single RedirectConfig shared block holds a list of RedirectRule rows - editors see and edit every redirect on one screen, no developer involvement, no deployments. Middleware issues the redirect before the request reaches the page router, so it works for still-published pages (vanity URLs) as well as 404 recovery.

RedirectRule + RedirectConfig content types
// src/components/blocks/RedirectRule/index.tsx
// One row per redirect. Data-only - renders nothing.

import { contentType } from "@optimizely/cms-sdk";

export const RedirectRuleType = contentType({
  key: "RedirectRule",
  baseType: "_component",
  compositionBehaviors: ["elementEnabled"],
  displayName: "Redirect Rule",
  properties: {
    fromPath:      { type: "string",  displayName: "From path (old URL)",  indexingType: "queryable" },
    toPath:        { type: "string",  displayName: "To path (new URL)",    indexingType: "queryable" },
    permanent:     { type: "boolean", displayName: "Permanent (308). Off = temporary (307).", indexingType: "queryable" },
    matchSubpaths: { type: "boolean", displayName: "Also redirect everything under this path" },
    enabled:       { type: "boolean", displayName: "Enabled", indexingType: "queryable" },
    note:          { type: "string",  displayName: "Internal note" },
  },
});

export default function RedirectRule() { return null; }


// src/components/blocks/RedirectConfig/index.tsx
// The singleton every editor opens - a content area of RedirectRule rows.
// sectionEnabled (not elementEnabled) because it owns a content area.

export const RedirectConfigType = contentType({
  key: "RedirectConfig",
  baseType: "_component",
  compositionBehaviors: ["sectionEnabled"],
  displayName: "Redirect Config",
  properties: {
    rules: { type: "array", displayName: "Redirect rules",
             items: { type: "content", allowedTypes: [RedirectRuleType] } },
    notes: { type: "string", displayName: "Notes for editors" },
  },
});

export default function RedirectConfig() { return null; }
Graph query - fetch the singleton, inline-expand the rows
// src/lib/graphql/queries/GetRedirectRules.ts

// Fetch the singleton by type, newest first. No where-clause on 'enabled' -
// filtering a field the Graph schema has not synced as queryable errors the
// whole query, so the enabled check happens in JS (same as GetSiteBanner).
const GET_REDIRECT_RULES_QUERY = /* GraphQL */ `
  query GetRedirectRules {
    RedirectConfig(orderBy: { _metadata: { lastModified: DESC } }, limit: 10) {
      items {
        rules {
          ... on RedirectRule { fromPath toPath permanent matchSubpaths enabled }
        }
      }
    }
  }
`;

export async function getRedirectRules() {
  const result = await graphqlFetch(GET_REDIRECT_RULES_QUERY, {},
    { next: { revalidate: 3600, tags: ["redirects"] } });
  return (result.data?.RedirectConfig?.items?.[0]?.rules ?? [])
    .filter((r) => r && r.enabled !== false && r.fromPath && r.toPath)
    .sort((a, b) => b.fromPath.length - a.fromPath.length); // exact beats prefix
}
src/app/api/redirects/route.ts - the cached source of truth
// src/app/api/redirects/route.ts
// Middleware has no Data Cache. This route does: the Graph call is cached with
// tags: ["redirects"], and the publish webhook busts it with
// revalidateTag("redirects"). Middleware reads this small JSON instead of Graph.

import { NextResponse } from "next/server";
import { getRedirectRules } from "@/lib/graphql/queries/GetRedirectRules";

export const revalidate = 3600;

export async function GET() {
  return NextResponse.json({ rules: await getRedirectRules() });
}
src/lib/redirects.ts + src/middleware.ts - the redirect check
// src/lib/redirects.ts - edge-safe, mirrors datafile.ts

const TTL_MS = 30_000;
let cache = null; // best-effort: a cold worker just does the subrequest

export async function loadRedirectRules(origin) {
  if (cache && Date.now() - cache.at < TTL_MS) return cache.rules;
  try {
    const res = await fetch(`${origin}/api/redirects`, { signal: AbortSignal.timeout(2000) });
    if (!res.ok) return cache?.rules ?? [];
    cache = { at: Date.now(), rules: (await res.json()).rules ?? [] };
    return cache.rules;
  } catch { return cache?.rules ?? []; }
}


// src/middleware.ts - after the /api, /preview, /demo, __v_ and .segments/
// guards, BEFORE the Feature Experimentation rewrite (which would append a
// /__v_ segment and break the plain-path match).

import { loadRedirectRules, matchRedirect } from "@/lib/redirects";

export async function middleware(request) {
  const response = NextResponse.next();
  // ... existing userId cookie logic ...

  if (request.nextUrl.pathname.startsWith("/api/")) return response;
  if (request.nextUrl.pathname.startsWith("/preview")) return response;
  if (/^\/demo(\/|$)/.test(request.nextUrl.pathname)) return response;
  if (request.nextUrl.pathname.includes("__v_")) return response;
  if (request.nextUrl.pathname.includes(".segments/")) return response;

  try {
    const rules = await loadRedirectRules(request.nextUrl.origin);
    const hit = rules.length ? matchRedirect(request.nextUrl.pathname, rules) : null;
    if (hit) {
      const dest = /^https?:\/\//i.test(hit.toPath)
        ? new URL(hit.toPath)
        : new URL(hit.toPath, request.nextUrl.origin);
      if (!dest.search && request.nextUrl.search) dest.search = request.nextUrl.search;
      return NextResponse.redirect(dest, hit.status); // 308 permanent, 307 temporary
    }
  } catch {
    // Never fail a request due to redirect lookup errors.
  }

  // ... existing Feature Experimentation variation rewrite logic ...
}

// Add "redirects" to the publish webhook handler (src/app/api/webhooks/route.ts):
//   revalidateTag("redirects");  // alongside "page" and "navigation"
Why the route handler. Middleware has no Data Cache and the proxy docs warn against relying on globals, so a direct Graph call there would run on every request and the publish webhook could never bust it. The /api/redirects route handler caches the Graph call and is busted instantly by revalidateTag("redirects"); middleware reads that small JSON through a ~30s in-memory guard, so the hot path does zero I/O.
Run the redirect check before the FX variation rewrite. If it runs after, the URL already has a variation segment appended (e.g. /savings-accounts/__v_homepage--business) and the plain-path match fails.

Choosing a status code#

The CMS-managed path exposes one checkbox - Permanent - and emits 308 when it is on, 307 when it is off. Google treats 308 as 301 and 307 as 302 for SEO, and both preserve the HTTP method. Emitting a literal 301/302 would need a route-level Response rather than NextResponse.redirect; for content pages it is not worth the extra surface.

308Permanent - checkbox on
HTTP method
Preserved
SEO equity
Transfers equity (like 301)
Use for
Page renames, URL restructuring, vanity URLs
307Temporary - checkbox off
HTTP method
Preserved
SEO equity
Does not transfer (like 302)
Use for
Promos, campaigns, maintenance pages

Option B: static redirects in next.config.ts#

Next.js resolves these before middleware runs - zero latency, wildcard and regex support. The trade-off: every change requires a code deployment. Good for one-time migrations (a rebrand, a URL cleanup pass). Use both together: static for known legacy redirects, CMS-managed for anything editors need to control going forward.

next.config.ts - zero-latency, deployment required
// next.config.ts
// Resolved before middleware - zero latency. Supports wildcards and regex.
// Trade-off: every new redirect requires a code change and a deployment.

const nextConfig = {
  async redirects() {
    return [
      { source: "/savings-accounts", destination: "/savings",      permanent: true  },  // 301
      { source: "/promo-summer",      destination: "/offers",       permanent: false },  // 302
      { source: "/personal/:path*",   destination: "/retail/:path*", permanent: true  },  // wildcard
    ];
  },
};

// next.config only emits 301/302 (permanent: true/false). The CMS-managed
// middleware path emits 308/307, which Google treats as the SEO equivalents.

Sitemap consistency#

Redirects and the sitemap are complementary - not redundant. The redirect handles the HTTP 308; the sitemap handles canonicality. When the old page is unpublished, Graph stops returning its URL and it drops out of the sitemap automatically. No changes to sitemap.ts needed.

Sitemap and redirects - how they complement each other
// No changes needed to src/app/sitemap.ts.
//
// GET_ALL_PAGE_PATHS_QUERY only returns currently published pages.
// When an editor unpublishes or renames the old page, Graph stops
// returning its URL - it disappears from the sitemap automatically.
//
// redirect rule  ->  handles the HTTP 308 for browsers and crawlers
// sitemap        ->  only lists the new canonical URL
//
// If the old page stays published (a vanity URL pointing at a live page),
// add a canonical tag in generateMetadata pointing at the real URL:
alternates: { canonical: `${siteUrl}/savings` }

Key Things to Know#

  • Headless CMSes don't create redirects automatically. The CMS provides content data only - the app layer must manage redirects explicitly.
  • One RedirectConfig block, edited on one screen. A content area of RedirectRule rows means no deployments for new redirects, and it covers live URLs, not just 404s.
  • Middleware reads a cached route handler, not Graph. /api/redirects caches the query and the "redirects" webhook tag activates new rules in seconds; a ~30s in-memory guard keeps the hot path I/O-free.
  • Run the redirect check before the FX rewrite in middleware. Otherwise variation segments in the URL break the path match.
  • The checkbox picks 308 or 307. Google treats them as 301/302 for SEO; both preserve the HTTP method.