Migration
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.
- Go to WordPress Admin → Tools → Export → All content. Download the .xml file.
- In VeloCMS Admin → Tools → Import → WordPress, upload the .xml.
- The importer runs in the background — large sites (2000+ posts) may take 10-15 minutes.
- After completion, download the redirect map CSV and implement it via Cloudflare Redirect Rules or nginx.
# 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>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 format | Example | Redirects 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.
# 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.jsonImporting 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.
- In Substack → Settings → Exports, request your export. You'll receive an email with a download link.
- The ZIP contains posts.csv and subscriber-list.csv.
- 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.
- In Ghost Admin → Settings → Labs → Export your content. Download the ghost-export.json.
- In VeloCMS Admin → Tools → Import → Ghost, upload the JSON file.
- Ghost's paid member tier maps to VeloCMS 'paid'; free members map to 'free'.
- Ghost custom domains are preserved in the redirect map.
# 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.csvPreserving 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.
# 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/Reference
WordPress WXR field → VeloCMS field mapping
| WordPress field | VeloCMS field | Notes |
|---|---|---|
| post_title | posts.title | Direct string copy |
| post_content (HTML) | posts.content_html | Stored as-is; re-import into TipTap JSON on first edit |
| post_name (slug) | posts.slug | Preserved exactly — forms basis for redirect map |
| post_status | posts.status | publish → published, draft → draft, others skipped |
| post_excerpt | posts.excerpt | Direct string copy; truncated to 300 chars if empty |
| post_date | posts.published_at | Converted from WP local time to UTC ISO 8601 |
| wp:category | posts.tags[] + categories collection | WP categories become both VeloCMS tags and category records |
| wp:tag | posts.tags[] | Direct array append |
| wp:featured_image URL | posts.featured_image (R2 URL) | Downloaded and re-uploaded to R2 bucket |
| wp:postmeta (ACF/custom fields) | Not imported by default | Use --include-meta flag + custom field mapper (advanced) |
| wp:author (login) | Not migrated | VeloCMS is single-author per tenant — posts are owned by tenant |
| wp:comment | Not imported | Comments are out of scope for v1 importer |
| page post_type | pages collection (page builder AST) | HTML converted to single-block rich-text AST |
| attachment post_type | media collection + R2 | Downloaded from wp:attachment_url, re-uploaded |
| permalink structure | Redirect map (CSV) | 301 redirects implemented via Cloudflare Bulk Redirects API |
Redirect strategy reference
| Scenario | HTTP status | SEO impact | Recommended for |
|---|---|---|---|
| Permanent URL change (slug renamed) | 301 Moved Permanently | PageRank passed to new URL | All WordPress → VeloCMS migrations |
| Temporary URL change | 302 Found | PageRank NOT passed | A/B testing, staging redirects only |
| URL canonicalized (www vs non-www) | 301 | Consolidates signals to canonical | Always use canonical in <link rel=canonical> too |
| Category removed from URL (/%category%/%postname%/ → /blog/%postname%/) | 301 | Passes link equity | WordPress sites using category in permalink |
| Page removed entirely | 410 Gone | Tells crawlers not to recrawl | Pages deliberately deleted, not just moved |
Redirect implementation options
| Method | Max redirects | Setup complexity | Recommended for |
|---|---|---|---|
| Cloudflare Bulk Redirects (via API) | Unlimited (list-based) | Low — one API call uploads CSV | All VeloCMS migrations on Cloudflare DNS |
| Cloudflare Page Rules | 10 (free) / 125 (Pro) | Low — Cloudflare UI | Handful of specific URL patterns |
| nginx rewrite rules | Unlimited | Medium — server access needed | Self-hosted without Cloudflare |
| Caddy rewrite | Unlimited | Medium | Self-hosted with Caddy reverse proxy |
| Next.js next.config.ts redirects | Unlimited (source file) | Low — but bloats build artifact | Only for small sites (< 50 redirects). Not recommended at scale. |
| PocketBase redirect collection | Unlimited (DB-backed) | Medium — needs custom route handler | Future VeloCMS feature — not yet shipped |
CLI importer flags
| Flag | Applies to | Description |
|---|---|---|
| --file <path> | All importers | Path to the export file (WXR .xml, Ghost .json, Substack .zip) |
| --tenant-id <id> | All importers | Target tenant ID. Use from PocketBase admin or GET /api/collections/tenants/records. |
| --images-only | WordPress | Skip post import, only sync failed image downloads from previous run |
| --retry-errors <path> | WordPress | Path to import-errors.json from previous run — retries only failed image downloads |
| --include-products | WordPress | Include WooCommerce product posts (type=product). Default: skipped. |
| --output-redirects <path> | WordPress, Ghost | Write redirect CSV to this path. Default: ./redirects-{timestamp}.csv |
| --dry-run | All importers | Parse and validate export without writing to PocketBase. Shows counts and errors. |
| --batch-size <n> | All importers | Number of posts to process per batch. Default: 50. Reduce if hitting PB write timeouts. |
| --skip-members | Ghost, Substack | Skip member/subscriber import, only import posts |
| --skip-posts | Ghost, Substack | Skip post import, only import members/subscribers |
Substack → VeloCMS field mapping
| Substack CSV field | VeloCMS field | Notes |
|---|---|---|
| title | posts.title | Direct copy |
| subtitle | posts.excerpt | Direct copy, max 300 chars |
| post_date | posts.published_at | ISO 8601 conversion |
| is_published | posts.status | true → published, false → draft |
| canonical_url | posts.slug (derived) | Slug extracted from URL path |
| body_html | posts.content_html | Stored as-is; TipTap JSON on first edit |
| cover_image | posts.featured_image (R2 URL) | Downloaded and re-uploaded to R2 |
| email (subscribers CSV) | blog_members.email | — |
| is_paying (subscribers CSV) | blog_members.tier | true → paid, false → free |
| free_trial_end (subscribers CSV) | Not imported | VeloCMS trial logic differs |
Ghost → VeloCMS field mapping
| Ghost JSON field | VeloCMS field | Notes |
|---|---|---|
| title | posts.title | Direct copy |
| slug | posts.slug | Direct copy — forms redirect source |
| html | posts.content_html | Ghost HTML stored as-is |
| mobiledoc / lexical | posts.content_html (converted) | Ghost editor formats converted to HTML during import |
| excerpt | posts.excerpt | Direct copy |
| published_at | posts.published_at | ISO 8601 direct copy |
| status | posts.status | published → published, draft → draft |
| visibility | posts.visibility | public → public, members → members, paid → paid |
| feature_image | posts.featured_image (R2 URL) | Downloaded and re-uploaded to R2 |
| tags[].name | posts.tags[] + categories | Tags become VeloCMS tags; primary tag becomes category |
| members[].email | blog_members.email | — |
| members[].status | blog_members.tier | paid → paid, free → free, comped → paid |
Dry run before migrating
# 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)Checking import progress
# 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.totalItemsSmoke-testing redirects before DNS cutover
# 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)Uploading bulk redirects to Cloudflare
# 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"