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

@bettercms-ai/next

v0.39.0

Published

The BetterCMS adapter for Next.js — typed, cache-aware content reads (getEntry/listEntries), draft preview, build-time content snapshot, and llms.txt generation for the App Router. The /blocks, /form, /field, /search and /visual-editing entries are framew

Readme

@bettercms-ai/next

The BetterCMS adapter for Next.js (App Router). Typed, cache-aware content reads, draft preview, and llms.txt generation — the deterministic floor under your CMS.

Pairs with @bettercms-ai/codegen: generate BetterCMSSchema from your content models, then get fully-typed getEntry/listEntries with autocomplete.

Install

npm install @bettercms-ai/next
# peers: next >= 14, react >= 18

Quick start

// lib/cms.ts
import { createBetterCMS } from "@bettercms-ai/next";
import type { BetterCMSSchema } from "./bettercms.generated"; // from @bettercms-ai/codegen

export const cms = createBetterCMS<BetterCMSSchema>({
  workspace: "acme",
  apiKey: process.env.BETTERCMS_API_KEY,
  // baseUrl defaults to https://api.bettercms.ai/api/v1/delivery
  revalidate: 60, // default ISR window (seconds); false = always fresh
});
// app/blog/[slug]/page.tsx
import { cms } from "@/lib/cms";
import { notFound } from "next/navigation";

export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const entry = await cms.getEntry<{ title: string; body: string }>(slug, {
    revalidate: 300,
    // The exact tags the revalidation webhook sends on publish (see "Caching & revalidation").
    tags: ["bcms:acme:all", `bcms:acme:entry:${slug}`],
  });
  if (!entry) notFound();
  return <article><h1>{entry.fields.title}</h1>{/* ... */}</article>;
}
// app/blog/page.tsx — list, typed by your model registry
const { items, hasNextPage } = await cms.listEntries("blog", { page: 1, perPage: 20 });

Environment variables

createBetterCMS, createDraftModeRoute, createRevalidateRoute and <VisualEditing> read no environment variables. You pass every value as an option, so the names in these examples are only a convention. The package reads env in two places:

| Variable | Read by | Meaning | | --- | --- | --- | | BETTERCMS_API_KEY | bettercms-snapshot CLI | Project key that can read models and entries (required) | | BETTERCMS_WORKSPACE | bettercms-snapshot CLI | Workspace slug (required) | | BETTERCMS_API_URL | bettercms-snapshot CLI | API base, default https://api.bettercms.ai/api/v1 | | BETTERCMS_PREVIEW | getContent() | 1 reads live instead of bcms-content.json (preview builds) |

Revalidation secret. The value is the project's revalidation webhook secret (Project → Settings → Revalidation). Store it as BCMS_REVALIDATE_SECRET: createRevalidateRoute() reads that when no secret is passed. Without either, the route accepts unsigned requests.

BetterCMS-generated deploy workflow (connected repo). The repo secret is BCMS_API_KEY (given only to the content-fetch step, never to the build) and the workspace is the repo variable BCMS_WORKSPACE_SLUG. The build step gets PUBLIC_BCMS_API_URL, PUBLIC_BCMS_WORKSPACE, PUBLIC_BCMS_PROJECT_ID and PUBLIC_SITE_URL. This package reads none of them for you. Pass them into createBetterCMS yourself. They are server-only in Next unless you re-export them with a NEXT_PUBLIC_ prefix.

API

createBetterCMS<Schema>(config)

  • workspace — workspace slug (required)
  • apiKey — delivery API key (sent as Authorization: Bearer)
  • baseUrl / previewBaseUrl — override the API base
  • revalidate — default ISR window (seconds) or false

getEntry<TFields>(slug, opts?) → BetterCMSEntry<TFields> | null

Single published entry by content slug. null on 404. Options: revalidate, tags, depth (0–2 ref hydration), select (field projection → result is Partial<TFields>), preview + previewToken.

listEntries(model, opts?) → EntryList<Fields>

Published entries for a model, paginated. Throws BetterCMSError (CONTENT_NOT_FOUND) for an unknown workspace/model; an empty model returns an empty page. Options: page, perPage, revalidate, tags, depth, select.

