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.3.1

Published

TypeScript SDK for building sites with [Headroom CMS](https://github.com/headroom-cms). Provides a type-safe API client, block renderers for React and Astro, and an Astro content loader integration.

Readme

@headroom-cms/api

TypeScript SDK for building sites with Headroom CMS. Provides a type-safe API client, block renderers for React and Astro, and an Astro content loader integration.

Installation

npm install @headroom-cms/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.getVersion() | number | Content version (for cache busting) | | client.mediaUrl(path) | string | Prepend base URL to a stored media path | | client.transformUrl(path, opts?) | string | Build a signed image transform URL |

Query Options for content.list

const { items, cursor, hasMore } = await client.content.list({
  collection: "posts",
  select: "meta",             // Required: "meta" | "full" | readonly string[]
  limit: 10,
  cursor: "...",              // Pagination cursor from previous response
  sort: "published_desc",     // "published_desc" | "published_asc" | "title_asc" | "title_desc"
  before: 1700000000,         // Unix timestamp — only items published before
  after: 1690000000,          // Unix timestamp — only items published after
  relatedTo: "01ABC",         // Reverse relationship: items pointing to this content ID
  relField: "author",         // Filter reverse query to a specific relationship field
});

Error Handling

API errors throw a HeadroomError with status and code properties:

import { HeadroomClient, HeadroomError } from "@headroom-cms/api";

try {
  const post = await client.content.get("nonexistent");
} catch (e) {
  if (e instanceof HeadroomError) {
    console.log(e.status); // 404
    console.log(e.code);   // "CONTENT_NOT_FOUND"
  }
}

Content Versioning (cache-key pinning)

Every public-API read goes through the CDN keyed on an X-Headroom-Version header that identifies the site's current content version. The client maintains an in-memory cached version, populated lazily on first read and refreshed on an interval:

  • First read does an extra round trip. On a cold client, the first content.* / collections.* / blockTypes.* call runs 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).

Block Rendering

Content bodies from Headroom contain an array of BlockNote blocks. The SDK provides renderers for both Astro and React.

Astro (Zero JS)

Import .astro components directly from @headroom-cms/api/blocks/*. These ship as source files compiled by your Astro build — no client-side JavaScript is emitted.

This example assumes the posts collection has a field literally named body of type blocks. If your blocks-typed field has a different name, substitute it for body here.

---
import BlockRenderer from "@headroom-cms/api/blocks/BlockRenderer.astro";
import type { Block, RefsMap } from "@headroom-cms/api";

const post = await client.content.bySlug("posts", slug);
// `body` is the name of this collection's `blocks`-typed field; block content
// is inlined under `post.fields.<fieldName>` by the public read.
const blocks = (post.fields?.body ?? []) as Block[];
const refs = (post._refs || {}) as RefsMap;
---

<BlockRenderer
  blocks={blocks}
  refs={refs}
  resolveContentLink={(ref) => `/${ref.collection}/${ref.slug}`}
  transformImage={(path) => client.transformUrl(path, { width: 1200, format: "webp" })}
/>

The block array is read off post.fields, not a post.body property — ContentItem has no body. This snippet pins that shape (see the README snippet check below):

import type { Block, ContentItem } from "@headroom-cms/api";

declare const post: ContentItem;
const blocks = (post.fields?.body ?? []) as Block[];
void blocks;

Props:

| Prop | Type | Description | |------|------|-------------| | blocks | Block[] | Block content array | | baseUrl | string? | Base URL for media (defaults to HEADROOM_MEDIA_URL env var) | | refs | RefsMap? | Content reference map for resolving headroom:// links | | resolveContentLink | (ref: PublicContentRef) => string | Custom URL builder for content links | | transformImage | (path: string) => string | Custom image URL transform (e.g. for responsive images) | | class | string? | CSS class for the wrapper <div> |

Available components (importable individually from @headroom-cms/api/blocks/*):

BlockRenderer, Paragraph, Heading, Image, CodeBlock, BulletList, NumberedList, CheckList, Table, InlineContent, Fallback

React

import { BlockRenderer } from "@headroom-cms/api/react";
import "@headroom-cms/api/react/headroom-blocks.css";

function PostBody({ blocks, refs }) {
  return (
    <BlockRenderer
      blocks={blocks}
      baseUrl="https://headroom.example.com"
      refs={refs}
      resolveContentLink={(ref) => `/${ref.collection}/${ref.slug}`}
    />
  );
}

Props:

| Prop | Type | Description | |------|------|-------------| | blocks | Block[] | Block content array | | baseUrl | string? | Base URL for media | | refs | RefsMap? | Content reference map | | resolveContentLink | (ref: PublicContentRef) => string | Custom URL builder | | components | BlockComponentMap? | Override or extend block components (see below) | | fallback | ComponentType \| null | Custom fallback for unknown blocks (null to suppress) | | className | string? | CSS class for the wrapper <div> |

Available block types: paragraph, heading, image, codeBlock, bulletListItem, numberedListItem, checkListItem, table

Content Links

Rich text can contain headroom://content/{collection}/{contentId} links that reference other content. The _refs map returned with each content item resolves these to metadata:

const post = await client.content.get("01ABC");

// post._refs = {
//   "01DEF": { contentId: "01DEF", collection: "posts", slug: "hello-world", title: "Hello World", published: true }
// }

The block renderer resolves these links automatically:

  • Default: headroom://content/posts/01DEF/{collection}/{slug} (i.e. /posts/hello-world)
  • Custom resolver: Pass 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
})}

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()],
});

Polls the Headroom API for version changes (default: every 5 seconds) and triggers a content sync when content is updated in the admin UI.

Note: headroomDevRefresh() only benefits content-collection setups — it triggers Astro's content layer to re-sync when the Headroom version changes. For pure-SDK sites that call client.content.* directly, it is a no-op (those calls always hit the API live).

When to Use Content Collections vs Direct SDK

| Scenario | Approach | Why | |----------|----------|-----| | Static site, content rarely changes | Content collections (headroomLoader) | Build-time fetch, typed via generated schemas | | SSR site, content must be fresh | Direct SDK (client.content.*) | No caching, always live | | Static site, frequent content edits | Content collections + deploy hook | Rebuild on publish via webhook | | Hybrid: listings static, detail fresh | Content collections for listings, SDK for detail pages | Best of both worlds |

Key rule: Content collections cache at build/startup. getCollection() in SSR will NOT re-fetch on each request. For always-fresh content, use client.content.* directly.

Zod Schema Codegen

Generate type-safe Zod schemas from your Headroom collection definitions:

import { HeadroomClient } from "@headroom-cms/api";
import { generateZodSchemas } from "@headroom-cms/api/codegen";

const client = new HeadroomClient({ /* ... */ });
const code = await generateZodSchemas(client);
// Write `code` to a file (e.g. src/lib/schemas.ts)

