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 are two ways to handle 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.
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 in the CMS. A RedirectRule content type gives editors a form-based UI to add rules directly - no developer involvement, no deployments. Next.js middleware queries the rules from Graph (cached at 3600s) and issues the redirect before the request reaches the page router.
// src/components/blocks/RedirectRule/index.tsx
// A data-only content type - no visual rendering.
// Editors create one item per redirect rule in the CMS.
import { contentType } from "@optimizely/cms-sdk";
export const RedirectRuleType = contentType({
key: "RedirectRule",
baseType: "_component",
compositionBehaviors: ["elementEnabled"],
displayName: "Redirect Rule",
description: "Maps an old URL path to a new destination. Consumed by Next.js middleware.",
properties: {
fromPath: { type: "string", displayName: "From path", indexingType: "queryable" },
toPath: { type: "string", displayName: "To path", indexingType: "queryable" },
statusCode: {
type: "string",
displayName: "Status code",
description: "301/308 = permanent. 302/307 = temporary. 307/308 preserve the HTTP method.",
format: "select",
enumValues: ["301", "302", "307", "308"],
indexingType: "queryable",
},
isActive: { type: "boolean", displayName: "Active" },
},
});
export default function RedirectRule() {
return null;
}// src/lib/graphql/queries/GetRedirectRules.ts
export const GET_REDIRECT_RULES_QUERY = /* GraphQL */ `
query GetRedirectRules {
RedirectRule(
where: { isActive: { eq: true } }
limit: 500
) {
items {
fromPath
toPath
statusCode
}
}
}
`;// src/middleware.ts (additions)
// Check redirect rules before the Feature Experimentation rewrite so the
// plain path is always what gets matched (no variation segment appended yet).
import { GET_REDIRECT_RULES_QUERY } from "@/lib/graphql/queries/GetRedirectRules";
import { graphqlFetch } from "@/lib/optimizely/client";
export async function middleware(request: NextRequest) {
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;
try {
const rulesRes = await graphqlFetch(
GET_REDIRECT_RULES_QUERY,
{},
{ next: { revalidate: 3600, tags: ["redirects"] } }
);
const rules = rulesRes.data?.RedirectRule?.items ?? [];
const match = rules.find((r) => r.fromPath === request.nextUrl.pathname);
if (match?.toPath) {
const destination = new URL(match.toPath, request.nextUrl.origin);
const status = parseInt(match.statusCode ?? "301", 10);
return NextResponse.redirect(destination, { status });
}
} catch {
// Never fail a request due to redirect lookup errors.
}
// ... existing Feature Experimentation variation rewrite logic ...
}
// Cache strategy: 3600s revalidate keeps the lookup in Next.js's fetch cache.
// Add "redirects" to the publish webhook handler so new rules go live instantly:
// revalidateTag("redirects"); // alongside "page" and "navigation"/savings-accounts/__v_homepage--business) and the plain-path match fails.Choosing a status code#
For content pages 301 and 302 cover almost every case. Use 307/308 only when redirecting API endpoints or form actions where the HTTP method must not change to a GET.
- HTTP method
- May change to GET
- SEO equity
- Transfers equity
- Use for
- Page renames, URL restructuring
- HTTP method
- May change to GET
- SEO equity
- Does not transfer
- Use for
- Promos, maintenance pages
- HTTP method
- Preserved
- SEO equity
- Does not transfer
- Use for
- Temporary API or form endpoint moves
- HTTP method
- Preserved
- SEO equity
- Transfers equity
- Use for
- Permanent API endpoint migrations
Option B: static redirects in next.config.mjs#
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.mjs
// Resolved before middleware - zero latency. Supports wildcards and regex.
// Trade-off: every new redirect requires a code change and a deployment.
export default {
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 supports 301/302 (permanent: true/false).
// For 307/308 (method-preserving), use the CMS-managed approach via middleware.Sitemap consistency#
Redirects and the sitemap are complementary - not redundant. The redirect handles the HTTP 301; 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 301 for browsers and crawlers
// sitemap → only lists the new canonical URL
//
// If the old page stays published (intentionally), add a canonical tag
// in generateMetadata pointing to the new 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.
- →CMS-managed rules give editors self-service control. A
RedirectRulecontent type means no deployments needed for new redirects. - →Run the redirect check before the FX rewrite in middleware. Otherwise variation segments in the URL break the path match.
- →Cache at 3600s, flush with a webhook tag. Redirect rules are stable - long TTL keeps middleware fast, the
"redirects"tag activates new rules in seconds without busting page cache. - →Use 301/302 for content pages, 307/308 for API endpoints. Method-preserving codes only matter when clients must retry a POST against the new URL.