Meridian handbook
Content modelling

Seeding and importing

Getting content into a project: seed sets (seed.yaml, documents per type and locale, assets, history and publishing states, references by seed key, verify, apply and reset), generated content, content create and update, and JSON export and import between environments.

There are four ways to put content into a project without typing it into the admin:

WayUse it forGoes through
A seed set (seed/)Development and demonstration content kept in the repository, applied to an empty databaseThe same validation, revisions and publish checks as an editor
engine-dmo seed generateMany plausible documents of a type, for testing lists, search and performanceEach field’s validation
engine-dmo content create and content updateA document or a change from a scriptThe admin’s save checks, and the publish checks with --publish
engine-dmo content export and content importMoving drafts between environments or through a spreadsheet-like round tripThe content store, as drafts, never published

All of them write values in the stored shapes each field type’s page describes. For moving a whole existing site from another CMS, see Migration.

Seed sets

A seed set is a directory, seed/ by default, that describes content by key:

seed.yaml

A project may hold several sets, as seed/<name>/, chosen with --set <name>.

seed.yaml

name: starter
description: The home page and three walks.
locales: [en-GB, cy-GB]
analytics:
  months: 13
  daily_views: 6
  seed: 42
profiles:
  core: [page/*, walk/cherwell-valley]
KeyWhat it does
name, descriptionShown by seed list.
localesThe locales the generated analytics cover. It does not limit the documents: a document may hold any locale the project configures. Add a new locale here when you add it to the project, so its pages get analytics too.
schema_digestThe schema digest the set was written against. When it differs from the current schema, apply warns and reconciles values as a restore would. Leave it out to accept any.
analyticsmonths of generated page-view history, daily_views per document at weight 1, a seed for the generator, searches (visitor queries, each with site, locale, query, weight, results) and search_days (default 120). Leave it out for none.
profilesNamed subsets: each a list of records as kind/key, with * patterns (walk/*, organisation/harbour-*). Applying a profile applies those records and everything they name. profiles/*.yaml add to the list.
libraryA shared media library to import photographs from by name: dir (relative to the set), and photos, categories and videos to import.

A document

documents/<type>/<key>.yaml. The folder is the type and the file name the key, unless the file says type: or key:.

# seed/documents/walk/cherwell-valley.yaml
site: main
locales:
  en-GB:
    title: The Cherwell valley walk
    slug: cherwell-valley-walk
    summary: Eight miles along the river and the canal, with a pub at the halfway point.
    difficulty: moderate
    distanceKm: 12.8
    startPoint: { lat: 52.0629, lon: -1.3398, precision: street }
    heroImage: { asset: valley-view }
    nearbyListings:
      - { id: hook-norton-brewery-tours, type: listing }
    stages:
      - _key: s1
        title: Banbury to Kings Sutton
        directions:
          - _type: block
            _key: s1p1
            style: normal
            markDefs: []
            children:
              - { _type: span, _key: s1p1s0, text: "Leave the town by the towpath.", marks: [] }
  cy-GB:
    title: Taith dyffryn Cherwell
    slug: taith-dyffryn-cherwell
    summary: Wyth milltir ar hyd yr afon a'r gamlas.
    stages:
      - _key: s1
        title: Banbury i Kings Sutton
publish: [en-GB, cy-GB]
tags: [walks.riverside]
history:
  - locale: en-GB
    note: First draft
    fields:
      title: Cherwell walk
      summary: A riverside walk.
KeyWhat it does
type, keyThe type and the seed key; default the folder and file name.
siteThe owner site; the default site when left out.
sitesOther sites the document is shared to.
variantThe variant the document is created in.
localesThe field values per locale code, by field name, in each field’s stored shape. The source locale is required (no en-GB content (the source locale) otherwise) and is written first. Every other locale starts from the source locale’s values and replaces the ones it gives, so shared values (translate: none, a container’s items) need only be written once; for a repeater or blocks field, a locale repeats the items with the same _keys and its own text. An asset, link or video field written in another locale replaces the shared choice for every locale, so repeat the same asset or leave the field out (Links, assets and videos).
publishThe locales to publish, through every publish check. Left out, the document stays a draft.
historyEarlier states, applied before locales as revisions with a note, so the document has a history to compare and restore.
pendingA later working revision per locale, saved after publishing and not published: changes waiting.
reviewLocales whose working revision is sent to the review queue.
schedulePer locale, a time to publish at (the document waits until the scheduler runs).
unpublish_atPer locale, a time to take it down.
trashPuts the document in the trash once everything else is done.
tagsVocabulary tag keys applied to the document.
weightPopularity for the generated analytics, default 1.
overridesPer-site values of site_overridable fields: site, then field, then a locale map for a translated field or the value itself.
organisation, landing, partner, partner_editsengine-dmo: the organisation a listing belongs to, a landing page it is the content of, and partner-made changes (Partners and the portal).

Dates and times in schedule, unpublish_at and date-valued fields may be absolute (2026-03-01, 2026-03-01T09:30) or relative to the day of applying (+21d, -6m, +3d 09:00), so a demonstration stays current.

A document of a singleton type fills in its site’s singleton rather than creating a second one.

The home page named by home: { type: page, slug: home } keeps the slug home in every locale: give the Welsh block slug: home, not a translated slug, or /cy/ answers 404 (Slugs and the home page).

References by key

A seed never contains a database id (the UUID a record receives when it is created). Records name each other by seed key, in the shapes the fields store. Configuration ids are not database ids and are allowed: a form field is seeded with its endpoint’s id from endpoints.yaml, contactForm: end_srhmvkf6j3.

FieldIn a seed
relation to a document{ id: <seed key>, type: <type> }
relation to a tag or smart list{ id: <tag or list key>, type: tag } (a vocabulary key, not a seed record)
asset, a video’s poster{ asset: <seed key> }, or just <seed key>
link to a document{ of: document, document: <type>/<seed key> }
link to an asset{ of: asset, asset: <seed key> }
a rich text linkthe same, as the link mark’s definition

Apply creates records in dependency order and replaces each key with the id the record received. Two documents that name each other are both created, then completed.

Assets

Each asset is a file and a YAML file of the same name beside it under assets/, at any depth. Files may be .jpg, .jpeg, .png, .webp, .gif, .pdf, .svg or .gpx. They go through the same upload pipeline as an editor’s upload.

# seed/assets/valley-view.yaml
title: The Cherwell valley from Crouch Hill
alt: { en-GB: Fields and hedgerows falling to the river under a wide sky, cy-GB: Caeau a gwrychoedd yn disgyn i'r afon }
caption: { en-GB: Looking south from Crouch Hill }
credit: Photograph by A. Walker
focal: { x: 0.5, y: 0.4 }
tags: [walks.riverside]
rights:
  holder: A. Walker
  licence: CC BY 4.0
  until: +2y
KeyWhat it does
keyThe seed key; default the file name.
fileThe file, relative to the YAML’s folder; default the file of the same name.
title, alt, caption, decorativeThe library’s title, alt text and caption per locale, and whether the image is decorative.
credit, licence, licence_url, author, source, rightsThe credit line and rights record (rights takes holder, licence, credit, source, licence_url, from, until, uses, territory).
focal, hotspot, cropThe asset’s own framing, as fractions.
sitesPer-site alt text and captions: site, then alt and caption maps.
tagsVocabulary tag keys.

Other records

DirectoryHolds
users/Development staff users: email, name, groups, password, totp_secret. Never created in production.
navigation/Navigation sets: key, site, slot, name, assigned, and items, each with a label map and one of document (<type>/<key>), asset, url or index, an optional suffix, and children.
organisations/, people/engine-dmo: CRM records with contacts, memberships and invitations (below).

Modules add their own directories in engine-dmo (claims, promotions, affiliates and others); see each module’s page under DMO.

Organisations and people

engine-dmo

These records are part of the DMO bundle (engine-dmo).

organisations/<key>.yaml is a business, and people/<key>.yaml someone in the CRM in their own right. The file name is the key a document names with its top-level organisation: <key>, unless the file says key:. Both go through the CRM store, so a seeded record is validated as a saved one is. An unknown key in these files is ignored, not refused, so check the result with seed apply’s summary (1 organisations, 2 people, 1 memberships).

# seed/organisations/old-bridge-inn.yaml
name: The Old Bridge Inn
legal_name: Old Bridge Inn Ltd
status: active
email: stay@oldbridgeinn.example
website: https://oldbridgeinn.example
phone: { raw: "01600 712345", region: GB }
address:
  country: GB
  line1: 4 Wye Bridge Street
  locality: Monmouth
  postal_code: NP25 3DY
tags: [stay.hotel]
contacts:
  - name: Sam Owner
    email: sam@oldbridgeinn.example
    role: General manager
    partner_role: owner
    is_primary: true
    started_on: -2y
  - person: pat-morgan
    role: Bookkeeper
memberships:
  - site: main
    tier: standard
    joined_on: -2y
    renews_on: +30d
    amount: "120.00"
    currency: GBP
    history:
      - { tier: gold, amount: "240.00", reason: Upgraded at the spring trade fair }
invitations:
  - email: alex@oldbridgeinn.example
    name: Alex Colleague
    role: Duty manager
    partner_role: contact
    site: main
    by: sam@oldbridgeinn.example
# seed/people/pat-morgan.yaml
name: Pat Morgan
email: pat@example.org
marketing_consent: -1y
tags: [press]

An organisation:

KeyWhat it does
keyThe seed key; default the file name.
nameRequired.
legal_name, email, websiteAs on the organisation’s page.
statusprospect, active (the default), lapsed or archived.
phone, addressMaps, stored as given.
customThe fields of config/crm/organisation.yaml, by handle.
tagsVocabulary tag keys.
networkThe network’s key. Default: the network of the first membership’s site, else the default site’s.
contacts, memberships, invitationsThe lists below.
trashPuts the record in the trash once everything else is done.

A contact is a person linked to the organisation. Each needs person or name.

KeyWhat it does
personA people/ key or the email of a contact seeded earlier, for someone linked to more than one organisation. Without it the person is created from name, email, phone and custom.
name, email, phone, customThe new person’s details.
roleThe job title, free text.
partner_roleThe partner role by key, such as owner or contact (the default). See partner roles.
is_primaryThe organisation’s primary contact.
started_on, ended_onDates. ended_on makes a former contact.

A membership is one organisation’s tier on one site. It needs the memberships module and tiers in config/memberships.yaml.

KeyWhat it does
site, tierThe site’s key (default the default site) and the tier’s key.
statuspending, active (the default), lapsed or cancelled.
joined_on, renews_on, ended_onDates.
amount, currency, external_ref, notesThe fee, as a quoted string, its currency, your own reference and staff notes.
historyLater changes, applied in order after the membership starts, which is what the churn and growth figures read. Each may set tier, status, renews_on, ended_on, amount and reason. So the membership’s own keys are its first state.

An invitation is someone asked to join the portal, sent through the portal itself, after the documents.

KeyWhat it does
email, name, role, partner_roleWho is invited, their job title and their role.
siteThe site. Default the default site.
byRequired: who invites them, by people/ key or a contact’s email. They must hold people.invite, as an owner does, or the apply stops.
acceptedThe invitation is accepted at once, which makes the person a contact too.

A person:

KeyWhat it does
keyThe seed key; default the file name.
name, or given_name and family_nameThe display name, or the two parts it is made from.
email, phone, custom, tagsTheir details, the fields of config/crm/person.yaml by handle, and vocabulary tag keys.
marketing_consentWhen they consented to marketing. Left out, they have not.
networkThe network’s key. Default the default site’s.
trashPuts the record in the trash once everything else is done.

Dates may be absolute or relative to the day of applying, as for documents. seed verify checks that a person and a document’s organisation name a record in the set, that each date reads and that each site exists; it does not check tier or partner_role, which only seed apply does.

Commands

CommandWhat it doesNeeds a database
engine-dmo seed listThe sets and what each holds.no
engine-dmo seed verify [--set] [--profile]Checks the set loads and matches the current schema: every type, field, locale and site exists, every record a record names is in the set (navigation items included), every navigation slot is declared, every date reads. It does not check field values (below). The check to run in CI after a schema change.no
engine-dmo seed apply [--set] [--profile] [--scale small|medium|large] [--timeout 30m]Applies the set through the same validation and revision machinery as an editor: vocabulary, assets, users, people, organisations, documents, navigation, then generated analytics.yes
engine-dmo seed reset --confirmRemoves all content, media and analytics, keeping users and configuration. Never runs in production.yes
engine-dmo seed write [--check]Writes generated seed files from seed/generate.yaml; --check exits with code 3 when the files differ.no

seed verify and seed write never connect, but they load the configuration first, and every ${VAR} in engine.yaml without a default must then be set. With no database, set DATABASE_URL to any value. Once engine.yaml reads signing_key: ${SIGNING_KEY}, set SIGNING_KEY too, to a development value of at least 32 characters (server.security.signing_key must be at least 32 characters otherwise), and likewise any other secret such as an SMTP password. The engine does not read a .env file; export the variables in the shell or the CI job:

export DATABASE_URL=postgres://unused SIGNING_KEY=$(openssl rand -hex 32)
engine-dmo seed verify

What seed verify does not check

seed verify checks names, not values. A select value that is not an option (difficulty: trivial) or a number below the field’s min passes it with Matches the schema. Values are checked when seed apply saves and publishes the document, through the field’s validation:

  • A document with publish: stops the apply at the first value that fails, naming the field: publish en refused: difficulty: "trivial" is not one of the options.
  • A document without publish: is stored as a draft, and a value that would fail publishing is kept without an error. It shows up only when an editor or content update --publish publishes it.

seed verify reports a problem by file, with no line or column, and reports an unknown field once for each locale block it appears in. Its summary counts documents, assets and users, not navigation sets, though it checks them; seed apply reports the navigation it wrote (navigation: 1 sets, 3 items).

seed apply is not an upsert. It refuses when the database already holds documents or assets (the database already holds N documents and M assets; run engine-dmo seed reset first), and in production it refuses unless the database is empty and --i-know-what-i-am-doing is given.

A value that fails validation stops the apply with the field named, but what was written before it stays: the records applied earlier, and the failing document itself as a draft. The next seed apply then refuses because the database is not empty. Fix the file, then run engine-dmo seed reset --confirm and engine-dmo seed apply again.

Apply works in this order, each step naming the records of the steps before it by the ids they received. In engine-dmo the modules’ own directories (claims, promotions and the rest) are applied after the step they depend on.

flowchart LR
  v["vocabulary"] --> a["assets"] --> u["users"] --> p["people and<br/>organisations<br/>(engine-dmo)"] --> d["documents<br/>history, publish,<br/>pending, schedule"] --> n["navigation"] --> t["invitations,<br/>then trash"] --> g["generated<br/>analytics"]

Example output:

seeded starter: 4 documents, 6 revisions, 4 published, 1 assets, 1 users, 1840 analytics events

Generated documents

engine-dmo seed generate --type walk --count 200 --near 52.06,-1.34 --publish

Writes documents of a type whose values pass each field’s validation, from built-in word lists and placeholder images. --seed makes the run repeatable over the same database; --locale and --site choose where; --near places generated points around a latitude and longitude where a field has no bounds. A relation field needs documents or tags to point at, so generate the targets first.

seed/generate.yaml describes generated seed files as recipes with placeholders ({pick LIST}, {int A B}, {point PLACE}, {relation RECIPE}, {pt NAME N} and more), written into ordinary seed files by seed write. engine-dmo seed write --help lists every placeholder.

Creating and updating from the command line

engine-dmo content create --type walk --locale en-GB --data '{
  "title": "Bloxham loop",
  "slug": "bloxham-loop",
  "summary": "A short circuit from the village green.",
  "difficulty": "easy",
  "startPoint": { "lat": 52.02, "lon": -1.37 }
}'

engine-dmo content update <document id> --locale cy-GB --data '{ "title": "Cylch Bloxham" }' --publish
CommandFlags
content create--type (required), --data (a JSON object of values by field name, or - for standard input; the values may also be the first argument), --locale (default the source locale), --site (owner site, default the default site), --publish
content update <id>--data, --locale (default the source locale), --publish. Only the fields given change; null clears one.

Both validate as the admin’s save does and refuse on an error. --publish then runs the publish checks for that locale. Relations and links name documents and assets by their database ids here. engine-dmo content get <id> shows a document’s working and published values by field name, and content list finds ids. All need a database.

Export and import between environments

engine-dmo content export --type walk --site main --out walks.json
engine-dmo content import --file walks.json --site main --dry-run
engine-dmo content import --file walks.json --site main --match slug

An export writes a type’s documents on a site (or a selection with --id or --ids-from), with every locale’s working values (or those named with --locale), as JSON or, with --format csv, a CSV with one row per document and locale for reading in a spreadsheet. Only JSON can be imported. Exports are audited.

The JSON file:

{
  "format": "meridian.content.v1",
  "site": "main",
  "type": "walk",
  "exported_at": "2026-11-02T09:00:00Z",
  "documents": [
    {
      "id": "1b9f...",
      "type": "walk",
      "variant": "",
      "locales": {
        "en-GB": { "status": "published", "values": { "title": "Bloxham loop", "slug": "bloxham-loop" } }
      }
    }
  ]
}
Rule
Formatformat must be meridian.content.v1; anything else is refused rather than half read.
SizeAt most 2000 documents per file.
Matching--match id (the default) updates the document with that id, or creates one under it, so a file moves between environments with its ids. --match slug matches the first locale’s slug among the type’s documents on the site, and creates a document under a new id when none matches.
PublishingNever. Everything is imported as drafts; status in the file is information only.
Rich textA portable_text value may be { "html": "<p>...</p>" }, converted by the same converter as the editor’s paste and normalised to the field; each adjustment is reported. An <img src="asset:<id>"> becomes the field’s image block pointing at that library asset; any other image is left out and counted, since an import never fetches a file.
PermissionsEvery write goes through the content store with your grants; a locale you may not create or edit is rejected with the reason.
Dry run--dry-run says what would happen to each locale and writes nothing.