Skip to main content

API sketch

Pseudocode, not final signatures. The point is the shape and where each concern lives. How the shipped surface behaves is argued in the reference pages, starting at reference/authoring/blocks.md, and every signature it involves is written from the sources into the API reference; this document keeps the reasoning.

At a glance

// cta.schema.ts — a sub-schema, extracted so five blocks can share it
export const ctaSchema = z.object({ label: z.string(), href: z.string() });

// hero.schema.ts
export const heroSchema = z.object({
title: z.string(),
tone: z.enum(["light", "dark"]),
image: z.string(),
cta: ctaSchema,
bullets: z.array(bulletSchema).max(4),
});

// hero.block.ts
export const heroBlock = defineBlock({
name: "Hero",
schema: heroSchema,
component: Hero, // props are InferProps<typeof heroSchema>
version: 1,
ui: {
order: ["title", "tone", "image", "cta", "bullets"],
groups: [
{ label: "Content", fields: ["title", "image", "cta", "bullets"] },
{ label: "Style", fields: ["tone"] },
],
fields: {
title: { label: "Headline", placeholder: "Say the thing" },
image: { control: "image" }, // resolved from the control registry
bullets: { rowLabel: "heading" }, // collapsed rows read as their own heading
},
},
});

Where UI hints live

Hybrid, split on "intrinsic to the field" versus "specific to this surface":

ConcernLives inWhy
Type, validation, required, defaultsSchemaIt is the contract. Props are inferred from it.
Conditional visibilitySchema (discriminated union)Changes what is valid, not merely what is shown.
Label, placeholder, order, grouping, control choice, row labelsui, keyed by field pathPresentation varies per surface — the same block renders in a sidebar, a modal, and a popover.
Static vs. request-time resolution (data)ui, keyed by field pathPer-field, not per-block — a hero's headline can freeze while its price stays live.

Rejected: embedding hints via each validator's own metadata slot (zod .meta(), valibot v.metadata(), arktype .configure()) behind one adapter. See Editing hints live beside the schema, not inside it.

Introspection is still needed, and is per-validator

Hints living outside the schema does not remove the need to read it — the editor must still discover what fields exist and what type each is, and Standard Schema will not tell us.

interface SchemaAdapter {
describe(schema: unknown): FieldNode[]; // path, kind, optional, enum members
}

unknown rather than a schema type, because CatalogEntry.schema is unknown and the capability the adapter actually needs — the Standard JSON Schema converter, spec 1.1 — is narrower than StandardSchemaV1. A stricter parameter would promise a guarantee the type cannot give, and the runtime check would still be required.

There is one door. The adapter reads a schema through the Standard JSON Schema converter the schema itself exposes — schema["~standard"].jsonSchema.input(options), specified by Standard Schema 1.1. A schema that does not expose it is rejected at registration.

That the converter hangs off the instance is the whole point: core calls no validator function and imports no validator, which is what keeps invariant 2 true. A validator's own top-level converter would have to be imported to be called, and the boundary gate fails the build on exactly that import.

Unrepresentable types throw, deliberately. The converter is called with unrepresentable: "throw", so a field JSON Schema cannot express fails block registration rather than degrading into a string field that looks fine in the studio and silently loses its type. Registration is a build step; failing there is cheap and visible.

Chosen over internal traversal. Reading a validator's internals — ._zod.def on zod v4, ._def before it — would cover the types JSON Schema cannot express. It is rejected on two counts: it requires core to know a specific validator, and those fields carry no stability guarantee, having already been renamed once in a way that broke dependents. No fallback path exists, so there is nothing to silently degrade to.

zod is the reference implementation; any validator exposing the converter works unchanged.

A parallel ui structure risks silent drift: a key that no longer refers to a real property. Every path is resolved against the schema at defineCatalog(), so an unresolvable key fails registration — see below. It has to be the catalog rather than the registry: hints live in the catalog entry, and createRegistry() takes blocks and never sees them.

A CI check covers what path resolution cannot: every block has a resolvable control for every prop, so a schema change cannot leave a field un-editable.

Control resolution: ranked testers, not a keyed map

A keyed map ({ string: TextControl, image: ImagePicker }) dispatches on one dimension only and is closed to extension — a consumer wanting a rich picker for a custom format has nowhere to register it. Instead, a control registers a tester that returns a rank; the highest rank wins.

type Tester = (hint: FieldHint, schema: FieldNode, context: Context) => boolean;
type RankedTester = (hint: FieldHint, schema: FieldNode, context: Context) => number;

