---
title: "Move a blog to Writavo"
description: "A step-by-step migration runbook from WordPress, Ghost, Sanity, Contentful, Webflow, markdown or a database: URLs, dates and images kept."
canonical: "https://writavo.com/docs/migrate"
last-updated: "2026-09-30"
---

# Move a blog to Writavo

Move an existing blog into Writavo without breaking it: same URLs, original publish dates, drafts kept as drafts, images copied, nothing changed on the old system.

## If you are an AI assistant asked to move a blog

Export the old blog, map it to one JSON document, dry run it until clean, import it, check it against the source, and leave the switch to the person. Report after each step. Two rules: never fix a problem by inventing data, and pass `confirm: true` only after the person agrees (it publishes with the original dates). Switching the live site is their decision.

## Principles

- **Read only at the source.** Never edit, delete or unpublish anything there.
- **Copy verbatim:** never rewrite, summarise or translate. Keep each slug exactly, and `published_at` as when the post first went public.
- **Keep exports private**, outside any git repository (for example `~/writavo-migration/`). Source credentials never go to Writavo. Importing puts nothing on the web.

## Step 0: connect to Writavo

Imports run through `import_content` on the hosted MCP server, `https://mcp.writavo.com/mcp`. [Set it up](/docs/mcp) with read and write for articles, categories, tags and authors, and media; to move comments, also set Reader comments to Read and moderate (without it, comments are skipped). Call `get_site_info` and `list_articles`, and have the person confirm the Site and article count.

## Step 1: inventory the source

Count, as your Step 7 checklist: posts by status, authors, terms, comments, images and their host, embeds, oldest and newest date, and every public URL pattern (each needs a redirect).

## Step 2: decide what is imported, and as what

| At the source | Import as |
|---|---|
| Published, public | published, with its original date |
| Draft, pending review | draft |
| Scheduled for the future | draft, then `schedule_article` after the import |
| Private, password protected, members only or paid | ask: draft, publish, or leave out |
| Trashed, archived, revisions, auto-saves, test posts | leave out |
| Static pages (About, Contact) | ask: usually not part of the blog |

Tell the person what you left out. Never publish anything that is not public today.

## Step 3: export

Test each recipe on one or two posts first.

#### WordPress: no conversion needed

- **Export file** (Tools > Export > All content): send the `.xml` as is, with `import_content` `upload: true` or `POST /v1/imports` with `Content-Type: application/xml` (up to 100 MB). Includes drafts, scheduled and private posts and their comments.
- **Live site**: `import_content` with `wordpress_url`, read only. Published posts only, unless you add an Application Password (Users > Profile > Application Passwords); approved comments only.
- Writavo converts it, copies images, keeps Yoast and Rank Math SEO and redirects old permalinks. The dry run lists what is left out.

#### WordPress (REST API, by hand)

Fallback only, for a site whose export fails and whose REST API is blocked.

shell:

```
# How many published posts
curl -sI "https://example.com/wp-json/wp/v2/posts?per_page=1" | grep -i x-wp-total

# Published posts, 100 per page, with author, terms and featured image embedded
curl -s "https://example.com/wp-json/wp/v2/posts?per_page=100&page=1&_embed=1" > ~/writavo-migration/wp-posts-1.json

# Drafts, scheduled and private posts (needs an application password)
curl -s -u "USER:APPLICATION_PASSWORD" "https://example.com/wp-json/wp/v2/posts?status=draft,future,private&context=edit&per_page=100&page=1&_embed=1" > ~/writavo-migration/wp-other-1.json
```

- `id` as `wp:<id>`. `date_gmt` is UTC with no suffix: append `Z`.
- `link` and `guid.rendered` (`?p=<id>`) go in `old_urls`; archives in top-level `redirects`.

#### Ghost (Admin API)

An Admin API key (Settings > Integrations), turned into a short-lived token; a Content API key covers published, public posts.

shell:

