Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Transfer plugin

Moving content between Ortha installs — routes, formats, policies and limits.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

Export and import for the Content Library. It owns no tables: it reads and writes existing content and adds two audit events.

  • Four formats: json, ndjson, zip, csv.
  • An export walks one hop: the selected records plus the records they point at.
  • An import matches records by a natural key, so the same file twice updates instead of duplicating.

Install

npm install @orthacms/transfer-server @orthacms/transfer-admin

Register

// apps/server/src/plugins.ts
import { TransferPlugin } from '@orthacms/transfer-server';

// after ContentPlugin and MediaServerPlugin — files travel with records
TransferPlugin(config.plugins.transfer);
// apps/admin/src/plugins.ts
import { transferAdminPlugin } from '@orthacms/transfer-admin';

transferAdminPlugin(); // after ContentPlugin(): it fills three of its slots

The server half needs the media plugin registered: it reads assets and the storage provider to pack and unpack files.

Older generated apps

create-orthacms-app registers both halves with an empty identity map. An older app may have the packages installed but not registered — add the two lines above and a transfer block to orthacms.config.ts.

Configuration

No environment variables. Everything lives in apps/server/config/transfer.ts:

// apps/server/config/transfer.ts
import type { TransferPluginConfig } from '@orthacms/transfer-server';

export function transferConfig(): TransferPluginConfig {
    return {
        // Which field(s) identify a record of each type
        identity: { product: ['sku'], author: ['email'] },
        // Overrides for DEFAULT_TRANSFER_LIMITS; {} keeps the defaults
        limits: { maxEntries: 10_000 }
    };
}
  • Set identity for any type where a wrong match is expensive. Without it the key is guessed: a field named like an identifier (slug, sku, email), then the first required text field. The guess is written to every export’s manifest.
  • A localized field can be a key: the locale is part of the key’s fingerprint, so en/hello and de/hello are different records.
  • A record is matched by its natural key first, then by its source $id if that id is a live row of the same type in this workspace.

Routes

All are admin routes: session cookie, X-Workspace-Id, and the workspace’s content grants. :typeName is the root type.

RoutePermissionBody
POST /api/content/:typeName/export/previewcontent:exportJSON
POST /api/content/:typeName/exportcontent:exportJSON → file download
POST /api/content/:typeName/import/previewcontent:importmultipart/form-data
POST /api/content/:typeName/importcontent:importmultipart/form-data
GET /api/content/:typeName/import/templatecontent:read— → CSV header row

Sign in first to get the cookie:

curl -c cookies.txt https://cms.example.com/api/auth/login \
  -H 'Content-Type: application/json' \
  -d '{"email":"admin@example.com","password":"…"}'

Export

Example language
curl -X POST https://cms.example.com/api/content/article/export \
-b cookies.txt \
-H 'X-Workspace-Id: 3f7a…' \
-H 'Content-Type: application/json' \
-d '{"ids":["9c4b…","1d77…"],"format":"zip",
     "depth":{"relations":true,"media":true,"locales":true,"relationLocales":false}}' \
-OJ   # saves as article-<timestamp>.zip
const response = await fetch('/api/content/article/export', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json', 'X-Workspace-Id': workspaceId },
  body: JSON.stringify({ ids, format: 'json' })
});
const count = Number(response.headers.get('X-Transfer-Records'));
const { manifest, records } = await response.json();
Body fieldDefaultNotes
idsrequired1–1000 entry ids of :typeName
formatrequiredjson, ndjson, zip, csv
depth.relationstruePull related records in as full records (one hop)
depth.mediatrueCarry file bytes — honoured by zip only
depth.localestrueEvery locale of the selected records
depth.relationLocalesfalseEvery locale of the related records too

The response is streamed with Content-Disposition: attachment and an X-Transfer-Records header. export/preview takes the same body and returns the counts without building the file:

{ "roots": 2, "related": 3, "assets": 9, "assetBytes": 48213377, "carriesFileBytes": true }

A json export looks like this (trimmed):

{
    "manifest": {
        "version": 1,
        "exportedAt": "2026-09-30T09:00:00.000Z",
        "sourceWorkspaceId": "3f7a…",
        "rootType": "article",
        "depth": { "relations": true, "media": true, "locales": true, "relationLocales": false },
        "identity": { "article": ["slug"], "author": ["email"] },
        "counts": { "roots": 2, "related": 1, "assets": 1, "assetBytes": 20481 }
    },
    "records": [
        {
            "$type": "article",
            "$id": "9c4b…",
            "$key": { "slug": "spring-launch" },
            "$depth": 0,
            "$locale": "en",
            "$status": "published",
            "values": { "title": "Spring launch", "slug": "spring-launch" },
            "relations": {
                "author": { "$type": "author", "$id": "77e2…", "$key": { "email": "ada@example.com" } }
            },
            "media": [
                { "field": "cover", "$id": "a1b2…", "url": "…", "name": "cover.jpg", "mimeType": "image/jpeg", "size": 20481 }
            ]
        }
    ]
}
FormatCarries filesLossless
jsonnoyes
ndjson — manifest on line 1, one record per linenoyes
zip — manifest, per-type record files, asset bytesyesyes
csvnono

