Wednesday, 17 June 2026

Two CMSs, One Website: Managing Routing During WordPress to Sitecore Migration

 


Introduction

Migrating an enterprise website from WordPress to Sitecore is not a one-time event.

Most organizations have hundreds of pages, multiple business stakeholders, SEO considerations, and ongoing content publishing requirements. A complete cutover often introduces unnecessary risk, especially when business teams expect uninterrupted service throughout the migration journey.

In one of our recent migration projects, the business decided to move from WordPress to Sitecore AI using a phased rollout strategy rather than a full migration in one shot. The objective was simple: migrate content incrementally while ensuring visitors continued to experience a single website.

Although the concept sounds straightforward, the routing architecture required careful planning.

During the migration period:

  • Some pages were served from Sitecore.
  • Some pages remained in WordPress.
  • Both platforms had to coexist.
  • Existing URLs could not change.
  • SEO rankings had to be protected.
  • Content teams needed the flexibility to migrate sections independently.

The challenge was not migrating content. The challenge was making two CMS platforms behave like one website.

This article explains the architecture, routing strategy, and Netlify Edge Function implementation we used to achieve that goal.

The Migration Challenge

The original website was fully managed in WordPress. The target platform was Sitecore AI running on a modern composable architecture. 

Migrating every page at once was not realistic. Different business units owned different sections of the website. Some content areas were ready for migration while others required redesign, content review, or approval cycles.

As a result, both platforms needed to remain active for several months. The business had one non-negotiable requirement:

Users must continue accessing content through the same URLs regardless of which CMS serves the page.

For example:

/products/business-banking

/resources/industry-report

/about-us

/contact

Those URLs already had search engine rankings, backlinks, marketing campaign references, and bookmarks.

Changing them was not an option.

High-Level Architecture

The website was hosted on Netlify, which provided an ideal place to introduce a routing layer.

The architecture looked like this:



Every request entered through Netlify. The Edge Function acted as a traffic controller. Instead of maintaining a large routing table, the architecture relied on Sitecore being the primary source of truth. If Sitecore could resolve a route, the request remained in Sitecore. If Sitecore could not resolve the route, the request automatically fell back to WordPress. This approach simplified migration management considerably.

Why We Chose a 404 Fallback Model

One of the first design decisions involved determining how route ownership would be managed.

Several approaches were considered:

Central Route Registry

Maintain a database containing all migrated routes. While technically possible, it introduced additional maintenance overhead. Every migration release would require route updates. Every rollback would require route updates. Operational complexity grows quickly.

Migration Mapping File

Store route ownership inside configuration files. This works for smaller websites but becomes difficult to maintain as the number of migrated pages increases.

Sitecore-First Routing

Allow Sitecore to handle every request first. If Sitecore returns content, serve it. If Sitecore returns 404, fall back to WordPress. This was the simplest and most maintainable option.

The implementation follows exactly this pattern. The Edge Function calls the Sitecore application first through context.next(). If Sitecore returns anything other than a 404, the response is immediately returned to the visitor. Only when Sitecore responds with a 404 does the WordPress proxy logic execute.

export default async function handler(req: Request, context: Context) {
  const requestUrl = new URL(req.url);
  const wpBase = new URL(WORDPRESS_BASE_URL);
  const proxyReq = req.clone();

  const sitecoreResponse = await context.next();
  if (sitecoreResponse.status !== 404) return sitecoreResponse;

  try {
    const normalizedPath = normalizeWordPressPath(requestUrl.pathname);
    const wpUrl = new URL(
      `${normalizedPath}${requestUrl.search}`,
      wpBase.origin
    ).toString();

    const proxyHeaders = buildProxyHeaders(proxyReq, wpBase);
    const wordpressResponse = await fetchWordPressWithRedirects(
      wpUrl,
      proxyReq,
      proxyHeaders
    );

    const responseHeaders = rewriteLocationHeader(
      wordpressResponse.headers,
      requestUrl,
      wpBase
    );

    return new Response(wordpressResponse.body, {
      status: wordpressResponse.status,
      headers: responseHeaders,
    });
  } catch (error) {
    console.error("[router] WordPress proxy error:", error);
    return sitecoreResponse;
  }
}