Caching & revalidation

Reads flow through fetch, which Next patches into the data cache. Pass tags and call revalidateTag(tag) on publish. revalidate: false with tags = cache-until-tag; without tags = no-store (always fresh).

The revalidation webhook sends exactly two tags for each published or unpublished resource, and createRevalidateRoute calls revalidateTag on each:

  • bcms:{workspace}:all: everything in the workspace
  • bcms:{workspace}:{type}:{slug}, where type is entry or page

Tag reads with those strings (bcms:acme:entry:${slug}, bcms:acme:page:${slug}, bcms:acme:all for lists). The client does not add them for you, and a tag of any other shape is never invalidated.

A deploy target in "revalidate" mode (a site hosted elsewhere) may point at the same route: it refreshes the paths and tags that call names, or the whole site when it names neither.

A converted page's values, live (bcmsPage)

A site @bettercms-ai/convert converted reads each page's values from bcms-content/<slug>.json. bcmsPage reads the same page live, in the same shape, so the helper's bcms() reads it unchanged:

// app/page.tsx (Server Component)
import { bcmsPage } from "@bettercms-ai/next";
import { BcmsPage } from "@bettercms-ai/next/page";
import bcmsHomeSnapshot from "../bcms-content/home.json";

export default async function Page() {
  const bcmsHome = await bcmsPage("home", bcmsHomeSnapshot);
  return <BcmsPage value={bcmsHome}>{/* … */}</BcmsPage>;
}

// a "use client" file under it
import { useBcmsPage } from "@bettercms-ai/next/page";
const page = useBcmsPage();

In Draft Mode it reads the preview (/api/v1/preview/<slug>, uncached). Otherwise it reads the published row from delivery, cached until a publish names it (bcms:{ws}:all, bcms:{ws}:entry:{slug}, bcms:{ws}:page:{slug}), so the page stays static and createRevalidateRoute refreshes it. When that read fails (an offline build), it falls back to the published content file (BCMS_CONTENT_FILE), then to the snapshot you passed. Client components get the same object through useBcmsPage(), so the server HTML and the hydrated page show the same copy.

Images from the CMS (next.config)

CMS images are remote URLs on cdn.bettercms.ai, which next/image refuses until images.remotePatterns allows it. Wrap the config:

// next.config.ts
import { withBetterCMS } from "@bettercms-ai/next/config";

export default withBetterCMS(nextConfig);

It adds the media host (and NEXT_PUBLIC_BCMS_MEDIA_URL, when the site serves media from its own) after your own patterns, once, and leaves the rest of the config as it is.

Draft preview

Drafts come only from the signed preview endpoint, through getEntry:

// app/api/draft/route.ts — validates the dashboard's preview token, enables Draft Mode,
// stores the token in a cookie, then redirects
import { createDraftModeRoute } from "@bettercms-ai/next";
export const { GET } = createDraftModeRoute({ apiUrl: "https://api.bettercms.ai" });
import { isDraftEnabled, getPreviewToken } from "@bettercms-ai/next";
const previewToken = (await isDraftEnabled()) ? await getPreviewToken() : null;
const entry = previewToken
  ? await cms.getEntry(slug, { preview: true, previewToken }) // uncached
  : await cms.getEntry(slug, { tags: [`bcms:acme:entry:${slug}`] });

getPage has no draft option. It always returns the published page. To show a draft page, use the token-scoped getEntry preview read above. For the Visual Editor canvas, wrap the page's blocks in <BcmsDraftBridge> (@bettercms-ai/next/draft-bridge) instead of <BcmsBlocks>. The editor pushes the draft block tree to it over postMessage, so the page fetches nothing. Put <BcmsDraftMarker /> in the root layout for Draft Mode renders.

Static export (output: "export")

Run bettercms-snapshot before next build, and read content with getContent(config):

{ "scripts": { "build": "bettercms-snapshot && next build" } }

Without bcms-content.json, reads fall back to a live no-store fetch. That makes the routes dynamic, and output: "export" silently drops them, leaving an empty out/. getContent turns that into a loud SNAPSHOT_MISSING build error. Set BETTERCMS_PREVIEW=1 to read live in a preview build instead.