A csv export that spans more than one type comes back as a ZIP with one CSV per type. Media cells in a CSV are not turned back into assets on import.

Import

Always preview first. The preview writes nothing and returns a verdict per record; the apply, with the same file and options, does what the preview said.

Example language
curl -X POST https://cms.example.com/api/content/article/import/preview \
-b cookies.txt \
-H 'X-Workspace-Id: 3f7a…' \
-F file=@article-export.zip \
-F policy=update \
-F relations=link
curl -X POST https://cms.example.com/api/content/article/import \
-b cookies.txt \
-H 'X-Workspace-Id: 3f7a…' \
-F file=@article-export.zip \
-F policy=update \
-F relations=link
const form = new FormData();
form.append('file', fileInput.files[0]);
form.append('policy', 'skip');
form.append('relations', 'link');

const preview = await fetch('/api/content/article/import/preview', {
  method: 'POST',
  credentials: 'include',
  headers: { 'X-Workspace-Id': workspaceId },
  body: form
}).then((r) => r.json());

if (preview.hasChanges) {
  await fetch('/api/content/article/import', {
      method: 'POST',
      credentials: 'include',
      headers: { 'X-Workspace-Id': workspaceId },
      body: form
  });
}
{
    "version": 1,
    "counts": { "create": 1, "update": 1, "skip": 0, "error": 1, "assetsNew": 1, "assetsReused": 0 },
    "hasChanges": true,
    "verdicts": [
        { "$type": "article", "$id": "9c4b…", "label": "Spring launch", "locale": "en",
          "action": "update", "reason": "matched", "targetId": "2f90…" },
        { "$type": "author", "$id": "77e2…", "label": "ada@example.com",
          "action": "create", "reason": "new" },
        { "$type": "article", "$id": "1d77…", "label": "Draft notes",
          "action": "error", "reason": "validation-failed", "issues": ["title is required"] }
    ]
}

The apply returns { counts, verdicts }. All records commit in one transaction; a failed import also deletes any files it already wrote to storage.

Form fieldValuesDefaultApplies to
filethe uploadrequired—
policyskip, update, duplicate, failskipSelected (depth-0) records that already exist
relationslink, update, recreatelinkRelated (depth-1) records

action is create, update, skip or error. reason explains it: new, matched, conflict-skipped, conflict-duplicated, no-identity, relation-linked, relation-recreated, unknown-type, unknown-field, validation-failed, unresolved-relations, dependency-failed, missing-asset, forbidden.

  • A record you may not create or update (no content:create / content:update) is error / forbidden.
  • Nothing in the file names the target workspace. The request’s X-Workspace-Id is stamped on every write; the manifest’s sourceWorkspaceId is ignored.
  • Every write goes through the same entry writer the editor uses: validation, grants, revisions, outbox, i18n. An import never publishes.

CSV template for a type:

curl https://cms.example.com/api/content/article/import/template \
  -b cookies.txt -H 'X-Workspace-Id: 3f7a…' -OJ   # article-template.csv

Limits

Override any of these in limits. The import-side archive limits are a safety boundary: an uploaded file is untrusted.

LimitDefault
maxEntries5,000
maxAssets2,000
maxBytes512 MB
maxImportRecords5,000
maxColumns200
maxUploadBytes256 MB
maxArchiveEntries10,000
maxArchiveEntryBytes128 MB
maxArchiveTotalBytes1 GB
maxCompressionRatio200

maxCompressionRatio catches a zip bomb before its bytes are inflated.

Permissions and audit

  • content:export and content:import — held by admin and contributor.
  • The CSV template needs only content:read.
  • An export writes transfer.content.exported; an applied import writes transfer.content.imported (with the counts).

Extension points

The admin half fills three content-admin slots: the entry’s ⋯ menu (export this record), the ⋯ menu of the records selection bar (export selection) and the collection’s ⋯ menu beside Add record (import). The server exports its building blocks — ExportEntriesUseCase, ImportEntriesUseCase, EntryGraphWalker, createZipStream, readZipDirectory — for tests or a job runner of your own.