GitHub Link- router.ts

Request Flow

The request lifecycle is straightforward.



This design creates a natural migration path. The moment a page is published in Sitecore, Sitecore becomes the owner of that URL. No routing table updates are required. No deployment changes are required. The ownership transition happens automatically.

URL Normalization Challenges

One challenge we encountered involved differences between Sitecore and WordPress URL structures.

During development and content migration, requests sometimes included:

/staging/5474/about-us

or

/old-site/en/banking

These routes made sense in Sitecore but did not exist in WordPress.

To handle this, the Edge Function performs URL normalization before forwarding requests to WordPress.

The router removes:

  • Staging prefixes
  • Sitecore site identifiers
  • Locale prefixes

Examples:

/staging/5474/about-us

becomes

/about-us/

and

/old-site/en/banking

becomes

/banking/

The router also automatically adds trailing slashes to WordPress page URLs while avoiding modifications to static assets and files. This ensures WordPress receives URLs in the format it expects.

function normalizeWordPressPath(pathname: string): string {
  let path = pathname || "/";

  // Remove staging prefix: /staging/5474/...
  path = path.replace(/^\/staging\/\d+(?=\/|$)/i, "") || "/";

  // Remove Sitecore site+locale prefix: /lng-consultancy/en/banking -> /banking
  path = path.replace(/^\/old-site\/[a-z]{2}(?=\/|$)/i, "") || "/";

  // Optional locale-only fallback: /en/banking -> /banking
  path = path.replace(/^\/[a-z]{2}(?=\/|$)/i, "") || "/";

  if (!path.startsWith("/")) path = `/${path}`;
  if (path === "") path = "/";

  // Add trailing slash for WP page permalinks (not files)
  const looksLikeFile = /\.[a-zA-Z0-9]+$/.test(path);
  if (!looksLikeFile && path !== "/" && !path.endsWith("/")) {
    path = `${path}/`;
  }

  return path;
}

GitHub Link- router.ts

Proxying Requests to WordPress

Once a route is determined to be unavailable in Sitecore, the request is forwarded to WordPress.

The Edge Function constructs a WordPress URL using the normalized path and original query string.

For example:

https://www.company.com/resources/report?id=123

might become:

https://wordpress-site.com/resources/report/?id=123

The visitor never sees this internal URL. Everything continues to appear under the primary website domain. From a user perspective, nothing changes.

Header Management

Forwarding requests sounds simple until authentication, language selection, and browser context enter the picture.

The router selectively forwards important request headers including:

  • Accept
  • Accept-Language
  • User-Agent
  • Content-Type
  • Authorization

At the same time, cookies are intentionally excluded. This was an important decision. Forwarding all cookies from Sitecore requests into WordPress often creates unnecessary coupling between systems and can introduce unexpected behaviour. The router only forwards the information WordPress genuinely needs to generate the response.

function buildProxyHeaders(req: Request, wpBase: URL): Headers {
  const headers = new Headers();

  const passThroughHeaders = [
    "accept",
    "accept-language",
    "user-agent",
    "content-type",
    "authorization",
    // intentionally not forwarding cookie
  ];

  for (const h of passThroughHeaders) {
    const v = req.headers.get(h);
    if (v) headers.set(h, v);
  }

  headers.set("x-forwarded-proto", "https");
  headers.set("x-forwarded-host", wpBase.host);
  headers.set("x-forwarded-server", wpBase.host);

  return headers;
}

GitHub Link- router.ts

Handling Redirects Correctly

Redirects become particularly interesting when multiple systems are involved.

Imagine WordPress returns:

Location:

https://old-site.com/about-us/

Without additional handling, visitors could suddenly be redirected to the WordPress origin. That would expose implementation details and break the unified website experience. To prevent this, the Edge Function rewrites redirect locations before returning responses. Internally generated WordPress redirects are transformed so they continue pointing to the public website domain. Visitors remain on the same website while WordPress remains hidden behind the routing layer.

