Migration

12 min readUpdated 27 Apr 2026

Migrating a blog is the moment when a platform earns trust or loses it. VeloCMS ships importers for the three most common sources — WordPress, Substack, and Ghost — and every importer handles the two things that migrations most often break: images (moving them to R2 without losing them) and URLs (preserving redirects so your SEO equity doesn't evaporate). This guide covers each platform's import path.

Importing from WordPress

WordPress exports in a format called WXR (WordPress eXtended RSS) — it's XML. The VeloCMS WordPress importer parses this file, converts the post HTML to the TipTap-compatible content format, downloads all referenced image URLs and re-uploads them to your R2 bucket, and creates a redirect map from your old WordPress slug structure to the new VeloCMS slugs.

  1. Go to WordPress Admin → Tools → Export → All content. Download the .xml file.
  2. In VeloCMS Admin → Tools → Import → WordPress, upload the .xml.
  3. The importer runs in the background — large sites (2000+ posts) may take 10-15 minutes.
  4. After completion, download the redirect map CSV and implement it via Cloudflare Redirect Rules or nginx.
Programmatic import (via CLI — large migrations)
# For migrations with 1000+ posts, use the CLI importer directly
# (bypasses the 30s admin timeout)
POCKETBASE_URL=https://your-pb.up.railway.app \
POCKETBASE_ADMIN_EMAIL=admin@example.com \
POCKETBASE_ADMIN_PASSWORD=yourpassword \
R2_BUCKET_NAME=velocms-media \
node scripts/import-wordpress.mjs --file ./export.xml --tenant-id <tenant-id>
bash

WordPress URL mapping

WordPress uses several slug formats depending on your permalink settings. The importer detects your old structure from the export and generates 301 redirect rules for Cloudflare:

WordPress formatExampleRedirects to
/%postname%//my-post//blog/my-post
/%year%/%monthnum%/%postname%//2023/04/my-post//blog/my-post
/?p=123/?p=123/blog/my-post
/category/tech/my-post//category/tech/my-post//blog/my-post

Image upload to R2

Every image referenced in WordPress post content is downloaded and re-uploaded to your Cloudflare R2 bucket during import. The importer replaces the original WordPress domain URLs in the post content with your R2 public URL. If an image download fails (404, rate limit), it's logged to import-errors.json and the post is imported with the original URL intact — you can re-run the image sync later.

Re-run image sync only
# After the main import, sync any failed images
node scripts/import-wordpress.mjs \
  --file ./export.xml \
  --tenant-id <id> \
  --images-only \
  --retry-errors ./import-errors.json
bash

Importing from Substack

Substack exports two files: posts as a CSV (metadata + HTML content) and a separate subscribers CSV. VeloCMS handles both — posts go into your posts collection, subscribers go into blog_members. The post HTML from Substack is reasonably clean and imports without needing a custom converter.

  1. In Substack → Settings → Exports, request your export. You'll receive an email with a download link.
  2. The ZIP contains posts.csv and subscriber-list.csv.
  3. In VeloCMS Admin → Tools → Import → Substack, upload both files (or just one if you only need posts or subscribers).

Importing from Ghost

Ghost exports a single JSON file that includes posts, pages, tags, and members in one document. The VeloCMS Ghost importer handles the full export — it converts Ghost's Mobiledoc/Lexical format to TipTap JSON for rich editing, maps Ghost tags to VeloCMS categories, and imports members with their tier assignments.

  1. In Ghost Admin → Settings → Labs → Export your content. Download the ghost-export.json.
  2. In VeloCMS Admin → Tools → Import → Ghost, upload the JSON file.
  3. Ghost's paid member tier maps to VeloCMS 'paid'; free members map to 'free'.
  4. Ghost custom domains are preserved in the redirect map.
Ghost import with custom redirects
# Programmatic Ghost import with redirect map output
POCKETBASE_URL=https://your-pb.up.railway.app \
POCKETBASE_ADMIN_EMAIL=admin@example.com \
POCKETBASE_ADMIN_PASSWORD=yourpassword \
node scripts/import-ghost.mjs \
  --file ./ghost-export.json \
  --tenant-id <id> \
  --output-redirects ./ghost-redirects.csv
bash

Preserving redirects

Every importer outputs a redirects CSV in the format source,destination,status_code. After import, implement these redirects at the CDN layer (Cloudflare Redirect Rules) or your server (nginx/Caddy). Implementing them in Next.js next.config.ts is an option but not recommended for large blogs — a thousand redirect rules in next.config.ts bloats the build artifact.

Import redirects into Cloudflare via API
# Upload bulk redirects to Cloudflare using the Bulk Redirects API
curl -X POST \
  "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/rules/lists" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"velocms-migration-redirects","kind":"redirect"}'

