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.
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.
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:
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 field | TypeScript |
|---|---|
| Text, long text, url, email, phone, date | string |
| Number | number |
| Checkbox | boolean |
| Select | union of the option labels, e.g. "Draft" | "Published" |
| Multi-select | array of that union |
| Person | { id, name } |
| Relation | { id }[]; expand the target with getEntry |
| Created / updated / created-by | surfaced 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_TOKENand 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? }).tokendefaults toPLAIN_TOKEN;cacheis passed through tofetch.