```
# Every post with its HTML, authors and tags (Admin API)
curl -s "https://example.com/ghost/api/admin/posts/?limit=all&formats=html&include=authors,tags" \
  -H "Authorization: Ghost $GHOST_ADMIN_TOKEN" -H "Accept-Version: v5.0" > ~/writavo-migration/ghost-posts.json

# Published posts only (Content API)
curl -s "https://example.com/ghost/api/content/posts/?key=$GHOST_CONTENT_KEY&limit=all&include=authors,tags" > ~/writavo-migration/ghost-published.json
```

- `id` as `ghost:<id>`. `visibility` other than `public` (members, paid) needs a decision.
- `primary_tag` is the category; drop internal tags (starting with `#`).

#### Sanity (GROQ)

Query over the HTTP API (`Authorization: Bearer <read token>` for a private dataset) or run `npx sanity dataset export`.

groq:

```
*[_type == "post"] | order(publishedAt asc) {
  _id,
  title,
  "slug": slug.current,
  publishedAt,
  _updatedAt,
  excerpt,
  body,
  "author": author->name,
  "categories": categories[]->{title, "slug": slug.current},
  "image": mainImage{alt, "url": asset->url}
}
```

- Field names are the starter's: read your schema first. `_id` as `sanity:<id>` without `drafts.`; a document only at `drafts.<id>` is a draft.
- `body` is Portable Text: serialize it with a rule per custom block.

#### Contentful

Drafts and first publish dates need the Management API (or `contentful space export`).

shell:

```
# Published entries of the blog post content type, with linked authors and assets
curl -s "https://cdn.contentful.com/spaces/$SPACE_ID/environments/master/entries?content_type=blogPost&include=2&limit=1000&skip=0" \
  -H "Authorization: Bearer $CONTENTFUL_DELIVERY_TOKEN" > ~/writavo-migration/contentful-published.json

# Everything, drafts included, with sys.firstPublishedAt (management API)
curl -s "https://api.contentful.com/spaces/$SPACE_ID/environments/master/entries?content_type=blogPost&limit=1000&skip=0" \
  -H "Authorization: Bearer $CONTENTFUL_MANAGEMENT_TOKEN" > ~/writavo-migration/contentful-all.json
```

- `sys.id` as `contentful:<id>`. `published_at` is `sys.firstPublishedAt` or the model's own date field, never `sys.createdAt`.
- Asset URLs start with `//images.ctfassets.net/`: prefix `https:` or they are not copied.

#### Webflow (CMS API v2)

A site API token with CMS read access. Items include drafts (`isDraft`) and archived ones (`isArchived`).

shell:

```
# Find the blog collection
curl -s "https://api.webflow.com/v2/sites/$SITE_ID/collections" -H "Authorization: Bearer $WEBFLOW_TOKEN"

# Its items, 100 at a time (repeat with offset=100, 200, ...)
curl -s "https://api.webflow.com/v2/collections/$COLLECTION_ID/items?limit=100&offset=0" \
  -H "Authorization: Bearer $WEBFLOW_TOKEN" > ~/writavo-migration/webflow-items-0.json
```

- `id` as `webflow:<id>`. Reference fields (author, category) hold ids in other collections: fetch those too.
- `lastPublished` is the LATEST publish, not the first. Use the collection's own date field, or ask.

#### Markdown or MDX files (Hugo, Jekyll, Astro, Next.js, Eleventy)

- The generator's config decides slugs, dates and drafts: check a few slugs against live URLs. `external_id` is the path (`md:posts/my-first-post.md`).
- A date with no timezone means the site's configured one (ask if unsure).
- Relative image paths cannot be copied: use the absolute https URL, or `upload_media` and the returned URL.

#### A custom database (Postgres, Supabase, MySQL)

Read-only connection, SELECT only. A `timestamp` without time zone needs the app's timezone: ask.

shell:

```
PGOPTIONS='-c default_transaction_read_only=on' psql "$SOURCE_DATABASE_URL" -At -c "
select json_agg(p order by p.id) from (
  select id, slug, title, body, excerpt, status, published_at, updated_at, author_id, category_id
  from posts
) p" > ~/writavo-migration/posts.json
```

## Step 4: map to the import format