function rewriteLocationHeader(
  responseHeaders: Headers,
  requestUrl: URL,
  wpBase: URL
): Headers {
  const headers = new Headers(responseHeaders);
  const location = headers.get("location");
  if (!location) return headers;

  try {
    const locUrl = new URL(location, wpBase.origin);
    if (locUrl.host === wpBase.host) {
      headers.set(
        "location",
        `${requestUrl.origin}${locUrl.pathname}${locUrl.search}${locUrl.hash}`
      );
    }
  } catch {
    // ignore invalid location header
  }

  return headers;
}

GitHub Link- router.ts

Managing Redirect Chains

WordPress plugins, SEO tools, and legacy URL rules often generate multiple redirects. The router therefore follows redirects manually. Instead of blindly accepting redirect responses, the implementation performs controlled redirect handling with a maximum redirect threshold. This prevents infinite loops while ensuring legitimate redirects continue to function. Operationally, this proved valuable because migration projects frequently expose old redirect rules that nobody remembers creating. The logging built into the Edge Function helped identify several redirect chains during testing that would otherwise have gone unnoticed.

async function fetchWordPressWithRedirects(
  initialUrl: string,
  req: Request,
  proxyHeaders: Headers
): Promise<Response> {
  const method = req.method.toUpperCase();
  const isGetOrHead = method === "GET" || method === "HEAD";

  if (!isGetOrHead) {
    // Body must be read once from cloned request
    const requestBody = await req.arrayBuffer();
    return fetch(initialUrl, {
      method,
      headers: proxyHeaders,
      body: requestBody,
      redirect: "manual",
    });
  }

  let currentUrl = initialUrl;

  for (let i = 0; i < MAX_REDIRECTS; i++) {
    const res = await fetch(currentUrl, {
      method,
      headers: proxyHeaders,
      redirect: "manual",
    });

    const location = res.headers.get("location");
    const isRedirect = res.status >= 300 && res.status < 400 && !!location;

    console.log(
      `[router] WP fetch ${i + 1}: ${currentUrl} -> status=${res.status}${
        location ? ` location=${location}` : ""
      }`
    );

    if (!isRedirect || !location) return res;
    currentUrl = new URL(location, currentUrl).toString();
  }

  return fetch(currentUrl, {
    method,
    headers: proxyHeaders,
    redirect: "manual",
  });
}

GitHub Link- router.ts

What This Means for Content Teams

One of the biggest advantages of this architecture is that migration ownership shifts from technical teams to content teams.

A typical migration process looks like this:

Before Migration

/about-us

exists only in WordPress. WordPress serves the page.

During Migration

Content authors rebuild the page in Sitecore. The page is tested and approved.

After Publication

Sitecore now resolves:

/about-us

The Edge Function receives a successful Sitecore response. The WordPress fallback is never triggered. Traffic automatically moves to Sitecore. No routing updates are required. No deployment is required. The URL remains unchanged.

Operational Lessons Learned

A few observations became clear during implementation. 

First, simplicity wins.It is tempting to create sophisticated route ownership databases and synchronization processes. In practice, allowing Sitecore to become the route authority dramatically reduced operational overhead.

Second, URL normalization requires more attention than most teams expect. Site structures, locale handling, and legacy URL patterns often contain years of accumulated complexity.

Third, logging is critical.

When a page unexpectedly appears from WordPress instead of Sitecore, the first question is always:

"Why did the fallback happen?"

Detailed routing logs significantly reduce troubleshooting time.

Finally, treat redirects as a first-class migration concern. Redirect behaviour that worked perfectly in a standalone WordPress environment may behave differently once a proxy layer is introduced.

Testing redirect scenarios early saves a lot of production support effort later.

Final Thoughts 

Running two CMS platforms behind a single website sounds complicated, but the routing strategy does not need to be. By placing Netlify Edge Functions in front of both systems and adopting a Sitecore-first, WordPress-fallback approach, we created a migration model that was easy to operate, easy to scale, and easy for content teams to understand.

  • Pages could move from WordPress to Sitecore independently.
  • URLs remained unchanged.
  • SEO value was preserved.
  • Users continued to interact with a single website.

Most importantly, the migration could progress at the pace the business needed without introducing unnecessary technical complexity.

Sometimes the best migration architecture is not the one with the most moving parts. It is the one that quietly stays out of the way and lets the business migrate content with confidence.

GitHub Link- router.ts

References


No comments:

Post a Comment