# Content

> Pages, posts and other typed entries per organization, with locales, scheduled and approved publishing, versions and a public read by slug.

Source: https://bettersupabase.com/docs/blocks/content

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.

```bash
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](/docs/blocks/versions) keeps a version of every edit and a frozen
`published` version each time an entry goes live.
[Approvals](/docs/blocks/approvals) gates publishing under
`requireApproval`. [Attachments](/docs/blocks/attachments) holds the cover
image an entry points at. The [outbox](/docs/blocks/outbox) and
[audit](/docs/blocks/audit) blocks record every change.

## Write entries [#write-entries]

```ts
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 [#publish-and-schedule]

```ts
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 draft
```

`publish(id)` sets an entry live now. `schedule(id, at)` sets it live at a
future time. The block's `publish` timer (every five minutes) publishes the
entries whose time has come: schedule it with the other
[block timers](/docs/blocks/queue#block-timers) and run it with the service
role's client.

```ts title="src/lib/jobs.ts"
const run = blockTickHandler({ "content.publish": () => content.publishDue() });
```

To publish a published entry again at a new time, archive it first.

### With approval [#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](/docs/blocks/approvals) 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 [#public-reads]

```ts
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 [#options]

```ts title="better-supabase.config.ts"
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                   |

The `before_content_entry_publish` and `after_content_entry_publish` hooks
run around every publish, from the button, the approval or the schedule.

## Events [#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](/docs/blocks/outbox). See
[Events](/docs/extending/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                    |