How to Migrate from Contentful to EmDash CMS

How to Migrate from Contentful to EmDash CMS

Worth saying plainly upfront: unlike WordPress, EmDash doesn't ship a built-in, guided Contentful import wizard today — its documented import sources are WordPress-specific (WXR file, WordPress.com OAuth, and a WordPress REST API probe). Migrating from Contentful is a scripted process using EmDash's own REST API and CLI, not a one-click wizard. This walks through the real, working path.

Table of Contents
  1. Step 1: Export Your Contentful Space
  2. Step 2: Map Contentful Content Types to EmDash Collections
  3. Step 3: Handle Assets
  4. Step 4: Convert Rich Text to Portable Text
  5. Step 5: Script the Import via CLI or REST API
  6. Step 6: For Recurring Migrations, Build a Custom Import Source
  7. Frequently Asked Questions
  8. Is there really no one-click Contentful migration?
  9. What's the hardest part of this migration in practice?
  10. Can I keep Contentful running during the migration?
  11. Should I migrate everything at once or in batches?
  12. The Bottom Line

Step 1: Export Your Contentful Space

Contentful publishes its own official export tool for exactly this purpose. Install and run it against your space:

npm install -g contentful-export
contentful-export --space-id YOUR_SPACE_ID --management-token YOUR_TOKEN --export-dir ./contentful-export
emdashkits.com

This produces a JSON file containing your content types, entries, assets, and locales — the full shape of your Contentful space, structured and ready to map into EmDash's schema.

Step 2: Map Contentful Content Types to EmDash Collections

Contentful's content-type fields map reasonably cleanly onto EmDash's 16 field types. The mapping is mostly one-to-one, with rich text and references needing the most attention:

  • Contentful Text / Symbol → EmDash string or text
  • Contentful Rich Text → EmDash portableText (structural conversion required, see Step 4)
  • Contentful Number / Integer → EmDash number or integer
  • Contentful Boolean → EmDash boolean
  • Contentful Date → EmDash datetime
  • Contentful Media (single) → EmDash image or file
  • Contentful Reference (single) → EmDash reference
  • Contentful Reference (many) / Array → EmDash reference with allowMultiple, or repeater

Create the matching collections and fields in EmDash's Visual Schema Builder (admin panel, no code required for the schema itself), or script it directly with the CLI's schema commands if you're mapping many content types and want it reproducible.

Read also:

Step 3: Handle Assets

Contentful's exported asset URLs point to Contentful's own CDN. Download each asset from its exported URL and upload it into EmDash's media library through the admin UI — EmDash's media library supports drag-and-drop uploads with signed URL support, and once uploaded, each asset gets an EmDash media ID you'll reference when creating entries in Step 5.

Step 4: Convert Rich Text to Portable Text

This is the step with no shortcut. Contentful's Rich Text field is also a block-based, structured format — conceptually close to Portable Text, but a different spec with different type names and structure. A direct field-by-field JSON transform (mapping Contentful's node types like `paragraph`, `heading-1`, `unordered-list`, and `hyperlink` to EmDash's `block` style variants and mark definitions) is the practical approach. For a small number of entries, this can be done by hand; for a large content library, write a small conversion script that walks the Contentful Rich Text document tree and emits EmDash's Portable Text block shape.

Portable Text is a specification for structured rich text. The value is stored as a JSON array of Portable Text blocks — each block has a _type, style, and children array of typed spans.

Step 5: Script the Import via CLI or REST API

With content types mapped and assets uploaded, create entries programmatically. The EmDash CLI's content commands work well for a scripted migration:

npx emdash content create posts --file entry-001.json --slug my-first-post

# Or pipe data directly:
cat entry-001.json | npx emdash content create posts --stdin
emdashkits.com

For a larger migration, the REST API is more practical to drive from a script that loops over your exported Contentful entries:

POST /_emdash/api/content/posts
Content-Type: application/json
Authorization: Bearer YOUR_API_TOKEN

{
  "title": "My First Post",
  "slug": "my-first-post",
  "content": [ /* converted Portable Text blocks */ ],
  "status": "draft"
}
emdashkits.com

Items are created as drafts by default unless you explicitly set `status: "published"` — a deliberate safety net, letting you review converted content in the EmDash admin before anything goes live, especially useful given the Rich Text conversion in Step 4 is the part most likely to need manual touch-ups.

Step 6: For Recurring Migrations, Build a Custom Import Source

If you're doing this migration repeatedly — an agency migrating multiple clients off Contentful, for instance — EmDash exposes a documented `ImportSource` interface (`probe`, `analyze`, `fetchContent`) you can implement and register on the integration, giving Contentful the same guided-wizard experience WordPress gets today, reusable across every future migration instead of a one-off script.

// astro.config.mjs
import { contentfulSource } from "./src/import/contentful-source";

export default defineConfig({
  integrations: [
    emdash({
      import: { sources: [contentfulSource] },
    }),
  ],
});
emdashkits.com

Frequently Asked Questions

Is there really no one-click Contentful migration?

