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

inscribed

v5.2.0

Published

Inline-editing CMS SDK for Next.js App Router projects

Readme

inscribed

npm version license

Zero-Configuration, JSX-First Inline-Editing CMS SDK for Next.js App Router.

inscribed lets you mark up regions of your existing React tree as editable, then edit them in place, directly on the page and from an admin drawer, allowing no separate CMS dashboard and no content modelling ceremony. The content you author in JSX is the schema. A discovery step walks your app/ directory, registers every editable region with your backend, and the same components render live content for visitors and an inline editor for admins.

The core is backend-agnostic. Everything that talks to a server goes through a small CmsTransport contract; a REST adapter ships as the default, but you can point inscribed at any backend (your own API, Strapi, Sanity, a database, a mock) by implementing that interface. See Bring your own backend.


Table of contents


Features

  • In-place editing. Visitors see content; admins edit it directly on the page, type into text, format RichText with a floating toolbar, swap images on the image, backed by a side drawer for structured types and block details. No context switch to a dashboard.
  • JSX-first content model. Declare editable regions with <EditableRegion>, <EditableList>, <CmsGroup>. The structure of your components is the content schema.
  • Static discovery. A CLI (cms-sync) AST-scans your app/ directory and registers a manifest of every region with your backend. It is idempotent, fits in a predev / prebuild hook.
  • Rich content types. Short/long plain text, RichText (Tiptap), Image, Link, Date, repeatable Lists, and bindings into collections of structured records.
  • Static by default. The root layout reads the whole site's blocks once per language, so every route prerenders at next build with its content in place, and a navigation renders from what the page already brought: no CMS request, no placeholder frame. A publish drops one cache tag and the next request regenerates the route. Collections fetch on the server too and stream behind their own Suspense boundary, so a slow one never holds up the page.
  • Draft autosave. Edits debounce to a draft endpoint as you type; publish is an explicit save.
  • Search metadata. Editors manage each page's title, description, share image and indexing from the drawer, records map theirs from their own fields, and canonical links, hreflang and a sitemap follow from the content.
  • Backend-agnostic core. A single CmsTransport seam isolates all data access. A REST adapter is the default; swap it for any backend.
  • Auth-agnostic core. Session, admin detection, and access tokens are injected callbacks. The core ships a public read-only default and depends on no auth library.

Requirements

inscribed is a peer of your app's framework runtime:

| Peer dependency | Supported range | | --------------- | --------------------------- | | next | ^16.0 | | react | ^19.2 | | react-dom | ^19.2 |

Node 18+ for the cms-sync CLI. The package is ESM-only.

Backend contract. 5.x talks to a backend that serves the whole site at /cms/content/all (and /cms/public/{clientKey}/content/all for anonymous reads), issues content:* capabilities, accepts the whole manifest at POST /cms/sync, and exposes the draft-discard DELETE endpoints. A backend without the whole-site read is read page by page over slugs in the config (see Content delivery); an older one answers some of the rest with 404 and the drawer will not mount for editors. See Bring your own backend for the full surface.

Localization additionally needs a backend that stores content per locale and accepts ?locale= on the content and collection endpoints. It is opt-in: leave locales unset and no locale is ever sent, so a backend that knows nothing about languages keeps working unchanged.

Installation

npm install inscribed

Quick start

The minimal path is a public, read-only site: content renders for everyone, editing is wired separately once auth is in place (see Editing & drafts).

1. Create a config

createCmsConfig returns a plain, serializable object and it is safe to pass across the Server → Client boundary.

// app/lib/cms-config.js
// Server entry on purpose: this file is imported by Server Components, and
// the "inscribed" client entry would make the factory uncallable there.
import { createCmsConfig } from "inscribed/page";

export const cmsConfig = createCmsConfig({
  baseUrl: process.env.CMS_URL,          // backend root, no trailing slash
  cdnUrl: process.env.CMS_CDN_URL,       // optional: image-upload root
  clientKey: process.env.CMS_CLIENT_KEY, // optional: this site's Client key on the reference backend; enables built-in auth + anonymous published reads
  siteUrl: process.env.SITE_URL,         // optional: public origin, for absolute canonical and hreflang links
  // globalSlug: "__global",             // optional: slug for site-wide blocks
  // theme: { accent: "#3b82f6" },       // optional: override the panel palette (see Theming)
});

2. Build a page factory

createCmsPage centralises the boilerplate: it reads the site's blocks server-side, resolves the session, and renders your provider.

// app/lib/cms.jsx
import { createCmsPage } from "inscribed/page";
import { CmsProvider } from "inscribed";

import { cmsConfig } from "./cms-config.js";

export const { CmsPage } = createCmsPage({
  config: cmsConfig,
  Provider: CmsProvider,
  // Public read-only by default. Add getSession / deriveAdmin / onAfterSave
  // and a getServiceToken provider to enable editing - see "Editing & drafts".
  // Add `collections` to get the server-rendered binding components too, see
  // "Collections".
});

3. Wrap the root layout and author content

<CmsPage> goes in the root layout, once. It reads every page's blocks in one request and hands them to the provider, so it never needs to know which page is rendering: nothing per page, no request header, and every route under it prerenders at build.

// app/layout.jsx
import { CmsPage } from "./lib/cms.jsx";

export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <CmsPage>{children}</CmsPage>
      </body>
    </html>
  );
}

A page is just its regions:

// app/page.jsx  (a Server Component)
import { EditableRegion } from "inscribed";

export default function Home() {
  return (
    <main>
      <EditableRegion
        blockPath="hero.title"
        as="h1"
        blockType="ShortText"
        defaultValue="Welcome"
      />
      <EditableRegion
        blockPath="hero.body"
        as="p"
        blockType="RichText"
        defaultValue="<p>Edit me.</p>"
      />
    </main>
  );
}

blockType and defaultValue are discovery-time metadata read by the sync CLI, ignored at runtime. They tell inscribed what kind of editor to show and what to seed the database row with. On a localized site defaultValue can name one value per language; see Localization.

There is no marker to add: every page.{js,jsx,ts,tsx} under app/ is a discovery root, and its slug comes from its own path, so cms-sync already knows this file owns the regions reachable from it (this file plus everything it imports). See Slugs for the derivation rules.

4. Register the manifest

Run the discovery + sync once so the backend knows about your regions. Wire it into your scripts so it stays in sync with the code:

