Content
Pages, posts and other typed entries per organization, with locales, scheduled and approved publishing, versions and a public read by slug.
The content block keeps an organization's content entries: pages, blog
posts, help articles or any other type you name. Each entry has a slug, a
title, a summary and a JSON body that your editor writes, in one of the
locales you configure. Members draft entries, publish them now or at a set
time, and archive them. Published entries are public: a visitor reads one by
type, slug and locale without signing in.
better-supabase add content # adds tenant, access, memberships and subjects| Permission | Lets a member | Default roles |
|---|---|---|
content.read | read the organization's entries, drafts too | member, admin |
content.write | create, edit and archive entries | member, admin |
content.publish | publish and schedule entries, approve requests | admin (content.*) |
The block works with other blocks when they are installed.
Versions keeps a version of every edit and a frozen
published version each time an entry goes live.
Approvals gates publishing under
requireApproval. Attachments holds the cover
image an entry points at. The outbox and
audit blocks record every change.
Write entries
import { createContent, rpcTransport } from "better-supabase/blocks/content";
const content = createContent({
transport: rpcTransport(supabase),
tenant: organizationId,
});
const about = await content
.create({
type: "page",
slug: "about",
title: "About us",
body: { type: "doc", content: [] },
})
.orThrow();
const dutch = await content
.create({
type: "page",
locale: "nl",
slug: "over-ons",
title: "Over ons",
translationOf: about.id,
})
.orThrow();create writes a draft. type must be one of options.types and locale
one of options.locales (the first locale when you leave it out). A slug is
lowercase letters and digits joined by - or /, such as about or
guides/setup, and is unique per organization, type and locale. The body is
a JSON object or array of at most 1 MB, so it holds whatever your editor
produces (a ProseMirror or Lexical document, or blocks of your own).
translationOf adds the entry to another entry's translation group, one
entry per locale. coverAttachmentId points at an attachment and needs the
attachments block. contentEntryInputSchema validates the input.
update(id, input) changes the slug, title, summary, body or cover, and
archive(id) takes an entry offline. get(id) returns one entry with its
translations, or null when the caller can't read it. list({ type, locale, status, translationGroupId, query, limit, offset }) returns the
organization's entries, latest change first; query matches the title or
slug, and limit runs from 1 to 200 (default 50).
Publish and schedule
await content.publish(about.id).orThrow();
await content
.schedule(post.id, Temporal.Instant.from("2026-03-01T09:00:00Z"))
.orThrow();
await content.schedule(post.id, null).orThrow(); // back to draftpublish(id) sets an entry live now. schedule(id, at) sets it live at a
future time; pg_cron runs publish_due_content_entries on
options.publishSchedule (every five minutes by default) and publishes the
entries whose time has come. With an empty schedule, call
content.publishDue() from your own job with the service role. To publish a
published entry again at a new time, archive it first.
With approval
With requireApproval: true, a member's publish or schedule opens an
approvals request with the action publish and leaves the entry a draft, with
approvalId and approvalStatus: "pending". Once the request is approved,
the entry goes live, or is scheduled when its time is still ahead. Editing,
archiving or unscheduling the draft withdraws the request. The default policy
asks for one approval by another member with content.publish; set
publishPolicy to an approvals policy for more
steps. Calls with the service role skip the request. While approval is on,
only members with content.publish change scheduled or published entries.
Public reads
const page = await content
.published({ type: "page", slug: "over-ons", locale: "nl" })
.orThrow();
const latest = await content
.listPublished({ type: "post", locale: "en", limit: 10 })
.orThrow();published returns one published entry with its body and its published
translations, or null. When the locale has no entry with that slug, it falls
back to the default locale. listPublished returns published entries
newest first, without bodies; before pages back from a publishedAt, and
limit runs from 1 to 100 (default 20). Both run as security definer
functions that the anon role may call: they return only published entries
and only their public fields, so they work with the publishable key on a
marketing site.
Options
blocks: {
content: {
options: {
types: ["page", "post", "article"],
locales: ["en", "nl", "de"],
requireApproval: true,
},
},
},| Option | Default | What it does |
|---|---|---|
types | ["page", "post"] | the entry types |
locales | ["en"] | the locales entries are written in; the first is the default |
requireApproval | false | publishing opens an approvals request; needs the approvals block |
publishPolicy | one approver | the approvals policy a publish request follows |
publishSchedule | "*/5 * * * *" | the pg_cron schedule for scheduled entries; empty turns it off |
The before_content_entry_publish and after_content_entry_publish hooks
run around every publish, from the button, the approval or the schedule.
Events
The block writes content_entry.created, content_entry.updated,
content_entry.publish_requested, content_entry.scheduled,
content_entry.unscheduled, content_entry.published and
content_entry.archived to the outbox. See
Events.
| Error code | When |
|---|---|
CONTENT_FORBIDDEN | the caller lacks the permission |
CONTENT_INVALID | a type, locale, slug, title, body or publish time doesn't validate |
CONTENT_NOT_FOUND | there is no such entry, or the caller can't read it |
CONTENT_SLUG_TAKEN | another entry of the type and locale has the slug |
CONTENT_TRANSLATION_EXISTS | the translation group already has an entry in the locale |
CONTENT_MEDIA_UNSUPPORTED | a cover is set without the attachments block |
APPROVAL_PENDING | a publish request for the entry is already open |
Last updated on