npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/api

Peer 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:

  • apiUrl serves /v1/* (client.content.*, client.collections.*, client.blockTypes.*)
  • mediaUrl serves /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 204 either 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 token that does not verify throws a HeadroomError with status 401, and the endpoint is rate limited per IP (429 past 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 one GET /v1/{site}/version fetch before the actual read, then injects the returned integer into the outgoing X-Headroom-Version header. 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 /version fetch 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 to 10000 (10 s). Lower = lower worst-case staleness, higher origin load. Setting it to 0 disables 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 public getVersion() method always hits the origin and returns a live value, independent of versionRefreshIntervalMs. It populates the internal cache as a side effect, so an immediately-following content.* 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() uses POST /content/batch and 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 /version fetch 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 reindex after enabling), this route answers 404 — deliberately indistinguishable from an unknown site. Treat 404 as "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".
  • snippet is 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.
  • score compares 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 a 400, not an empty page); limit 1–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 resolveContentLink to 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, not body. A submission response has fields: { message, rating, ... } at the top level. Block editors are not supported for submission collections, so there is no body.content array.

  • SubmissionMetadata shape:

    | 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, optional name) | | 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 ...submissionMeta and puts user fields under a top-level fields object. Regular-content collections also use a top-level fields: { ... } object (Phase 7 of CONTENT_MODEL_UNIFICATION consolidated the legacy body wrapper into fields for both content types). See steering/ZOD.md for 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 load

interval 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:

  1. 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 from astro/dist/cli/dev/index.js:86 as const agentDetected = !process.env.ASTRO_DEV_BACKGROUND && isRunByAgent().
  2. 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 isTTY check anywhere in astro/dist/cli/. A CI job, a > log redirect and a plain script all keep the ordinary foreground behaviour — only agent detection and --background change it. Measured against Astro 7.2.0 with stdout non-TTY in both arms: with CLAUDECODE set the command returned in 2s and the server survived; with CLAUDECODE unset 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 loads line goes to .astro/dev.log, not your terminal. Read it with astro dev logs --follow; the format is structured JSON ({"message":…,"label":"headroom-dev-refresh","level":"info"}), not Astro 6's human-formatted lines. The label is a function of HOW you registered the integration, because Astro derives it from forkIntegrationLogger(integration.name) on whichever integration owns the hook (measured live on 7.2.0, both paths):

    | Registration | label in .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 registered headroom({…}) 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 dev reuse the server that is already running and silently ignore --port — asking for 4431 while 4430 is up exits 0 after printing Dev 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-lock is not the escape here: it is rejected with a non-zero exit whenever backgrounding is active ("--ignore-lock cannot 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 names astro dev stop / astro dev --force itself.

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. On alive the panel enters reload mode (reachable).
  • ready (carries contentIds) — a live provider is mounted for those ids. A ready whose contentIds include the previewed id upgrades the panel to live mode, at any time (a slow client:visible island still upgrades).
  • No alive within the timeout (even though the iframe fired load, e.g. X-Frame-Options: DENY painted a blocked frame) → the panel declares the frame unreachable and offers an "Open live preview window" popup (which still runs live/reload mode via window.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"; BlockRenderer comes from @headroom-cms/api/react, which is server-safe and renderable from a React Server Component. They are separate entries because the preview provider calls React.createContext at module scope — bundling it alongside BlockRenderer made merely importing the renderer throw in RSC. Importing the preview symbols from @headroom-cms/api/react will 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 + path image 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 build

Package 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 public

README 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