// package.json
{
  "scripts": {
    "predev": "cms-sync",
    "prebuild": "cms-sync"
  }
}

That's the full read path: every route is built with its content in place, a publish marks the cache stale and the next request regenerates the route, and moving between pages renders from what the page already brought (see Content delivery). Editing is the same components plus an auth adapter covered next.


Core concepts

Authoring & discovery

inscribed has no schema file. You declare editable regions inline in your JSX and a static discovery step turns those declarations into a backend manifest.

  • Declare regions with <EditableRegion> / <EditableList>, each carrying blockType + defaultValue literals. (<CollectionRegion> / <CollectionItem> bindings are runtime-only and never enter the manifest; see Collections.)
  • Root nothing by hand: every page.{js,jsx,ts,tsx} under app/ is a discovery root, and its slug is derived from its path (see Slugs). The scanner starts at each page file, follows relative imports from there, and files each reachable region under that page's slug.
  • Discover by running cms-sync. It AST-scans app/, follows relative imports and jsconfig/tsconfig paths aliases, with or without baseUrl (also into files outside app/, e.g. a root-level components/ dir), applies <CmsGroup> prefixes, collects scope="global" regions under the global slug, and builds one manifest per page slug. Files that fail to parse are skipped with a warning; an alias that resolves to nothing warns too instead of silently dropping the file.
  • Sync pushes each manifest to the backend (idempotent). New regions get a row seeded from defaultValue; removed regions are pruned. When discovery finds nothing, cms-sync refuses to push (an empty manifest would soft-delete every remote slug) unless you pass --allow-empty.

Because discovery reads the JSX statically, blockType and defaultValue must be plain literals, the scanner can't evaluate variables or imports.

Rendering it yourself. Pass a function as <EditableRegion>'s children and the markup is yours; it receives the block's value. This is how the types that draw nothing on their own get onto a page:

<EditableRegion blockPath="stats.count" blockType="Number" defaultValue={0} as="p">
  {(value) => <strong>{value} people</strong>}
</EditableRegion>

as still names the tag the region lays out as. Visitors get the children and nothing else, no wrapper element and no listeners; admins get the same hover ring and drawer chip every region has. The chip is the only way in: the children are yours, links and buttons included, so the region takes no click of its own.

The cost is in-place editing. A caret needs a node the SDK produced, so a text or rich-text region handed over this way is edited in the drawer instead, and an image loses its on-image overlay. Every other type loses nothing, since none of them had page-side editing to begin with.

Choosing from a vocabulary. A Select stores a key, and the key means nothing without the list it came from, so it has its own declaration site the way ObjectArray has <EditableList>:

<EditableChoice
  blockPath="post.status"
  defaultValue="draft"
  source={{ kind: "static", values: ["draft", "published", "archived"] }}
/>

It is a region in every other way: same chrome, same group and visibility rules, and a function child renders the value your own way. source is the one prop that never enters the manifest, since a vocabulary is the page's business and the drawer is its only reader.

A block source names another block on the page as the list, which makes the vocabulary content rather than code: an editor adds an option by editing that block instead of opening the repo.

<EditableRegion blockPath="tags" blockType="StringArray" defaultValue={["news"]}>
  {(tags) => tags.map((t) => <li key={t}>{t}</li>)}
</EditableRegion>

<EditableChoice blockPath="featured.tag" defaultValue=""
                source={{ kind: "block", blockPath: "tags" }} />

A StringArray block offers its entries; an ObjectArray offers one field of each row, named by labelField. What gets stored is that field's value, never a row index, so reordering the source list cannot repoint a reference and deleting a row leaves the stored text standing. Blocks on this page and scope="global" ones are both in reach, since the two are fetched together.

A Select inside a list row declares its vocabulary on the column instead, where the rest of the row's shape is:

<EditableList
  blockPath="features"
  itemSchema={{
    name:  { blockType: "ShortText", defaultValue: "" },
    state: {
      blockType: "Select",
      defaultValue: "draft",
      source: { kind: "static", values: ["draft", "live"] },
    },
  }}
>
  {(item) => <li>{item.name}</li>}
</EditableList>

source is dropped on the way to the manifest there too. A row column takes a static source only: a block one has to be resolved against the page, which the list does not do.

A block with nothing on the page. For a value with no presence on screen at all (a setting that only reaches an API call, say), declare it from the hook instead, which has no element to wrap. A page's title and description have a helper of their own, which reaches the document head on the server; see Search & metadata.

const { value: apiKey } = useCmsBlock("settings.key", {
  blockType: "ShortText",
  defaultValue: "",
});

Slugs

A slug is the page identity blocks are addressed by. It is derived from the page file's own path, so it is never written down twice:

| Page file | Slug | | --------- | ---- | | app/page.jsx | / | | app/about/page.jsx | /about | | app/(marketing)/pricing/page.jsx | /pricing | | app/news/[id]/page.jsx | /news/[id] | | app/[locale]/about/page.jsx | /about (with locales configured) |

The rules behind that table:

  • Route groups ((marketing)) organise files without appearing in the URL, so they drop out of the slug too.
  • Dynamic segments ([id]) stay as written: the manifest addresses the route template, not one concrete URL, so /news/1 and /news/2 share a page. That means they also share one set of rows; see Per-URL content before declaring regions on such a page.
  • The leading segment is the locale when cms.config.js exports locales, and it drops out: which language a page is in is not part of which page it is. Without locales it is kept like any other segment. inscribed only ever reads a language from the first path segment (see Localization), which is what makes this unambiguous.
  • Private folders (_lib), parallel-route slots (@modal) and intercepting routes ((.)photo) are not pages of their own, so they are skipped entirely and the regions inside them reach no manifest.
  • A page that declares no regions owns no rows, so its slug never reaches the backend. A collection detail view or a form page costs nothing.

Dynamic routes need nothing extra at runtime. The client matches the concrete path against the site's slugs, so /news/1 reads /news/[id]: an exact slug wins over a template, a template with fewer dynamic segments over one with more, and a catch-all ([...path]) loses to anything more specific. Nothing on the page names the slug, so nothing can disagree with the folder.

Check the derivation with cms-sync --dry-run. It prints each slug beside the page file it came from, which is where a surprise shows up.

Per-URL content

