@plainalpha/content
v0.0.3
Published
Plain Content SDK — consume a Plain repo's Collections as type-safe content (blog posts, changelogs) in your own Vite/Astro/React app. Scans the remote field schema and generates TypeScript types, like content collections.
Readme
@plainalpha/content
Consume a Plain repo's Collections as type-safe content in your own site — blog posts, changelogs, docs, anything you model as a collection. Like Astro content collections, but the schema lives in Plain and the types are generated by scanning it.
- Live fetch. Records are fetched from Plain's API at request/build time, so publishing in Plain updates your site without a code change.
- Fully typed.
plain-content syncscans the remote field schema and generates a.d.ts, sogetCollection("blog")returns entries with the right field keys,selectliteral unions, relations, and person refs. - Safe by default. Calls never throw — they return
{ data, error }, so a flaky network can't crash your build. The token is read server-side only.
Install
npm install @plainalpha/content1. Mint a content token
In Plain: Settings → Registry tokens → New token → Content (read-only). Copy
the PLAIN_TOKEN=… line into your site's environment (.env, CI secrets, your
host's env). The token is read-only and scoped to your org. Keep it
server-side — it must never reach the browser.
# .env
PLAIN_TOKEN=plain_rt_…2. Query content
import { defineContent } from "@plainalpha/content";
const content = defineContent({ owner: "acme", repo: "site" });
// ^ your org slug ^ the repo holding the collection
// List a collection (auto-paginated). Cells are keyed by field name.
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.publishDate; // string | null (YYYY-MM-DD)
post.author; // { id, name } | null
post.body; // markdown string | null
}
// One entry by id (a miss is { data: null, error: null }).
const { data: post } = await content.getEntry("blog", id, { body: "markdown" });Before you generate types (step 3), the same calls work — cells are just typed
as the generic CellValue instead of the narrowed shapes above.
3. Generate types
Types come from a generated .d.ts that declaration-merges your collections
onto the SDK. Two ways to keep it fresh:
Vite / Astro (plugin)
// vite.config.ts (or astro.config.mjs → vite.plugins)
import { plainContent } from "@plainalpha/content/vite";
export default {
plugins: [plainContent({ owner: "acme", repo: "site" })],
};The plugin regenerates types on build/dev start and watches for remote schema changes while you develop.
Any project (CLI)
npx plain-content sync # fetch schema → write types (and plain.schema.json)
npx plain-content sync --watch # keep regenerating as the schema changesEither way, two files are produced:
| File | Commit it? | What it is |
| --------------------- | --------------- | --------------------------------------------------------------- |
| plain.schema.json | yes | the extracted schema — the reviewable source of truth |
| .plain/content.d.ts | no (gitignored) | the generated types |
| plain-content.d.ts | yes | a one-line /// <reference /> stub so tsserver finds the types |
In CI, regenerate the types offline from the committed schema before typechecking — no token or network needed:
npx plain-content generate # plain.schema.json → .plain/content.d.ts
npx plain-content check # fail if the committed schema is stale (drift gate)Field type mapping
| 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: string; name: string \| null } |
| relation | { id: string }[] — expand a record target with getEntry |
| created / updated / created-by | surfaced as createdAt / updatedAt / createdBy on every entry |
Every cell is optional and nullable (title?: string | null): a record may not
have set a value. select/multi-select unions key on the option label you
wrote, so renaming an option (or a field, or a collection) is a schema change —
re-run sync and check flags it in CI.
Coming from Astro content collections
| Astro | @plainalpha/content |
| -------------------------------------- | ------------------------------------------------------ |
| defineCollection({ schema }) in code | the schema lives in your Plain collection |
| astro sync → .astro/types.d.ts | plain-content sync → .plain/content.d.ts |
| getCollection("blog") | content.getCollection("blog") → { data, error } |
| getEntry("blog", id) | content.getEntry("blog", id) |
| entry.data.title | post.title (cells are flattened onto the entry) |
| await entry.render() | post.body (markdown; render it with your renderer) |
| reference("authors") | relation cells are { id }[]; resolve with getEntry |
Drift detection
Types come from your committed plain.schema.json, but data is fetched live, so
the schema can change after you last synced. Two guards:
CI:
plain-content checkre-fetches and fails if the committed schema is stale.Runtime (opt-in): pass the snapshot's hash and the SDK warns (or errors, in
strictmode) when a response's schema differs:import schema from "./plain.schema.json"; const content = defineContent({ owner, repo, schemaHash: schema.schemaHash });
Notes
- Server-only. The SDK reads
PLAIN_TOKENand refuses to send it from a browser context. 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;baseUrltoPLAIN_API_URLthenhttps://alpha.plain.jxd.dev;cacheis passed through tofetch.
