Recipes › Move from WordPress
Introduction
Content
Using content
Plugins
Operations
Recipes
Move from WordPress
What you'll build
Your WordPress posts, pages, categories and images in Easy CMS, with a redirect from every old address, from a script you can run again until launch day. Uses: the Local API, uploads from a link, rich text and redirects.
The script reads your old site through the WordPress REST API, which every WordPress site has at
/wp-json/wp/v2/ (unless a plugin turned it off), so you need no plugin and no database access.
It reads what visitors can see: published posts and pages. For drafts, see
Drafts and private posts.
1. Collections for the content
Add collections like these, or map the script's fields onto the ones you have. wpId remembers
which WordPress item each document came from, so running the script again updates documents
instead of adding them twice; wpUrl on media does the same for images.
import { redirectsPlugin } from '@easy-cms/plugin-redirects'
export default defineConfig({
// …
collections: [
// The built-in media collection, plus where each imported image came from.
{ slug: 'media', fields: [{ name: 'wpUrl', type: 'text', unique: true }] },
{
slug: 'categories',
useAsTitle: 'name',
fields: [
{ name: 'name', type: 'text', required: true },
{ name: 'slug', type: 'slug', from: 'name' },
{ name: 'wpId', type: 'number', unique: true, admin: { position: 'sidebar' } },
],
},
{
slug: 'posts',
drafts: true,
useAsTitle: 'title',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'slug', type: 'slug', from: 'title' },
{ name: 'excerpt', type: 'textarea' },
{ name: 'publishedAt', type: 'date' },
{ name: 'cover', type: 'upload' },
{ name: 'categories', type: 'relationship', to: 'categories', hasMany: true },
{ name: 'body', type: 'richText' },
{ name: 'wpId', type: 'number', unique: true, admin: { position: 'sidebar' } },
],
},
{
slug: 'pages',
drafts: true,
useAsTitle: 'title',
fields: [
{ name: 'title', type: 'text', required: true },
{ name: 'slug', type: 'slug', from: 'title' },
{ name: 'body', type: 'richText' },
{ name: 'wpId', type: 'number', unique: true, admin: { position: 'sidebar' } },
],
},
],
plugins: [redirectsPlugin()],
})Then install what the script uses, and create a migration:
npm install @easy-cms/plugin-redirects @tiptap/html @tiptap/starter-kit @tiptap/extension-imagepnpm add @easy-cms/plugin-redirects @tiptap/html @tiptap/starter-kit @tiptap/extension-imageyarn add @easy-cms/plugin-redirects @tiptap/html @tiptap/starter-kit @tiptap/extension-imagebun add @easy-cms/plugin-redirects @tiptap/html @tiptap/starter-kit @tiptap/extension-imagenpx easy-cms migrate:create wordpresspnpm exec easy-cms migrate:create wordpressyarn easy-cms migrate:create wordpressbunx easy-cms migrate:create wordpressThe three @tiptap packages turn WordPress HTML into the JSON a richText field stores, with
the same editor the admin uses. Only the import script needs them.
2. The script
// Import a WordPress site's published posts, pages, categories and images into Easy CMS.
// Run it again any time: documents are matched by their WordPress id and updated.
//
// npx tsx --env-file=.env scripts/import-wordpress.ts https://old-site.com [--limit 10]
import { createEasyCMS } from '@easy-cms/core'
import Image from '@tiptap/extension-image'
import { generateJSON } from '@tiptap/html'
import StarterKit from '@tiptap/starter-kit'
import config from '../easy-cms.config'
const site = process.argv[2]?.replace(/\/+$/, '')
if (!site) throw new Error('Usage: import-wordpress.ts https://old-site.com [--limit 10]')
const limitArg = process.argv.indexOf('--limit')
const limit = limitArg > 0 ? Number(process.argv[limitArg + 1]) : Infinity
// Where each kind of document lives on the new site; old addresses redirect there.
const newPath = { posts: (slug: string) => `/blog/${slug}`, pages: (slug: string) => `/${slug}` }
const cms = await createEasyCMS(config)
const extensions = [StarterKit, Image]
type Node = { type: string; text?: string; attrs?: Record<string, unknown>; content?: Node[] }
type WPItem = {
id: number
slug: string
link: string
date_gmt: string
title: { rendered: string }
content: { rendered: string }
excerpt?: { rendered: string }
categories?: number[]
_embedded?: { 'wp:featuredmedia'?: { source_url?: string; alt_text?: string }[] }
}
/** Every item of a WordPress REST collection, 100 a page, up to `max`. */
async function wp<T>(path: string, query = '', max = Infinity): Promise<T[]> {
const items: T[] = []
for (let page = 1; items.length < max; page++) {
const res = await fetch(`${site}/wp-json/wp/v2/${path}?per_page=100&page=${page}${query}`)
if (!res.ok) throw new Error(`${path}: ${res.status} ${await res.text()}`)
items.push(...((await res.json()) as T[]))
if (page >= Number(res.headers.get('x-wp-totalpages') ?? 1)) break
}
return items.slice(0, max)
}
/** Text without tags or entities: "Hello – world" → "Hello – world". */
function plain(html: string) {
const walk = (node: Node): string => node.text ?? (node.content ?? []).map(walk).join(' ')
return walk(generateJSON(html, extensions) as Node).replace(/\s+/g, ' ').trim()
}
/** Each image downloaded once into the media library, also across runs (by `wpUrl`). */
const media = new Map<string, { id: number | string; url: string } | null>()
async function upload(url: string, alt = '') {
if (!media.has(url)) {
const { docs } = await cms.find('media', { where: { wpUrl: { equals: url } }, limit: 1 })
try {
media.set(url, (docs[0] ?? (await cms.uploadFromURL(url, { alt, wpUrl: url }))) as never)
} catch (error) {
console.warn(` image kept as a link: ${url} (${(error as Error).message})`)
media.set(url, null)
}
}
return media.get(url) ?? null
}
/** WordPress HTML as rich text, its images in the media library. */
async function richText(html: string) {
const doc = generateJSON(html, extensions) as Node
const visit = async (node: Node) => {
if (node.type === 'image' && typeof node.attrs?.src === 'string') {
const file = await upload(node.attrs.src, String(node.attrs.alt ?? ''))
if (file) node.attrs.src = file.url
}
for (const child of node.content ?? []) await visit(child)
}
await visit(doc)
return doc
}
/** Creates or updates the document with this WordPress id. */
async function save(collection: 'categories' | 'posts' | 'pages', wpId: number, data: Record<string, unknown>) {
const { docs } = await cms.find(collection, { where: { wpId: { equals: wpId } }, limit: 1, draft: true })
return docs[0]
? cms.update(collection, docs[0].id, { ...data, wpId } as never)
: cms.create(collection, { ...data, wpId } as never)
}
/** A 301 from the old address, when it differs from the new one. */
async function redirect(oldLink: string, to: string) {
// Stored without the trailing slash WordPress adds.
const from = decodeURI(new URL(oldLink).pathname).replace(/(.)\/$/, '$1')
if (from === to) return
const { totalDocs } = await cms.find('redirects', { where: { from: { equals: from } }, limit: 0 })
if (!totalDocs) await cms.create('redirects', { from, to, type: '301' })
}
// Categories first, so posts can point to them.
const categoryIds = new Map<number, number | string>()
for (const c of await wp<{ id: number; name: string; slug: string }>('categories')) {
const doc = await save('categories', c.id, { name: plain(c.name), slug: decodeURIComponent(c.slug) })
categoryIds.set(c.id, doc.id)
}
console.log(`categories: ${categoryIds.size}`)
for (const kind of ['posts', 'pages'] as const) {
const items = await wp<WPItem>(kind, '&_embed=wp:featuredmedia', limit)
for (const item of items) {
const slug = decodeURIComponent(item.slug)
const featured = item._embedded?.['wp:featuredmedia']?.[0]
const data: Record<string, unknown> = {
title: plain(item.title.rendered) || slug,
slug,
body: await richText(item.content.rendered),
status: 'published',
}
if (kind === 'posts') {
data.excerpt = item.excerpt ? plain(item.excerpt.rendered) : null
data.publishedAt = `${item.date_gmt}Z`
data.categories = (item.categories ?? []).map((id) => categoryIds.get(id)).filter(Boolean)
data.cover = featured?.source_url ? ((await upload(featured.source_url, featured.alt_text))?.id ?? null) : null
}
await save(kind, item.id, data)
await redirect(item.link, newPath[kind](slug))
console.log(`${kind}: ${slug}`)
}
}
console.log(`images: ${[...media.values()].filter(Boolean).length} in the media library`)
await cms.destroy()newPath says where posts and pages live on your new site: change it to match your routes.
3. Run it
Try it on your local database first, with a few items:
npx tsx --env-file=.env scripts/import-wordpress.ts https://old-site.com --limit 10Open /admin and look through the posts: text, headings, lists, links, images, the cover and
categories. Then run it without --limit. Each run prints what it imported:
categories: 22
posts: hello-world
pages: about
images: 48 in the media libraryRunning it again is safe: documents and images already imported are updated, not copied, and existing redirects are kept. Run it once more on launch day to bring in the last posts.
For the production database, run it from your machine with the production settings, after
easy-cms migrate, and with NODE_ENV=production so the schema isn't pushed into it:
NODE_ENV=production DATABASE_URL=… npx tsx --env-file=.env scripts/import-wordpress.ts https://old-site.comImages go to the storage your config sets: on serverless hosts make sure it is S3, R2 or Vercel
Blob there too, not the local uploads/ folder.
4. Redirects
The script adds a 301 from each old address to the new one, such as
/2024/05/hello-world/ → /blog/hello-world, so links and search rankings carry over. Thai
addresses work too: WordPress's %e0%b8%aa… slugs are stored as the Thai text. Make your site
follow them as Serve them shows: a server middleware in Nuxt, the proxy
in Next.js. Old category, tag and date archive pages aren't redirected; add the ones
that matter in Settings → Redirects.
What comes across, and what doesn't
| Comes across | Doesn't |
|---|---|
| Titles, slugs, excerpts, dates | Tags and authors (add them like categories) |
| Paragraphs, headings, lists, quotes, code, links, bold and italic | Embeds (YouTube, tweets) and other <iframe>s: add them back, or allow them with a Tiptap extension |
| Images in posts, featured images | Page builder layouts: their text comes across, their columns don't |
| Categories | Custom fields (ACF), menus, comments |
| Shortcodes, as the HTML WordPress renders for them | Unpublished content, unless you sign in (below) |
Custom post types come across the same way when they are in the REST API (show_in_rest): add
a collection and their REST name to the loop.
Drafts and private posts
To read unpublished content, sign in with an
application password
(Users → Profile → Application Passwords in WordPress). Keep it in .env, not in the script:
const auth = `Basic ${Buffer.from(`${process.env.WP_USER}:${process.env.WP_APP_PASSWORD}`).toString('base64')}`
// in wp(): fetch(url, { headers: { Authorization: auth } })
// and ask for every status: wp<WPItem>(kind, '&_embed=wp:featuredmedia&status=publish,draft,private', limit)Map WordPress's status to 'published' or 'draft' in data.status. Delete the application
password when the move is done.