Content quickstart
One content type from declaration to delivery, with the same content read back through both APIs.
Every request below carries a bearer token and the four tenancy headers. Set them once:
TOK=<your bearer token>H=(-H "Authorization: Bearer $TOK" \ -H 'X-Customer: acme' -H 'X-Product: site' -H 'X-Env: prod' -H 'X-Tenant: acme')BASE=https://content.example.com1. Declare the content type
Section titled “1. Declare the content type”A content type is an entity with a cms: block, in a file of its own in the product’s content/
folder. This walkthrough uses three files:
content/ types/blog.post.yaml # the content type apps/blog/app.yaml # the app that signs the preview token in step 9 stores/stores.yaml # the store the image in step 8 is uploaded tocontent/types/blog.post.yaml holds the entity and nothing around it:
entities: - name: blog.post status: active default-order: documentid cms: kind: entry # entry | singleton space: blog access: actions: read: { language: expr, expression: 'true' } create: { language: expr, expression: '"editor" in user.roles' } update: { language: expr, expression: '"editor" in user.roles' } delete: { language: expr, expression: '"admin" in user.roles' } fields: - name: id type: ulid defaultvalue: ulid() validations: - type: required - type: final - name: title type: string required: true cms: { localized: true } - name: slug type: string required: true cms: { localized: true } - name: body type: text nullable: true - name: hero type: file # a reference into the asset plane nullable: true - name: price type: int nullable: true cms: { localized: false } - name: authorid type: ulid nullable: truecontent/apps/blog/app.yaml names the type as one of the app’s own. Naming it declares nothing;
the file above does that:
name: blogactive: truecontentstore: defaultcontenttypes: - blog.postpreview: secret: ${CONTENT_PREVIEW_SECRET}content/stores/stores.yaml declares the store. Where its bytes live is the tenant’s configuration,
see Operations:
contentstores: - name: default type: native active: true access: createasset: { language: expr, rule: 'true' } getasset: { language: expr, rule: 'true' } downloadasset: { language: expr, rule: 'true' }No file names a datastore. The Data block declares datastores once, and the type lands in the one declared for the Content block.
Three things happen on the next engine build:
documentid,locale,statusandpublishedonare added to the entity, along with the identity index over(documentid, locale).- The table is created, or the additive part of the difference is applied to the existing one.
- The type becomes addressable as
/api/postsand as/spaces/blog/environments/master/entries?content_type=post.
2. Confirm the type is live
Section titled “2. Confirm the type is live”curl -s "${H[@]}" "$BASE/spaces/blog/environments/master/content_types" | jq '.items[].sys.id'["post"]3. Create an entry
Section titled “3. Create an entry”The write surface is Strapi-shaped. The body is {"data": {...}}, and the reply is
{"data": {...}, "meta": {}}, so a client’s own serialisation round-trips.
curl -s "${H[@]}" -X POST "$BASE/api/posts?locale=en" \ -H 'Content-Type: application/json' \ -d '{"data": {"title": "Hello world", "slug": "hello", "body": "First post.", "price": 100}}'{ "data": { "id": "01J9Z…", "documentId": "01J9Y…", "locale": "en", "title": "Hello world", "slug": "hello", "body": "First post.", "price": 100, "createdAt": "2026-09-15T09:12:44Z", "updatedAt": "2026-09-15T09:12:44Z" }, "meta": {}}A new entry is a draft. There is no publishedAt, and it does not appear on the delivery path
until you publish it. Keep the documentId: it is the cross-locale identity, and it is what both
APIs address the document by.
4. Publish it
Section titled “4. Publish it”DOC=01J9Y…curl -s "${H[@]}" -X PUT "$BASE/api/posts/$DOC?locale=en" \ -H 'Content-Type: application/json' \ -d '{"data": {}, "publish": true}'publish is a state move, not a field. Omit it and an edit leaves the publish state alone, which
is what you want when correcting a typo on a live page.
5. Read it through the Strapi API
Section titled “5. Read it through the Strapi API”curl -s "${H[@]}" "$BASE/api/posts?locale=en"{ "data": [ { "id": "01J9Z…", "documentId": "01J9Y…", "locale": "en", "title": "Hello world", "slug": "hello", "body": "First post.", "price": 100, "createdAt": "2026-09-15T09:12:44Z", "updatedAt": "2026-09-15T09:14:02Z", "publishedAt": "2026-09-15T09:14:02Z" } ], "meta": { "pagination": { "page": 1, "pageSize": 25, "pageCount": 1, "total": 1 } }}Filters, sorting and pagination are Strapi’s own:
curl -s "${H[@]}" "$BASE/api/posts?locale=en&filters[price][\$gt]=50&sort=title:asc&pagination[pageSize]=10"6. Read the same entry through the Contentful API
Section titled “6. Read the same entry through the Contentful API”curl -s "${H[@]}" "$BASE/spaces/blog/environments/master/entries?content_type=post&locale=en"{ "sys": { "type": "Array" }, "total": 1, "skip": 0, "limit": 100, "items": [ { "sys": { "type": "Entry", "id": "01J9Y…", "space": { "sys": { "type": "Link", "linkType": "Space", "id": "blog" } }, "contentType": { "sys": { "type": "Link", "linkType": "ContentType", "id": "post" } }, "locale": "en", "createdAt": "2026-09-15T09:12:44Z", "updatedAt": "2026-09-15T09:14:02Z", "firstPublishedAt": "2026-09-15T09:14:02Z", "publishedAt": "2026-09-15T09:14:02Z", "revision": 1 }, "fields": { "title": "Hello world", "slug": "hello", "body": "First post.", "price": 100 } } ]}sys.id is the same value as documentId. One identity, two envelopes.
7. Add a translation
Section titled “7. Add a translation”A document is one row per locale. Write the German row against the same documentId:
curl -s "${H[@]}" -X PUT "$BASE/api/posts/$DOC?locale=de" \ -H 'Content-Type: application/json' \ -d '{"data": {"title": "Hallo Welt", "slug": "hallo"}, "publish": true}'price carries cms: { localized: false }, so its value is propagated across the document’s
locale rows on write. A shared value cannot drift between translations.
Publish state is per locale row, so German can sit in draft while English is live.
8. Upload an image and attach it
Section titled “8. Upload an image and attach it”Assets live in a content store. Upload first:
curl -s "${H[@]}" -X POST "$BASE/store/default/asset?path=/images&createfolders=true" \ -F "file=@./hero.jpg"{ "id": "01J9X…", "name": "hero.jpg", "contenttype": "image/jpeg", "filesize": 184320 }Then set the hero field to a reference to it:
curl -s "${H[@]}" -X PUT "$BASE/api/posts/$DOC?locale=en" \ -H 'Content-Type: application/json' \ -d '{"data": {"hero": "kisai-asset:default/01J9X…"}}'On delivery, a file field comes back as both the reference and a URL, so a client can display it
and re-reference it without a second lookup:
"hero": { "ref": "kisai-asset:default/01J9X…", "url": "/store/default/asset/id/01J9X…"}The URL is relative, because the service sits behind a gateway that may rewrite paths.
Append image parameters to it to get a derivative:
/store/default/asset/id/01J9X…?w=800&fm=webpSee Image transforms.
9. Render a draft from a front end
Section titled “9. Render a draft from a front end”Your site holds a delivery credential for published content and no editorial rights. Someone who can edit the type mints a scoped grant and puts it in the link they share:
curl -s "${H[@]}" -X POST "$BASE/app/blog/preview-token" \ -H 'Content-Type: application/json' \ -d "{\"type\": \"posts\", \"documentId\": \"$DOC\", \"locale\": \"en\", \"ttlSeconds\": 900}"{ "token": "kp1.eyJzcCI6…", "expiresAt": "2026-09-15T09:30:00Z", "type": "blog.post", "space": "blog", "documentId": "01J9Y…", "locale": "en"}The site presents it alongside its ordinary credential:
curl -s "${H[@]}" "$BASE/api/posts/$DOC?locale=en&preview=kp1.eyJzcCI6…"Presenting a token is itself the request for the draft view, so a pasted link needs nothing else.
An explicit status=published still wins, which is how the same link checks the live version.
Server-rendered pages should use the X-Preview-Token header instead, which keeps the grant out
of referrers and browser history.
10. Delete
Section titled “10. Delete”curl -s "${H[@]}" -X DELETE "$BASE/api/posts/$DOC?locale=de"204 No Content. The locale is part of the address, so this removes the German row and leaves
English alone.
- The content model: relations, singletons, dynamic types
- Strapi delivery: the full filter and populate dialect
- Contentful delivery:
includes, the locale pivot - Assets: stores, ranged reads, presigned redirects