Developer Demo
URL Redirects
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.
// 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; }// 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
// 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 - 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"/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./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.
- HTTP method
- Preserved
- SEO equity
- Transfers equity (like 301)
- Use for
- Page renames, URL restructuring, vanity URLs
- 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
// 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.
// 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
RedirectConfigblock, edited on one screen. A content area ofRedirectRulerows means no deployments for new redirects, and it covers live URLs, not just 404s. - →Middleware reads a cached route handler, not Graph.
/api/redirectscaches 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.