const rankWith = (rank: number, tester: Tester): RankedTester =>
(hint, schema, context) => (tester(hint, schema, context) ? rank : NOT_APPLICABLE);
export const controls = registerControls([
[rankWith(1, schemaTypeIs("string")), TextControl],
[rankWith(2, schemaTypeIs("array")), RepeaterControl],
[rankWith(3, and(schemaTypeIs("string"), formatIs("uri"))), LinkControl],
[rankWith(4, hintIs("control", "image")), ImagePickerControl],
[rankWith(5, scopeEndIs("colour")), TokenPickerControl],
]);

A structural default, a format-based override, an explicit hint, and a path-specific special case all compose through one ranking mechanism; a consumer adds a control by registering a tester rather than by us widening a union. Predicate vocabulary: schemaTypeIs, formatIs, scopeEndsWith, scopeEndIs, optionIs, hasOption, combined with and / or.

There is deliberately no default token or colour picker registered. A well-built block schema exposes semantic unions — tone: "brand" | "neutral" — which a select already serves.

Hints are addressed by path, not by a mapped type

A mapped type ({ [K in keyof InferProps<S>]?: FieldHint }) only protects top-level scalars: cta.label is not expressible, and a typo like bullets: { rowLabel: "headnig" } type-checks happily. Hints are addressed instead by a JSON-Pointer-style path, resolved against the schema at runtime:

ui: {
fields: {
"title": { label: "Headline" },
"cta.label": { label: "Button text" },
"bullets": { rowLabel: "heading" },
"bullets[].icon": { control: "image" },
"price": { data: { revalidate: 60 } },
},
}

Compile-time safety is therefore not claimed — every path is resolved against the schema at defineCatalog(), and one that does not resolve fails registration. Resolution order for a field: explicit ui.fields[path].control → registered control for a named format → structural default from the schema node's type.

Repeaters

A collapsed repeater row shows one of its own fields, never "Item 3":

ui: { fields: { bullets: { rowLabel: "heading", min: 1, max: 4 } } }

Data lifecycle is a field hint, not a block flag

Rejected: block-level data: "static" | "request" — all-or-nothing, so a hero with a static headline and a live price could not be expressed without forking the block. data is a field hint instead, same mechanism as label or control, resolved by path and validated at defineCatalog(). See Data lifecycle is per field for the three states.

ui: {
fields: {
headline: {}, // omitted — static, frozen at compile. The default.
price: { data: { revalidate: 60 } }, // your resolver fills it, cached that long
stock: { data: { revalidate: 60 } }, // stale-while-revalidate
},
}

Editor live-fetch preview is a studio mode, orthogonal to this flag — it previews request-time data in the inspector without changing what the compiler freezes.

Artifact.tree reflects the mix at compile: a static field freezes into ArtifactNode.props; a request or revalidate field compiles to an entry in ArtifactNode.holes instead, resolved by the block at render. See Artifact.

Where a block sits

A block is an app-level component, the same layer as the application's own server components: it composes design-system components and wires data into them, with the artifact substituting for a query as the data source.

Artifact serializable data only — an image URL or asset id, never a node

Nubbin block app-level. Renders <Image> with the app's own pipeline

Design system pure and framework-agnostic. Receives the rendered node

A design system taking image: ReactNode rather than imageUrl: string is not in tension with a CMS — it refuses the URL to stay portable, and the block is the adapter between the two. Block props are therefore always plain serializable data: that is what an artifact can hold, and the block is where data becomes nodes.

Slots articulate structure

A slot declares what may go in it, not merely that it exists:

export const marketingLayout = defineBlock({
name: "MarketingLayout",
schema: z.object({}),
component: MarketingLayout,
slots: {
hero: { allow: ["Hero", "VideoHero"], min: 1, max: 1 },
body: { min: 1, max: 8 }, // any registered block
aside: { allow: ["CtaCard"], max: 2 },
},
});

The compiler enforces these, so a violation is a publish-time error, not a layout that renders wrong; the studio reads the same constraints to grey out invalid drop targets during a drag. createRegistry resolves every allow entry against the blocks it holds and throws on one that matches none, because a slot allowing only a name nothing answers to rejects every drop instead. Reading a slot's live occupancy needs no dedicated API: the constraint is registry.get(block).slots[name] and the count is elements[id].slots[name].length.

Validation happens at three tiers, not one

TierWhenScopeCost
FieldOn commit in the inspectorThe edited prop against its resolved sub-schemaLocal, synchronous
NodeOn blur / selection changeAll of a node's props togetherLocal
Documentcompile(), at publishEvery node, slots, references, reachabilityThe publish gate

Field-level cannot catch cross-field constraints — a .refine() on the object needs the whole node — which is why node-level exists rather than being folded in.