A dynamic-segment slug is one manifest entry, so every concrete URL under it reads and writes the same rows. For /search/[q], whose copy is the same whatever was searched for, that is exactly right. For /kampanya/[slug], where each campaign has its own words, it is a trap: editing /kampanya/yaz-2026 also rewrites /kampanya/kara-cuma. Nothing in the page says which one you meant, so cms-sync warns whenever a dynamic-segment page declares regions and leaves the choice to you.

When each URL needs its own content, two patterns cover it:

Records the editor creates belong in a collection. The route renders one record and declares no regions of its own, so it never enters the manifest:

// app/news/[slug]/page.jsx
<CollectionItem collection="news" slug={slug} missing={<NotFound />}>
  <CollectionField name="title" as="h1" />
  <CollectionField name="body" />
</CollectionItem>

A fixed set of pages the developer owns gets a folder each, with the markup shared through a component. Every folder derives its own slug and therefore its own rows, while the shape stays written once:

// app/kampanya/_body.jsx  (a leading _ keeps Next from routing it)
export function CampaignBody() {
  return (
    <CmsGroup name="hero">
      <EditableRegion blockPath="title" blockType="ShortText" defaultValue="Campaign" as="h1" />
      <EditableRegion blockPath="body" blockType="RichText" defaultValue="<p>Edit me.</p>" />
    </CmsGroup>
  );
}

// app/kampanya/yaz-2026/page.jsx   -> /kampanya/yaz-2026, its own rows
// app/kampanya/kara-cuma/page.jsx  -> /kampanya/kara-cuma, its own rows
import { CampaignBody } from "../_body.jsx";

export default function Page() {
  return <main><CampaignBody /></main>;
}

This is the "shared component reachable from two slugs contributes its regions to both" rule doing its job: one declaration, one set of rows per page.

Blocks & block types

A block is a single editable value addressed by a dot-notation blockPath (e.g. hero.title). The value shape depends on its blockType:

| blockType | Value shape | Editor | | ------------ | ----------- | ------ | | ShortText | string | single-line input | | LongText | string | multi-line textarea | | RichText | HTML string (sanitised) | Tiptap | | Image | { src, alt } | on-image replace / drop-zone + alt | | Link | { href, label } | URL + label | | Date | ISO 8601 string | date picker / countdown | | Number | number \| null | number input | | Bool | boolean | switch | | Url | string | URL input | | Select | string | picker over a source | | StringArray | string[] | tag input, typed freely | | ObjectArray | array of objects shaped by itemSchema | repeatable items |

Not every type draws itself. Number, Bool, Select, StringArray and Date are data: the drawer edits them, and you read them with useCmsBlock and decide how they look. A boolean has no visual form, a Select usually stores a key rather than display text, and how a date reads is a language and design choice. <EditableRegion> renders the rest.

LongText keeps its line breaks. The region renders with white-space: pre-wrap, and Enter in the in-place editor inserts a real newline into the value; left to the browser it would insert a <br>, which the block never sees. Set white-space in your own style and yours wins.

Text was a legacy alias of LongText and is gone in 4.x. Blocks that still arrive typed Text are folded to LongText as they enter the runtime, so older rows and custom transports keep working.

For full control over rendering, pass a function as the region's children (see Discovery & sync): it declares and wraps the block exactly the same way, but the markup is yours. useCmsBlock(blockPath) is the same value without any chrome, returning the raw value, version and an update() callback.

Groups

<CmsGroup name="hero"> prefixes the blockPath of every descendant region. A <EditableRegion blockPath="title"> inside it reads/writes hero.title. Groups nest (dot-joined), and discovery applies the exact same prefix so you never repeat the group name in each path. In admin mode the group also draws a labelled outline so editors can see section boundaries.

The prefix follows the render site, not the file. Wrapping an imported component in a group prefixes the regions it declares too, so the group name is written once, where the component is used:

// page.jsx           -> the list below syncs as hero.highlights
<CmsGroup name="hero">
  <HeroHighlights />
</CmsGroup>

// hero-highlights.jsx -> no group, no repeated prefix
<EditableList blockPath="highlights" … />

A component rendered under two different groups contributes its regions once per prefix.

One limit: discovery follows static JSX, so <CmsGroup name="hero">{children}</CmsGroup> is opaque to it. The prefix still applies at runtime, but the manifest registers those regions unprefixed and they never resolve; cms-sync warns when it sees this. Render the components inside the group instead of taking them as children.

A <CollectionItem> inside a group is filed under it too, but by a different route: its binding carries the group name rather than baking it into a path (see Collections). Nothing of it reaches the manifest, so the {children} limit above doesn't apply to collection bindings.

<CmsGroup> also accepts visible / editable to lock or hide a whole section in one place; the mode cascades to every descendant. See Access control.

Lists

<EditableList> renders a List-typed block as repeatable items via a render-prop. You provide an itemSchema describing each item's fields each field's blockType is one of the leaf types above (ShortText, LongText, RichText, Image, Link, Date). Admins get add / remove / reorder controls and the whole list saves atomically as one version. It accepts the same visible / editable gates as <EditableRegion> (see Access control) read-only drops the add/move/delete affordances and locks the drawer card.

"use client";
import { EditableList } from "inscribed";

export function Team() {
  return (
    <EditableList
      blockPath="team.members"
      itemSchema={{
        name:  { blockType: "ShortText", defaultValue: "" },
        photo: { blockType: "Image",     defaultValue: { src: "", alt: "" } },
      }}
    >
      {(item, i) => (
        <article key={i}>
          <img src={item.photo.src} alt={item.photo.alt} />
          <h3>{item.name}</h3>
        </article>
      )}
    </EditableList>
  );
}

By default the list renders no element of its own, so items land directly in whatever container you wrap it in. Pass as (with the layout props that container had) to fold the wrapper into the list and get the page-side ring and label chip on the block as a whole:

<EditableList
  blockPath="team.members"
  as="div"
  style={{ display: "grid", gap: 12 }}
  itemSchema={{ … }}
>

The as wrapper renders in public mode too, so the layout is identical for visitors and admins, only the ring and chip are admin-only.

Admins get an add slot after the last item, drawn as a ghost of a real card so the grid keeps its shape. Pass noInlineAdd for layouts a ghost card would spoil (a slider, a fixed-size grid); items are then added from the drawer and the rest of the page-side editing is unchanged.

<EditableList blockPath="hero.slides" as="div" noInlineAdd itemSchema={{ … }}>