Not today — EmDash's built-in, wizard-guided import sources are WordPress-specific. A Contentful migration is a real, working process using documented tools (EmDash's REST API, CLI, and Custom Sources interface), just not a single guided flow the way WordPress import is.

What's the hardest part of this migration in practice?

Converting Contentful's Rich Text fields to Portable Text — both are block-based and structurally similar, but the actual JSON shapes differ enough that a naive copy-paste won't work. Budget real time for this step specifically, especially for content with complex embedded entries or heavy inline formatting.

Can I keep Contentful running during the migration?

Yes — the export step is read-only against your Contentful space, so there's no reason to take it offline while you build and test the migration script against EmDash.

Should I migrate everything at once or in batches?

For anything beyond a small content library, batch by content type and verify each batch in EmDash's admin before moving to the next — that isolates a Rich Text conversion bug to one batch instead of discovering it after importing everything.

The Bottom Line

Migrating from Contentful to EmDash today is a real, scripted process — Contentful's own export tool, a field-type mapping pass, a Rich-Text-to-Portable-Text conversion, and either CLI or REST API calls to create the entries — not a guided wizard the way WordPress migration is. For teams doing this repeatedly, building a proper `ImportSource` turns that one-off script into a reusable, guided flow. See our 10-minute EmDash setup guide to get a project running before you start mapping content types.

Share

Comments

Write a comment

Related Articles

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

July 16, 2026

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

July 16, 2026

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

July 16, 2026

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

TL;DR — A custom session-cookie login flow appeared to succeed on localhost (the OTP verified, the response looked fine) but every subsequent request to a login-gated page treated the visitor as logged out. Identical code worked fine on the live HTTPS site. The cookie's Secure attribute was hardcoded to true — and per the cookie spec, browsers never store or send a Secure cookie over a plain, non-HTTPS connection.

Table of Contents
  1. Diagnostic
  2. Root cause
  3. Fix
  4. Lessons learned

Diagnostic

Check the actual Set-Cookie response header and the browser's own cookie storage panel — on localhost over http://, the cookie is sent by the server but never actually stored by the browser.

Root cause

// before -- assumes the app is always served over HTTPS
setCookie("session", token, { secure: true, httpOnly: true });
emdashkits.com

A cookie config that quietly assumes "we're always on HTTPS" breaks the instant you test over plain HTTP, which local dev servers commonly are.

Read also:

Fix

// after -- derive secure from the actual request protocol
const isHttps = request.url.startsWith("https://");
setCookie("session", token, { secure: isHttps, httpOnly: true });
emdashkits.com

Lessons learned

  • Any Secure-flagged cookie needs to key off the real request scheme, not an assumption baked in once at cookie-creation time.
  • "Works in production, silently fails in local dev" is a strong signal to check cookie flags before anything else in an auth flow.
  • Check other cookies in the same codebase for the same hardcoded assumption — if one cookie has this bug, sibling cookies set the same way are worth auditing too.
Share
Previous Article

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

Comments

Write a comment

Related Articles

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

July 16, 2026

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

July 16, 2026

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

Uploaded Images Disappear After Every Deploy on Shared Hosting (and Other Reverse-Proxy Gotchas)

July 16, 2026

Uploaded Images Disappear After Every Deploy on Shared Hosting (and Other Reverse-Proxy Gotchas)

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

TL;DR — GA4's gtag.js snippet was installed correctly, the page loaded with no visible JavaScript error, and GA4's real-time report still showed zero activity. The CMS's default Content-Security-Policy had no allowance for analytics domains and no config option to add one — so every request to Google's tracking endpoints was blocked at the browser level before it could fail loudly.

Table of Contents
  1. Diagnostic
  2. Root cause
  3. Lessons learned

Diagnostic

Check the browser's dedicated CSP violation reporting, not the regular console error list — CSP blocks are reported through their own channel, not thrown as normal script errors, so "no console errors" doesn't mean nothing was blocked.

Root cause

The CSP's script-src and connect-src directives had no entry for googletagmanager.com or google-analytics.com, and the CMS exposed no configuration surface to add one — the only way in was patching the CSP directives directly.

// patch-package: add analytics domains to the existing CSP directives
scriptSrc.push("https://www.googletagmanager.com");
connectSrc.push("https://www.google-analytics.com", "https://www.googletagmanager.com");
emdashkits.com
Read also:

Lessons learned

  • "No console errors" is not proof nothing was blocked — CSP violations live in their own reporting surface and are easy to miss if you're only scanning for red error text.
  • Before adding any third-party script tag to a site with a CSP already in place, check the CSP's directives first rather than assuming a silently-empty analytics dashboard means a snippet-installation mistake.
Share
Previous Article

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

Next Article

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

Comments

Write a comment

Related Articles

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

July 16, 2026

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

July 16, 2026

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

Uploaded Images Disappear After Every Deploy on Shared Hosting (and Other Reverse-Proxy Gotchas)

July 16, 2026

Uploaded Images Disappear After Every Deploy on Shared Hosting (and Other Reverse-Proxy Gotchas)