Post
Migrating From WordPress Is Easy. Keeping Its URLs Is Not.
A WordPress migration looks simple from a distance: export the posts, convert them to Markdown, generate HTML and deploy. That description leaves out the twelve years the old site may have spent online.
Search engines know its URLs, other sites link to them and readers have bookmarks. Some posts live three categories deep; navigation, date archives and comments all sit outside the post body.
Content is only the visible part of the migration. The difficult part is preserving what readers and other sites already depend on.
A successful build can still be a broken migration
Imagine a converter receives this WordPress URL:
1/2019/06/building-a-static-site/
but discards it and publishes the post at:
1/blog/building-a-static-site/
The new page may render perfectly and score well in Lighthouse, while every existing link to the article returns a 404.
From inside the new site everything looks correct. From outside it is broken.
That is what makes migration bugs unpleasant: many of them are invisible if you only click around the new site.
flowchart LR
A["Old WordPress site"] --> B["Export"]
B --> C["Markdown"]
C --> D["Static site"]
E["Google"] --> U["Old URLs"]
F["Other websites"] --> U
G["Bookmarks"] --> U
U --> D
The top half of that diagram is file conversion. Preserving the paths used by the bottom half is the migration work.
URLs are already public API
Developers are comfortable treating an API endpoint as a contract.
Change:
1/api/v1/customer/42
without compatibility and somebody's integration breaks.
A published URL deserves much the same treatment.
Once /category/linux/server/post-name/ has been indexed, shared, emailed and
linked from another site, it no longer belongs entirely to your application.
The export used by ssg migrate wordpress carries each page's original link:
into frontmatter, and the generator uses that path. A normal migration therefore
keeps the post URL instead of redirecting it. If you deliberately replace that
path, record the former URL in frontmatter aliases: or in the redirects:
configuration; the build then writes the corresponding rule to _redirects.
WordPress has more structure than posts and pages
A basic migration script often recognises only two types:
1post
2page
Real installations also contain custom post types, sticky posts, nested categories, menus, comments, category and date archives, and pagination.
Sometimes language information belongs to a directory because a multilingual plugin managed it, rather than appearing as metadata on every post.
These are ordinary WordPress structures, but they are easy to miss after an export has flattened the application into a pile of files.
The importer's objective is not merely:
Turn WordPress XML into Markdown.
It is better stated as:
Reconstruct enough of the old site's behaviour that replacing WordPress does not unexpectedly replace the site.
Categories are a good example
A category can look trivial in an export:
1Engineering
But on the site it may actually be:
1/notes/engineering/
or:
1/category/company/engineering/
or part of a hierarchy:
1/category/development/go/
Flatten the hierarchy and you have changed URLs.
Reconstruct the hierarchy incorrectly and the URLs still change.
The Markdown file containing a post cannot tell you all of this by itself. Migration needs to understand the taxonomy as a structure, not merely copy the category names into frontmatter.
The same applies to menus. A navigation menu is not content inside a page, but users certainly notice when it disappears.
Old archives still receive traffic
Few greenfield static sites begin with a requirement for monthly archives from June 2014. A migration is different.
If the WordPress site exposed:
1/2014/06/
for twelve years, there is a reasonable chance that URL exists in a search index, analytics report, external link or someone's bookmarks.
Date archives and category archives are exactly the kind of feature that feels safe to delete because nobody on the migration team personally uses it.
That is not evidence that nobody uses it.
SSG migrations enable date archives and preserve category archive paths from
the export. They can also rebuild custom-post-type archives when wpexporter
1.8.15 or newer records WordPress's has_archive value. Those surfaces are
reconstructed from source metadata rather than inferred from the Markdown body.
Comments are content too
Comments are awkward in static publishing because they originally belonged to a database-backed application, but that does not make them disposable.
A ten-year-old technical article may have corrections, alternatives and useful discussion below it. Sometimes the most valuable sentence on the page was not written by the author.
A migration that preserves the article and silently drops that discussion has not migrated the whole document readers were using.
There are legitimate reasons to leave comments behind. It should be a decision,
not an accidental consequence of choosing a static generator. SSG's WordPress
migration can export approved comments into comments.json; unapproved and
spam comments are not exposed by the anonymous REST response it consumes.
Multilingual migrations expose another hidden assumption
A bilingual WordPress installation may encode language through a plugin and URL structure:
1/de/produkte/
2/fr/produits/
The exported Markdown may not contain:
1lang: de
on every document.
Writing that field manually into hundreds of files works exactly until the next export overwrites them.
SSG's language_sections lets the migration describe the structure once:
1language_sections:
2 de: de
3 fr: fr
4 home: en
Resolution follows the documented order: an explicit page language, the longest matching section and finally the site's default language.
That order matters because migrations are not necessarily one-time events. During a transition, you may export the source repeatedly while the old CMS is still receiving content.
Anything requiring a manual repair after every export is a recurring chore, not part of the migration pipeline.
Preview the old URLs, not just the new pages
There is one test I now consider essential after a migration.
Do not begin at /.
Begin with the old URLs.
Take them from analytics, the sitemap, a crawler or the WordPress database and request them against the new site.
1old post URL → 200 or redirect → 200
2old category URL → 200 or redirect → 200
3old archive URL → 200 or redirect → 200
4deleted content → intentional 404 or provider-supported 410
This was difficult to test locally when the development server did not interpret
the generated _redirects file. The deployment platform knew the redirects;
the preview did not.
That gap is now closed in SSG: the built-in server understands the redirect rules the build generates.
A migration should not need production traffic to discover whether its old URLs work.
Static is the destination, not the excuse
Moving from WordPress to static output removes the PHP request path and database query required to serve an article, leaving less application surface to maintain. Those are good reasons to migrate, but not reasons to ignore useful behaviour from the old application.
The useful question is not:
Did all 3,842 posts become Markdown?
It is:
Can somebody who knew the old site continue using the new one without knowing we replaced the engine underneath it?
When the answer is yes, the migration is finished.