Pagedeck

Publication Window

A collection can name the fields of its entries that hold the instants an entry starts and stops being a page. publishField names the first, unpublishField the second, and a build collects only the entries inside the window they describe.

defineCollection({
  name: "articles",
  loader: articleLoader,
  schema: articleSchema,
  // The fields of the entry's own data. Dotted for nesting: "meta.publish_at".
  publishField: "publishAt",
  unpublishField: "unpublishAt",
});

A collection that declares neither field is untouched. It is unscheduled, every entry of it is a page, and it collects exactly as it did before either field existed — no query changes, no bytes move, and nothing about the feature is paid for. That is the whole of the opt-in: there is no site-level switch to turn on beside it.

Either field alone is a whole declaration. A collection that schedules only its starts declares publishField, one that schedules only its ends declares unpublishField, and the query drops the clause it was not given. Both make the collection scheduled.

What the fields hold

An ISO-8601 timestamp in UTC, as text:

{ "title": "Release notes", "publishAt": "2026-09-01T09:00:00Z" }

UTC, and this matters. The instants are compared as text, so they have to be written in a format that sorts chronologically. 2026-09-01T09:00:00Z does; 2026-09-01T09:00:00+02:00 sorts wrong against it.

A field that is absent or null is no bound at that end, not a missing value. An entry with no publish time is already published; an entry with no unpublish time never expires. Most entries of a scheduled collection carry neither, and they are all pages.

The field is a name and not a callback because the selection happens in SQL: an entry outside its window is never loaded, let alone routed and then dropped. Scheduling costs no extra column, no extra table and no migration — the timestamps are read out of the JSON your loader already stored.

The window is half-open

In at publishField, out at unpublishField. An entry publishing at exactly the build's instant is in; an entry unpublishing at exactly the build's instant is out.

That is what lets one entry hand over to the next at a single written instant. Give an entry an unpublishAt and its successor the same value as its publishAt, and there is no moment in which both are live and no moment in which neither is:

[
  { "path": "banner-summer", "unpublishAt": "2026-09-01T00:00:00Z" },
  { "path": "banner-autumn", "publishAt": "2026-09-01T00:00:00Z" }
]

Write the two ends the other way round — closed at both — and the changeover is either a duplicate or a gap depending on which side you rounded.

The build's instant

A build has one instant, and it is the createdAt of the build stamp it was handed. Nothing in the collection pass reads a clock of its own.

So two builds of one commit agree: build the same site twice from the same stamp and you get the same pages, which is what makes the output comparable at all. The dev server passes the moment the request arrived instead, which is the instant a preview is asking about.

A scheduled collection collected with no instant is refused rather than published wholesale — the message names the collection and the field it is scheduled by. Publishing everything would be a site shipping drafts because a caller forgot an argument.

What ships today

The exclusion, and that is all of it. A build collects the entries inside their window and leaves the rest out. Deciding when to run a build so that an entry appears at the hour you scheduled it for is your scheduler's concern, not the framework's — nothing here polls, wakes up, or triggers anything.

In practice that means an entry becomes a page on the first build that starts after its publishAt, and stops being one on the first build that starts at or after its unpublishAt. Between those builds the site says what the last build said.