Migrating Between Static Site Generators
Every static site generator eventually stops fitting: a Jekyll site outgrows its plugin ecosystem, a Hugo site needs component-level interactivity, a Gatsby site wants to stop paying for a GraphQL layer it never used. Migration is a well-understood problem, but it is one where the expensive mistakes are structural rather than technical — a lost URL scheme costs far more than a week of template porting.
This guide covers the whole path: inventorying what you actually have, converting content mechanically, porting templates and shortcodes, preserving every URL, and cutting over in stages you can reverse. It sits under Choosing the Right Static Site Generator for Production, which covers picking the target in the first place.
Phase 1 — Inventory What You Actually Have
Start with facts, not impressions. Three lists decide the size of the job:
Every URL. Take it from the production sitemap, not the repository — redirects, aliases and hand-written HTML files mean the two rarely match:
curl -s https://example.com/sitemap.xml \
| grep -oE '<loc>[^<]+' | sed 's/<loc>//' | sort > urls-before.txt
wc -l urls-before.txt
Every content feature in use. Grep the corpus for shortcodes, includes and custom markup, and count them. A shortcode used 400 times must be ported; one used twice can be inlined and deleted:
grep -rhoE '\{\{<[[:space:]]*[a-z-]+' content/ | sort | uniq -c | sort -rn
Every plugin and what it produces. For each one, record what output it generates, because that is what has to exist afterwards — not the plugin itself. Half of a typical Jekyll plugin list turns out to be functionality the target generator has natively, which is the subject of Replacing Jekyll Plugins When Migrating to Eleventy.
| Inventory item | Where it comes from | What it tells you |
|---|---|---|
| URL list | Production sitemap + server logs | The contract you must not break |
| Shortcode census | Grep over content/ | How much template porting is real work |
| Plugin list | Config file + lockfile | Which outputs need a new source |
| Data files | _data/, data/, collections | Whether the content model translates |
| Build time | Current CI logs | The baseline you are trying to beat |
Cost the move before committing to it
The inventory gives you enough to estimate honestly, and an honest estimate is what keeps a migration from stalling at 80% for a quarter. Three numbers dominate: how many pages convert mechanically, how many shortcodes need real reimplementation, and how much plugin behaviour has no equivalent in the target.
Use the census to make the call concrete. If ninety per cent of shortcode uses come from five shortcodes, the job is small regardless of how many exist. If instead you have forty shortcodes each used a handful of times, the cheapest move is often to delete most of them during the migration — replacing a bespoke callout used twice with plain Markdown is a smaller change than porting it, and it makes the corpus more portable next time.
The same reasoning applies to plugins. Record what each one produces, not what it is: a sitemap, a tag page, an RSS feed, a syntax-highlighted block. Most targets produce those natively, which is why a list of twenty Jekyll plugins routinely collapses to three genuine gaps.
Phase 2 — Convert Content With a Script You Can Re-Run
The single most valuable rule in a migration: never hand-edit converted content. Write a conversion script, run it into a clean directory, and re-run it whenever the source changes. Writers keep publishing on the old site, and their new posts cost you one re-run rather than a merge conflict.
// scripts/convert.mjs — Jekyll front matter and shortcodes → Eleventy/Astro-friendly Markdown
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { globSync } from 'node:fs';
import path from 'node:path';
import matter from 'gray-matter';
for (const src of globSync('old/_posts/**/*.md')) {
const { data, content } = matter(await readFile(src, 'utf8'));
const slug = path.basename(src).replace(/^\d{4}-\d{2}-\d{2}-/, '').replace(/\.md$/, '');
const body = content
.replace(/\{%\s*highlight\s+(\w+)\s*%\}/g, '```$1')
.replace(/\{%\s*endhighlight\s*%\}/g, '```')
.replace(/\{\{\s*site\.baseurl\s*\}\}/g, '')
.replace(/\{%\s*include\s+note\.html\s+text=["'](.+?)["']\s*%\}/g, '> **Note:** $1');
const front = {
title: data.title,
description: data.description ?? data.excerpt?.slice(0, 155),
date: data.date,
tags: data.tags ?? data.categories ?? [],
// Preserve the ORIGINAL url so the router can reproduce it exactly
permalink: data.permalink ?? `/blog/${slug}/`,
};
const out = path.join('new/src/content/blog', `${slug}.md`);
await mkdir(path.dirname(out), { recursive: true });
await writeFile(out, matter.stringify(body, front));
}
Run the script into a directory that is deleted and recreated every time, and keep that directory out of version control until cutover. The moment converted output becomes something people edit, the conversion stops being a function of the source and becomes a one-way door. If a page genuinely needs a manual fix, fix it in the source on the old site — where writers can see it — or add a rule to the script; never in the output.
Keep a report from each run: how many files converted, how many hit an unhandled pattern, and which ones. That list is the migration's real progress bar, and it shrinks in a way a percentage of "pages done" never does.
Keep the original permalink in front matter even when the new generator would derive the same path. It makes the URL contract explicit and survives a later restructure of the source tree.
Phase 3 — Port Templates, Then Shortcodes
Port the layout chain first — base layout, page layout, list layout — and only then the shortcodes, because shortcodes render inside layouts and debugging both at once is miserable. The conceptual mapping is usually straightforward even when the syntax is not:
| Concept | Jekyll | Hugo | Eleventy | Astro |
|---|---|---|---|---|
| Page template | Liquid layout | layouts/_default/single.html | Nunjucks/Liquid layout | .astro page or layout |
| Reusable snippet | _includes/ | partials/ | _includes/ | component |
| In-content component | {% include %} | shortcode | shortcode | MDX component |
| Site-wide data | _data/ | data/ | _data/ | src/data import |
| Content list | site.posts | .Pages | collections | content collections |
The mechanical mapping is in Porting Shortcodes and Includes Between Generators. Two rules save the most time. Port the five most-used shortcodes and stub the rest to render their raw content — a stub that renders text is far better than a build error while you work through the tail. And resist redesigning during a migration: a simultaneous redesign makes every visual diff ambiguous, and the diff is your main verification tool.
Rebuilding the content model, not just the templates
Layout syntax is the visible difference between generators; the content model is the one that decides how much you have to rewrite. Jekyll gives you collections defined in config, with everything else reachable through a global site object. Hugo gives you a page tree where a directory is a section and page bundles keep resources next to content. Eleventy gives you the data cascade, where a value can arrive from front matter, a directory data file, or a computed function. Astro gives you typed content collections with a schema that fails the build when a page is missing a field.
Those models are not interchangeable, and the mismatch shows up in three predictable places. Taxonomies: Hugo generates tag and category pages natively, while Eleventy expects you to build the collection and the pagination yourself. Ordering: a weight field, a date, or an explicit array in a data file — pick one and convert to it, rather than porting three ordering mechanisms. And cross-references: any shortcode that resolves a link by page title or ID needs the target's lookup equivalent, or those links break silently.
Decide the target model before writing the conversion script, because the script is what encodes it. A conversion that emits front matter for a model you have not settled on is a conversion you will run twice. If the destination is Astro, defining the collection schema first is the cheapest possible way to find every page missing a description — the build tells you, page by page, instead of a reviewer noticing three months later. That relationship between schema and content is explored further in Content Collections vs the Eleventy Data Cascade.
Phase 4 — Verify Before Any Traffic Moves
Three checks, all automatable, catch nearly everything:
# 1. URL parity — every old URL must exist in the new build
find new/dist -name '*.html' | sed 's|new/dist||; s|/index.html|/|' | sort > urls-after.txt
comm -23 urls-before.txt urls-after.txt > urls-missing.txt
[ -s urls-missing.txt ] && { echo "MISSING:"; cat urls-missing.txt; exit 1; }
# 2. Content parity — visible text per page, ignoring markup
for u in $(head -50 urls-before.txt); do
diff <(curl -s "https://example.com$u" | sed 's/<[^>]*>//g' | tr -s ' \n') \
<(curl -s "https://preview.example.com$u" | sed 's/<[^>]*>//g' | tr -s ' \n') \
> /dev/null || echo "DIFFERS: $u"
done
# 3. Link integrity inside the new build
npx linkinator ./new/dist --recurse --silent
A fourth check is worth adding once the first three are green: compare the rendered structure rather than the text. Extract the heading outline and the outbound link list from each page in both builds and diff those. It catches a class of error the text diff misses — a shortcode that renders its content but drops the heading level, a related-links partial that silently emits nothing because the data key changed name. Both produce pages that read correctly and navigate badly.
outline() { curl -s "$1" | grep -oE '<h[2-3][^>]*>[^<]+' | sed 's/<[^>]*>//'; }
for u in $(cat urls-before.txt); do
diff <(outline "https://example.com$u") <(outline "https://preview.example.com$u") \
> /dev/null || echo "OUTLINE DIFFERS: $u"
done
The first check is the one that must be a hard gate in CI. Content differences are visible to a human reviewer; a missing URL is invisible until traffic finds it. Keeping redirects correct across the cutover is detailed in Keeping Redirects Working After an SSG Migration.
Phase 5 — Cut Over in Reversible Stages
Do not flip the whole site at once. Route one path prefix to the new deployment at the edge, watch it for a few days, then take the next:
// Cloudflare Worker: /guides/* on the new build, everything else on the old
export default {
async fetch(request) {
const url = new URL(request.url);
const origin = url.pathname.startsWith('/guides/')
? 'https://new-build.pages.dev'
: 'https://old-build.pages.dev';
return fetch(new Request(new URL(url.pathname + url.search, origin), request));
},
};
Order the stages by risk rather than by size. Start with a section that is real but not critical — release notes, a changelog, an archive — so the first stage exercises the whole pipeline without putting your highest-traffic pages behind an untested build. Take the busiest section last, when the edge routing, the redirects and the deploy process have all been through several rounds.
Watch three signals during each stage: 404 rate at the edge, Core Web Vitals per template, and search-console coverage. If any moves the wrong way, change one line to send the prefix back to the old origin — a rollback measured in seconds, in the spirit of Rollbacks and Deploy Safety for Static Sites.
What Changes for the People Writing
A migration is usually justified in engineering terms and experienced by writers. If the authoring experience gets worse, the migration fails regardless of how fast the build is — content stops arriving, and nobody says why for a month.
Three things are worth protecting explicitly. Front matter field names: if description becomes summary, every author has to learn it and every old page needs a migration — keep the old names and map them in the template instead. The local preview command: whatever npm start did before, it should do after. And image handling: if authors could drop a file next to the Markdown and reference it relatively, that must keep working, which is one of the details worth checking against the target's image pipeline before committing.
Write the authoring guide as part of the migration rather than after it, and have someone who did not do the migration follow it to publish a real page. Whatever they get stuck on is what you forgot to port.
Common Pitfalls
- Hand-editing converted content. It makes the conversion unrepeatable, which forces a content freeze and turns every day of the migration into merge work.
- Redesigning at the same time. Every diff becomes ambiguous. Migrate first, redesign in a separate release.
- Trusting the repository for the URL list. Redirects, aliases and legacy files live outside it. Take URLs from production.
- Porting every shortcode before rendering anything. Stub the tail and get a full build early; you will discover which ones matter from the diff.
- Cutting over on a Friday. Stage cutovers when the people who know both builds are available.
- Skipping the build-time baseline. If speed was the reason for migrating, record the old number first or you will never know whether it worked.
Key Takeaways
- Inventory URLs from production, shortcodes from the corpus, and plugins by their outputs rather than their names.
- Convert content with a re-runnable script and never hand-edit the result; the old site keeps publishing until cutover.
- Port layouts before shortcodes, stub whatever is left over, and do not redesign mid-migration.
- Gate on URL parity in CI — a missing URL is the one failure that hides until it is expensive.
- Cut over one path prefix at a time behind an edge router so every stage reverses in seconds.
FAQ
How long does a documentation site migration actually take?
For a 500-page site with a handful of custom shortcodes, budget two to four weeks of part-time work: a few days for the content inventory and conversion script, a week for templates and components, a few days for redirects and verification, and a buffer for the residue of one-off pages. Sites with heavy plugin logic take longer because that code has to be rewritten rather than converted.
Should I migrate content and templates at the same time?
No. Convert content first and prove it renders in the new generator with throwaway templates, then port the design. Doing both at once means every rendering difference has two possible causes and debugging takes twice as long.
What is the biggest risk in an SSG migration?
Losing URLs. Content conversion problems are visible and fixable; a silently changed URL scheme costs search rankings and breaks every inbound link, and the damage is only obvious weeks later. Freeze the URL map before you start and verify it in CI.
Do I need to migrate everything at once?
No, and usually you should not. Routing a path prefix to the new site at the edge lets you move one section at a time with the old site serving everything else, so each step is small and independently reversible.
How do I keep writers productive during the migration?
Keep the old site publishable until cutover and convert content in a branch that you re-run rather than hand-edit. If the conversion is a script, a week of new posts on the old site costs one re-run rather than a merge conflict.
Is it worth migrating just for build speed?
Sometimes, but check the cheaper options first: incremental builds, caching and template profiling often recover most of the difference. Migrate when the constraint is structural — the generator's model does not fit the site — not merely when the build is slow.
Related
- Parent: Choosing the Right Static Site Generator for Production — how to pick the target before you move.
- Migrating a Docs Site From Jekyll to Hugo — the most common path, end to end.
- Migrating From Hugo to Astro Without Breaking URLs — when you need components, not just pages.
- Porting Shortcodes and Includes Between Generators — the template-level mapping.
- Keeping Redirects Working After an SSG Migration — the URL contract in detail.
- SSG Framework Selection Matrix — the trade-offs behind the destination.