<EditableList> uses a render-prop, a function child, so it must live in a "use client" component. Wrap the usage and import that wrapper into your server page. The Collection components below take element children instead, precisely so they don't need this.

Collections

Collections are a separate namespace for structured data that lives outside the page (e.g. all News articles, all Teams). The page binds to a collection and renders its records.

The collection layer is an opt-in capability with its own entry point: import it from inscribed/collections, not inscribed, so apps that don't use collections never pull it into their bundle. Opting in is one decision, the collections option below; nothing in inscribed reaches into the layer on its own, including the provider that holds its state.

  • <CollectionRegion collection="news" filter={...} limit={...}> renders a list.
  • <CollectionItem collection="news" slug="q1-notes"> renders one record.

Neither takes a blockPath. A binding is identified by what it points at, the record for an item and the (collection, filter) window for a region, so the same article rendered in two places on a page is one drawer card, not two. Inside a <CmsGroup> an item's card is filed under that group; group and label override the placement and the card text without changing which record is bound.

Fetching on the server

Both components come in two forms with the same children contract. Which one you import decides where the data is fetched:

| Import from | Fetches | Use when | | ----------- | ------- | -------- | | your createCmsPage factory | server, streamed | the default: content reaches the HTML a crawler sees | | inscribed/collections | client, on mount | you are already inside a "use client" component |

The server form takes the route's language from <CmsPage>. Next renders a page beside its layout rather than inside it, though, so a region can ask before <CmsPage> has published the language; it then reads the request header the proxy sets, which makes the route dynamic, and next build warns about it. Passing locale to the region rules that out. Without it, an async page that awaits its params comes late enough while the layout awaits nothing slower than params before <CmsPage>; a synchronous page does not. A single-language site has no language to find and reads nothing.

A <CollectionItem> reads its record in that language too, so a record placed on a page in another language shows its translation (or missing when it has none). It never falls back to the header: rendered before <CmsPage> has published the language, it reads the record as it was asked for, and next build warns. locale pins the language, which on a detail route takes that order out of play (locale={locale} from the page's params), and locale={null} reads without one. The client form reads the slug as it is given.

Opt in with the collections option; the factory then returns the components beside CmsPage:

// app/lib/cms.jsx
import { CmsProvider } from "inscribed";
import { CollectionProvider, CollectionRecord, CollectionRows } from "inscribed/collections";
import { createCmsConfig, createCmsPage } from "inscribed/page";
import { revalidateCmsCollection, revalidateCmsSlug } from "inscribed/actions";

export const { CmsPage, CollectionRegion, CollectionItem } = createCmsPage({
  config: createCmsConfig({ baseUrl: process.env.CMS_URL }),
  Provider: CmsProvider,
  collections: { CollectionProvider, CollectionRecord, CollectionRows },
  onAfterSave: revalidateCmsSlug,
  onAfterCollectionSave: revalidateCmsCollection,
});

All three are internals; they appear in your wiring for the same reason Provider does. Only a module's own exports become client references across the Server → Client boundary, so the client half of these components has to be handed in by name: reached from the server entry's own imports it would lose its "use client" boundary, and wrapped in an object it would arrive undefined.

CollectionProvider holds the collection state that the page bindings and the admin drawer share. The factory forwards it to Provider as the collections prop; mounting <CmsProvider> yourself means passing it there directly:

<CmsProvider config={config} collections={CollectionProvider}>

It has to be a prop rather than something you nest inside <CmsProvider>, because it wraps the admin drawer too, and the drawer is a sibling of your children, not a descendant.

Each server component is a synchronous shell wrapping its own <Suspense> around an async fetch. That is what keeps a slow collection off the critical path: the page shell flushes immediately and the records stream in behind it, still landing in the document. You write no boundary of your own, which matters because a collection may be reading external data whose latency you don't control.

// app/page.jsx  (a Server Component)
import { CollectionField } from "inscribed/collections";
import { CollectionRegion } from "./lib/cms.jsx";

export default function Home() {
  return (
    <CollectionRegion
      collection="news"
      limit={5}
      as="ul"
      fallback={<NewsSkeleton />}
      empty={<p>No news yet.</p>}
    >
      <li>
        <CollectionField name="title" as="h3" />
        <CollectionField name="summary" as="p" />
      </li>
    </CollectionRegion>
  );
}

Children are elements, not a function, which is what lets them cross the Server → Client boundary. A region renders its children once per record, each under that record's own scope, so <CollectionField> resolves against the row it sits in. as folds a wrapper element (here <ul>) into the region and takes any extra props.

Every state other than "records resolved" is a prop:

| Prop | Shown when | | ---- | ---------- | | fallback | the read is in flight (client form only; the server form has awaited it) | | empty | a region resolves to zero records | | missing | an item's record does not exist (404) | | error | the read failed for any other reason; defaults to missing |

Server-fetched records are cached under cms-collection-{key} (plus cms-collection-{key}-{slug} for a single record), so publishing one must drop those tags: that is what onAfterCollectionSave is for. Omit it and the page keeps serving the pre-publish row.

Computing with a record

<CollectionField> renders a field. When markup needs to compute with one, an href built from the slug, a conditional, a formatted date, reach for useCollectionRecord() in a small client component nested inside the record:

// app/news-card-link.jsx
"use client";
import Link from "next/link";
import { useCollectionRecord } from "inscribed/collections";

export function NewsCardLink({ children }) {
  const { slug } = useCollectionRecord();
  return <Link href={`/news/${slug}`}>{children}</Link>;
}

data on that record is draft-overlaid, so an editor sees what they are typing and a visitor sees what is published.

Interactive windows stay on the client. A filter, a search or pagination driven by user input can't be resolved on the server, so build those with useCollection / useCollectionItem in your own component. useCollection(key, { q }) searches the displayField and the slug on the backend, ignoring case and diacritics. Those hooks remain the full client-side API, and refetch (which needs a callback, and so no longer crosses the children boundary) lives there too.

Editing a field in place

<CollectionField> renders one field of the enclosing item and, for an admin who may edit the record, turns it into the same in-place editor <EditableRegion> uses. The item's ring then carries publish and revert, so a quick fix never has to travel to the drawer:

<CollectionItem collection="news" slug={slug} missing={<NotFound />}>
  <article>
    <CollectionField name="title" as="h1" style={titleStyle} />
    <CollectionField name="summary" as="p" style={summaryStyle} />
  </article>
</CollectionItem>

A detail route has two more things to settle, and CollectionItem carries both, so the page never has to know how Next resolves a route:

// app/[locale]/news/[slug]/page.jsx
export const generateStaticParams = CollectionItem.staticParams("news");

export const generateMetadata = CollectionItem.metadata("news", {
  map: (item) => ({ title: item.data.title }),
  path: (slug, { locale }) => localePath(`/news/${slug}`, locale),
});

staticParams lists the collection's slugs, so next build renders a page per record rather than leaving each one to its first visitor. It pages through the collection and stops at max (default 1000); a slug past that still works, Next just renders it on demand. On a localized site it runs once per language and reads the rows in that one.

metadata names the record's own address as the canonical one, which matters because a renamed record keeps answering to its old slug: without it two URLs serve one record and a crawler picks. path is how that address gets built, and it is the one option worth passing. Leave it out and the address is derived from the request instead, which makes the whole route dynamic for the sake of one link. A collection with an seo entry in the config names its path and its search fields there once, and the call shrinks to CollectionItem.metadata("news"); see Search & metadata.

An Image field gets the same treatment as an image region: hover the picture for replace/remove, or drop one onto the empty field. Alt text stays in the drawer, where a text input belongs.

A RichText field needs one word from you, because the field's type comes from /me and visitors never see it: without the flag they would read the markup as text while you edited it as prose.

<CollectionField name="body" as="div" html />

It renders the toolbar editor for an editor and sanitised HTML for everyone else. cms-sync can't catch a missing html, so the component warns in dev.

The element is the same for visitors and admins, so the page doesn't shift when you sign in. ShortText, LongText, RichText and Image edit in place; every other type renders read-only and keeps its drawer editor, which remains the complete surface for the record. A date picker or a repeatable sub-form has no sensible affordance mid-paragraph, and unlike text, markup and { src, alt } they aren't recognisable without the schema.

Page fields and the drawer card edit one draft and stay in step both ways: type on the page and the open card follows along, type in the card and the page does. Only one surface runs the autosave, so a keystroke is one request no matter how many places show it, and a card nobody can see (collapsed, or behind a shut panel) stops mirroring until it's back in view.

Editing a collection item is schema-driven: the backend's /schema describes each field's type, and <CollectionFieldsForm> (from inscribed/compose) renders one input per type:

| type | Value shape | Editor | | ------ | ----------- | ------ | | ShortText / LongText | string | input / textarea | | RichText | HTML string | Tiptap | | Number | number \| null | number input | | Bool | boolean | switch | | Url | string | URL input | | Date | ISO 8601 string | date-time input | | Image | { src, alt } | upload dropzone + alt | | StringArray | string[] | tag input, typed freely | | ObjectArray | array of objects shaped by itemFields | repeatable sub-form |

An Image field uploads through the transport's uploadImage and stores the returned CDN URL as src; alt is required once src is set (the backend rejects a half-filled image). The form is styled neutrally (it inherits the host page's font and colours), so it reads on both the dark drawer and a light page.