This generates a TypeScript file with Zod schemas for each collection, ready to pass to headroomLoader({ schema }). See the sample site for a working example with a generate-schemas.sh script.

Field Types

See docs/FIELD_TYPES.md for the complete field-type reference: every type a collection field can declare, its options.* keys, the admin widget it produces, the generated TS/Zod shape, when to use each (and when not to reach for json), and field-type migration semantics.

Styling

React

Import the default stylesheet:

import "@headroom-cms/api/react/headroom-blocks.css";

Styles use low-specificity :where() selectors, making them easy to override. Customize via CSS custom properties:

| Property | Default | Used by | |----------|---------|---------| | --hr-code-bg | #f3f4f6 | Inline code background | | --hr-link-color | #2563eb | Link color | | --hr-image-radius | 0.5rem | Image border radius | | --hr-caption-color | #6b7280 | Image caption color | | --hr-code-block-bg | #1e1e1e | Code block background | | --hr-code-block-color | #d4d4d4 | Code block text color | | --hr-accent | #2563eb | Checkbox accent color | | --hr-table-header-bg | #f9fafb | Table header background |

Astro

Astro block components render semantic HTML with no built-in styles. Use Tailwind or your own CSS to style the output. The components use standard HTML elements (<p>, <h1><h6>, <ul>, <ol>, <figure>, <table>, etc.) that work naturally with Tailwind's prose class.

TypeScript Types

All types are exported from the main entry point:

import type {
  // Config
  HeadroomConfig,
  TransformOptions,

  // Content
  ContentItem,
  ContentMetadata,
  ContentListResult,
  BatchContentResult,

  // Blocks
  Block,
  InlineContent,
  TextContent,
  LinkContent,
  TextStyles,
  TableContent,
  TableRow,

  // References
  ContentRef,
  PublicContentRef,
  RefsMap,

  // Collections
  Collection,
  CollectionSummary,
  CollectionListResult,
  FieldDef,
  RelationshipDef,

  // Block Types
  BlockTypeDef,
  BlockTypeListResult,
} from "@headroom-cms/api";

React-specific types:

import type { BlockRendererProps, BlockComponentProps, BlockComponentMap } from "@headroom-cms/api/react";

Building & Publishing

pnpm build         # Build all entry points (ESM + CJS + types)
pnpm test          # Run tests
pnpm test:watch    # Run tests in watch mode
pnpm typecheck     # TypeScript type checking
pnpm dev           # Watch mode 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 components | | @headroom-cms/api/react/headroom-blocks.css | CSS | Default block styles | | @headroom-cms/api/blocks/* | Astro source | Astro block components (compiled by consumer) | | @headroom-cms/api/astro | ESM | Astro content loader + dev refresh | | @headroom-cms/api/codegen | ESM + CJS | Zod schema generation | | @headroom-cms/api/comments | ESM + CJS | Comment helpers + tree builder |

npm Publish

pnpm build
npm publish --access 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