Validate against the real schema, never against the JSON Schema projection. Introspection projects the schema to JSON Schema to describe fields for control resolution, and that projection silently drops .refine() / .superRefine() / .transform(). Standard Schema's validate() runs anywhere and sees everything, so introspection and correctness deliberately use different paths.

A draft may hold invalid values indefinitely — blocking a save mid-edit is hostile, since an author is often part-way through a change. head may point at content that will not compile; publish is the gate. The same reasoning applies to a delete that would violate a slot's min: the studio warns, compile refuses.

Catalog and registry are two things

// Catalog — pure data. Serializable, no components, no React.
// The studio fetches this to build the palette and the inspector.
export const catalog = defineCatalog({
Hero: { schema: heroSchema, ui: heroUi, defaults: heroDefaults, docs: heroDocs },
FAQ: { schema: faqSchema, ui: faqUi, defaults: faqDefaults },
});

// Registry — name → lazy importer. The consumer's app holds this.
export const registry = defineRegistry({
Hero: () => import("./blocks/Hero"),
FAQ: () => import("./blocks/FAQ"),
});

Not a flat array of components: each import() is statically analysable, so the bundler emits a chunk per block and the route resolves only what the artifact names.

const artifact = await resolveArtifact(store, params.slug);
return <Renderer artifact={artifact} registry={registry} />; // loads only what it names

See Catalog and registry are separate for why. The catalog is also why core must not depend on React (invariant 2): CI validating artifacts against current schemas needs the catalog and nothing else.

Registration is implicit but filtered: a <Name>.block.ts file beside a component registers it, so only the fraction of a design system meant to be author-placeable is exposed.

Registry and compile

// Compile reads the catalog for schemas and hints, and the registry for structure. Neither
// carries a component, so compile needs none.
const artifact = compile(documentVersion, catalog, registry, route);
// throws NubbinError { code, issues: [{ code, message, at, path }] }

Issue paths point at the offending node, so the studio can select it rather than showing a wall of text.

Store adapter

Same ArtifactStore interface used by the output layer — see ArtifactStore.

Next wiring

// app/[...slug]/page.tsx
export default async function Page({ params }: { params: Promise<{ slug: string[] }> }) {
const { slug } = await params;
const artifact = await resolveArtifact(store, slug);
if (!artifact) notFound(); // unpublished has no artifact — a real server 404
return <Renderer artifact={artifact} registry={blockRegistry} resolveHole={resolveHole} />;
}

registry is the prop name; blockRegistry is what goes in it. RendererProps.registry is typed BlockRegistry — the render-side map of () => Promise<BlockComponent<never>> from defineRegistry, where never is what admits real components: a block typed against its own props cannot substitute for one required to take anything, so a wider stored type would turn every one away. The compile-side Registry from createRegistry is a different type with get and names, and it is what compile validates against. Passing it here, or passing the render-side map to compile, is the same mistake in two directions, and it has reached four separate tickets — see registry file naming.

[...slug], not [[...slug]], wherever the application also has a hand-written app/page.tsx: the optional form claims / as well, and Next rejects two owners of one path. An application with no coded home page doubles the brackets, and routeFromSlug maps an absent slug to /.

Renderer is an async server component. It loads the blocks blockVersions names, fills each node's holes, and stamps data-nubbin-node on the root element the block returns — so a block is a server component whose one root is an HTML element, and nothing wraps it. A route whose artifact declares holes passes a resolveHole resolver; a fully static one needs none.

Package boundary this implies

Block definitions and the compiler must not drag the editor into a consumer's production bundle. ui is plain serializable data in the core package; the controls that read it live in the studio.

Failure modes designed against

  1. Custom editors as second-class. Answered by the open control registry.
  2. Per-keystroke re-render on a large tree. The inspector edits one node by id, never the whole document.
  3. Hint drift. Answered by path resolution at defineCatalog() plus the CI completeness check.
  4. Auto-generation degrading at depth. JSON Forms and react-jsonschema-form both handle scalars and one level of nesting well, then degrade — hence explicit rowLabel, groups, and order rather than hoping structure alone reads well.
  5. Hidden fields retaining stale values. A conditionally hidden field that keeps its last saved value with no clearing means published props can carry data for fields the author never saw. A field hidden by a discriminated union must have its value dropped at compile, not merely skipped in the editor.
  6. Repeater rows keyed by index. "Item 1, Item 2" is useless once rows are reordered; rows need a row-label escape hatch and a stable key independent of index, or reordering re-mounts every row and loses focus.
  7. Types JSON Schema cannot express — bigint, Date, class instances, unions with non-trivial discriminants. These need an explicit control rather than best-effort inference, and registration should fail loudly when one is left un-resolvable.
  8. Validator internals shifting. Internal traversal has no stability guarantee. Isolated behind the adapter, pinned, and tested.