Skip to content

Migrate from WordPress

Use the EmDash admin to analyze a WordPress site, prepare compatible collections, import content, copy media, and rewrite URLs. Keep the WordPress site available until the imported site has passed verification.

Prepare the following items:

  • A backup of the WordPress database and wp-content/uploads.
  • An EmDash site with storage configured if you plan to copy media.
  • An EmDash administrator account. WordPress import requires the import:execute permission.
  • Either a complete WordPress eXtended RSS (WXR) export or the EmDash Exporter plugin installed on the WordPress site.

Record the current permalink structure, canonical origin, redirects, menus, active post types, taxonomies, and any plugin-owned fields that the new site must preserve.

Method Use it when Included data
WXR upload You can export from Tools → Export Data represented in the export, including posts, pages, custom post types, taxonomy terms, reusable blocks, authors, and attachment URLs
EmDash Exporter You control the WordPress site and can install the exporter Authenticated content plus supported site settings, SEO fields, taxonomies, and media metadata; comments, menus, the logo, and the favicon with exporter 1.2.0 or later

Entering a site URL without the exporter only probes the public WordPress REST API. The probe can detect WordPress and count public posts, pages, and media, but it cannot perform a direct REST import.

In WordPress, open Tools → Export, select All content, and download the XML export file. A WXR file points to media on the WordPress origin; it does not contain the attachment bytes.

Check the exporter version on the WordPress Plugins screen. Comments, menus, the logo, and the favicon need exporter 1.2.0 or later. With an earlier version, the import completes without them and does not report them as missing.

With exporter 1.2.0 or later, open Tools → EmDash Migration in WordPress and generate a migration key. Paste the key into the EmDash import page.

With any exporter version, you can enter the WordPress site URL. When the exporter is detected, authorize EmDash through the WordPress application-password screen. On local HTTP development sites, enter the WordPress username and application password manually because the authorization callback requires HTTPS.

  1. In the admin sidebar, select Import to open the Import from WordPress page, or visit /_emdash/admin/import/wordpress.

  2. Upload the WXR file, paste the exporter migration key, or enter the WordPress URL.

  3. Review the analysis. For each WordPress post type, confirm the target collection, required fields, and compatibility with any existing fields.

  4. Map WordPress authors to EmDash users. An unmapped source author can be represented by a guest byline, so presentation credit does not depend on granting an EmDash login.

  5. For an exporter import, choose whether to import menus, site title and tagline, logo and favicon, and supported SEO values. These switches are not part of the WXR path.

  6. Start the import. EmDash creates missing compatible collections and fields before it creates entries.

  7. If attachments were found, run the media step. EmDash downloads the source bytes, stores them through the configured storage adapter, and rewrites matching content URLs.

The importer maps WordPress statuses as follows:

WordPress EmDash
publish published
draft draft
pending draft
private draft
future, trash, or an unknown status draft

Scheduled dates and WordPress visibility rules are not reconstructed from these statuses. Review every imported draft before publication.

Gutenberg markup is converted to Portable Text. Classic HTML is processed by the same converter. Inspect complex blocks, embeds, shortcodes, page-builder markup, and plugin-defined blocks in the rendered EmDash site.

Blocks that WordPress renders on the server, such as Latest Posts or blocks from plugins, have no saved markup. They are imported as HTML blocks that contain the original block comment, for example <!-- wp:latest-posts {"postsToShow":3} /-->. These blocks show no content on the site. Open the block’s HTML tab in the editor to see what it was, then rebuild it or remove it.

WXR wp_block entries are imported as sections. They are not imported into an ordinary content collection.

The default post-type mappings include post to posts and page to pages. Custom post types use a sanitized collection slug. The prepare step adds the standard title, Portable Text content, excerpt, and featured-image fields that the analysis requires.

Existing fields with compatible types are reused. A type mismatch blocks that collection mapping; the importer does not coerce an existing field to another type.

WXR analysis reports non-internal post-meta keys and suggests field names and types, but the WXR database import writes the standard imported fields rather than copying every arbitrary meta key. Treat the analysis as a checklist for fields that may need a separate conversion.