llms.txt

// app/llms.txt/route.ts
import { llmsTxtRoute } from "@bettercms-ai/next";
export const GET = llmsTxtRoute({ models, title: "Acme", workspace: "acme" });

generateLlmsTxt(models, opts) (pure) and fetchModels({ apiUrl, apiKey }) are also exported. Author-controlled values are escaped so content can't forge document structure.

Forms

Forms ship in your content snapshot (bcms-content.json → forms, written by the build Action). Render one natively with <BcmsForm> — real inputs, no iframe, your own styles. It handles conditional showIf fields, URL-query prefill, the honeypot, an optional Turnstile widget, validation errors, and submission. No API key (the endpoint is Turnstile-gated).

// app/contact/page.tsx — a Server Component reads the Action's snapshot...
import { getForm } from "@bettercms-ai/next";
import { ContactForm } from "./contact-form";

export default function ContactPage() {
  const form = getForm("Contact"); // by name or id, from bcms-content.json
  return form ? <ContactForm form={form} /> : null;
}
// ./contact-form.tsx — <BcmsForm> ships from its own "use client" entry.
import { BcmsForm } from "@bettercms-ai/next/form";
import type { DeliveryForm } from "@bettercms-ai/sdk";

export function ContactForm({ form }: { form: DeliveryForm }) {
  return <BcmsForm form={form} turnstileSiteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY} />;
}

Not on React? Submit from anywhere with the framework-agnostic core:

import { submitForm } from "@bettercms-ai/sdk";
await submitForm({ formId, data: { email: "[email protected]" }, turnstileToken });

The dashboard's copy-paste embed snippet remains the zero-build fallback for non-CMS sites.

Page blocks (<BcmsBlocks>) — forms placed in the page builder

When you build a page in the BetterCMS dashboard and drop a Form block onto it, the form renders inline exactly where you placed it. Render the page's blocks with <BcmsBlocks> and pass the forms from readForms() — a form block resolves its formId against them and renders <BcmsForm> for you. Every other block type (heading, text, image, button, spacer, video, columns) renders to plain, class-driven markup you can style.

🔴 Pass components={readComponents()} too. Pages authored as SECTIONS — the shape the dashboard's "Add a section" picker and the AI both produce — are lists of component blocks. A component block whose definition was not delivered renders nothing, so a page can build green and come back showing only your layout's nav and footer. Both readers pull from the same bcms-content.json the deploy Action writes, so this costs one extra call.

// app/[slug]/page.tsx
import { createBetterCMS, readForms, readComponents } from "@bettercms-ai/next";
import { BcmsBlocks } from "@bettercms-ai/next/blocks"; // its own "use client" entry

const cms = createBetterCMS({ workspace: "my-workspace", apiKey: process.env.BETTERCMS_API_KEY! });

export default async function Page({ params }: { params: { slug: string } }) {
  // The page's blocks come from the delivery API (`entry.blocks`); forms from the snapshot.
  const page = await cms.getPage(params.slug);
  const { forms, turnstileSiteKey } = readForms();
  return (
    <BcmsBlocks
      blocks={page.blocks}
      forms={forms}
      // 🔴 NOT optional in practice. A page built out of SECTIONS is a list of `component`
      // blocks, and a `component` block with no matching definition renders NOTHING. Omit
      // this and such a page builds green, returns 200, and shows only your nav and footer.
      components={readComponents()}
      turnstileSiteKey={turnstileSiteKey ?? undefined}
    />
  );
}

Sections in the CMS order (<BcmsSections>)

A page whose sections are written in its own file renders them in the order its CMS page lists them. A server component (no client JavaScript), with its own entry:

import { sectionsFor } from "@bettercms-ai/next";
import { BcmsSections, BcmsSection } from "@bettercms-ai/next/sections";

