Skip to content

Drafts and publishing

Every document carries a status: draft or published. It is not a field you declare — the storage layer owns it, and it appears on documents as _status.

There is one record per document. Publishing does not create a copy; it changes the status of the thing the editor has been working on. So an editor who edits a published document is editing what your site will render on its next build, whether or not they press Publish again.

A document created through the Studio or the API starts as a draft on the filesystem adapter — the adapter defaults it. The Postgres adapter applies no default: it stores whatever _status the request carried, and stores nothing if the request carried none.

That difference has a sharp edge. A document created on Postgres without an explicit status has no _status at all, which means it matches neither _status = 'published' nor _status = 'draft'. It is invisible to both filters and visible only to a query that does not filter on status. If you create documents programmatically against Postgres, set the status yourself:

Terminal window
trokky docs create post article.json --status published
const posts = await client
.from<PostDocument>('post')
.published()
.fetch()

.published() adds _status: 'published' to the filter; .draft() is its opposite. Both are equality filters, which is why they work when the comparison helpers do not — see Querying content.

Nothing applies this filter for you. A query without .published() returns drafts alongside published documents, mixed together and indistinguishable unless you look at _status. This is the single most common way an unfinished article reaches a live site.

There is no public read path to fall back on: every document endpoint requires a token, so a leak is always a missing filter rather than an open door. Media are the exception — image files are served without authentication, so a draft’s cover image is fetchable by anyone with the URL. That is covered in Images and media.

Singletons return null when they are not published

Section titled “Singletons return null when they are not published”

client.singleton('homepage').fetch() reads the collection and then checks the status itself. If the document has a status and it is not published, the method returns null — you get no document rather than a draft one.

That is usually what you want on a live site and confusing the first time you meet it. A singleton that Trokky auto-created is saved without a status, so on the filesystem adapter it lands as a draft and a freshly scaffolded homepage reads as null until somebody publishes it in the Studio. The document exists; the query is refusing it. On Postgres the same document has no status at all, and the check lets it through — the same project behaves differently on the two adapters.

There is no flag to turn that off. To read an unpublished singleton, query the collection directly:

const [homepage] = await client.from<HomepageDocument>('homepage').limit(1).fetch()

Singletons covers the rest of their behaviour.

Status travels in the document body, like any other field:

PUT /api/collections/post/post-8f3a1c
Authorization: Bearer <token>
Content-Type: application/json
{ "data": { "_status": "published" } }

Two rules govern that request.

An update omits what it does not send, so leaving _status out means “unchanged”, not “back to draft”. The server merges your body over the stored document and carries the existing status forward.

Publishing and unpublishing are a separate permission. Moving a document to published, or away from it, requires content:publish — or <collection>:publish, a matching wildcard, or the admin role. A token that can write documents but not publish them gets a 403 on that request and a 200 on the same request without the status change. That is deliberate: it lets you hand out a writing token that cannot put anything live. Users and authentication has the permission list.

Clients cannot forge the rest of the system fields. Any key beginning with _ is stripped from an incoming document except _status and _type, so _createdAt, _createdBy and _revision stay under the server’s control.

GET /api/stats/:collection

returns totalDocuments, publishedDocuments and draftDocuments for a collection. Draft is computed as total minus published, so anything with a missing or unrecognised status is counted as a draft here even though .draft() will not return it.

That includes archived. The Studio’s bulk status action offers archived as a third option, but the document type only knows draft and published, and the generated types only declare those two. An archived document is returned by an unfiltered query, excluded by both .published() and .draft(), and counted as a draft by /stats. Treat it as a way to hide something from editors, not as a state your frontend can reason about.

There is no preview server and no draft token. Build a preview the way you build the site, with the filter inverted:

const preview = process.env.TROKKY_PREVIEW === '1'
const query = client.from<PostDocument>('post')
if (!preview) query.published()
const posts = await query.fetch()

Run that build on a host nobody else can reach. The API token you use is the same one — it can already see drafts, because every token can.