One JSON document: `format: "writavo-import"`, `version: 1`, authors, categories and tags declared once and referenced by articles. Unknown fields are refused. The full definition of every field and limit is the [import schema](/schemas/import-v1.json) (also `import_content` with no arguments, or the `writavo://import-format` resource). The core article fields:

| Field | Required | Note |
|---|---|---|
| `external_id` | always | Source id with a prefix (`wp:1042`). Never changed or reused: re-runs match on it. |
| `status` | always | `published` or `draft`. |
| `title`, `content` | published | Content in markdown, images as `![alt](https://...)`. |
| `slug` | published | The source slug, unchanged: lowercase letters, digits, single hyphens. One that breaks this needs the person's decision (it changes the URL). |
| `published_at` | published | ORIGINAL date with timezone (`2021-03-04T09:30:00Z`), in the past, not before 1990. |
| `content_updated_at` | no | Not before `published_at`. |
| `author`, `category`, `categories`, `tags` | no | References to the declared lists; `category` is the PRIMARY one. |
| `old_urls` | no | Up to 20 URLs the post had. Each 301s to the article once published. |

Also optional (see the schema): `excerpt`, SEO fields, `featured_image`, `faqs`, `howto_steps`, `comparison`, `format`, `source`, and `cost_history` (old production cost: never billed, never against a spend cap, never public). Authors match by exact name and terms by slug; missing ones are created, and existing terms only fill empty fields. `is_active: false` creates a term ARCHIVED.

##### Optional top-level sections

Sent after the articles. The dry run lists any permission the connection lacks.

