Migration
How to move an existing site into Meridian without losing content, media or search rankings: the engine's three imports (media manifests, content files with the HTML path, redirect CSVs) and their formats, the meridian-migrate toolkit for WordPress and Sanity exports, crawling the old site and matching its addresses, and a rehearsed cutover runbook.
A migration moves a live site’s content, media and addresses into a Meridian install. The engine imports three kinds of file, each with a dry run, each safe to run again:
| File | Imported with | Holds |
|---|---|---|
A media manifest (meridian.media.v1) | engine-dmo media import | Files, with the ids they are to have, alt text, captions, framing and rights |
A content file (meridian.content.v1) | engine-dmo content import | Documents and their locales’ values; rich text as Portable Text or as HTML |
| A redirect CSV | engine-dmo redirects import | The old addresses and where each now goes |
You can write these files with any tool. For WordPress and Sanity, the migration toolkit (meridian-migrate) writes them from the platform’s own export through a mapping file, and maps the old site’s addresses to the new documents. Organisations, people and memberships (on engine-dmo) come in through engine-dmo crm import, described in CRM.
engine-cms behaves the same for content, media and redirects.
The order
- Configure the project’s types, routes and locales, and apply them:
engine-dmo schema apply. - Import the media, so content can refer to it by id.
- Import the content, in an order where a document comes after those it refers to; forward references in a second pass.
- Review the drafts, then publish.
- Import the redirects, once the documents they point at exist.
Every import writes through the same validation, permissions and audit as the admin, and nothing is published by an import.
Media manifests
{
"format": "meridian.media.v1",
"assets": [
{
"id": "6f1c2b9e-3d4a-5b6c-8d7e-9f0a1b2c3d4e",
"file": "files/6f1c2b9e-3d4a-5b6c-8d7e-9f0a1b2c3d4e.jpg",
"filename": "harbour-at-dawn.jpg",
"alt": { "en-GB": "Fishing boats in the harbour at dawn" },
"caption": { "en-GB": "The harbour in October" },
"focal": { "x": 0.5, "y": 0.4 },
"rights": { "holder": "Jo Smith", "licence": "CC-BY-4.0", "credit": "Photo: Jo Smith" },
"source": "https://old.example.org/wp-content/uploads/2024/10/harbour.jpg",
"tags": ["harbour"],
"source_id": "attachment:1042"
}
]
} | Key | Required | Meaning |
|---|---|---|
format | Yes | meridian.media.v1. |
assets[].id | Yes | The UUID the asset will have, chosen by you so content can name it first. Unique in the file. |
assets[].file | Yes | The original, relative to the manifest; it may not leave the manifest’s directory. |
filename | The name the library shows; the file’s own name when absent. | |
alt, caption | By locale. | |
decorative | An image with no alt text by intent. | |
focal | {x, y}, each 0 to 1. | |
hotspot, crop | {x, y, w, h} in fractions of the whole image from its top left; a rectangle outside the image is refused. | |
rights | holder, licence, uses, territory, from and until (YYYY-MM-DD), credit, source. | |
rights_review | Why the asset needs its rights established; counted in the report. | |
source | Where the original came from, recorded as the rights source when the record names none. | |
tags | Tag keys of the vocabulary; a key it does not hold is left out. | |
source_id | The asset’s id in the old system, for the report. |
A manifest holds at most 20,000 assets, and unknown keys are refused.
engine-dmo media import --manifest out/media/manifest.json --dry-run --project .
engine-dmo media import --manifest out/media/manifest.json --project . Each file goes through the media pipeline as an upload does: its type sniffed, its size checked, its metadata (camera location included) stripped. An id the library already holds is reported exists and left alone, so a rehearsal never undoes work done in the library since. The report counts the assets without a licence; the library’s No rights recorded filter is the queue to work through. See Media.
Content files
{
"format": "meridian.content.v1",
"site": "main",
"type": "article",
"documents": [
{
"id": "1b4e28ba-2fa1-51d2-883f-0016d3cca427",
"locales": {
"en-GB": {
"values": {
"title": "The harbour in winter",
"slug": "harbour-in-winter",
"summary": "What to see when the boats are in.",
"publishedAt": "2024-12-02T09:00:00Z",
"body": { "html": "<p>The boats come in at dusk.</p><img src=\"asset:6f1c2b9e-3d4a-5b6c-8d7e-9f0a1b2c3d4e\" alt=\"Boats at dusk\">" }
}
}
}
}
]
} | Key | Meaning |
|---|---|
format | meridian.content.v1. |
site | The site the file was written for. |
type | The type of every document that names none of its own. |
documents[].id | The document’s UUID. |
documents[].type | Its type, overriding the file’s. |
documents[].variant | Its entry variant, where the type declares variants. |
documents[].locales | By locale code: {values}, each value by field name in the shape the admin saves. status (written by an export) is ignored. |
At most 2,000 documents a file. engine-dmo content export writes the same shape, so an export is the best template for your own files.
engine-dmo content import --site main --file out/content/0001.json --dry-run --project .
engine-dmo content import --site main --file out/content/0001.json --project . | Flag | Meaning |
|---|---|
--file | The file; - reads standard input. |
--site | The site; the default site when absent. |
--match | id (the default): a document is matched by its id, which is then required, and created with that id when new. slug: matched by its slug, and created with a new id when none matches. |
--dry-run | Say what would happen to each locale and write nothing. |
Each locale of each document is create, update, unchanged or reject, with a reason for a rejection: an unknown type, a type not on the site, a locale the site does not enable, a missing or malformed id, an id that belongs to another type or site or is in the trash, field validation errors, or a permission the person running it lacks. Values are validated as a save in the admin is, and a slug another document already holds is a validation error. An import never publishes; a second run of the same file reports every locale unchanged.
Rich text as HTML
A portable_text field’s value may be {"html": "..."} instead of Portable Text blocks. The import converts it with the same converter the editor uses for pasted HTML, then normalises the blocks to what the field allows (its styles, lists and marks); every adjustment is a note on the row, so nothing changes silently.
- An
<img>whosesrcisasset:<uuid>becomes the field’s image block pointing at that library asset, with the HTML’s alt text. Import the media first and write the ids into the HTML. - Any other
<img>is left out and counted in a note. The import never fetches a file from an address in an import file. - Only top-level
portable_textfields convert; a rich text field inside ablocksorrepeaterfield must be given as Portable Text.
Publishing what you imported
engine-dmo content publish --ids-from out/ids.txt --all-locales --project . --ids-from reads one document id a line. --all-locales publishes every locale with a working revision, the source locale first, through the publish checks; a locale whose checks fail is skipped and named.
Redirect CSVs
source,target,status,locale,note
/2019/05/harbour-walk,document:1b4e28ba-2fa1-51d2-883f-0016d3cca427,301,,from the old blog
/visit-us,/visit,301,,
/old-shop,,410,,the shop closed | Column | Meaning |
|---|---|
source | The old path, without the site’s locale prefix. |
target | A path on the site, an absolute URL, document:<uuid> (the rule follows the document if its address changes), or empty with status 410. |
status | 301 (the default), 302, 307, 308 or 410. |
locale | The locale the rule applies to, or empty for every locale. |
note | Free text, kept with the rule. |
Other columns are ignored.
engine-dmo redirects import --site main --file redirects.csv --dry-run --project .
engine-dmo redirects import --site main --file redirects.csv --project .
engine-dmo redirects list --site main --origin imported --project .
engine-dmo redirects rollback --site main --batch <batch-id> --project . Every row is checked: the source and status, the locale, a duplicate in the file or in the site’s rules, that a target document exists, that a target path is one the site answers, and loops and chains through every rule the site has. Any error refuses the whole import unless --skip-invalid. A document target not yet published is a warning (the rule serves once it is), and a row whose document is already published at that address is unneeded. The import is one transaction with origin imported and a batch id, which rollback --batch disables. The import writes exact rules only; prefix and wildcard rules belong in routes.yaml. See Redirects and URLs.
The migration toolkit
A separate program
meridian-migrate is a command of its own, not part of the engine binary or the container images. It runs on a workstation beside the export, never connects to the engine’s database, and writes the files above. It imports nothing of the engine, so its version need not match the engine’s. A release does not publish a build of it: today it is built from the Meridian source repository, so ask Cherwell Studio for it. Without it, write the three import formats above with any tool; they are the engine’s contract and need only the binary.meridian-migrate run --mapping mapping.yaml --source <export> --out out [--dry-run] [--uploads dir]
meridian-migrate crawl --start https://old.example.org --out crawl.json [--delay 1s] [--max 10000]
meridian-migrate redirects --crawl crawl.json --urls out/urls.json --out redirects.csv --review redirects-review.csv Every id it writes is a UUID derived from the mapping’s namespace and the source’s own id, so a second run writes the same files, a second media import finds every asset present, and a second content import reports every locale unchanged. A rehearsal can run any number of times against the same staging database.
The mapping file
format: meridian.migrate.v1
source: wordpress # or sanity
namespace: example-org-2026 # seeds every id; keep it for every rehearsal and the cutover
site: main # the Meridian site the import files name
locales:
default: en-GB # a record with no locale, and the first locale a document is created in
map: { en: en-GB, fr: fr-FR } # source code to Meridian code; a code not named is rejected
media: # dotted paths into each asset's metadata
credit: creditLine
holder: source.name
licence: license
source_url: source.url
review_tag: rights-review # optional: a tag key for every asset without a licence
types:
- from: post # the source type: a WordPress post_type or taxonomy, a Sanity _type
to: article # the Meridian type
statuses: [publish] # the states imported; empty imports the published state only
dedupe: [slug] # slug, title or both: later duplicates are rejected
fields: # a field of the Meridian type: a source field (dotted path), or { from, as }
title: title
slug: slug
summary: excerpt
publishedAt: { from: date, as: value }
hero: { from: featured_image, as: asset }
topics: { from: categories, as: reference }
body: { from: content, as: html }
- from: category
to: topic
fields:
title: title
slug: slug The whole file is checked before anything runs, and every problem is listed together.
| Key | Meaning |
|---|---|
format | meridian.migrate.v1. |
source | wordpress or sanity. |
namespace | Required; seeds every derived id. Never change it between rehearsals. |
site | The site key the import files name. |
locales.default, locales.map | As above. With an empty map, a source code that is already a locale code is kept. |
i18n.document_field, i18n.metadata_type | Sanity’s document-level internationalisation: the field holding the language (language is the plugin’s default) and the type of the documents linking translations (translation.metadata). |
media.* | Dotted paths into each asset’s metadata for its rights record, and an optional review tag. |
types[].from, types[].to | The source type and the Meridian type. Each source type is mapped once. |
types[].url | The old address pattern with {slug} and {locale}, for a source that does not record addresses (Sanity). |
types[].statuses | The source states imported; others are rejected with the reason. |
types[].dedupe | slug, title or both. A later duplicate is rejected, and references to it follow the first. |
types[].fields | Meridian field handle to a source field. as is text (the default: a string, a slug’s current, a number), portable_text (Portable Text passed through and mapped, or HTML sent down the HTML path), html (always the HTML path), asset (an image object or a WordPress featured image, one or a list), reference (to the document the source id became) or value (any JSON, with references and assets inside it resolved). |
types[].styles, types[].decorators | Rename Portable Text block styles and decorators; a decorator mapped to "" is dropped. |
types[].annotations | Portable Text mark definitions by source _type: link or drop. A mark that holds a reference becomes a document link. |
types[].blocks | Portable Text object blocks by source _type: {to, asset_field, fields} (fields is Meridian field to source field), or drop. |
What a run writes
| File | What it is |
|---|---|
content/0001.json, 0002.json, … | Content files, at most 2,000 documents each, ordered so a document comes after those it refers to |
content/NNNN-references.json | References that would point forward (a cycle, or a document naming itself), set as updates once every document exists. Import it last. |
media/manifest.json, media/files/ | The media manifest and each original under its Meridian id |
urls.json | Every imported document’s old addresses per locale, with its id, title and slug: the input for address matching |
ids.txt | Every document id in import order, for content publish --ids-from |
rejects.csv | level, source_id, type, reason: each record or asset left out (reject) or kept with something lost (warning), rejects first |
summary.json | Counts: records, documents and locales by type, duplicates, assets, duplicate files, rights review, deferred references, addresses |
--dry-run writes only rejects.csv and summary.json and downloads nothing. Start every migration with it, and treat each reject as a decision: map the type, change a status, or accept the loss.
Records of one translation group become the locales of one document, the default locale first. Within a Meridian type, a document whose first locale has the same slug (or normalised title) as an earlier one is rejected as a duplicate. Media are deduplicated by content: two files with the same SHA-256 import once.
WordPress
source: wordpress reads a WXR export (Tools, Export, All content), versions 1.0 to 1.2.
- Posts, pages and custom post types are records of their
post_type, withtitle,slug,content(HTML),excerpt,author(display name, as text),dateandmodified(UTC),parent,featured_image,categories,tags, each custom taxonomy by its name, andmeta(post meta without a leading underscore). WordPress’s own types (menu items, revisions, templates) are skipped. - Categories, tags and terms are records of type
category,post_tagor the taxonomy, withtitle,slug,descriptionandparent. An unmapped type is rejected, which is a decision: map it, or leave it. - Attachments are assets, with alt text from
_wp_attachment_image_altand the caption from the excerpt. Originals are read from--uploads(a copy ofwp-content/uploads) when given, else downloaded politely (one request at a time,--delayapart, default1s, with--user-agent; add a contact address to it). - HTML is cleaned for the engine’s HTML path: classic editor paragraphs restored,
[caption]turned into the image and a paragraph,[embed]into a link, any other shortcode reported once per post and left as text. Each<img>of an imported attachment, including a resized copy (photo-300x200.jpgisphoto.jpg), is rewritten toasset:<id>; any other image is reported. - Multilingual plugins (WPML, Polylang) are not read: every record takes the default locale.
Sanity
source: sanity reads the output of sanity dataset export, the .tar.gz or its unpacked directory (data.ndjson, assets.json, images/, files/).
_idis the source id (adrafts.prefix removed) and_typethe source type; system documents are skipped. A draft of a published document is ignored in favour of the published version; a draft with no published version has statusdraft.slug.currentis read where a text or slug field names a slug object;{_ref}anywhere is a reference by source id.- Both asset forms are read, with
originalFilename,altText,description(ortitle) as the caption,creditLine,source {name, id, url}and any custom metadata the mapping’smediapaths name. The CDN address is recorded, never fetched. - Portable Text passes through with its keys, styles, lists and marks; what is Sanity’s is mapped by the mapping, and anything it does not name is left out with a warning.
- Document-level internationalisation (
@sanity/document-internationalization) and the two common field-level shapes (sanity-plugin-internationalized-array, and locale objects such aslocaleString) become locales.
Crawling the old site and matching addresses
meridian-migrate crawl --start https://old.example.org --out crawl.json --user-agent "Example migration; ops@example.org"
meridian-migrate redirects --crawl crawl.json --urls out/urls.json --out redirects.csv --review redirects-review.csv The crawl keeps to robots.txt (its Crawl-delay wins when longer than --delay), reads the sitemaps it names, /sitemap.xml and any --sitemap, and follows same-host links one request at a time: no nofollow links, no files, fragments and tracking parameters removed, query strings dropped unless --keep-query. It records each page’s status, title and canonical address, and each redirect with its target. --max (default 10,000) stops it and says so.
redirects maps every old address (the source’s own from urls.json, and each crawled page that answered 200) to a document:
- an address the source recorded for a document matches it outright, with confidence 1;
- any other is scored against every document of its locale by the similarity of its last path segment to the slug and of its page title to the title;
- two candidates within 0.05 of each other always go to review;
- the home page is left alone.
A match at or above --threshold (default 0.8) goes to redirects.csv with a document:<id> target and a confidence column; the rest go to the review file with the best candidate, the others and an empty decision column for a person to fill: redirect (copy the row into the redirects file), recreate, or 410. --locale-prefix /fr=fr-FR (repeatable) removes an old locale prefix from the source and sets the locale column.
The runbook
Do every step against staging first, and rehearse the whole cutover at least twice.
Export and assess. Take the export (sanity dataset export production export.tar.gz; or WordPress’s All content export with a copy of wp-content/uploads). Record the counts by type.
Write the mapping against the project’s schema, already applied. Choose the namespace once.
Dry run the toolkit and work through rejects.csv until it holds only what is meant to be left. Then run it for real.
Crawl the live site, record the ranking baseline of its top pages, run meridian-migrate redirects, and work through the review file.
Import into staging, dry runs first: the media manifest; each content file in order, the references file last; on engine-dmo, the CRM; review the drafts in the admin; content publish --ids-from out/ids.txt --all-locales; then the redirects until the dry run is clean.
Work the rights queue (the library’s No rights recorded filter) until every image in published content has a rights record.
Rehearse again from a fresh export and a reset staging database, timing each step. Because ids derive from source ids, every rehearsal and the cutover write the same ids.
Cut over. Freeze editing at the source, take the final export, run the toolkit and the imports against production, check a sample of old addresses answer 301 to the right page (engine-dmo redirects list --origin imported), switch DNS, then watch the 404 report daily for two weeks and compare rankings after 30 days.
Before DNS moves, a rollback is a database restore; a redirect batch alone is engine-dmo redirects rollback --batch <id>.
Not handled
- Links inside migrated rich text that point at the old site stay as addresses; the redirect set serves them.
- Old media addresses (
/wp-content/uploads/..., the Sanity CDN) get no redirects to the imported assets. - Multilingual WordPress (WPML, Polylang).
- Sources other than WordPress and Sanity need their files written by your own tool, in the formats above.
Member API
How to build your own member portal (a single-page app or a headless site's account area) on the member API at /api/member/v1: what it requires, signing members in through the engine's sign-in screens, the same-origin session cookie and CSRF token, a worked request sequence, the form description, errors, rate limits and the OpenAPI contract.
Deploying
How a Meridian site runs in production, the parts of a deployment (engine, PostgreSQL, storage, optional search and mail), the deploy sequence, and which page covers each task.