Both of our migration guides — Gatsby to Astro 5 and Gatsby to Next.js App Router — assume you eventually flip a switch: the old site goes away, the new one takes over. On a marketing site with forty pages that is fine. On a 4,000-URL site that earns money from organic search, a big-bang cutover is the single riskiest hour of the project. One bad redirect map and you spend the next quarter explaining a traffic cliff.
The alternative is the strangler pattern: stand the new site up alongside the Gatsby build, route a slice of URLs to it at the edge, verify, then move the next slice. The Gatsby site keeps serving everything you have not migrated yet. This tutorial covers the routing mechanics, the SEO details that break during a split, and how to verify each slice before you widen it.
The shape of the setup
One hostname, two origins:
example.com (edge / CDN)
|
+------------------+------------------+
| |
/tutorials/*, /docs/* everything else
| |
new-site (Astro/Next) legacy Gatsby build
The edge layer does the splitting. Both origins deploy independently. Neither knows the other exists. Crucially, users and crawlers only ever see example.com — the per-origin URLs (legacy.example.com, new.example.com) are implementation detail and must never be indexable.
Pick the slices by directory, not by page. Splitting /tutorials/a and /tutorials/b across origins gives you two different headers, two different footers, and a support ticket. Migrate whole path prefixes.
Step 1: give each origin a private hostname
Deploy the Gatsby build to legacy.example.com and the new build to new.example.com. Both need to be reachable but not crawlable:
# static headers on BOTH origin hostnames
X-Robots-Tag: noindex, nofollow
On Netlify, in netlify.toml for the origin deploy:
[[headers]]
for = "/*"
[headers.values]
X-Robots-Tag = "noindex, nofollow"
The edge must strip that header on the public hostname, or you will noindex your entire site. Test that explicitly (Step 5) before sending any traffic.
Step 2: split traffic at the edge
Netlify — rewrites in netlify.toml on the public site. Status 200 is a rewrite (the URL stays), 301 would be a redirect (the URL changes):
[[redirects]]
from = "/tutorials/*"
to = "https://new.example.com/tutorials/:splat"
status = 200
force = true
[[redirects]]
from = "/docs/*"
to = "https://new.example.com/docs/:splat"
status = 200
force = true
# catch-all: everything else stays on the Gatsby origin
[[redirects]]
from = "/*"
to = "https://legacy.example.com/:splat"
status = 200
force = true
Order matters: the first match wins, so the catch-all goes last.
Cloudflare Workers — more control, and you can strip headers on the way back:
const NEW_PREFIXES = ['/tutorials/', '/docs/'];
const NEW_ORIGIN = 'https://new.example.com';
const LEGACY_ORIGIN = 'https://legacy.example.com';
export default {
async fetch(request) {
const url = new URL(request.url);
const useNew = NEW_PREFIXES.some((p) => url.pathname.startsWith(p));
const origin = useNew ? NEW_ORIGIN : LEGACY_ORIGIN;
const upstream = new URL(url.pathname + url.search, origin);
const res = await fetch(upstream, {
method: request.method,
headers: request.headers,
body: request.body,
redirect: 'manual',
});
const out = new Response(res.body, res);
out.headers.delete('x-robots-tag'); // origin-level noindex must not leak
out.headers.set('x-served-by', useNew ? 'new' : 'legacy');
return out;
},
};
The x-served-by header costs nothing and turns "which origin served this?" from guesswork into a curl -I.
Next.js as the front door — if the new site is Next.js you can skip a separate edge layer and let it proxy the remainder:
// next.config.js
module.exports = {
async rewrites() {
return {
fallback: [
{ source: '/:path*', destination: 'https://legacy.example.com/:path*' },
],
};
},
};
fallback rewrites run only after Next.js fails to match a route of its own, which is exactly the semantics you want: migrated pages win, everything else falls through to Gatsby. beforeFiles would send everything to the legacy origin and is the usual mistake here.
Step 3: keep trailing slashes identical
This is the bug that bites every split migration. Gatsby emits /tutorials/some-post/ with a trailing slash by default. Next.js defaults to no trailing slash. Astro's default is trailingSlash: 'ignore', which behaves differently again per host.
If the two origins disagree, the edge rewrite hits a 301 on the upstream, and you either serve a redirect loop or a chain that quietly halves your crawl budget. Pin it explicitly on both sides:
// next.config.js
module.exports = { trailingSlash: true };
// astro.config.mjs
export default defineConfig({ trailingSlash: 'always', build: { format: 'directory' } });
And in Gatsby, if you need to change the legacy side instead:
// gatsby-node.js
exports.onCreatePage = ({ page, actions }) => {
if (!page.path.endsWith('/')) {
actions.deletePage(page);
actions.createPage({ ...page, path: `${page.path}/` });
}
};
Whichever you pick, assert it in the smoke test. Do not rely on remembering.
Step 4: one sitemap, canonicals on the public host
While the site is split, three things must be true:
- Every canonical points at
https://example.com/..., never at an origin hostname. In Astro setsite: 'https://example.com'; in Next.js setmetadataBase; in Gatsby checksiteMetadata.siteUrlis still the public host. - One sitemap index, served from the public host, listing URLs from both origins. Easiest reliable approach: each origin generates its own sitemap, and the edge serves a hand-maintained
sitemap-index.xmlthat references both.
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap><loc>https://example.com/sitemap-legacy.xml</loc></sitemap>
<sitemap><loc>https://example.com/sitemap-new.xml</loc></sitemap>
</sitemapindex>
Rewrite /sitemap-legacy.xml to the Gatsby origin's sitemap and /sitemap-new.xml to the new one, the same way you rewrite pages. Then confirm every <loc> inside both files uses https://example.com — a sitemap full of origin hostnames is the fastest way to get the wrong URLs indexed.
- Analytics and consent are configured once. Two origins means two copies of your tag setup, and it is easy to end up double-counting migrated pages or losing them entirely. Verify pageviews for a migrated path in your analytics tool before moving the next slice.
Step 5: verify a slice before you widen it
Run this against production after every routing change. It is short enough that nobody skips it:
#!/usr/bin/env bash
set -euo pipefail
BASE=https://example.com
check() { # path expected_origin
local path="$1" expect="$2"
local out; out=$(curl -sS -o /dev/null -D - "$BASE$path")
local code; code=$(printf '%s' "$out" | head -1 | awk '{print $2}')
local by; by=$(printf '%s' "$out" | grep -i '^x-served-by:' | tr -d '\r' | awk '{print $2}')
local rob; rob=$(printf '%s' "$out" | grep -ic '^x-robots-tag:' || true)
[ "$code" = "200" ] || { echo "FAIL $path -> HTTP $code"; exit 1; }
[ "$by" = "$expect" ] || { echo "FAIL $path -> served by '$by', expected '$expect'"; exit 1; }
[ "$rob" = "0" ] || { echo "FAIL $path -> X-Robots-Tag leaked from origin"; exit 1; }
echo "ok $path ($by)"
}
check / legacy
check /services/ legacy
check /tutorials/ new
check /tutorials/some-post/ new
# canonical must be on the public host
curl -sS "$BASE/tutorials/some-post/" \
| grep -o '<link rel="canonical"[^>]*>' \
| grep -q "$BASE" && echo "ok canonical" || { echo "FAIL canonical"; exit 1; }
# no redirect on the trailing-slash form the sitemap advertises
curl -sS -o /dev/null -w '%{http_code} %{redirect_url}\n' "$BASE/tutorials/some-post/"
Add to it as you find failures. A split migration goes wrong in small, repeatable ways, and every one of them is cheap to assert.
Step 6: retire the Gatsby origin
When the last prefix moves, the legacy origin still has a job for a while: proving nothing was missed. Before you delete it,
- pull the last 90 days of URLs from server logs or Search Console and check each one resolves on the new site with a
200or an intentional301; - keep the redirect map in the new site, not in the edge layer, so it survives the CDN going away;
- leave the origin deployed but dark for a month — it costs nothing on a static host and it is the only cheap way to answer "what did this page used to say?";
- remove the fallback rewrite last, and watch 404s for a week afterwards.
When not to do this
The split adds a routing layer, a second deploy pipeline, and a class of bug that only appears in production. For a site under a few hundred pages, with no meaningful organic search traffic, a straight cutover on a quiet evening is genuinely simpler and you should do that instead.
Reach for the strangler pattern when the site is large, when search traffic is revenue, when the migration will take months of part-time work, or when you need to prove the new stack on a low-risk section before committing the rest. Those are exactly the projects where "we'll migrate it all next sprint" turns into eighteen months of a half-finished rewrite.
If you are planning a phased move off Gatsby and want a second pair of eyes on the routing and SEO plan before traffic is involved, get in touch — a review at this stage is much cheaper than a rollback later.