- **`redirects`**: URLs that belong to no article (archives, pages, feeds).
- **`comments`**: needs Reader comments; re-runs never duplicate ([details](/docs/comments#import)).
- **`engagement`** (views, reactions, shares): needs `engagement:write`. Each row SETS its day, so re-runs never double-count.
- **`seo`** (keywords, competitors, positions): needs `seo:write`; free. New keywords past the plan's limit are skipped; positions from 2000-01-01 to yesterday.

writavo import format, version 1:

```json
{
  "format": "writavo-import",
  "version": 1,
  "source": {
    "name": "Old blog",
    "url": "https://example.com"
  },
  "authors": [
    {
      "ref": "jane",
      "name": "Jane Doe",
      "bio": "Writes about gardening.",
      "avatar_url": "https://example.com/jane.png",
      "is_ai_generated": false
    }
  ],
  "categories": [
    {
      "slug": "guides",
      "name": "Guides"
    }
  ],
  "tags": [
    {
      "slug": "soil",
      "name": "Soil"
    }
  ],
  "articles": [
    {
      "external_id": "wp:1042",
      "status": "published",
      "title": "How to test your soil",
      "slug": "how-to-test-your-soil",
      "content": "Good gardens start underground.\n\n![A soil test kit](https://example.com/images/kit.jpg)\n\n## What you need\n\nA jar, water and a day.",
      "excerpt": "A simple jar test tells you most of what a lab would.",
      "seo_title": "How to test your soil at home",
      "seo_description": "A jar test in three steps.",
      "featured_image": {
        "url": "https://example.com/images/soil.jpg",
        "alt": "A handful of dark soil"
      },
      "author": "jane",
      "category": "guides",
      "tags": [
        "soil"
      ],
      "published_at": "2021-03-04T09:30:00Z",
      "content_updated_at": "2022-01-10T12:00:00Z"
    },
    {
      "external_id": "wp:1107",
      "status": "draft",
      "title": "Composting in winter",
      "content": "Unfinished notes.",
      "author": "jane"
    }
  ]
}
```

> Never squeeze something into the wrong field or invent a value. List what does not fit, per article, and let the person decide.

Not carried: author email and social links, custom fields, view counts, revisions, membership rules. Only https images up to 10 MB are copied; others keep their URL and are reported. YouTube, Vimeo and X embeds become `::embed{url="https://..."}`; list other embeds.

## Step 5: dry run

Every call is a dry run unless `dry_run: false`: it writes nothing and reports every problem at once.

tool calls:

```
# Best, when the assistant can run shell commands: upload the file itself (up to 10 MB)
import_content { "upload": true }
# -> a one-time curl command: PUT the file, then
import_content { "import_id": "imp_..." }

# Or from an https URL the server can fetch (a signed storage URL)
import_content { "url": "https://..." }

# Or inline, for a small document only: parts of at most 50 articles and 512 KB
import_content { "data": { "format": "writavo-import", "version": 1, "authors": [...], "categories": [...], "tags": [...], "articles": [ ...up to 50... ] } }
import_content { "data": { "format": "writavo-import", "version": 1, "articles": [ ...the next 50... ] }, "import_id": "imp_..." }
```

Resend corrected entries under the same `import_id` until clean, then show the person the report.

## Step 6: import

> This publishes with the original dates, and changes to live articles apply at once. Pass `confirm: true` only after the person agrees; without it, nothing changes.

tool calls:

```
# Start it: it runs in the background on Writavo's server until every article is done
import_content { "import_id": "imp_...", "dry_run": false, "confirm": true }
# Check on it every minute or two; nothing needs to stay connected
import_content { "import_id": "imp_...", "status": true }

# The same from a script, the CLI or another CMS (REST, a secret key with articles:write)
POST /v1/imports                      body: the import document      -> 201 { import_id, dry_run }
POST /v1/imports/{import_id}/start    { "publish": false }            -> 202 { state: "running" }
GET  /v1/imports/{import_id}                                           -> { state, progress, last_report }
writavo create-import --body-file blog.json
```

`cancel: true` stops after the current batch; `retry_failed: true` retries failed items; `publish: false` imports all as drafts and leaves live articles alone. Schedule scheduled posts afterwards with `schedule_article`. Never delete or unpublish anything, on the source or on Writavo.

## Step 7: verify parity

Compare with the Step 1 inventory (`list_articles`, `GET /v1/comments` for comment counts) and report every difference: counts, slugs character for character, dates to the second, terms on every article, no images left on the old host (search for `wp-content/uploads`), drafts still drafts, the source unchanged.

## Re-running for changes

Export and import again as often as needed: re-runs update and add, never duplicate or delete. List posts deleted at the source for the person; deleting is their call.

## Step 8: cut-over

The person's decision, on the dashboard's Delivery page: a subdirectory, a subdomain, or the [headless API](/docs/headless) (their site also reads `GET /v1/redirects` for old URLs).

> Rankings depend on URLs: keep each article at its current URL where you can. Imported redirects answer other old URLs with a 301.

1. **Cover URLs Writavo never sees.** If the old blog was at the domain root and the new one is under `/blog`, export those redirects (Redirects page, or `GET /v1/redirects/export?only=unserved`) as an nginx, Apache, Cloudflare or Vercel file and install it first.
2. **Switch in one step.** So no article is live at two addresses.
3. **Check and resubmit.** Check canonical tags on a few pages; resubmit the sitemap in webmaster tools.
4. **Keep the old system read only** until the new blog is checked.

## Without the MCP server

Write your own importer with the API. For each article:

1. **Find it** `GET /articles?external_id=<id>`: `PATCH` if it exists, else `POST /articles` with an `Idempotency-Key` (always a draft).
2. **Move its images** Use the three step media flow and replace the old URLs.
3. **Publish with its original date** Public articles only. `published_at` applies on first publish only.

publish with the original date:

```bash
curl -X POST https://api.writavo.com/v1/articles/$ARTICLE_ID/publish \
  -H "Authorization: Bearer $WRITAVO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"published_at": "2024-03-18T09:00:00Z", "content_updated_at": "2025-01-07T14:30:00Z"}'
```

Comments: `POST /v1/comments/import`, up to 2,000 per call, a dry run unless `dry_run` is false. See [Import and export comments](/docs/comments#import).

## Limits and cost

- CMS usage, the same on every plan: 10,000 documents and 25 GB of media included, then pay-as-you-go.
- 120 writes and 60 media reservations a minute per key; honour `Retry-After` (the MCP tool paces itself).
- Request body 1 MiB; image 10 MB; a stored import document 10 MB, kept 7 days after last use.
- Webhooks fire for imported articles: register endpoints afterwards.
