Plain
Join now

The content SDK

Read a repo's collections as type-safe content in your own site.

@plainalpha/content lets an outside site read a repository's collections as content: blog posts, a changelog, docs, anything you already model as rows. The schema lives in Plain, the SDK generates the types by scanning it, and records are fetched live, so publishing a record updates your site without a deploy. If you have used Astro content collections, the shape is familiar; the difference is that the schema is the collection, not a file in your repo.

Install

pnpm add @plainalpha/content

Mint a content token

In Settings → Registry tokens, create a token with the Content (read-only) scope. It is the same registry token machinery as installs and publishes, but content:read does exactly one thing: read this org's collections over the content API, never mutate, never touch packages. Put the value in your site's environment as PLAIN_TOKEN and keep it server-side.

.env
PLAIN_TOKEN=plain_rt_...

Query a collection

defineContent points at one repository; getCollection lists a collection and getEntry fetches one record by id. Cells are flattened onto the entry and keyed by field name. Calls never throw: they return { data, error }, so a flaky network can't crash your build.

posts.tsserver
import { defineContent } from "@plainalpha/content";

const content = defineContent({ owner: "acme", repo: "site" });

const { data: posts, error } = await content.getCollection("blog", { body: "markdown" });
if (error) throw new Error(error.message);

for (const post of posts) {
  post.title; // string | null
  post.status; // "Draft" | "Published" | null   ← the select's option labels
  post.author; // { id, name } | null
  post.body; // markdown string | null
}

A getEntry miss is not an error; it is { data: null, error: null }.

Generate types

The narrowed field types above come from a generated declaration that merges your collections onto the SDK. Run the CLI to sync them from the live schema, or wire the Vite plugin to keep them fresh while you develop:

vite.config.tsbuild
import { plainContent } from "@plainalpha/content/vite";

export default {
  plugins: [plainContent({ owner: "acme", repo: "site" })],
};
npx plain-content sync   # fetch schema → write types and plain.schema.json

sync writes two things that matter: plain.schema.json, the extracted schema you commit as the reviewable source of truth, and the generated types it gitignores. Before generated types exist, the same calls still work; cells are just the generic CellValue instead of the narrowed shapes.

Field mapping

Plain field types map onto TypeScript predictably:

Plain fieldTypeScript
Text, long text, url, email, phone, datestring
Numbernumber
Checkboxboolean
Selectunion of the option labels, e.g. "Draft" | "Published"
Multi-selectarray of that union
Person{ id, name }
Relation{ id }[]; expand the target with getEntry
Created / updated / created-bysurfaced as createdAt / updatedAt / createdBy

Every cell is optional and nullable: a record may not have set a value. Select unions key on the option label you wrote, so renaming an option, a field, or a collection is a schema change.

Drift detection

Types come from your committed plain.schema.json, but data is fetched live, so the schema can move after you last synced. Two guards catch it. In CI, plain-content check re-fetches and fails if the committed schema is stale, with no token needed beyond that step; plain-content generate rebuilds the types from the committed schema offline. At runtime, pass the snapshot's hash to defineContent and the SDK warns, or errors in strict mode, when a response's schema differs.

Notes

  • Server-only. The SDK reads PLAIN_TOKEN and refuses to send it from a browser. Call it from a loader, server component, route handler, or build step, never a client component.
  • Configuration. defineContent({ owner, repo, token?, baseUrl?, cache?, schemaHash?, strict? }). token defaults to PLAIN_TOKEN; cache is passed through to fetch.