# Then upload the redirect items CSV via:
# https://developers.cloudflare.com/rules/bulk-redirects/
bash

Reference

WordPress WXR field → VeloCMS field mapping

WordPress fieldVeloCMS fieldNotes
post_titleposts.titleDirect string copy
post_content (HTML)posts.content_htmlStored as-is; re-import into TipTap JSON on first edit
post_name (slug)posts.slugPreserved exactly — forms basis for redirect map
post_statusposts.statuspublish → published, draft → draft, others skipped
post_excerptposts.excerptDirect string copy; truncated to 300 chars if empty
post_dateposts.published_atConverted from WP local time to UTC ISO 8601
wp:categoryposts.tags[] + categories collectionWP categories become both VeloCMS tags and category records
wp:tagposts.tags[]Direct array append
wp:featured_image URLposts.featured_image (R2 URL)Downloaded and re-uploaded to R2 bucket
wp:postmeta (ACF/custom fields)Not imported by defaultUse --include-meta flag + custom field mapper (advanced)
wp:author (login)Not migratedVeloCMS is single-author per tenant — posts are owned by tenant
wp:commentNot importedComments are out of scope for v1 importer
page post_typepages collection (page builder AST)HTML converted to single-block rich-text AST
attachment post_typemedia collection + R2Downloaded from wp:attachment_url, re-uploaded
permalink structureRedirect map (CSV)301 redirects implemented via Cloudflare Bulk Redirects API

Redirect strategy reference

ScenarioHTTP statusSEO impactRecommended for
Permanent URL change (slug renamed)301 Moved PermanentlyPageRank passed to new URLAll WordPress → VeloCMS migrations
Temporary URL change302 FoundPageRank NOT passedA/B testing, staging redirects only
URL canonicalized (www vs non-www)301Consolidates signals to canonicalAlways use canonical in <link rel=canonical> too
Category removed from URL (/%category%/%postname%/ → /blog/%postname%/)301Passes link equityWordPress sites using category in permalink
Page removed entirely410 GoneTells crawlers not to recrawlPages deliberately deleted, not just moved

Redirect implementation options

MethodMax redirectsSetup complexityRecommended for
Cloudflare Bulk Redirects (via API)Unlimited (list-based)Low — one API call uploads CSVAll VeloCMS migrations on Cloudflare DNS
Cloudflare Page Rules10 (free) / 125 (Pro)Low — Cloudflare UIHandful of specific URL patterns
nginx rewrite rulesUnlimitedMedium — server access neededSelf-hosted without Cloudflare
Caddy rewriteUnlimitedMediumSelf-hosted with Caddy reverse proxy
Next.js next.config.ts redirectsUnlimited (source file)Low — but bloats build artifactOnly for small sites (< 50 redirects). Not recommended at scale.
PocketBase redirect collectionUnlimited (DB-backed)Medium — needs custom route handlerFuture VeloCMS feature — not yet shipped

CLI importer flags

FlagApplies toDescription
--file <path>All importersPath to the export file (WXR .xml, Ghost .json, Substack .zip)
--tenant-id <id>All importersTarget tenant ID. Use from PocketBase admin or GET /api/collections/tenants/records.
--images-onlyWordPressSkip post import, only sync failed image downloads from previous run
--retry-errors <path>WordPressPath to import-errors.json from previous run — retries only failed image downloads
--include-productsWordPressInclude WooCommerce product posts (type=product). Default: skipped.
--output-redirects <path>WordPress, GhostWrite redirect CSV to this path. Default: ./redirects-{timestamp}.csv
--dry-runAll importersParse and validate export without writing to PocketBase. Shows counts and errors.
--batch-size <n>All importersNumber of posts to process per batch. Default: 50. Reduce if hitting PB write timeouts.
--skip-membersGhost, SubstackSkip member/subscriber import, only import posts
--skip-postsGhost, SubstackSkip post import, only import members/subscribers