Creating items from your own page. For collections that support creation (an AutoGenerated slug plus create permission), mount <CollectionComposer> on any route to render a bare "add one item" form. It carries no CMS chrome, it inherits the page's styling, so the screen reads like a normal "new article" page rather than an admin panel:

// app/news/new/page.jsx
"use client";
import { CollectionComposer } from "inscribed/compose";
import { useRouter } from "next/navigation";

export default function NewNews() {
  const router = useRouter();
  return (
    <CollectionComposer
      collection="News"
      submitLabel="Publish"                          // default: "Oluştur"
      onCreated={(item) => router.push(`/news/${item.slug}`)}
    />
  );
}

onCreated receives the created item (with its backend-assigned slug); omit it and the form resets with an inline confirmation instead. useCollectionCreate backs the single-language form, here and in the drawer's "new item" card, for hosts that want their own markup.

Several languages at once. When the collection holds more than one language (locales in /me), the composer opens on the page's language and offers the others as chips beside it. Adding one gives each prose field (ShortText, LongText, RichText) a row per language; every other field is asked once and written into each language's record. One submit creates the languages in order, each after the first joined to the first one's translation group, and onCreated gets the page-language record plus every record created as a second argument. If a later language fails, the ones that landed stay created and the button retries only the rest. Passing locale or translationOf keeps the form to that one language. The drawer's create pane works the same way, with a slug per language for UserDefined collections.

Your own markup. A host that needs more than the composer (a live preview beside the form, say) builds it from the same parts. useMultilingualCreate holds a value set per language, <LanguageChips> adds and drops languages, and <MultilingualFields> renders the form. Claim the new-item draft slot with useCreateDraftRole, or your form and the drawer's create pane both autosave into it:

"use client";
import { useId } from "react";
import {
  LanguageChips, MultilingualFields, useCreateDraftRole, useMultilingualCreate,
} from "inscribed/compose";

// `meta` is the collection's entry from `useMyCollections()`, once it has loaded.
function NewsForm({ meta, locale, onCreated }) {
  const languages = meta.locales;
  const primary = languages.includes(locale) ? locale : languages[0];
  const scopeId = useId();
  const active = useCreateDraftRole("News", scopeId);
  const create = useMultilingualCreate({
    collectionKey: "News", schema: meta.schema, languages, primary, active,
  });

  return (
    <>
      <LanguageChips
        languages={languages} added={create.added} statusOf={create.statusOf}
        hasDraft={create.hasDraft} onAdd={create.add} onRemove={create.remove}
        disabled={create.isPending}
      />
      <MultilingualFields fields={meta.schema.fields} create={create} needsSlug={false} />
      <Preview values={create.valuesFor(primary)} />
      <button onClick={() => create.submit(onCreated)} disabled={create.isPending}>
        Publish
      </button>
    </>
  );
}

needsSlug is for UserDefined collections, which take a slug per language.

These live at inscribed/compose rather than inscribed/collections, because every one of them reaches the field editors. A page that only lists records would otherwise download that weight through the shared entry, with no way for a bundler to shake it back out.

The composer renders nothing for visitors without create access, and warns in dev when the collection key isn't among the session's accessible collections (GET /cms/collections/me). A misspelled key or a missing membership shows a blank, not an error, so check the dev console first.

Editing & drafts

Editing turns on when the provider knows the visitor is an admin and how to get their access token. There are two ways to get there.

