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-adminRegister
// 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 slotsThe 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
identityfor 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/helloandde/helloare different records. - A record is matched by its natural key first, then by its source
$idif 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.
| Route | Permission | Body |
|---|---|---|
POST /api/content/:typeName/export/preview | content:export | JSON |
POST /api/content/:typeName/export | content:export | JSON → file download |
POST /api/content/:typeName/import/preview | content:import | multipart/form-data |
POST /api/content/:typeName/import | content:import | multipart/form-data |
GET /api/content/:typeName/import/template | content: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
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 field | Default | Notes |
|---|---|---|
ids | required | 1–1000 entry ids of :typeName |
format | required | json, ndjson, zip, csv |
depth.relations | true | Pull related records in as full records (one hop) |
depth.media | true | Carry file bytes — honoured by zip only |
depth.locales | true | Every locale of the selected records |
depth.relationLocales | false | Every 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 }
]
}
]
}| Format | Carries files | Lossless |
|---|---|---|
json | no | yes |
ndjson — manifest on line 1, one record per line | no | yes |
zip — manifest, per-type record files, asset bytes | yes | yes |
csv | no | no |
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.
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 field | Values | Default | Applies to |
|---|---|---|---|
file | the upload | required | — |
policy | skip, update, duplicate, fail | skip | Selected (depth-0) records that already exist |
relations | link, update, recreate | link | Related (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) iserror/forbidden. - Nothing in the file names the target workspace. The request’s
X-Workspace-Idis stamped on every write; the manifest’ssourceWorkspaceIdis 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.csvLimits
Override any of these in limits. The import-side archive limits are a safety
boundary: an uploaded file is untrusted.
| Limit | Default |
|---|---|
maxEntries | 5,000 |
maxAssets | 2,000 |
maxBytes | 512 MB |
maxImportRecords | 5,000 |
maxColumns | 200 |
maxUploadBytes | 256 MB |
maxArchiveEntries | 10,000 |
maxArchiveEntryBytes | 128 MB |
maxArchiveTotalBytes | 1 GB |
maxCompressionRatio | 200 |
maxCompressionRatio catches a zip bomb before its bytes are inflated.
Permissions and audit
content:exportandcontent:import— held by admin and contributor.- The CSV template needs only
content:read. - An export writes
transfer.content.exported; an applied import writestransfer.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.