Substack → VeloCMS field mapping

Substack CSV fieldVeloCMS fieldNotes
titleposts.titleDirect copy
subtitleposts.excerptDirect copy, max 300 chars
post_dateposts.published_atISO 8601 conversion
is_publishedposts.statustrue → published, false → draft
canonical_urlposts.slug (derived)Slug extracted from URL path
body_htmlposts.content_htmlStored as-is; TipTap JSON on first edit
cover_imageposts.featured_image (R2 URL)Downloaded and re-uploaded to R2
email (subscribers CSV)blog_members.email
is_paying (subscribers CSV)blog_members.tiertrue → paid, false → free
free_trial_end (subscribers CSV)Not importedVeloCMS trial logic differs

Ghost → VeloCMS field mapping

Ghost JSON fieldVeloCMS fieldNotes
titleposts.titleDirect copy
slugposts.slugDirect copy — forms redirect source
htmlposts.content_htmlGhost HTML stored as-is
mobiledoc / lexicalposts.content_html (converted)Ghost editor formats converted to HTML during import
excerptposts.excerptDirect copy
published_atposts.published_atISO 8601 direct copy
statusposts.statuspublished → published, draft → draft
visibilityposts.visibilitypublic → public, members → members, paid → paid
feature_imageposts.featured_image (R2 URL)Downloaded and re-uploaded to R2
tags[].nameposts.tags[] + categoriesTags become VeloCMS tags; primary tag becomes category
members[].emailblog_members.email
members[].statusblog_members.tierpaid → paid, free → free, comped → paid

Dry run before migrating

Dry run — validate without writing
# Parse and validate the export without writing anything to PocketBase.
# Shows what would be imported: post count, image count, member count, errors.
node scripts/import-wordpress.mjs \
  --file ./export.xml \
  --tenant-id <id> \
  --dry-run

# Example output:
# Posts to import: 2,341
# Images to download: 1,847
# Categories to create: 12
# Validation errors: 3 (see ./dry-run-errors.json)
bash

Checking import progress

Check import status via PocketBase API
# List recently imported posts (last 10, sorted by creation time)
curl -G '$POCKETBASE_URL/api/collections/posts/records' \
  --data-urlencode 'sort=-created' \
  --data-urlencode 'perPage=10' \
  --data-urlencode 'fields=id,title,slug,status,created' \
  -H 'Authorization: Bearer <admin-token>'

# Count total posts in tenant
curl '$POCKETBASE_URL/api/collections/posts/records?perPage=1' \
  -H 'Authorization: Bearer <admin-token>'
# Check response.totalItems
bash

Smoke-testing redirects before DNS cutover

Spot-check 30 redirects from old site
# Before changing DNS, confirm your redirect rules work against the old domain.
# Redirect CSV format: source,destination,status_code

while IFS=',' read -r source dest _code; do
  actual=$(curl -s -o /dev/null -w "%{redirect_url}" "https://oldsite.com$source")
  expected="https://newsite.com$dest"
  if [ "$actual" = "$expected" ]; then
    echo "PASS: $source"
  else
    echo "FAIL: $source → got $actual (expected $expected)"
  fi
done < <(tail -n +2 redirects.csv | shuf | head -30)
bash

Uploading bulk redirects to Cloudflare

Cloudflare Bulk Redirects upload
# Step 1: Create a redirect list
LIST_ID=$(curl -s -X POST \
  "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/rules/lists" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"migration-redirects","kind":"redirect"}' \
  | jq -r '.result.id')

# Step 2: Upload items to the list
# (The importer outputs redirect items JSON via --output-cf-json flag)
node scripts/import-wordpress.mjs \
  --file ./export.xml \
  --tenant-id <id> \
  --output-cf-json ./cf-redirects.json

curl -X POST \
  "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/rules/lists/$LIST_ID/items" \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  -H "Content-Type: application/json" \
  --data-binary "@cf-redirects.json"
bash