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.

RedirectRule content type
// 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;
}
Graph query - fetch active rules
// 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 - redirect check + cache strategy
// 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"
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#

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.

301Permanent
HTTP method
May change to GET
SEO equity
Transfers equity
Use for
Page renames, URL restructuring
302Temporary
HTTP method
May change to GET
SEO equity
Does not transfer
Use for
Promos, maintenance pages
307Temporary
HTTP method
Preserved
SEO equity
Does not transfer
Use for
Temporary API or form endpoint moves
308Permanent
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 - zero-latency, deployment required
// 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.

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 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 RedirectRule content 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.