Draft / Published Status
Enable a Draft / Published lifecycle on collections and singles, with side-by-side examples for code-first config and the Visual Schema Builder.
Nextly supports an opt-in Draft / Published lifecycle for collections and singles. When enabled, every record carries a status system column ('draft' or 'published'); the admin entry create/edit page splits its primary action into Save Draft + Publish (or Update), and public-facing API queries can filter unpublished records out by default.
This is a meta-level toggle on the collection or single -- you do not declare a status field yourself. Nextly injects the column at schema-generation time when you opt in.
Enabling on a collection
Code-first
Set status: true at the top level of defineCollection:
import { defineCollection, text, richText } from "nextly/config";
export const Posts = defineCollection({
slug: "posts",
status: true, // enable Draft / Published lifecycle
labels: { singular: "Post", plural: "Posts" },
fields: [
text({ name: "title", required: true }),
text({ name: "slug", required: true, unique: true }),
richText({ name: "content" }),
],
});Visual Schema Builder
- Open the collection in the Builder.
- Click the Settings icon in the toolbar.
- Switch to the Advanced tab.
- Toggle Status (Draft / Published) on.
- Click Save. Nextly will run a schema migration that adds the
statuscolumn to the data table; existing rows backfill to'draft'so nothing is accidentally published.
Both paths produce identical runtime behaviour -- the same column, the same admin UI, the same query shape.
What the system column does
| Property | Value |
|---|---|
| Type | varchar(20) on Postgres / MySQL, text on SQLite |
| Default | 'draft' |
| Nullable | No (NOT NULL) |
| Indexed | Yes — {collection}_status_idx |
| Allowed values | 'draft', 'published' |
The Nextly runtime injects this column when status: true is set on the collection or single config. You never declare a status field yourself in fields: [...] -- and if a user-defined field with the same name exists, the system flag will conflict with it. Migrate any legacy select({ name: "status" }) field off before toggling the system flag.
Enabling on a single
Same shape -- set status: true at the top level of defineSingle:
import { defineSingle, richText } from "nextly/config";
export const Homepage = defineSingle({
slug: "homepage",
status: true,
label: { singular: "Homepage" },
fields: [
richText({ name: "hero" }),
],
});In the Visual Schema Builder, the toggle lives in the same Settings → Advanced tab on the single's Builder page.
Localized documents: moving every language at once
On a localized collection or single, the publication state lives per language:
the main row carries the document's own status, and each translation carries
its own _status. An ordinary write moves the language it names, which is what
lets you publish a German translation without touching the English one.
That leaves a third thing to say — "move the whole document, every language" — and it is spelled with the wildcard locale:
// Take the document down in every language it has.
await nextly.update({
collection: "pages",
id: page.id,
locale: "*",
data: { status: "draft" },
});Without it, a write that names no locale moves the DEFAULT language only, so a takedown would leave the other translations live.
Two things to know about the wildcard:
- It moves a publication status and nothing else. A write that names any other field alongside it is rejected, because "write these values into every language" would copy one translation's text over all the others. To change field values, name the language they belong to.
- It only reaches languages that exist. A translation with no stored row has nothing to publish, and one is not created for it — the absence of a row is the record that the document was never translated into that language.
Scheduled releases use this automatically: a release member that names no language moves every translation of its document when the release runs.
Filtering on the public site
Use the standard field-filter syntax with the column name status:
import { nextly } from "nextly";
// Public list page — only published posts.
const published = await nextly.find({
collection: "posts",
where: { status: { equals: "published" } },
});
// Admin-only path — show all entries regardless of status.
const all = await nextly.find({ collection: "posts" });The nextly proxy above assumes Nextly is already initialised in the process, and most projects are not. Of the scaffolds, only blog ships a src/instrumentation.ts that calls createRegister(config)() before the first request; base and blank ship none, so on those the proxy throws Nextly services not initialized the moment it is the first Nextly code a worker runs. The same applies to a standalone script, and to any app that removed the hook.
Use await getNextly({ config }) unless you know the process has been initialised. It initialises rather than assuming, so it is correct in both cases; the proxy is the lighter import for code that provably runs after initialisation.
The query shape ({ status: { equals: "published" } }) works identically across all three dialects. The system column is indexed, so filtering by status is fast even on large tables.
Admin behaviour
Action bar
When status: true is enabled on a collection or single, the entry create/edit page action bar splits its primary action:
- Create mode:
[Save Draft](ghost) +[Publish](primary). Pressing Save Draft creates the entry as'draft'; Publish creates it as'published'. - Edit mode:
[Save Draft](ghost) +[Update](primary). Save Draft demotes to'draft'; Update saves with the entry's current status (or'published'if the entry was already published).
When status: false (the default), the action bar collapses to a single [Create] / [Save] button.
Document panel + meta strip
The current state is visible in two places:
- Document panel (right rail, when expanded): a labeled "Status" row with a
DRAFTorPUBLISHEDpill, alongside ID, Created, and Updated. - Meta strip (below the title bar, when the rail is collapsed): the same pill renders inline before the slug, so editors can see state without re-opening the rail.
Toggling status off
Disabling status: true on a collection that previously had it enabled is a destructive change -- the column gets dropped and any existing draft / published values are lost.
- Code-first: removing
status: truefrom your config and saving the file triggers the schema-change pipeline. You'll get a confirmation dialog (or the CLI--accept-data-lossflag in non-interactive contexts) before the column is dropped. - Builder UI: toggling Status off in Settings → Advanced and saving prompts the same destructive-change confirmation dialog. Acknowledge to drop the column.
There is no soft-delete or migration script — the column goes away. If you need to retain the data, export your entries first.
Migrating from a user-defined status field
If your collection already has a user-defined select({ name: "status" }) field -- for example, the legacy blog template shipped this pattern before the system flag existed -- migrate by:
- Remove the user-defined field from
fields: [...]in your config. - Add
status: trueat the top of the same collection. - Run the schema migration (Nextly will detect the field removal and the system column addition; both are applied together).
- Verify your queries still use
{ status: { equals: "published" } }-- the column name and value space are preserved, so query call-sites don't need to change. - Verify your seed writes the same
status: 'draft' | 'published'values -- the system column accepts the same shape.
The blog template (create-nextly-app blog) ships this migration applied; new projects scaffolded from the blog template after this release get the unified Save Draft / Publish admin UI for free.