@headroom-cms/api
v0.9.0
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.
Downloads
390
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.search(query, opts?) | SearchResult | Full-text search published content — see below |
| 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 |
| client.unsubscribeFromNewsletter(email, token, sendId?) | void | Opt a recipient out of the site's newsletter — see below |
Newsletter unsubscribe
Headroom sends newsletters, but the unsubscribe page is yours: the site's
newsletter.unsubscribeUrl points at your frontend, and Headroom substitutes
{token} and {email} into it per recipient. Your page reads them off its own
query string and forwards them to this method, so recipients never see the API
host.
// src/pages/unsubscribe.astro (server-rendered)
const params = new URL(Astro.request.url).searchParams;
await client.unsubscribeFromNewsletter(
params.get("email")!,
params.get("token")!,
params.get("sendId") ?? undefined,
);Call this from your server, not the browser — the API key must not reach the
client. Like every other SDK call, this one is sent with your site's
HEADROOM_API_KEY, so the unsubscribe page cannot be a credential-free static
asset. Server-render it (as above) and the key stays server-side. The key is not
what authorizes the opt-out — the per-recipient token is — but it is still a
site-wide credential you do not want to publish.
Here is the same call without the framework globals, so you can see the exact signature:
import { HeadroomClient } from "@headroom-cms/api";
declare const query: URLSearchParams;
const client = new HeadroomClient({
apiUrl: "https://api.example.com",
mediaUrl: "https://media.example.com",
site: "example.com",
apiKey: "hk_live_…",
});
await client.unsubscribeFromNewsletter(
query.get("email")!,
query.get("token")!,
query.get("sendId") ?? undefined,
);Two things to get right in the page you build around it:
- It resolves identically whether or not the address is a known recipient.
The endpoint answers
204either way so it cannot be used to test which addresses are subscribed. Render "you're unsubscribed" on success — never "we could not find that address". - A
tokenthat does not verify throws aHeadroomErrorwith status401, and the endpoint is rate limited per IP (429past the cap). Both mean "show a retry / contact link", not "already unsubscribed".
sendId is optional and purely for attribution — supplying it records the
unsubscribe against that send's analytics.
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).
Search
client.search(query, opts?) runs a full-text query over the site's
published content and returns a scored, paginated envelope.
import { HeadroomClient, HeadroomError } from "@headroom-cms/api";
declare const client: HeadroomClient;
declare const q: string;
try {
const { total, results } = await client.search(q, {
collection: "posts",
limit: 10,
});
console.log(`${total} matches`);
for (const hit of results) {
console.log(hit.title, hit.slug, hit.score);
}
} catch (e) {
if (e instanceof HeadroomError && e.status === 404) {
// Search is not enabled for this site — a settings state, not an error.
} else if (e instanceof HeadroomError && e.code === "SEARCH_INDEX_BUILDING") {
// 503 while the site's index builds for the first time. Retry in ~5s.
} else {
throw e;
}
}Things worth knowing before you build a search box on it:
- Search is opt-in per site and off by default. Until an administrator
enables it (Site Settings → Search, or
headroom search reindexafter enabling), this route answers404— deliberately indistinguishable from an unknown site. Treat404as "no search here", not as a bug. - Published content only, and that is structural. The published index and the administrative one are separate artifacts, so a phrase that exists only in an unpublished draft cannot match here even by accident.
- Terms are ANDed and matched whole-word. There is no prefix, phrase or fuzzy matching on the public route, and no stemming — "run" does not match "running". Accents and case fold, so "café" matches "cafe".
snippetis the item's own snippet, not a highlighted excerpt. Headroom does not store body text in the index, so there is nothing to highlight from.scorecompares only within one response. It is unnormalized BM25 and shifts as the site's corpus changes. Results already arrive sorted by it.- Bounds:
query≤ 256 bytes and must contain at least one indexable term (an empty or punctuation-only query is a400, not an empty page);limit1–100 (default 20);offset≤ 1,000.
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
@headroom-cms/api/react is server-safe: it carries no "use client"
directive, so under React Server Components BlockRenderer and every block
component render on the server and accept a resolveContentLink function and a
components map straight from a Server Component. Nothing here needs a client
boundary — live preview is the one exception, and it is the consumer that
declares it (see React island).
0.4.0-0.6.0 shipped this entry with a blanket
"use client"banner, which made every RSC page rendering blocks fail with "Functions cannot be passed directly to Client Components". Fixed in 0.7.0.
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
})}Writing your own table component
A table block's content is an object ({ type: "tableContent", rows }) rather
than an array of inline runs, and a cell has two live shapes: a bare array of
inline runs, or a tableCell object carrying that array on .content. BlockNote
writes the object form as soon as a table uses any per-cell feature (a merged
cell, a per-cell color), and both shapes exist in stored documents — mapping a
cell directly throws cell.map is not a function, which in an SSR route is a 500
on the whole page.
The built-in Table components handle both. If you write your own, normalize
through the exported helpers rather than narrowing by hand:
import {
tableCellContent,
tableCellSpans,
tableHeaderCounts,
} from "@headroom-cms/api";
import type { Block, TableContent } from "@headroom-cms/api";
function tableCells(block: Block) {
const content = block.content as TableContent | undefined;
const { headerRows, headerCols } = tableHeaderCounts(content);
return (content?.rows ?? []).map((row, rowIndex) =>
row.cells.map((cell, cellIndex) => ({
tag: rowIndex < headerRows || cellIndex < headerCols ? "th" : "td",
runs: tableCellContent(cell), // InlineContent[], whichever shape it arrived in
...tableCellSpans(cell), // { colspan?, rowspan? } — omitted when 1
})),
);
}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()],
});Checks the Headroom API for version changes and triggers a content sync when content is updated in the admin UI.
It is demand-driven, not a poller. A check runs when your dev server serves a page (document requests only — assets and HMR don't trigger one), throttled to at most one per interval. With no traffic it makes zero requests, so an idle or forgotten astro dev costs nothing.
headroomDevRefresh({ interval: 5000 }) // min ms BETWEEN checks (default 5000)
headroomDevRefresh({ interval: 0 }) // check on every page loadinterval is a throttle floor, not a poll period. Because the check is awaited before the page renders, a publish is visible on your next reload rather than arriving on an already-open page.
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).
If you use the composite headroom() integration, this is already folded in — pass refreshInterval to configure it, or refreshInterval: 0 to disable it entirely.
Under Astro 7 this integration's log line may not be on your terminal — see
Astro 7: astro dev sometimes detaches below.
Astro 7: astro dev sometimes detaches
This section is the normative home for Headroom's astro dev lifecycle guidance. The
scaffolded-project CLAUDE.md, this repo's root CLAUDE.md and
packages/sample-site/scripts/newsletter-render-check.sh all point here rather than restating
the mechanism.
astro dev runs in the foreground by default. It detaches — printing one JSON line and
exiting 0 while the server keeps running — in exactly two cases:
- Astro detects an AI-agent environment. The check is
am-i-vibing's environment-variable detection (CLAUDECODE,CURSOR_TRACE_ID,REPL_ID,AIDER_API_KEY, …), reached fromastro/dist/cli/dev/index.js:86asconst agentDetected = !process.env.ASTRO_DEV_BACKGROUND && isRunByAgent(). - You passed
--background.
ASTRO_DEV_BACKGROUND=1 forces the foreground even inside a detected agent environment.
stdout being a TTY is not consulted. There is no
isTTYcheck anywhere inastro/dist/cli/. A CI job, a> logredirect and a plain script all keep the ordinary foreground behaviour — only agent detection and--backgroundchange it. Measured against Astro 7.2.0 with stdout non-TTY in both arms: withCLAUDECODEset the command returned in 2s and the server survived; withCLAUDECODEunset the identical command blocked until it was killed at 21s, printed Astro's human-formatted output, and wrote no.astro/dev.log.
Two consequences for headroomDevRefresh() specifically, when the server has detached:
Its
Checking https://…/version on page loadsline goes to.astro/dev.log, not your terminal. Read it withastro dev logs --follow; the format is structured JSON ({"message":…,"label":"headroom-dev-refresh","level":"info"}), not Astro 6's human-formatted lines. Thelabelis a function of HOW you registered the integration, because Astro derives it fromforkIntegrationLogger(integration.name)on whichever integration owns the hook (measured live on 7.2.0, both paths):| Registration |
labelin.astro/dev.log| |---|---| |integrations: [headroomDevRefresh()]— standalone, as shown above |headroom-dev-refresh| |integrations: [headroom({…})]— folded in, which forwards its own logger |headroom|So for the standalone form documented in this section a filter on
"headroom"alone matches nothing; if you registeredheadroom({…})instead, filter on"headroom"and"headroom-dev-refresh"matches nothing. Grep for the message text if you are unsure which form a project uses.A lock file makes a second
astro devreuse the server that is already running and silently ignore--port— asking for4431while4430is up exits 0 after printingDev server already running at http://localhost:4430 (pid …). Stop the old server first (astro dev stop) or replace it (astro dev --force, which honours the new--port).--ignore-lockis not the escape here: it is rejected with a non-zero exit whenever backgrounding is active ("--ignore-lockcannot be used together with an auto-detected AI agent environment"). It is a foreground-only flag. In the foreground the same collision is loud rather than silent — Astro throws and namesastro dev stop/astro dev --forceitself.
Manage a detached server with astro dev status | logs | stop, and detach deliberately with
astro dev --background. Because a detached server is invisible to shell job control, any
script that needs a dev server should start it with astro dev --background and stop it with
astro dev stop, never & plus kill — $! is the PID of a shim that has already exited,
so kill reports success while the real server keeps running and keeps holding its port. The
next run then silently reuses that stale server via the lock file, answering from different code
(and often a different secret) with no error anywhere.
(Measured against Astro 7.2.0 — steering/2026-08-10_ASTRO_7_UPGRADE.md E3.2.)
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.
Live preview & editing
Headroom's admin content editor can host a live preview panel that iframes
your site rendering the draft being edited. The site is the guest; the admin
stays top-level (so its Cognito session keeps working — a cross-site iframe would
have its storage partitioned). Everything the site needs travels in the URL (a
scoped, short-lived preview token) plus an optional postMessage bridge — no
admin credentials ever reach the customer origin.
The panel negotiates one of two modes per iframe load, automatically:
| Mode | When | Update mechanism |
|------|------|------------------|
| Reload | Any page reachable through _headroom/goto (zero-JS Astro pages included) | After each autosave the panel re-navigates the iframe through {env.url}/_headroom/goto/{id}?preview={token}. A slug edit still lands on the draft's new path; the token re-mints transparently near expiry. |
| Live | Pages that mount the SDK's HeadroomPreviewProvider (React islands / Next) | A ready handshake upgrades the panel to postMessage. Scalar fields and body text/structure patch on keystroke (optimistic); media, refs, relationships, and signed images arrive from a server-normalized payload after each autosave (authoritative, supersedes the optimistic patch). |
Mode-negotiation contract
The site announces itself and the panel classifies the frame; the messages carry
the shared { type, v } editing envelope (see @headroom-cms/api/editing):
alive— the frame reached the site. The provider posts it on mount; the Astro preview middleware injects the same zero-JS ping on published-token HTML. Onalivethe panel enters reload mode (reachable).ready(carriescontentIds) — a live provider is mounted for those ids. AreadywhosecontentIdsinclude the previewed id upgrades the panel to live mode, at any time (a slowclient:visibleisland still upgrades).- No
alivewithin the timeout (even though the iframe firedload, e.g.X-Frame-Options: DENYpainted a blocked frame) → the panel declares the frame unreachable and offers an "Open live preview window" popup (which still runs live/reload mode viawindow.opener) plus a plain new-tab fallback.
Site → admin messages carry content ids only; admin → site messages carry
draft data and always target the environment origin exactly (never "*"). The
provider accepts messages only from an admin origin you configure and from
window.parent.
React island (useHeadroomContent)
A page becomes live-capable by wrapping its content in HeadroomPreviewProvider
and reading the SSR-fetched item through useHeadroomContent. Registration is
what advertises the id in the ready handshake — no provider means the hook
simply returns the initial data unchanged (isLive: false).
These three exports use React hooks, so they run only inside a client
boundary you declare. The SDK deliberately does not declare one for you: a
"use client" directive is entry-scoped, and putting it on ./react would drag
the whole block renderer into the client graph. Under Next's App Router that
means a "use client" file of your own (as below); in Astro it means a
client:* directive on the island, which needs no directive in the source.
"use client";
import {
HeadroomPreviewProvider,
useHeadroomContent,
HeadroomPreviewToolbar,
} from "@headroom-cms/api/react/preview";
import { BlockRenderer } from "@headroom-cms/api/react";
// `initial` is the draft fetched server-side via client.withPreview(token).content.get(id).
function Article({ initial, baseUrl }) {
const { data, isLive } = useHeadroomContent(initial); // live-updates while edited
return (
<article>
<h1>{data.title}</h1>
{/* `content` is the blocks-typed body field in this collection's schema. */}
<BlockRenderer blocks={data.fields.content} baseUrl={baseUrl} refs={data._refs} />
</article>
);
}
export default function PreviewArticle({ initial, baseUrl, adminUrl, site }) {
return (
<HeadroomPreviewProvider adminOrigins={[adminUrl]}>
<Article initial={initial} baseUrl={baseUrl} />
{/* Toolbar on a shared preview URL (plain tab); auto-hides inside the panel iframe. */}
<HeadroomPreviewToolbar adminUrl={adminUrl} site={site} />
</HeadroomPreviewProvider>
);
}Two import paths, on purpose. The preview provider/hook/toolbar come from
@headroom-cms/api/react/preview, which ships"use client";BlockRenderercomes from@headroom-cms/api/react, which is server-safe and renderable from a React Server Component. They are separate entries because the preview provider callsReact.createContextat module scope — bundling it alongsideBlockRenderermade merely importing the renderer throw in RSC. Importing the preview symbols from@headroom-cms/api/reactwill fail; that path stopped exporting them in 0.8.0.
adminOrigins is the origin(s) of your Headroom admin UI — the sole postMessage
target and the only accepted message source. HeadroomPreviewToolbar renders a
fixed "Editing draft · Open editor" badge on a standalone preview tab and links
to {adminUrl}/sites/{site}/content/-/edit/{contentId}; it detects the admin
panel iframe and hides itself there (the panel supplies its own chrome).
Image caveat. The React renderer emits unsigned
baseUrl + pathimage URLs (signing is server-only), so previews show originals where the published page shows signed transforms.
Astro preview-island pattern
Published pages stay zero-JS; preview pages hydrate one island. A detail
route reads the preview params and branches: preview requests fetch the draft
by id and render a single React island; published requests keep the existing
zero-JS <BlockRenderer.astro> path untouched.
---
import { readPreviewParams } from "@headroom-cms/api";
import PreviewArticle from "../components/PreviewArticle.tsx";
const preview = readPreviewParams(Astro.url.searchParams);
const item = preview
? await client.withPreview(preview.token).content.get(preview.contentId)
: await client.content.bySlug("posts", Astro.params.slug);
---
{preview
? <PreviewArticle client:load initial={item} baseUrl={mediaUrl} adminUrl={adminUrl} site={site} />
: <BlockRenderer blocks={item.fields.content} refs={item._refs} baseUrl={mediaUrl} />}Astro sites that skip the island still get reload mode with no code beyond
the preview branch — the island is what unlocks live mode. Because the island
renders the body through BlockRenderer, the live body patch flows into it with
no extra site code. Wire the _headroom/goto + manifest routes with the Astro
integration (@headroom-cms/api/astro), and stamp the preview response headers
via the preview middleware (@headroom-cms/api/astro/middleware/preview) — that
middleware injects the zero-JS alive ping and rewrites frame-ancestors to the
admin origin on preview responses only, so published pages keep their framing
protection.
// astro.config.mjs
import headroom from "@headroom-cms/api/astro";
export default defineConfig({
output: "server", // SSR needed for goto + preview
adapter: node({ mode: "standalone" }),
integrations: [
headroom({
capabilities: ["goto", "preview"],
adminOrigins: ["https://admin.example.com"],
// Pass `resolve` as a MODULE SPECIFIER (string), NOT an inline closure.
resolve: "./goto-resolve.mjs",
}),
],
});resolve must be a module specifier for production SSR. The integration
captures resolve in astro:config:setup, which runs at build time but not
inside the production node server (node ./dist/server/entry.mjs). An inline
resolve: (entry) => … closure therefore survives astro dev and static builds
but is lost in a production SSR build — goto then errors at request time
(the build also warns). Pass a module specifier whose default export is
your resolver (resolve: "./goto-resolve.mjs", resolved relative to your project
root); the integration bundles that default export into the server via a Vite
virtual module so goto resolves the real path in production. (The Next.js
handler below is unaffected — its resolve closure runs at request time in the
route handler.)
readPreviewParams / previewResponseHeaders / previewFramingHeaders are on
the core @headroom-cms/api entry; PREVIEW_TOKEN_PARAM /
PREVIEW_CONTENT_PARAM are on @headroom-cms/api/callbacks.
Next.js Integration
Next.js gets the same _headroom/ protocol surface Astro does, so the Headroom
admin's live-preview panel meets both frameworks identically.
_headroom/ route handler
Serve the capability manifest and the goto redirect from one App Router route:
// app/%5Fheadroom/[...headroom]/route.ts
import { createHeadroomHandler } from "@headroom-cms/api/next/handler";
export const { GET } = createHeadroomHandler({
capabilities: ["goto", "preview"],
// Map a content entry to the same-origin path your app renders it at, or null.
resolve: (entry) =>
entry.collection === "posts" ? `/blog/${entry.slug}` : null,
adminOrigins: ["https://admin.example.com"], // for the manifest CORS echo
});
export const dynamic = "force-dynamic";The folder name is load-bearing. In the App Router a folder literally named
_headroom is a private folder
— it and everything under it are removed from routing, so the handler would
silently 404. %5F is Next's documented escape for a literal leading underscore
in a route segment; the served path is still /_headroom/…, so the manifest and
headroom doctor --env see the standard protocol paths. Do not "tidy" the
folder back to _headroom.
The handler is web-standard Request/Response and imports nothing from next,
so the same export also drops into a Hono or Cloudflare Workers route.
Config resolves from the HEADROOM_* env vars (configFromEnv()); pass
config to override.
Preview page pattern
A detail page reads the preview params (§ preview protocol) and, when present,
fetches the draft through client.withPreview(token) — otherwise it renders
the published entry as usual. In Next 15 params/searchParams are Promises:
// app/blog/[slug]/page.tsx (Server Component)
import { readPreviewParams } from "@headroom-cms/api";
export default async function Page({ params, searchParams }) {
const { slug } = await params;
const preview = readPreviewParams(await searchParams);
const post = preview
? await client.withPreview(preview.token).content.get(preview.contentId)
: await client.content.bySlug("posts", slug);
return <ArticleIsland initial={post} />; // client component: useHeadroomContent
}ArticleIsland is a client component using useHeadroomContent (see
React / the preview provider) so the admin panel can push live edits
into the framed page. A Next preview page hydrates that provider, whose ready
message subsumes the frame-alive ping — no extra ping is needed (the ping only
matters for zero-JS Astro pages).
Preview response headers (middleware)
A Server Component cannot set response headers, so the draft token-hygiene
headers (no-referrer, no-store, noindex) and the framing headers that let the
admin panel iframe the page come from middleware.ts, matched on the preview
param:
// middleware.ts
import { NextResponse } from "next/server";
import {
previewResponseHeaders,
previewFramingHeaders,
} from "@headroom-cms/api";
import { PREVIEW_TOKEN_PARAM } from "@headroom-cms/api/callbacks";
const ADMIN_ORIGINS = ["https://admin.example.com"];
export function middleware(req) {
if (!req.nextUrl.searchParams.has(PREVIEW_TOKEN_PARAM)) {
return NextResponse.next();
}
const res = NextResponse.next();
for (const [k, v] of Object.entries(previewResponseHeaders())) {
res.headers.set(k, v);
}
for (const [k, v] of Object.entries(previewFramingHeaders(ADMIN_ORIGINS))) {
res.headers.set(k, v);
}
return res;
}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,
TableCell,
TableCellProps,
// 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 + live-preview components — no "use client" (server-safe; preview needs the consumer's own client boundary) |
| @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/next | ESM + CJS | Next.js React client helpers (DevRefresh) — "use client" |
| @headroom-cms/api/next/handler | ESM + CJS | Next.js App Router _headroom/ route handler (server) |
| @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
