@headroom-cms/api
v0.3.1
Published
TypeScript SDK for building sites with [Headroom CMS](https://github.com/headroom-cms). Provides a type-safe API client, block renderers for React and Astro, and an Astro content loader integration.
Readme
@headroom-cms/api
TypeScript SDK for building sites with Headroom CMS. Provides a type-safe API client, block renderers for React and Astro, and an Astro content loader integration.
Installation
npm install @headroom-cms/apiPeer dependencies (optional — install only what you use):
| Peer | When needed |
|------|-------------|
| react + react-dom >= 18 | React block rendering (@headroom-cms/api/react) |
| astro >= 5 | Astro content loader + dev refresh (@headroom-cms/api/astro) |
Quick Start
import { HeadroomClient } from "@headroom-cms/api";
const client = new HeadroomClient({
apiUrl: "https://api.headroom.example.com",
mediaUrl: "https://media.headroom.example.com",
site: "mysite.com",
apiKey: "headroom_xxxxx",
});
const { items } = await client.content.list({ collection: "posts", select: "meta" });API Client
Configuration
interface HeadroomConfig {
apiUrl: string; // Headroom API CDN URL (required — used for /v1/* requests)
mediaUrl: string; // Headroom media CDN URL (required — used for mediaUrl()/transformUrl())
site: string; // Site host identifier
apiKey: string; // Public API key
imageSigningSecret?: string; // OPTIONAL override — normally auto-fetched from /version
versionRefreshIntervalMs?: number; // Cache-key refresh interval (default 10_000)
}The API and media CDNs are always separate CloudFront distributions:
apiUrlserves/v1/*(client.content.*,client.collections.*,client.blockTypes.*)mediaUrlserves/media/*and/img/*(client.mediaUrl(),client.transformUrl())
The constructor throws if either is missing.
Methods
The client exposes resource-scoped sub-clients. select is a required field
on every list/batch method: pass "meta" for listings (no body), "full" when
you need the block body, or a readonly string[] to cherry-pick fields.
Content (client.content)
| Method | Returns | Description |
|--------|---------|-------------|
| list(options) | ContentListResult | List published content (requires select) |
| get(contentId) | ContentItem | Get a single content item with body and relationships |
| bySlug(collection, slug) | ContentItem \| undefined | Look up content by slug (returns undefined on 404) |
| singleton(collection) | ContentItem | Get singleton content (e.g. site settings) |
| batch(options) | BatchContentResult | Fetch up to 50 items in one request (requires select) |
Submissions (client.submissions)
| Method | Returns | Description |
|--------|---------|-------------|
| create(input) | SubmitResult | Create a submission |
| update(contentId, input) | SubmitResult | Update a submission |
| list(options) | SubmissionListResult | List submissions (requires select) |
| listMine(options) | SubmissionListResult | List the session user's own submissions |
| get(contentId) | SubmissionItem | Get one submission |
| batch(options) | BatchSubmissionsResult | Batch-fetch submissions (requires select) |
Collections & Block Types
| Method | Returns | Description |
|--------|---------|-------------|
| client.collections.list() | CollectionListResult | List all collections |
| client.collections.get(name) | Collection | Get collection schema with fields and relationships |
| client.blockTypes.list() | BlockTypeListResult | List block type definitions |
Top-level utilities
| Method | Returns | Description |
|--------|---------|-------------|
| client.getVersion() | number | Content version (for cache busting) |
| client.mediaUrl(path) | string | Prepend base URL to a stored media path |
| client.transformUrl(path, opts?) | string | Build a signed image transform URL |
Query Options for content.list
const { items, cursor, hasMore } = await client.content.list({
collection: "posts",
select: "meta", // Required: "meta" | "full" | readonly string[]
limit: 10,
cursor: "...", // Pagination cursor from previous response
sort: "published_desc", // "published_desc" | "published_asc" | "title_asc" | "title_desc"
before: 1700000000, // Unix timestamp — only items published before
after: 1690000000, // Unix timestamp — only items published after
relatedTo: "01ABC", // Reverse relationship: items pointing to this content ID
relField: "author", // Filter reverse query to a specific relationship field
});Error Handling
API errors throw a HeadroomError with status and code properties:
import { HeadroomClient, HeadroomError } from "@headroom-cms/api";
try {
const post = await client.content.get("nonexistent");
} catch (e) {
if (e instanceof HeadroomError) {
console.log(e.status); // 404
console.log(e.code); // "CONTENT_NOT_FOUND"
}
}Content Versioning (cache-key pinning)
Every public-API read goes through the CDN keyed on an X-Headroom-Version header that identifies the site's current content version. The client maintains an in-memory cached version, populated lazily on first read and refreshed on an interval:
- First read does an extra round trip. On a cold client, the first
content.*/collections.*/blockTypes.*call runs oneGET /v1/{site}/versionfetch before the actual read, then injects the returned integer into the outgoingX-Headroom-Versionheader. Subsequent reads within the refresh interval reuse the cached value and incur no extra round trip. - Single-flight on concurrent cold reads. If N parallel reads fire on a cold cache, exactly one
/versionfetch runs and the other N − 1 await the same promise. This matters at process startup in SSR consumers that fan out many reads immediately. - Tune with
versionRefreshIntervalMs. Defaults to10000(10 s). Lower = lower worst-case staleness, higher origin load. Setting it to0disables background refresh entirely — the client fetches once on first read and caches forever. Only advisable for short-lived processes. client.getVersion()stays fresh-from-origin. The publicgetVersion()method always hits the origin and returns a live value, independent ofversionRefreshIntervalMs. It populates the internal cache as a side effect, so an immediately-followingcontent.*read on the same client reuses it (no redundant second round trip). This is load-bearing for the Astro content loader's change-detection short-circuit and the dev-refresh polling loop.- Batch reads go through POST.
content.batch()usesPOST /content/batchand is not CDN-cached. It therefore does not consult or send the version header — every call is an origin round trip. Prefer individual GETs (via.list()/.get()/.bySlug()) when the cache hit ratio matters. - No SDK-level retries. A failed
/versionfetch propagates the error up through the calling_fetchGet. Retry in your consuming framework (Astro loaders catch errors; a worker can catch-and-retry).
Block Rendering
Content bodies from Headroom contain an array of BlockNote blocks. The SDK provides renderers for both Astro and React.
Astro (Zero JS)
Import .astro components directly from @headroom-cms/api/blocks/*. These ship as source files compiled by your Astro build — no client-side JavaScript is emitted.
This example assumes the posts collection has a field literally named body
of type blocks. If your blocks-typed field has a different name, substitute
it for body here.
---
import BlockRenderer from "@headroom-cms/api/blocks/BlockRenderer.astro";
import type { Block, RefsMap } from "@headroom-cms/api";
const post = await client.content.bySlug("posts", slug);
// `body` is the name of this collection's `blocks`-typed field; block content
// is inlined under `post.fields.<fieldName>` by the public read.
const blocks = (post.fields?.body ?? []) as Block[];
const refs = (post._refs || {}) as RefsMap;
---
<BlockRenderer
blocks={blocks}
refs={refs}
resolveContentLink={(ref) => `/${ref.collection}/${ref.slug}`}
transformImage={(path) => client.transformUrl(path, { width: 1200, format: "webp" })}
/>The block array is read off post.fields, not a post.body property —
ContentItem has no body. This snippet pins that shape (see the
README snippet check below):
import type { Block, ContentItem } from "@headroom-cms/api";
declare const post: ContentItem;
const blocks = (post.fields?.body ?? []) as Block[];
void blocks;Props:
| Prop | Type | Description |
|------|------|-------------|
| blocks | Block[] | Block content array |
| baseUrl | string? | Base URL for media (defaults to HEADROOM_MEDIA_URL env var) |
| refs | RefsMap? | Content reference map for resolving headroom:// links |
| resolveContentLink | (ref: PublicContentRef) => string | Custom URL builder for content links |
| transformImage | (path: string) => string | Custom image URL transform (e.g. for responsive images) |
| class | string? | CSS class for the wrapper <div> |
Available components (importable individually from @headroom-cms/api/blocks/*):
BlockRenderer, Paragraph, Heading, Image, CodeBlock, BulletList, NumberedList, CheckList, Table, InlineContent, Fallback
React
import { BlockRenderer } from "@headroom-cms/api/react";
import "@headroom-cms/api/react/headroom-blocks.css";
function PostBody({ blocks, refs }) {
return (
<BlockRenderer
blocks={blocks}
baseUrl="https://headroom.example.com"
refs={refs}
resolveContentLink={(ref) => `/${ref.collection}/${ref.slug}`}
/>
);
}Props:
| Prop | Type | Description |
|------|------|-------------|
| blocks | Block[] | Block content array |
| baseUrl | string? | Base URL for media |
| refs | RefsMap? | Content reference map |
| resolveContentLink | (ref: PublicContentRef) => string | Custom URL builder |
| components | BlockComponentMap? | Override or extend block components (see below) |
| fallback | ComponentType \| null | Custom fallback for unknown blocks (null to suppress) |
| className | string? | CSS class for the wrapper <div> |
Available block types: paragraph, heading, image, codeBlock, bulletListItem, numberedListItem, checkListItem, table
Content Links
Rich text can contain headroom://content/{collection}/{contentId} links that reference other content. The _refs map returned with each content item resolves these to metadata:
const post = await client.content.get("01ABC");
// post._refs = {
// "01DEF": { contentId: "01DEF", collection: "posts", slug: "hello-world", title: "Hello World", published: true }
// }The block renderer resolves these links automatically:
- Default:
headroom://content/posts/01DEF→/{collection}/{slug}(i.e./posts/hello-world) - Custom resolver: Pass
resolveContentLinkto map to your site's URL structure - Broken links: Unpublished or missing references render as
#
You can also resolve links manually:
import type { PublicContentRef } from "@headroom-cms/api";
function resolveContentLink(ref: PublicContentRef): string {
if (!ref.published) return "#";
switch (ref.collection) {
case "posts": return `/blog/${ref.slug}`;
case "projects": return `/projects/${ref.slug}`;
default: return `/${ref.slug}`;
}
}Custom Block Components
React
Pass a components map to override built-in blocks or render custom block types:
import { BlockRenderer } from "@headroom-cms/api/react";
import type { BlockComponentProps } from "@headroom-cms/api/react";
function CallToAction({ block }: BlockComponentProps) {
return (
<div className="cta-banner">
<p>{block.props?.text as string}</p>
<a href={block.props?.url as string}>Learn more</a>
</div>
);
}
<BlockRenderer
blocks={blocks}
components={{ callToAction: CallToAction }}
/>Astro
For custom Astro blocks, create your own wrapper around the individual block components. Import and render the built-in components alongside your custom ones:
---
import Paragraph from "@headroom-cms/api/blocks/Paragraph.astro";
import Heading from "@headroom-cms/api/blocks/Heading.astro";
import Image from "@headroom-cms/api/blocks/Image.astro";
// ... other built-in imports
import MyCustomBlock from "../components/MyCustomBlock.astro";
const { blocks, refs, resolveContentLink } = Astro.props;
---
{blocks.map((block) => {
if (block.type === "myCustomBlock") return <MyCustomBlock block={block} />;
if (block.type === "paragraph") return <Paragraph block={block} refs={refs} resolveContentLink={resolveContentLink} />;
if (block.type === "heading") return <Heading block={block} refs={refs} resolveContentLink={resolveContentLink} />;
if (block.type === "image") return <Image block={block} />;
// ... handle remaining types
})}Relationships
Collections can define relationships to other collections. These are populated on single-content responses:
// Forward relationships (e.g. a project's "artists")
const project = await client.content.get("01ABC");
const artists = project.relationships?.artists; // ContentRef[]
// Reverse query: find all projects for an artist
const { items } = await client.content.list({
collection: "projects",
select: "meta",
relatedTo: "01ARTIST",
relField: "artists",
});Submission Collections
Collections with mode === "submission" capture user-submitted records (comments, reviews, feedback, form responses) rather than author-edited content. Submissions live on a disjoint /submissions/* resource with their own dedicated types — SubmissionMetadata / SubmissionItem — and are never mixed into ContentMetadata / ContentItem:
User-defined fields live under
fields, notbody. A submission response hasfields: { message, rating, ... }at the top level. Block editors are not supported for submission collections, so there is nobody.contentarray.SubmissionMetadatashape:| Property | Type | Description | |----------|------|-------------| |
contentId|string| | |collection|string| | |publishedAt|number| Doubles as create time | |mode|"submission"| Literal discriminator | |siteUserId|string?| ID of the authenticated submitter (omitted for anonymous submissions) | |siteUser|SiteUserBrief?| Public-safe snapshot of the submitter (userId, optionalname) | |fields|Record<string, unknown>| User-defined field values (required on well-formed submissions) |Generated schemas match this shape. When you run
generateZodSchemas, submission collections emit a schema that spreads...submissionMetaand puts user fields under a top-levelfieldsobject. Regular-content collections also use a top-levelfields: { ... }object (Phase 7 of CONTENT_MODEL_UNIFICATION consolidated the legacybodywrapper intofieldsfor both content types). Seesteering/ZOD.mdfor the full generator contract.
Snapshot semantics for siteUser
SiteUserBrief on a submission is snapshotted at submit time from the session's validated user record. The backend resolves the submitter's display name from DynamoDB during session validation (the lookup it already performs to verify account status), and writes that name onto the content record as part of the submission. Historical submissions therefore reflect the user's name as of the moment of submission, not the user's current name and not the name embedded in the JWT at token-issue time. If a user later renames themselves, older submissions keep the earlier name — which is the intended behavior for comments, reviews, and similar audit-style records.
Privacy: no email in SiteUserBrief
SiteUserBrief deliberately omits email. Emails are "own-user-only" PII and never appear on public content responses — not on the submitter's own submissions, and certainly not on other users' submissions. Callers that need the currently authenticated user's own email should read it from the self-auth session:
const session = await client.auth.getSession();
// session.user is an AuthUser with { userId, name, email, ... }
console.log(session.user.email);AuthUser (returned by getSession, verifyOTP, and updateProfile) is the canonical source for self-PII. Public content endpoints always return the narrower SiteUserBrief shape.
SubmitResult returns minimal metadata
client.submissions.create() currently resolves to a minimal SubmitResult describing only the created item's identifiers (not the full content record). Clients that need to render a just-submitted item inline — for example, appending it to a list of comments — should follow up with client.submissions.list() (or client.submissions.get() for a single record) to fetch the full submission with its populated siteUser snapshot.
Media URLs
Media paths in content responses (block image URLs, cover images, field values) are stored as relative paths like /media/{site}/{mediaId}/original.jpg.
const client = new HeadroomClient({
apiUrl: "https://api.headroom.example.com",
mediaUrl: "https://media.headroom.example.com",
site: "mysite.com",
apiKey: "headroom_xxxxx",
// No imageSigningSecret needed — the SDK fetches the per-site secret
// from /version automatically and caches it in memory. Pass an explicit
// imageSigningSecret only if you need to lock the value (build-time /
// offline) — it acts as an override.
});
// Full URL for the original (cover is now a MediaRef with a .url field)
client.mediaUrl(post.cover?.url);
// → "https://media.headroom.example.com/media/mysite.com/01ABC/original.jpg"
// Signed transform URL (resized, converted to webp).
// transformUrl() is synchronous — any prior `await client.content.*(...)`
// already warmed the per-site secret cache via the /version side effect.
client.transformUrl(post.cover?.url, { width: 800, format: "webp" });
// → "https://media.headroom.example.com/img/mysite.com/01ABC/original.jpg?format=webp&w=800&sig=abc123..."Transform Options
interface TransformOptions {
width?: number; // Target width in pixels
height?: number; // Target height in pixels
fit?: "cover" | "contain" | "fill" | "inside" | "outside";
format?: "webp" | "avif" | "jpeg" | "png"; // Output format
quality?: number; // 1-100
}Transforms work automatically — the SDK fetches the per-site signing secret from /version on the first content read (or on an explicit await client.prefetchImageSigningSecret() call) and caches it in memory. transformUrl() is synchronous and reads the warmed cache. If the cache is cold at call time, transformUrl() falls back to mediaUrl() and emits a one-time dev-mode warning. Pass an explicit imageSigningSecret to HeadroomConfig to override the auto-fetched value (useful for build-time / offline runs).
Astro Integration
Content Loader
Use headroomLoader() to load Headroom content into Astro's content layer:
// src/content.config.ts
import { defineCollection } from "astro:content";
import { headroomLoader } from "@headroom-cms/api/astro";
export const collections = {
posts: defineCollection({
loader: headroomLoader({ collection: "posts" }),
}),
pages: defineCollection({
loader: headroomLoader({ collection: "pages", bodies: true }),
}),
};Options:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| collection | string | — | Headroom collection name |
| bodies | boolean | false | Fetch full content bodies (not just metadata) |
| config | HeadroomConfig? | from env | Override client config |
| schema | ZodType? | — | Zod schema for type-safe data access |
The loader reads config from environment variables by default:
HEADROOM_API_URL=https://api.headroom.example.com
HEADROOM_MEDIA_URL=https://media.headroom.example.com
HEADROOM_SITE=mysite.com
HEADROOM_API_KEY=headroom_xxxxx
# HEADROOM_IMAGE_SIGNING_SECRET is no longer required — the SDK fetches
# the per-site secret from /version automatically. Set it only if you
# want to lock the value (build-time / offline) — it acts as an override.Dev Refresh
Add headroomDevRefresh() to your Astro config for automatic content reloading during development:
// astro.config.mjs
import { headroomDevRefresh } from "@headroom-cms/api/astro";
export default defineConfig({
integrations: [headroomDevRefresh()],
});Polls the Headroom API for version changes (default: every 5 seconds) and triggers a content sync when content is updated in the admin UI.
Note: headroomDevRefresh() only benefits content-collection setups — it triggers Astro's content layer to re-sync when the Headroom version changes. For pure-SDK sites that call client.content.* directly, it is a no-op (those calls always hit the API live).
When to Use Content Collections vs Direct SDK
| Scenario | Approach | Why |
|----------|----------|-----|
| Static site, content rarely changes | Content collections (headroomLoader) | Build-time fetch, typed via generated schemas |
| SSR site, content must be fresh | Direct SDK (client.content.*) | No caching, always live |
| Static site, frequent content edits | Content collections + deploy hook | Rebuild on publish via webhook |
| Hybrid: listings static, detail fresh | Content collections for listings, SDK for detail pages | Best of both worlds |
Key rule: Content collections cache at build/startup. getCollection() in SSR will NOT re-fetch on each request. For always-fresh content, use client.content.* directly.
Zod Schema Codegen
Generate type-safe Zod schemas from your Headroom collection definitions:
import { HeadroomClient } from "@headroom-cms/api";
import { generateZodSchemas } from "@headroom-cms/api/codegen";
const client = new HeadroomClient({ /* ... */ });
const code = await generateZodSchemas(client);
// Write `code` to a file (e.g. src/lib/schemas.ts)This generates a TypeScript file with Zod schemas for each collection, ready to pass to headroomLoader({ schema }). See the sample site for a working example with a generate-schemas.sh script.
Field Types
See docs/FIELD_TYPES.md for the complete field-type reference: every type a collection field can declare, its options.* keys, the admin widget it produces, the generated TS/Zod shape, when to use each (and when not to reach for json), and field-type migration semantics.
Styling
React
Import the default stylesheet:
import "@headroom-cms/api/react/headroom-blocks.css";Styles use low-specificity :where() selectors, making them easy to override. Customize via CSS custom properties:
| Property | Default | Used by |
|----------|---------|---------|
| --hr-code-bg | #f3f4f6 | Inline code background |
| --hr-link-color | #2563eb | Link color |
| --hr-image-radius | 0.5rem | Image border radius |
| --hr-caption-color | #6b7280 | Image caption color |
| --hr-code-block-bg | #1e1e1e | Code block background |
| --hr-code-block-color | #d4d4d4 | Code block text color |
| --hr-accent | #2563eb | Checkbox accent color |
| --hr-table-header-bg | #f9fafb | Table header background |
Astro
Astro block components render semantic HTML with no built-in styles. Use Tailwind or your own CSS to style the output. The components use standard HTML elements (<p>, <h1>–<h6>, <ul>, <ol>, <figure>, <table>, etc.) that work naturally with Tailwind's prose class.
TypeScript Types
All types are exported from the main entry point:
import type {
// Config
HeadroomConfig,
TransformOptions,
// Content
ContentItem,
ContentMetadata,
ContentListResult,
BatchContentResult,
// Blocks
Block,
InlineContent,
TextContent,
LinkContent,
TextStyles,
TableContent,
TableRow,
// References
ContentRef,
PublicContentRef,
RefsMap,
// Collections
Collection,
CollectionSummary,
CollectionListResult,
FieldDef,
RelationshipDef,
// Block Types
BlockTypeDef,
BlockTypeListResult,
} from "@headroom-cms/api";React-specific types:
import type { BlockRendererProps, BlockComponentProps, BlockComponentMap } from "@headroom-cms/api/react";Building & Publishing
pnpm build # Build all entry points (ESM + CJS + types)
pnpm test # Run tests
pnpm test:watch # Run tests in watch mode
pnpm typecheck # TypeScript type checking
pnpm dev # Watch mode buildPackage Entry Points
| Import path | Format | Description |
|-------------|--------|-------------|
| @headroom-cms/api | ESM + CJS | API client and types |
| @headroom-cms/api/react | ESM + CJS | React block renderer components |
| @headroom-cms/api/react/headroom-blocks.css | CSS | Default block styles |
| @headroom-cms/api/blocks/* | Astro source | Astro block components (compiled by consumer) |
| @headroom-cms/api/astro | ESM | Astro content loader + dev refresh |
| @headroom-cms/api/codegen | ESM + CJS | Zod schema generation |
| @headroom-cms/api/comments | ESM + CJS | Comment helpers + tree builder |
npm Publish
pnpm build
npm publish --access publicREADME snippet check (contributor note)
pnpm test:readme type-checks the README's code samples against this package's
own published types, so a sample referencing a removed or wrong API shape (e.g.
the historical post.body.content instead of post.fields.body) fails loudly
instead of reaching a reader.
The check is opt-in by marker: only fenced blocks tagged with a trailing
check token are type-checked — ```ts check and ```tsx check.
Untagged blocks (and ```astro frontmatter, which tsc can't parse) are
skipped, because most README snippets are illustrative fragments (undeclared
client/slug, ... elisions) that don't compile standalone.
A check-tagged block must be self-contained: it stands alone as a module and
imports only from the package's public entry points. If a snippet needs a value
the example can't construct, declare const x: SomeType; it. Reach for the
marker on snippets that pin an API shape worth protecting from drift — not as
a blanket requirement on every block.
The files field in package.json includes only dist/ and blocks/ directories.
License
PolyForm Noncommercial 1.0.0
