Guide
Migrating a site into SSG (`ssg migrate`)
One command takes a live site to a working SSG project — WordPress, Drupal or a plain crawl — with URLs, media and taxonomies carried across intact:
1ssg migrate wordpress https://example.com --content pages,posts,media
It scaffolds the project when none exists (config, content/<source>/,
static/, .gitignore — existing files are never overwritten), pulls the
content into SSG's native model (Markdown with frontmatter, metadata.json,
media/) and builds the site. The run ends with an honest report — what
landed, what was skipped and why — plus the next step.
Providers
1ssg migrate --list
| Provider | Engine | Content kinds |
|---|---|---|
wordpress |
wpexporter ≥ 1.8.11 (REST API) | pages, posts, media, tags, users, menus, products |
The migration asks the engine for the SSG format (--ssg-sections) and for a
metadata crawl (--assisted-crawl), so content/<source>/metadata.json
arrives with marketing (verification tokens, social profiles, og:image,
favicon, theme colour) and analytics (GA4, GTM, Pixel, …) alongside the
content. wpexporter 1.8.11 is the minimum. Every export this asks for depends on it —
the SSG section markers (1.8.1), the media scope (1.8.2), custom post types
(1.8.4), comments (1.8.5), and the fixes to ordered lists, term slugs, post-loop
pages and shortcode expansion that landed through 1.8.11. An older engine is
refused before anything is written, rather than producing an export that
looks complete and is not:
1❌ wpexporter 1.8.4 is too old — ssg migrate needs 1.8.11 or newer.
A banner ssg cannot read (a wrapper, a fork) is reported and the run continues:
a formatting difference is not proof of an old engine. The snap bundles a
current one, so snap refresh static-site-generator is the fix there.
Providers are built into the ssg binary — a new source type is a new
provider behind the same interface. The wordpress provider delegates the
data pull to wpexporter, the same way WebP output delegates to cwebp:
it is an optional external tool, discovered on PATH. When it is missing,
ssg migrate explains how to install it:
1go install github.com/tradik/wpexporter/cmd/wpexporter@latest
2# or
3snap install wpexporter
The migration report names the engine that ran, including where it came from:
1⚙️ engine: wpexporter 1.8.7 (bundled with the snap)
Snap users need none of this: the static-site-generator snap bundles the
engine (since 1.8.29), because a strictly confined snap sees only its own
files — the host's wpexporter is invisible to it, and installing the
wpexporter snap does not help either, since one snap cannot execute another.
Same reason cwebp is bundled for WebP output. The bundled copy is built from
the exporter's latest release at the moment the snap is built, so it is
refreshed by every ssg release and by a weekly rebuild — if the report names an
older engine than you expect, that is why, and snap refresh is the fix.
Selecting content
--content names what to fetch; everything else is skipped. Without the
flag the provider's full default export runs.
1ssg migrate wordpress https://example.com --content pages,posts
Site metadata always ships. Tags, users and menus describe the site around
the content, so they are exported whether or not --content lists them — a
migration that silently drops the navigation, the category names and the post
authors is not a migration. Exclude them deliberately:
1ssg migrate wordpress https://example.com --content pages,posts,no-menus
Kinds: pages, posts, media, custom, comments, products,
tags, users, menus. custom is every post type a theme or plugin
registered — Services, Portfolio, Team — which on a real site is often more
documents than pages and posts together. Naming --content at all opts every
unlisted kind out, so --content pages,posts,media leaves both the theme's
own types and the readers' comments behind. List what you want:
1ssg migrate wordpress https://example.com --content pages,posts,media,custom,comments
An unknown kind is a hard error (a typo must not silently export the whole site). A recognised-but-undeliverable kind is reported as skipped in the final summary, never dropped silently — there are none today.
Passing the engine's own flags (--no-custom-types, --no-comments) to
ssg migrate is rejected with the --content equivalent, because they are one
command apart and the mistake costs a whole content type.
Comments
Reader comments are the one content a site owner did not write and cannot
re-create. They come from /wp/v2/comments, which a public WordPress serves
without authentication — and serves approved comments only, which is what a
migration wants (pending and spam rows are moderation state, not content).
They land in content/<source>/comments.json (wpexporter 1.8.5+), addressed by
page URL rather than by WordPress's post ID, which means nothing after a
migration:
1{
2 "total": 128,
3 "pages": 31,
4 "comments": [
5 {
6 "id": 4711, "post": 812, "parent": 0,
7 "post_url": "/blog/wms-implementation-pitfalls/",
8 "author": "Jan Kowalski",
9 "date": "2024-03-01T10:00:00Z",
10 "content": "<p>Świetny tekst — u nas WMS wszedł dokładnie tak.</p>",
11 "status": "approved"
12 }
13 ]
14}
Records are sorted by id, so a reply never precedes the comment it answers when
they are replayed into a table with a parent reference — for example the D1
schema of the comments worker (ssg new worker comments), whose
url column is exactly this file's post_url. The migration report states how
many arrived; a site whose REST route is disabled or gated migrates without
them and says so.
Comments reach each page's template as .Comments, threaded by parent and in
the order they were written, and the bundled theme renders them on posts:
{{with .Comments}}{{range .}}<b>{{.Author}}</b> {{.Content | safeHTML}}
{{range .Replies}}<b>{{.Author}}</b> {{.Content | safeHTML}}
Media: only what the content uses
A migration downloads the files the content references, not the whole library.
WordPress keeps a dozen renditions of every upload and a theme demo leaves its
own behind: one real site's library was 5,255 files and 197 MB, of which 74
files (2.8 MB) were ever referenced — and ssg generates its own responsive
variants regardless. --all-media takes everything:
1ssg migrate wordpress https://example.com --all-media
Files the library does not list — a page builder's own crops, the favicon, the
og:image — are downloaded and localised too (wpexporter 1.8.2+), so the
migrated site does not keep fetching images from the host it was migrated off.
Live mode: --watch --http
1ssg migrate wordpress https://example.com --content pages,posts,media --watch --http
Live mode migrates in front of your eyes, in this order:
- Server first — the project is scaffolded (if missing) and the
watch+HTTP server starts immediately, printing its address
(
http://127.0.0.1:8888). - Data lands incrementally — the engine writes pages, posts and media
into
content/as it fetches them; the watcher rebuilds after each batch and auto-reload refreshes the browser, so you watch the site fill up. - Report + next step — the summary prints and the server keeps running until Ctrl+C.
--host and --port address that server, exactly as they do for ssg --http:
1ssg migrate wordpress https://example.com --watch --http --port 8889
A port already in use shifts forward (8889 → 8890 → …) and the address actually served is the one announced — a migration is too long to lose to someone else's dev server.
Without the flags, the same migration runs as a plain batch: fetch everything, build once, report, exit.
What the site's own wiring becomes
The crawl writes two blocks into content/<source>/metadata.json, and ssg
picks both up:
| Block | Contents | Where it goes |
|---|---|---|
marketing |
favicon, apple-touch-icon, theme colour, og:site_name, default og:image, twitter:site, social profile links, verification tokens |
.Site.Marketing in templates; injected into <head> when seo: true (only what the theme did not already emit) |
analytics |
GA4, GTM, Pixel, Hotjar, Clarity … ids | .Site.Analytics in templates; rendered only with analytics: true |
marketing.colors |
the theme's palette by role — primary, secondary, accent, text, background, link | .Site.Colors.<role> in templates, --ssg-color-<role> on :root, and written into colors: in the config |
site |
the name, tagline and timezone the CMS holds in its own settings | written into title:, description: and timezone: in the config; .Site.Title / .Site.Description |
An exporter may write an analytics id as a string, as a list (the same
container found in the head and again in a plugin's footer) or as a bare
number. All three are read; the first usable id per vendor is the one the
generator emits, and a value it cannot read as an id is skipped rather than
failing the build (#131).
The config is completed from the export
After the fetch, migrate reads metadata.json and fills in the configuration
keys the source site already answered — title, description, timezone and
colors — so the first build carries the site's own name and colours instead of
an untitled site in the starter palette:
1🪪 Completed .ssg.yaml from the source site:
2 ✅ title: Magna Valor
3 ✅ description: Supply Chain Global Advisory
4 ✅ timezone: Europe/Warsaw
5 ✅ colors: 6 colour(s) from the source theme
Only keys the config does not have are written, so re-running a migration
never undoes an edit of yours, and the file is edited as a YAML document — your
comments and key order survive. A timezone WordPress reports as a bare offset
(UTC+2) is skipped rather than written as something that will not load: it
carries no DST rules.
The palette arrives only when the engine collects it: that is wpexporter
1.8.2, which is not released yet
(tradik/wpexporter#27). With
1.8.1 the migration completes title, description and timezone as
described, and simply finds no colours. The same release will export custom
post types — a theme's Services, Portfolio or Team entries, silently lost
today (#28) — which will land
under pages/<type-slug>/ and keep their original URLs.
Tracking is opt-in on purpose: loading third-party JavaScript on every page is
your decision, not a side effect of moving content. Verification tokens and
icons are plain metadata — they load nothing — so they ride with seo.
1seo: true
2analytics: true # only when you want GTM/GA4 live again
Navigation needs credentials
WordPress gates menus (and its own settings) behind edit_theme_options, so an
anonymous export comes back with none — and the site builds, silently, with no
navigation at all. Pass credentials and the menus travel with the content:
1ssg migrate wordpress https://example.com --auth-user editor --auth-pass "$WP_APP_PASSWORD"
2ssg migrate wordpress https://example.com --auth-token "$WP_TOKEN"
Use a WordPress application password, not the account's own. Credentials are
handed to the engine and never written to .ssg.yaml — that file gets
committed.
Menus reach templates as .Site.Menus.<location> (and .<slug> for a menu the
theme never assigned), each with .Tree giving the entries nested and in the
site's own order:
{{with index .Site.Menus "primary"}}
<nav>
{{range .Tree}}
<a href="{{.URL}}">{{.Title}}</a>
{{range .Children}}<a href="{{.URL}}">{{.Title}}</a>
</nav>
The bundled themes adopt this from 1.8.35 — a theme file is also read by whatever ssg the reader has pinned, so it can only use fields the previous release already knew. Paste the snippet above into your own theme today. When no menus arrive, the report says why rather than leaving you to guess:
1⚠️ menus: not readable without authentication — WordPress gates them behind
2 edit_theme_options; re-run with --auth-user/--auth-pass or --auth-token
Comments and the engine's age
comments can be excluded from --content only by wpexporter 1.8.5 or
newer. On an older engine they are exported regardless and the report says
so, rather than failing a migration over a flag the engine does not know:
1⚠️ comments: exported anyway — this wpexporter cannot be asked to skip them
2 (needs 1.8.5); delete content/<source>/comments.json if you do not want them
A site that blocks the crawl
Bot protection in front of WordPress is ordinary, and the sites most worth
migrating are the ones with enough traffic to have been attacked. It looks like
a broken REST API: every collection empty, every request answered 500.
1Incomplete: posts: stopped at page 1 after 0 records: API returned status 500 — Cloudflare
2refused the request — this is the site's bot protection, not its REST API.
The engine's diagnosis is exact, and two flags answer it:
1ssg migrate wordpress https://example.com \
2 --user-agent "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/latest Safari/537.36" \
3 --rate-limit 400
--user-agent matters because the block commonly matches the engine's default
agent; --rate-limit is milliseconds between requests, for a site rejecting the
pace rather than the caller. Credentials do not help here —
--auth-user/--auth-pass answer WordPress's own gate, not the CDN in front of
it.
Anything else the engine accepts goes through verbatim, so a flag newer than this release of ssg is still reachable:
1ssg migrate wordpress https://example.com --engine-arg --some-new-flag --engine-arg value
Each flag and its value are separate --engine-arg arguments — pairing them
here would guess at a syntax the engine owns. They are appended after everything
ssg derives, so they can override it.
Which engine runs
ssg migrate runs the newest wpexporter it can reach, and says which one it
used:
1 🔧 engine: wpexporter 1.8.12 (/home/you/go/bin/wpexporter)
The order is:
--engine PATH, or theSSG_WPEXPORTERenvironment variable. An explicit choice always wins, and a binary that cannot be run is an error naming it — never a silent fall back to a different engine.- Otherwise the newest of everything reachable:
PATH, and — inside the snap — the bundled copy plus~/go/bin,~/.local/binand~/bin.
Why the snap searches your home directory
The snap ships its own wpexporter, so snap refresh wpexporter used to change
nothing: the bundled copy was the only one on the snap's PATH, and every engine
fix waited for the next ssg rebuild.
Strict confinement is narrower than it looks. Measured from inside the snap:
| candidate | result |
|---|---|
the bundled $SNAP/bin/wpexporter |
runs |
/snap/bin/wpexporter — the wpexporter snap |
cannot run: it is a snapd wrapper, and a snap cannot execute another snap |
an absolute path under your real $HOME |
runs — the home interface grants read and execute |
So the way to stay ahead of the snap's rebuild cadence is a real binary in your home directory:
1go install github.com/tradik/wpexporter/cmd/wpexporter@latest # → ~/go/bin
2ssg migrate wordpress https://example.com # picks it up, if newer
Installing the wpexporter snap does not help a snap-installed ssg — nothing can, from inside confinement. The migration report says so rather than leaving you guessing:
1 ⚠️ skipped /snap/bin/wpexporter (it is the wpexporter snap, and a snap
2 cannot execute another snap)
The bundled engine is a floor, not a ceiling: it runs whenever nothing newer is reachable, and an older copy in your home directory never displaces it.
The theme's own post types
Services, Portfolio, Team — a theme registers its own types, and they carry real pages. They are exported by default; select or skip them:
1ssg migrate wordpress https://example.com --custom-types cpt_services,cpt_team
2ssg migrate wordpress https://example.com --no-custom-types
After the migration
The site's WordPress front page (link: "/") becomes the front page here too,
so the generated post listing needs a home of its own:
1posts_page: blog # /blog/ — otherwise the listing is not generated
Check the content renders as markup, not as text:
1ssg repair # report anything a page builder left indented
2ssg repair --fix # rewrite those files in place
Exports made with wpexporter before 1.8.2 indent their builder markup, which
CommonMark reads as a code block — the page then shows </div> to the visitor.
Every build also reports it (check_markup, on by default). See
CONFIGURATION.
The migrated site builds on the simple starter theme. To rebuild the
source site's look, hand the project to an AI agent over the
MCP server. The server speaks stdio, so the assistant launches
it — you register it once:
1claude mcp add ssg -- ssg mcp # Claude Code, this project
Claude Desktop takes the same thing as JSON in claude_desktop_config.json:
1{"mcpServers": {"ssg": {"command": "ssg", "args": ["mcp"], "cwd": "/path/to/project"}}}
Add --http (... -- ssg mcp --http) to get a live preview on
http://127.0.0.1:8888 that refreshes after every change the agent makes.
Then ask the agent to study the original site and recreate its template on top of the migrated content — the content model is already in place, so the agent only designs.
Options
| Flag | Meaning |
|---|---|
--content a,b,c |
Content kinds to fetch (default: everything the provider offers): pages, posts, media, custom, products, tags, users, menus |
--all-media |
Download the whole media library instead of only the files the content references |
--watch --http |
Live mode: server first, then watch the data load |
--source NAME |
Content source directory name (default: the site's host, www. stripped) |
--auth-user U --auth-pass P |
Credentials for the source CMS (menus and settings need them) |
--auth-token T |
Bearer token instead of the user/password pair |
--custom-types a,b |
The theme's own post types to export |
--no-custom-types |
Skip the theme's own post types |
--engine PATH |
The wpexporter binary to run (also SSG_WPEXPORTER). See Which engine runs |
--user-agent S |
Identify the engine as S. See A site that blocks the crawl |
--rate-limit MS |
Milliseconds between the engine's requests |
--engine-arg A |
Hand A to the engine verbatim; repeatable |
--no-crawl |
Skip the SEO/marketing crawl (faster; no tracking ids, social profiles or icons) |
--quiet, -q |
Suppress progress output |
--list |
List built-in providers with versions |
Notes
migrateis a reserved subcommand. A source directory literally namedmigratestill builds via--source=migrate.- Old URLs are preserved: the WordPress export carries each page's original
link:in frontmatter, and SSG publishes flat URLs from it, so permalinks keep working without a redirect map. - Running
ssg migrateagain re-exports into the same source directory; the scaffold step never overwrites files it finds.