The EmDash Exporter path can copy custom meta and Advanced Custom Fields (ACF) values when the target collection has matching fields. For an ACF field group with a “Post Type is equal to” location rule, the analysis suggests a field for every ACF field in the group: text, number, and true/false fields get matching types, and select, checkbox, repeater, group, relationship, and other fields that can hold a list or an object become JSON fields. Fields of groups assigned by other location rules are suggested only from the meta keys that the exporter reports. A collection whose fields an earlier import created from the meta keys keeps them, so its repeaters arrive as one field per row and sub-field. JSON values keep their WordPress shape: relationship, post object, user, and taxonomy fields point to WordPress posts, users, and terms by their WordPress IDs rather than to EmDash entries, and image, file, and gallery fields point to the WordPress media files rather than imported EmDash media. ACF group sub-fields stored on more than one post can also appear as separate fields next to the group’s JSON field; keep the copy your templates use. Review these values before using them in templates.

The exporter path can also create and populate the supported featured-image and Yoast or Rank Math SEO fields when their import switches are enabled.

A mapped WordPress author becomes the entry owner. Presentation credits use bylines. If no user mapping exists, EmDash creates or reuses a guest byline based on the WordPress author identity.

Categories and tags are imported into the matching EmDash taxonomy definitions. The exporter path can create definitions for custom taxonomies. A WXR custom taxonomy without a matching EmDash definition is reported as missing and its assignments are skipped.

Media deduplication compares a SHA-1 hash of the downloaded bytes. An existing media row with the same hash is reused. Filenames and WordPress IDs are not the deduplication key.

The source site must remain reachable while EmDash downloads attachments. Redirects from an attachment URL are validated before they are followed.

The admin import is designed to skip existing entries, but its matching boundary matters:

  • Existing content is matched by collection, slug, and locale, not by WordPress ID.
  • If a source slug changed between attempts, inspect both entries and resolve the duplicate manually.
  • Rerunning the media step reuses already stored byte-identical files by content hash.
  • The exporter flow sends bounded chunks. Restarting the browser flow reruns content pages and rebuilds relationship maps from skipped entries.

The admin does not persist a WXR resume file. The --resume flag on emdash import wordpress belongs to a separate CLI workflow that writes converted JSON files to disk; it does not resume an admin database import.

Check the imported site against the source:

  • Count entries by post type and status.
  • Open representative Gutenberg, Classic Editor, shortcode, and page-builder content.
  • Confirm featured images and inline media resolve from EmDash URLs.
  • Check authors, bylines, categories, tags, custom taxonomies, and translations.
  • Test menus, comments, site identity, and SEO fields when imported through the exporter.
  • Crawl old public URLs and prepare redirects for every changed route.
  • Verify drafts remain inaccessible to logged-out visitors.

Keep the backup and WordPress deployment until the new site has run successfully in production and rollback is no longer required.

Export the file again from Tools → Export and confirm the download completed. Do not edit the XML with a tool that changes its encoding.

An existing field has the same slug but a different type. Change the post-type mapping or reconcile the field under Content Types before retrying. The prepare step adds missing fields but does not change existing field types.

Cloudflare’s bot protection can answer EmDash’s requests with a challenge page instead of the exporter API. EmDash then reports that bot protection blocked the connection. Let the exporter requests through for the duration of the migration:

  • With Super Bot Fight Mode or other Cloudflare WAF features, add a custom rule with the Skip action and place it above any rule that issues challenges. Select All Super Bot Fight Mode rules, and Security Level if the zone uses I’m Under Attack mode. This expression matches the exporter API with and without pretty permalinks, including WordPress installs in a subdirectory, and the media files EmDash downloads:

    (http.request.uri.path contains "/wp-json/emdash/v1/")
    or (http.request.uri.query contains "rest_route=%2Femdash%2Fv1%2F")
    or (http.request.uri.path contains "/wp-content/uploads/")
  • Bot Fight Mode on the Cloudflare Free plan can’t be skipped by rules. Turn it off during the migration and turn it back on afterwards.

Check that the source attachment is reachable without a WordPress session. Media behind authentication, removed files, and redirects to private network addresses fail individually and remain in the media error list.

Use getEmDashCollection() for archive routes and getEmDashEntry() for a slug route. entry.id is the route identifier and is normally the slug; entry.data.id is the stored content ID used by relationship helpers.

The following route renders an imported post:

src/pages/posts/[slug].astro
---
import { decodeSlug, getEmDashEntry } from "emdash";
import { PortableText } from "emdash/ui";
const slug = decodeSlug(Astro.params.slug);
if (!slug) return Astro.rewrite("/404");
const { entry: post, error } = await getEmDashEntry("posts", slug);
if (error) return new Response("Could not load the post", { status: 500 });
if (!post) return Astro.rewrite("/404");
---
<article>
<h1>{post.data.title}</h1>
<PortableText value={post.data.content} />
</article>

Read Porting WordPress themes for route and component mapping. Read Content import for the endpoint contracts and exact retry boundaries.