Zero-config, against the reference backend: set clientKey in the config and register the site's origin as a Client on the backend. Editors sign in by opening any page with ?cms-login; the provider runs the backend's cookie + refresh flow client-side (single-flight and multi-tab safe, so the backend's refresh-token reuse detection never trips), checks the token's capabilities, and mounts the drawer for users holding content:write on this clientKey. A ?cms-logout link signs them back out (as does the drawer footer's button). Anonymous visitors trigger zero auth traffic and read published content through the public endpoint (needs the client's allowAnonymousContentRead flag); protected setups pass a render-preset service key via getServiceToken instead. Note the session cookie is scoped to the API origin, so the drawer mounts a beat after hydration - the server always renders the public view.

Bring your own auth in two pieces:

  1. Server side: give createCmsPage an auth adapter so it can resolve the session and decide isAdmin:

    export const { CmsPage } = createCmsPage({
      config: cmsConfig,
      Provider: AdminCmsProvider,            // your wrapper, see below
      getServiceToken,                        // server-only read token (optional)
      getSession: () => auth(),               // your session resolver
      deriveAdmin: (session) => Boolean(session?.user?.isAdmin),
      onAfterSave: revalidateCmsSlug,         // from "inscribed/actions"
      // Only if your wrapper needs a session on the client (see the note below):
      // sessionForClient: (session) => ({ user: session.user }),
    });

    A session resolver that reads the request makes every route dynamic. <CmsPage> awaits getSession in the root layout, and a resolver that touches cookies() or headers() (most do, NextAuth's auth() among them) opts the whole site out of static rendering: nothing prerenders and every request renders on the server. That is the trade for deciding admin server-side. To keep the site static, leave getSession out and decide admin in the browser: the built-in auth above does exactly that, and a wrapper around CmsProvider can pass isAdmin from a client-side session the same way.

    The session stays on the server unless you opt in. Provider is a Client Component, so every prop it receives is serialized into the page payload and shipped to the browser. Sessions routinely carry an access token, a refresh token or internal claims, none of which inscribed reads. If your wrapper feeds a session provider (NextAuth's <SessionProvider>, say), add sessionForClient and return only the fields that may travel.

  2. Client side: CmsProvider needs getAccessToken to attach a Bearer token to write requests. Since that's a client concern, wrap CmsProvider in a thin "use client" component that supplies it from your session:

    "use client";
    import { CmsProvider } from "inscribed";
    import { useSession } from "your-auth-lib/react";
    
    export function AdminCmsProvider(props) {
      const { getToken } = useSession();
      return <CmsProvider {...props} getAccessToken={getToken} />;
    }

Once enabled, admins edit the same page in place. Text and RichText blocks edit where they sit, click and type, with a small floating toolbar for RichText formatting; Image blocks edit on the image (a replace / remove overlay, or an upload drop-zone when empty). Link, Date, List, and Collection blocks open the side drawer instead. Every block also shows a hover label chip that opens its drawer card for structured details (an image's alt text and URL, a link's label) whatever its type. Focusing a block to edit it highlights the region without opening the drawer; only the chip does.

Edits autosave as drafts (debounced ~1s to the draft endpoint) while a live preview overlays the page; publishing is an explicit save in the drawer. Discarding clears the server draft. inscribed itself depends on no auth library; these are all injected callbacks, with a public read-only default.

A refused publish is resolved per block. The backend rejects a write whose version is behind, which is what happens when someone else published the same block while you were editing it. The drawer reloads the page, marks the cards it clashed on, and shows both candidates on each: the value the server now holds against the one you typed. Take theirs drops your edit for that block, keep mine leaves it pending so the next save writes it at the version just fetched. Your text stays in the draft either way, so nothing goes without you choosing it. A conflict carrying no block-level detail (two writes racing on one row) only asks for a retry, since there is nothing to compare.

Localization

Three steps, no new files. Declare the languages once somewhere the proxy can also read — cms.config.js, which the cms-sync CLI already looks for:

// cms.config.js
export const locales = ["tr", "en"];

Order is meaningful: the first entry is the default locale — the one that sits at the root with no prefix. There is no separate defaultLocale option, because the backend derives its own default the same way and two inputs are two things that can disagree. List the language your existing content is written in first.

Hand that to both the config and the proxy:

// app/lib/cms.jsx
import { locales } from "../../cms.config.js";

export const cmsConfig = createCmsConfig({ baseUrl: process.env.CMS_URL, locales });
// proxy.js
import { createCmsMiddleware } from "inscribed/middleware";
import * as cms from "./cms.config.js";

export const proxy = createCmsMiddleware(cms);
export const config = { matcher: ["/((?!api|_next/static|_next/image|favicon.ico|.*\\..*).*)"] };

The matcher leaves out paths with a dot in them, so public/ files such as robots.txt or an image are served as they are instead of being rewritten under the default language and answered with a 404. A page whose slug has a dot in it would be left out the same way; spell those out in the matcher if you have any.

A request for the default language under its own prefix (/tr/about) is redirected to the unprefixed address with a 308, so each page has one address per language rather than two that serve the same content.

Then move your routes under app/[locale]/ and make that folder's layout the root layout, so the language is a segment param and nothing has to read the request to learn it:

// app/[locale]/layout.jsx        ← the root layout; there is no app/layout.jsx
import { CmsPage } from "../lib/cms.jsx";
import { locales } from "../../cms.config.js";

export function generateStaticParams() {
  return locales.map((locale) => ({ locale }));
}

export default async function RootLayout({ children, params }) {
  const { locale } = await params;
  return (
    <html lang={locale}>
      <body><CmsPage locale={locale}>{children}</CmsPage></body>
    </html>
  );
}

locale is the one thing <CmsPage> is keyed on: it reads the whole site in that language, and generateStaticParams is what builds every language ahead of time. A localized site that omits the prop fails at build with the line to add; a segment value outside locales is not found.

Leave dynamicParams out of this layout. Next applies false to every route under it, not just the language: a collection detail route would then know only the records generateStaticParams listed at build, and a record created or renamed later would answer 404 until the next build. An unknown language needs no help from it; <CmsPage> and the collection bindings answer it with a 404.

The provider remounts when the language changes, since a layout instance belongs to its segment's value. The editor's session survives it (the built-in browser auth is module state, not React state), the drawer closes, and the new language's blocks arrive with the new layout, so the switch paints complete.

getCmsRoute() comes back from createCmsPage for a Server Component with no params of its own. It reads the request header, and a header read opts that route out of static rendering, so anything under app/[locale]/ should take params.locale instead. Called from a root layout or not-found.js, which every route renders, it makes the whole site dynamic. A component deep in the tree that has no params can read the segment with Next's next/root-params (import { locale } from "next/root-params", then await locale()), which keeps the route static.

The default language stays at the root and the others sit behind their prefix: /about is Turkish, /en/about is English. The proxy rewrites the unprefixed path onto app/[locale]/ so tr never reaches the address bar; a site that prefixes every language needs no proxy at all. It also sets the x-pathname header that getCmsRoute(), a record redirect built without path, and a collection binding that renders before <CmsPage> has published the language fall back to.

A leading segment counts as a locale only when locales lists it, so a page at /en-masse is not mistaken for English. (Reading also handles a prefix on every language, if you would rather write your own proxy for that; the bundled one and localePath commit to default-at-root.)

Reach the active language from a component — no locale prop threading, no second copy of the list:

"use client";
import Link from "next/link";
import { useCmsRoute } from "inscribed";

const { locale, path, localePath } = useCmsRoute();
<Link href={localePath("/about")}>…</Link>          // stays in the current language
<Link href={localePath(path, "en")}>English</Link>  // a language switcher

path is the page on screen with its language prefix stripped. slug is where its content is stored, which on a dynamic route is the template (/news/[id]), so a switcher built from it would link there.

Server Components can't call hooks, so createCmsPage hands back the same helper already bound to your config:

export const { CmsPage, localePath } = createCmsPage({ /* … */ });

<Link href={localePath("/about", locale)}>…</Link>

Link with next/link rather than a plain <a>: a plain anchor reloads the whole document, and an editor's drawer with it.

Your manifest does not change. The slug is what a page is; the locale is which language of it you are looking at. So a localized app still syncs one entry per page: app/[locale]/about/page.jsx becomes /about, because a leading segment is a language once locales is set (see Slugs).

cms-sync sends that one entry, plus the language list itself as ?locales=tr,en. That is what keeps cms.config.js the single home for it: the backend learns which languages exist from the code rather than from a second copy someone has to remember to update. It then materializes a row per locale, each seeded with the block's defaultValue.

Seeding each language differently. defaultValue also takes a map keyed by language, so the English row doesn't start life holding Turkish copy:

<EditableRegion
  blockPath="hero.title"
  blockType="ShortText"
  defaultValue={{ tr: "Hoş geldiniz", en: "Welcome" }}
/>

An object whose keys are all in locales is read that way, including for the types whose value is itself an object — those nest under the language, { tr: { src, alt }, en: { src, alt } }. Their own keys (src, href, name) are not language tags, so an ordinary defaultValue={{ src: "/hero.png" }} stays one seed for every language.

A language the map leaves out seeds with the first entry of locales (what every language got before maps existed), and cms-sync names it in a warning. A key that is not a language warns too rather than syncing a half-map as the value itself: a typo (eng) or a forgotten locales export in cms.config.js is otherwise invisible until someone reads the English page.

This seeds, it does not translate. Rows that already exist keep their content, so a map added after the first sync only reaches languages and blocks that weren't there yet, unless cms-sync --reseed rewrites the rows nobody has edited (see CLI).

Adding a language is one step: put it in locales, re-run cms-sync. Removing one is the same step — its rows fall out of the desired state and are soft-deleted like any other removed block, and restored if you add it back.

Nothing falls back — an untranslated block renders its default value, so a missing translation is visible rather than quietly wearing another language's text.

Everything downstream follows the route on its own:

| Surface | Behaviour | | ------- | --------- | | Blocks | Read and written in the route's locale; __global too, so the header matches the page | | Drafts | One slot per language, on their own autosave lanes | | Cache tags | cms-site-{locale} for the site read and cms-{locale}-{slug} per page, so publishing one language leaves the others cached | | useCollection | Lists the route's locale unless you pass locale yourself | | New records | Composed in the route's locale, with a per-language draft slot | | The drawer | Offers the other languages when a text block is rewritten, and publishes them together |

Keeping the languages in step

Nothing falls back, which is honest but leaves a gap: rewrite a paragraph in Turkish and the English copy still says the old thing, correctly and invisibly.

So the drawer asks. Rewrite more than a few words of a text block and the other languages appear under it, each prefilled with what it currently says:

hero.body   [ Şirketimiz 1998'den beri… ]
            ┌───────────────────────────────────────┐
            │ 🌐 Bu metin diğer dillerde değişmedi  │
            │ EN  /en   [ Our company has served… ] │
            └───────────────────────────────────────┘

Type into it and that language joins the next publish, so the translation goes out with the block it sits under: one PUT per language, each versioned against its own row, each revalidating its own tag. So "publish" means the same thing it always did: everything you have pending.

The prompt is deliberately quiet. It only appears for ShortText, LongText and RichText (translating a date or an image URL is not a thing), only once the diff crosses a few words, and only after typing settles — a typo fix or a bolded word never triggers it. RichText is compared on its text, so reformatting is not a rewrite. Past three other languages the editors would dwarf the block they hang off, so it degrades to a dismissible line naming them.

This is not machine translation: the field is prefilled with what that language says now, its draft included, and you write the rest. What you type is that language's draft: it autosaves the way an edit on that language's own page would, so leaving the page loses nothing and the English page shows it too. Undo goes back to what the field said before you started, not to the published text, and the row's own undo reverts the row in every language written from it. Discarding every change on the page puts those translations back too. A language pulled into the publish by a translation leaves it again when that translation is undone.

The prompt only offers itself for prose. Every row also has an Edit in other languages button, shown on hover and on its own once the row holds a change, that opens the same panel on request, for an image or a link that should differ per language. Lists and selects are the exceptions: the panel has no editor for them.

Drafts left in another language. Edit the English page, leave without publishing, and the Turkish page's drawer still finds them: a row above the save bar, Waiting in other languages, holds a chip per language (+ EN 3). They are never included on their own. Click the chip, or Add to publish on that language's group in the preview, and the next save publishes them as well, each against its own row, with the button naming every language it writes (Save · TR + EN). A language goes in whole: writing a translation into it includes its other drafts on this page too, because the backend clears a language's whole draft on a page when it publishes there. If one language lands and another does not, the banner says which, and the button (Retry · EN) resends only what failed. To find them the drawer reads the page in each other language while it is open: one request per language, shared with the prompt above, and repeated after each publish.

Translating a collection record

Collection records are one row per language, and their slugs stay unique across the whole collection. So a record and its translation carry different slugs (yeni-urun, new-product) — which is what you want for search engines anyway, and why every per-slug endpoint identifies a record without being told a locale.

What links them is a translation group. Every record gets one when it is created, so a record with no translations is simply the only member of its own group; nothing has to be created later to link them. Reading a single record tells you the rest of its group:

GET /cms/collections/news/new-product
{
  "slug": "new-product", "locale": "en",
  "translationGroupId": "8f3f…",
  "translations": [{ "locale": "tr", "slug": "yeni-urun" }]
}

To write a translation, pass that group id back:

const { translationGroupId } = useCollectionRecord();

<CollectionComposer collection="news" translationOf={translationGroupId} locale="en" />

locale is explicit here on purpose. Everywhere else the language comes from the route, but a translation is the one flow where it can't: the editor is reading the Turkish page while writing the English copy.

The admin drawer does this for you. A record's detail pane shows one chip per language the collection declares — the current one, the ones that exist (click to open), and the ones missing (click to compose). Which is also why the chips matter: without them an editor can write a whole record before the backend rejects it as a duplicate, and the rejection can't say where the existing one is.

A record placed on a page with <CollectionItem> does the same from its card on the page tab. The card's Edit in other languages button opens the record's prose in each other language:

  • The translation exists: the button opens that record, on its draft, with a link to open the whole record in the collections area.
  • The translation is missing: the button opens an empty form, and the record's other fields (an image, a date) are copied into the new record.

The card's save then goes out as one click (Save · TR + EN):

  • It publishes the record first, then every language written from the card.
  • It creates the missing ones in the record's translation group.
  • It checks every language before sending anything.
  • If a language fails, the card names it and the button retries only that one.

A draft that record already had from elsewhere is shown but stays out until it is written here. Text typed for a missing language waits in the browser, so it survives a navigation but not a reload.

None of this decides what language the panel itself speaks. That is adminLocale, and it is a separate setting on purpose: these locales are your content's, and are arbitrary, while the panel speaks whatever someone has written a catalog for.

Omit locales and none of this engages: no locale reaches the wire, tags keep their pre-i18n shape, and the backend answers with the Client's default language.

Search & metadata

How a page appears in search results and in shared links. Every part below is optional, and each reads the content the site already brought, so none of them costs a request of its own.

The site's address. Search engines want canonical links and hreflang absolute, so the config names the site's public origin:

// app/lib/cms-config.js
export const cmsConfig = createCmsConfig({
  baseUrl: process.env.CMS_URL,
  locales,
  siteUrl: process.env.SITE_URL, // "https://example.com"
});

Without it canonical links stay relative, and so do hreflang links, which Google ignores (dev warns).

The root layout

// app/[locale]/layout.jsx
import { CmsPage } from "../lib/cms.jsx";

export const generateMetadata = CmsPage.siteMetadata({ siteName: "Acme" });

It sets metadataBase from siteUrl, the title template (%s | Acme, or pass titleTemplate), og:site_name, and the home page's share image (below) as the image of every page without one of its own. siteName also takes one value per language: { tr: "Acme", en: "Acme Inc." }. It replaces the layout's static metadata export, since Next takes one or the other per segment.

A page's own fields

// app/[locale]/hakkinda/page.jsx
import { CmsPage } from "../../lib/cms.jsx";

export const generateMetadata = CmsPage.metadata("/hakkinda", {
  title: { tr: "Hakkında", en: "About" },
  description: { tr: "Bölümün tarihi ve kadrosu.", en: "The department's history and staff." },
});

cms-sync reads the call, exported as it is or inside a generateMetadata of the page's own, and its slug and defaults have to be plain literals, as a region's props do: discovery reads the source rather than running it. The page gets four blocks, seeded from the defaults (one value, or one per language as with defaultValue):

| Block | Type | Becomes | | ----- | ---- | ------- | | seo.title | ShortText | <title> (the browser tab and the search result) and og:title | | seo.description | LongText | the meta description and og:description | | seo.image | Image | og:image, with its alt text | | seo.noindex | Bool | robots: noindex, follow |

Editors find them in the page's SEO group in the drawer, one set per language, with drafts and publish like any block; a publish refreshes the page's head with its content. The drawer counts the title and the description, and turning noindex on asks first, since a page taken out of search comes back only after the next crawl.

The slug is written out because generateMetadata is never told the page's address, only its params. cms-sync fails when it differs from the slug the file derives (cms-sync --dry-run lists them), rather than syncing blocks that nothing reads.

When a field is empty, it falls back in this order, and never to another page's value:

| Field | Empty in the CMS | Empty in code too | | ----- | ---------------- | ----------------- | | Title | the code's default for that language | the layout's title.default (the site name) | | Description | the code's default | none: the tag is dropped and Google writes its own snippet | | Image | the code's default | the layout's image (the home page's, with siteMetadata) | | Noindex | the code's default | indexable |

A description falls back to nothing rather than to the site's because the same description on every page helps no search result. The defaults in code seed the blocks on the first sync only, as defaultValue does: changing them later leaves what editors wrote alone (see --reseed under CLI).

The page also gets its canonical link and hreflang for every language, with x-default on the default one. The home page's title is used whole, outside the template, which would name the site twice. A dynamic-segment page (/search/[q]) shares one set of fields across its URLs, as it shares its content, and builds its links from the route's params. A page that doesn't call CmsPage.metadata keeps whatever metadata it exports, and gets no canonical link.

A collection record's fields

A record's search fields are fields it already has, so a collection maps them instead of adding new ones. The mapping lives in the config rather than in the page, because the drawer and the language switcher read it in the browser:

// app/lib/cms-config.js
export const cmsConfig = createCmsConfig({
  // ...
  seo: {
    news: {
      path: "/news/[slug]",                       // where a record lives
      title: "title",
      description: ["seoDescription", "summary"], // the first with text wins
      image: "cover",
      noindex: "hidden",                          // optional: a Bool field
    },
  },
});
// app/[locale]/news/[slug]/page.jsx
export const generateStaticParams = CollectionItem.staticParams("news");
export const generateMetadata = CollectionItem.metadata("news");

path does the job of the option shown under [Editing