export default function Home() {
  return (
    <main>
      <BcmsSections {...sectionsFor("home")}>
        <BcmsSection bind="hero">{(root) => <Hero rootProps={root} />}</BcmsSection>
        <BcmsSection bind="code:sec1"><LogoStrip /></BcmsSection>
        <BcmsSection bind="features"><section className="features">…</section></BcmsSection>
      </BcmsSections>
    </main>
  );
}
  • A block bound to a section renders it at the block's position. A single DOM element gets data-bcms-block, data-bcms-style and the block's style cloned onto it. A component child is given a function instead: spread root on the element it renders. Without the function a component is stamped on a box: display:contents while the block has no style, and one display:flow-root box carrying the style once it has (in a flex or grid parent the section is then one item).
  • A section added in the editor (a block with no bind) renders through <BcmsBlocks>.
  • A section no block names: code:<bind> always renders where you wrote it. A bare <bind> does too until the list names one of the page's sections; after that it is a removed section.
  • blocks null (no snapshot) renders every child in source order, untouched.

Pages Router: the page also renders in the browser, so the list must be a page prop: export const getStaticProps = sectionsStaticProps("home"), then <BcmsSections {...bcmsSections}> in a page that takes { bcmsSections }. A page with a data function of its own passes it in: sectionsStaticProps("home", pageStaticProps) (or sectionsServerSideProps) calls it and adds bcmsSections to the props it answers. npx @bettercms-ai/convert --sections writes this wrap for you.

Phone fields with a country code picker

A phone field whose author enabled Country code picker in the builder renders a country control beside the number. Whatever the visitor picks, the field still submits one value, under its own key, in E.164 (+14155550123) — there is no second key to handle.

Two classes are yours to style:

| Class | Element | |---|---| | bcms-phone | the wrapper around the country control and the number | | bcms-phone-country | the country control itself |

@bettercms-ai/next formats the number as it is typed. The formatter and its phone metadata (~80 KB) live in a separate chunk that loads only when a form actually has a picker — a form without one ships none of it. Until that chunk arrives the field is a plain tel input inside the same bcms-phone wrapper, so your CSS applies before and after.

SEO — per-page <head> with generateMetadata

getPage() returns the page's metaTitle, metaDescription, and the rich metaJson (OG / Twitter / canonical / JSON-LD) edited in the dashboard's SEO panel. Map them into the route's <head> with buildMetadata — per-page values layer over your site defaults (the same page-over-site precedence the live *.bettercms.site renderer uses):

// app/[slug]/page.tsx
import type { Metadata } from "next";
import { createBetterCMS, buildMetadata, resolveSeo, type SiteSeoDefaults } from "@bettercms-ai/next";

const cms = createBetterCMS({ workspace: "my-workspace", apiKey: process.env.BETTERCMS_API_KEY! });

// Your project-wide fallbacks (optional). Per-page meta always wins.
const siteDefaults: SiteSeoDefaults = {
  metaDescription: "Selected work, experience, and contact information.",
  ogImage: "https://example.com/og-default.png",
  twitterHandle: "@acme",
};

export async function generateMetadata(
  { params }: { params: { slug: string } },
): Promise<Metadata> {
  const page = await cms.getPage(params.slug);
  if (!page) return {};
  return buildMetadata(page, siteDefaults);
}

buildMetadata covers <title>, description, canonical, Open Graph, and Twitter. JSON-LD isn't part of Next's Metadata, so emit it from the page component using resolveSeo(...).jsonLd:

export default async function Page({ params }: { params: { slug: string } }) {
  const page = await cms.getPage(params.slug);
  if (!page) return null;
  const { jsonLd } = resolveSeo(page, siteDefaults);
  return (
    <>
      {jsonLd.map((node, i) => (
        <script
          key={i}
          type="application/ld+json"
          dangerouslySetInnerHTML={{ __html: JSON.stringify(node) }}
        />
      ))}
      {/* …render page.blocks… */}
    </>
  );
}

Keeping SEO fresh on publish

Reads are ISR-cached (revalidate, default 60s), so a SEO edit appears after the window elapses. For instant refresh on publish, configure the project's revalidation webhook (Project → Settings) to point at a createRevalidateRoute handler, passing the webhook secret as secret. Tag your reads with the bcms:{workspace}:… tags from "Caching & revalidation". cms.getPage(slug, { tags: [`bcms:acme:page:${slug}`] }) is the page form. Without the webhook, edits still propagate, but on the ISR interval instead of immediately.

License

UNLICENSED — internal BetterCMS package.