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 \
[email protected] \
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 \
[email protected] \
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