register-component
When to use
Read a React component, infer a prop schema, and write a registerComponent call so Studio's palette uses the customer's component instead of built-in defaults. Lazy by default.
Use when the user wants Studio's palette to surface their components: "make my Hero available in Studio", "Studio shows defaults instead of mine", "register components in src/components". The BYOC moment. Do NOT use to register design tokens (use import-design-tokens). Do NOT invent prop schemas. Only register props visible in source.
Auth preflight: settle the credential before the first API call. Resolve it OAuth-first per
authenticate-cma:CS_OAUTH_ACCESS_TOKEN, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. Never ask the user for a sessionauthtoken. If nothing resolves, or a refresh fails with400 invalid_refresh_token, hand them! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth(it needs a TTY and a browser, so it cannot be run for them) and wait.403 error_code 316is a valid credential aimed at another org: fix the org or theapi_key, do not re-authenticate.
Reuse preflight: mandatory when the project already has compositions. Before naming a single new atomic, component or Section, run
match-existing-pattern§ Step 1b: it groups existing Sections by the schema they bind and ranks the component palette by real usage. Decomposition is where reuse has to bite. Once this step namesHeadinginstead of the project'salpha-atom-heading, the duplicate survives into the plan and into the build. Mark every row reuse, extend, or new, and never new without having looked.
Register a component with Studio
Atomic check before you register anything. Registration is where a monolith becomes permanent. If the component renders more than one heading / image / body / list, stop and run
decompose-jsx-to-atomicsfirst. Register its atomics and containers, not the whole thing. Registering a page-level component is how a project ends up with one Section, nothing reusable, and a coarse prop that renders blank. Exception: it is genuinely interactive (form, carousel, modal) and cannot be expressed with built-ins, then register it whole and expose anactionprop so authors can still wire behavior.And check reuse first: header, footer, nav and menus are shared across every page. Register the atomics once and bind their lists through a Repeater, rather than registering a per-page variant.
Before you start: walk Q2, Q3, Q4 of the framework
The four questions are defined once in design-component-library § The four-question decision framework. This skill runs the last three. Q1 (classify: atom, layout, or compound?) is answered before you land here: if the input is a compound, decompose-jsx-to-atomics or decompose-design decomposes it into atoms + layouts before registration. If it is already a valid atom or layout, proceed. What registration adds:
- Q2 — if an existing primitive covers the shape, reuse it; don't re-register.
- Q3 — a prop that fails either half is refused here. Let a DS token decide instead.
- Q4 — a prop that fails either half stays static per-Section.
If any answer is ambiguous, halt and ask the user to disambiguate. Never silently register a prop that fails Q3 or expose a prop that fails Q4.
Before you start: pick the layer
Every component you register is one of two shapes. Decide which before writing the registration:
- Layer 1: Atomic (renders one CMS field). All scalar props.
<Heading>,<Text>,<Image>,<Button>. This skill's default path. - Layer 2: Container / Skeleton / Layout (holds other components). Has at least one
slot-typed prop as a drop zone.<Card>with a body slot,<Split>with left+right slots. Same skill. § "Exposing extensible regions with slot props" covers it.
If the thing you want to register is neither: a pure layout primitive (Grid, Stack, Container, Box), a whole page-shaped composition, or a component with an array-of-objects prop. Stop. Read From designs to Sections: the three-layer mental model first. It's a 10-minute page that saves hours of "why doesn't my Grid work in Studio."
Context
Studio composes pages from React components. By default it uses its own built-ins (Hero, Card, Button) so authors can play immediately, but for production, every component should come from the customer's library. Registering tells Studio's palette "use THIS component for type 'site-hero'" so authors drop branded components, not defaults.
The registration is a registerComponent call that names the component, gives it a unique type, and declares the prop schema Studio's right-panel will offer for binding. Prop types are strict and finite, exactly: string, boolean, number, choice, href, imageurl, datestring, array, object, slot, json_rte, any. There is no text, link, color, or image, common false-friends from other systems.
Reference: docs/15-build/10-components/20-register-a-component.md, docs/15-build/10-components/25-component-schema-props-types-and-defaults.md.
Task
-
Locate the registration entry point. Look for
src/register-components.tsx,src/register-studio.ts, orsrc/lib/contentstack.ts(whichever file importsregisterComponentfrom@contentstack/studio-react). If none exists, createsrc/register-components.tsxand ensure it's imported once at app startup (before any<StudioCanvas />or<StudioComponent />mounts). -
Read the component at
componentPath. Extract the prop names and types. Source of truth ranked by reliability:- Explicit TypeScript interface (
interface HeroProps { … }), most reliable propTypesblock, second-best- Destructured args + JSDoc comments, fallback only
- Explicit TypeScript interface (
-
Map each prop to a Studio prop type. Use the exact strings below (13 in total). Anything else is rejected:
Source shape Studio prop type string/string | undefinedstringnumbernumberbooleanbooleanstringconstrained to known values (union literal, enum)choice(setoptions:to the literal values.defaultValueis the bare value for single-select ("centered") and an array only whenmultiSelect: true)URL string used as hrefhrefURL string used as image srcimageurlISO date string datestringarray array, binds to a multi-value field (Reference multi, Group multi, Modular Block, scalar multi). Reference sources needdata_sources.resolvedReferences. Groups / Modular Blocks / scalar multi-values live on the entry and need no resolution. See § Array & object props: binding rulesobject object, binds when the shape matches an entry Group / Global Field. See § Array & object props: binding rulesReact children/ placeholder for extensible regionslot, fillable at template time viause-section-slot§ component-slot-prop placement. The prop turns that region of the component into an author-controlled drop target. NodefaultValue(slots start empty).rich text (JSON RTE field, HTML payload) json_rte, notstring. RTE renders markup, andstringwould show it as literal<p>text.unknown / can't infer, and the value is a primitive any, never for object- or array-shaped data. See the flatten trap belowA callback an author should be able to point at your app's logic ( onClick,onSelect,onSubmit)action: the author picks a registered studio function. Your component just calls the prop. NeedsregisterStudioFunctions. Seewire-studio-state. Omit this and interactivity stays hardcoded, so the component is a black box authors can't wireThe
anyflatten trap: why structured data renders blankanyis not a harmless catch-all. Verified instudio-registry/src/data-binder/retrieve-data.ts, the binder runs a recursive single-key unwrap on every prop type exceptobjectandarray:// Unwrap single-key objects (common in Contentstack Modular Blocks)// We only do this if we are not explicitly expecting an object or array.if (type !== "object" && type !== "array" && !handledRepeaterContext) {resolvedData = getFlattenedData(resolvedData);and
getFlattenedDatadescends as long as the object has exactly one key, ignoring$and_metadata:const keys = Object.keys(data).filter((k) => k !== "$" && k !== "_metadata");if (keys.length === 1) return getFlattenedData(data[keys[0]]); // recursesSo a single-field block bound to an
anyprop is silently replaced by its inner value. A block likeimage_grid → { image: [...] }ortabs → { tabs: [...] }has one real key, so the object you expected never arrives. The component receives the bare inner array. Read it as an object and every lookup isundefined. The array's indices surface as{0,1,2,3}. Blank render, no error, nothing in the console. The_metadataexclusion makes it worse: a block carrying one field plus_metadatastill counts as single-key and still unwraps.The fix is the prop type, not the data.
objectandarrayare the only two types that skip the unwrap:The bound field is Use Why A Group / Global Field / whole block object objectSkips the unwrap. The shape arrives intact A list: Modular Blocks, multi-Reference, multi-Group, scalar list arraySkips the unwrap. Render with .map()A genuine primitive of unknown type anyFlattening a primitive is a no-op, so it's safe here only Match the type to the real shape. "The shape varies per block" is not a reason to reach for
any. It's a reason to useobject.This is a resolver-level behavior (
retrievePropValue), so it bites UI / picker-authored bindings exactly as it bites API-authored ones, not an API-only footgun. It is also not specific to Modular Blocks: any single-key object collapses: a Group with one sub-field, a single-item wrapper, a block carrying one field plus_metadata. Rule of thumb: whenever the value you want is an object or a list, type the propobject/array.anyis safe only for a genuine primitive.These 12 strings govern YOUR registered components only. Studio's built-in nodes (
header,plain-text,rich-text,image, …) carry their own prop-type vocabulary, e.g. the built-in Header's text prop is typedplaintext, which is not in the list above and is not valid in aregisterComponentschema. Reading an existing composition and copying a built-in's prop type into your registration is a silent rejection. The two vocabularies overlap in places and diverge in others. Never transplant between them. Built-in node types are cataloged inauthor-composition-via-api§ Built-in nodetypevalues. -
Set
defaultValuefor every prop where the component has a default value in its source. The default appears as the palette tile preview. Without it, the tile renders blank and authors don't know what the component looks like. UsedefaultValue:exactly, NOTdefault:(the latter is silently ignored). -
Ship a
thumbnailUrl: every registration, no exceptions. (This and thestudioAttributescontract below are two of the six incomplete-the-build. Both ship in the same commit as the registration, never as a follow-up.)thumbnailUrldefaults to""in the registry (studio-registry,registry-options-processor), and Studio then renders a text placeholder instead of a preview. A palette of twenty text placeholders is unusable for the marketers this whole exercise exists to serve, and nothing errors, so it ships unnoticed.Rules that matter (full guidance + the
thumb()helper and tier glyph library:palette-conventions):- Inline SVG data URI, built with
encodeURIComponent. Never a CDN/asset URL. The palette renders inside Studio's iframe, where external requests hit CORS or fail silently, leaving you back at a blank tile. - Brand tokens for colors, not invented hex.
- Preview the shape, not the pixels: the thumbnail says what kind of thing this is (atomic / slot-shell / pattern).
registerComponent({type: "site-hero",displayName: "Hero",component: () => import("./components/Hero"),sections: ["Acme · Patterns"],thumbnailUrl: thumb(HeroGlyph, "Hero Block"), // data:image/svg+xml,...props: { /* … */ },});One argument, not two.
registerComponent(Hero, { name, … })— component first, config second — throwsComponent type is required.Verified against the published@contentstack/studio-registry@1.9.0:registerComponentis typed as one element of the arrayregisterComponentstakes, so a component passed first is read as the config andtypecomes back undefined. Pass one config object;typeandcomponentare the only required fields.registerComponents([{ … }], options?)takes an array of the same shape.Retrofitting an existing registration file? Every
registerComponentcall withoutthumbnailUrlis a blank tile today. Sweep them all:grep -c "registerComponent" <file>vsgrep -c "thumbnailUrl" <file>. The counts must match. - Inline SVG data URI, built with
5b. Next.js App Router: registration must run in the CLIENT realm. A registered component that is (or renders) a "use client" component cannot be registered from a Server Component module graph. On the server the SDK sees a client reference, and Next throws at render time ("Attempted to call the default export of <module> from the server but it's on the client") or the palette silently comes up empty because the registry that got populated was the server-side one.
The rule: registerComponents() runs from the same client boundary that runs studioSdk.init. install-studio already creates one for init (app/studio-init.tsx, "use client", side-effect import of @/lib/contentstack). Put the registration import there too, so both land in the client bundle in the right order:
// app/studio-init.tsx
"use client";
import "@/lib/contentstack"; // studioSdk.init
import "@/lib/studio-components"; // registerComponent calls — SAME client boundary
export function StudioInit() { return null; }
Do not import the registration module from app/layout.tsx, a page.tsx Server Component, or any non-"use client" module. Pages Router / Vite / Remix don't have this split. A module-scope import in _app.tsx or main.tsx is fine. Related: install-studio § App Router init boundary, and troubleshoot-ssr-rendering for the registry-singleton symptoms.
-
Write the registration: prefer LAZY by default. Append (or update) the registration file. The recommended shape is lazy: the component is downloaded only when it first renders, keeping the initial bundle small and code-splitting each registration automatically. The SDK wraps the dynamic
import()withReact.lazy+Suspensefor you.import { registerComponent } from "@contentstack/studio-react";registerComponent({type: "site-hero", // componentType — unique UIDdisplayName: "Hero", // componentName — palette labelcomponent: () => // ← LAZY: arity-0 thunk that returns a dynamic importimport("./components/Hero").then(m => ({ default: m.Hero })),props: {headline: { type: "string", displayName: "Headline", defaultValue: "Welcome" },description: { type: "string", displayName: "Description", defaultValue: "Lorem ipsum" },imageUrl: { type: "imageurl", displayName: "Image", defaultValue: "https://…" },ctaHref: { type: "href", displayName: "CTA Link", defaultValue: "/get-started" },layout: { type: "choice", displayName: "Layout", defaultValue: ["centered"], options: ["centered", "split"] },},});If the component is the default export:
component: () => import("./components/Hero")is enough. An explicit formregisterLazyComponent(config, loader)exists with identical runtime behavior.5a. Exposing extensible regions with
slotprops. To let template authors drop content into a region of the component (a card body, a sidebar, a CTA area), declare aslot-typed prop. The component renders the prop wherever the extensible region should appear. Studio surfaces it as a drop target for any component or Section.import { registerComponent } from "@contentstack/studio-react";registerComponent({type: "site-card",displayName: "Card",component: () => import("./components/Card"),props: {title: { type: "string", displayName: "Title", defaultValue: "Card title" },body: { type: "slot", displayName: "Body" }, // ← extensible region, no defaultValue},});The React component receives the slot as a prop (or via
children, depending on what its TypeScript interface declares) and renders it inside its tree:function Card({ title, body }: { title: string; body: React.ReactNode }) {return <div className="card"><h3>{title}</h3>{body}</div>;}At template-authoring time, the slot region shows as a dashed drop target. Authors drop a component or Section into it (see
use-section-slot§ component-slot-prop placement for the canonical filling pattern). Slots take ANY component or Section. There is no type constraint. Slot fills are stored per-template, so the same Card can carry different filled content on different templates.For a richer pattern (slot count driven by another prop, slot template factories), see
docs/15-build/10-components/25-component-schema-props-types-and-defaults.md§ slot.5b. The two valid
component:shapes, decided by arity:Shape component:valueWhen Lazy (default) arity-0 function returning a Promise(dynamicimport())Every BYOC registration unless tiny+hot. Eager function with ≥1 parameter (ordinary React component) Tiny components on hot paths. component: () => import("./components/Hero") // ✅ LAZYcomponent: Hero // ✅ EAGER — function Hero(props) {…}component: (props) => createElement(Hero, props) // ✅ EAGER — wrap arity-0Arity-0 trap. A parameter-less function that does NOT return a Promise (e.g.
function Header() {…}) is treated as a lazy loader, called outside render, and throws React #321 "Invalid hook call". Rule: return aPromise(lazy), OR give it apropsparameter (eager). -
Confirm Studio palette shows the new tile. Open the canvas, switch the palette accordion to Registered Components, find
Hero. The tile must show the thumbnail image. A text placeholder meansthumbnailUrlis missing or the data URI is malformed. A broken-image icon means you used an external URL and the iframe blocked it. The tile preview itself renders from thedefaultValues. If that area is blank, a prop is missing a default or the prop type was wrong.
Inputs needed from the user
componentPath: file path to read.componentName: display label.componentType: unique kebab-case UID (reject duplicates. Check the registration file before writing).
Do NOT invent component paths. If the user just says "register my Hero" without a path, ask which file.
The registration contracts: read the one you need
Registration has four contracts a component must honour. They are reference material, split out so a task loads only what it touches.
| Read this | For |
|---|---|
register-component-prop-contracts | Array and object binding rules, tolerant image and link signatures, {{entry.*}} resolution |
register-component-render-contracts | Layout-agnostic rule, null-safe rendering |
register-component-cslp | $ CSLP props and the studioAttributes contract. Both or neither. |
register-component-pitfalls | What fails silently, and why. Read before reporting done. |
Acceptance
This skill succeeds only when ALL of the following are true.
- The registration file contains a
registerComponentcall for the suppliedcomponentTypethat did not exist before this skill ran. - The component spreads
studioAttributeson its root element (or the registration setswrap), verified in the DOM by adata-cslpon the root. Without it the node has no CSLP tag and Visual Editor cannot select it. - Every bindable prop that renders visible content spreads its
$-twin on the element rendering it, verified by adata-cslpon each of those elements. - An empty bound value still renders its element when its
$-twin is present (notnull), so a new entry's field can be clicked, and decorative overlays over images havepointer-events: none(make-visual-editor-editable§ 4–5). - Every prop on the source component's TypeScript interface (or propTypes) appears in the
props:object, no prop dropped silently. - Every
type:value is one of the 13 allowed strings (string,boolean,number,choice,href,imageurl,datestring,array,object,slot,json_rte,any,action), nottext,link,color, orimage. - Any callback an author should control is an
actionprop, not a hardcoded handler. Otherwise the component's behavior is fixed at build time. Seewire-studio-state. - A
choiceprop'sdefaultValueshape followsmultiSelect: multi-select (multiSelect: true) must be an array (["centered"]). Single-select takes the bare value ("centered"). Single-select still accepts a one-element array, but only for backward compatibility, prefer the bare value. Source:SingleChoiceProp = PropBase<…, string | string[]>andMultiChoiceProp = PropBase<…, string[]>instudio-registry. -
defaultValue:(notdefault:) is set on every prop where the source component has a default. Exception:slotprops never carry adefaultValue, they start empty until template authors fill them. - Choice props include an
options:array of the allowed literal values. - The registration file is imported once at app startup, verified by grep for the import in
main.tsx/_app.tsx/App.tsx. - Studio's Registered Components palette accordion shows the new tile with the supplied
displayName. - The tile preview renders (not blank) because the defaults are populated.
-
component:follows one of the two valid shapes: lazy (arity-0 function that returns aPromiseof the component, typically() => import("./Foo")) OR eager (function with at least one parameter, i.e. an ordinary React component). The default recommendation is lazy. A zero-parameter function that does NOT return a Promise triggers React #321 / "Invalid hook call" at render. - The component is layout-agnostic,
width: 100%of its container, no hard-codedmax-widthto "protect" against wrong contexts, no styles that rely on a specific ancestor selector (.fs-grid > .fs-card { ... }). Layout sizing is delegated to the parent Section, not baked into the component. - Call-site literal sweep. If this component is already used in production code, grep every JSX use of it. Every literal prop set at a call site (
isInteractive={false},variant="compact",columns={3},isLogoBgWhite={false}) becomes adefaultValueon the corresponding registered prop, or a named preset. Skipping this is the single most common cause of "composed page looks 80% right but wrong on hover/motion/spacing." Sample grep:rg -tn tsx -o "<ComponentName[^>]*/>" | head. If the component is new (no production call sites yet), acceptance is trivially satisfied, but state that explicitly. - Tolerant image signatures: every
imageurlprop's TS type acceptsstring | { url?: string } | null | undefined, and the component internally coerces. See § Tolerant image signatures above. This is a first-class contract, not an optional nicety. Studio's picker binds sometimes the object, sometimes.url, and either shape must render correctly. - Tolerant link signatures: every
href-typed prop binds against a link field'shrefleaf (never the whole link object). Where the design mandates a single link prop, the component coercesstring | {title, href}inside viahref()/label()helpers. See § Tolerant link signatures above. - Palette group + thumbnail: the registration declares
sections: ["<Brand> · Elements | Patterns | Layouts"](never"Template"or"Section"in the name) and athumbnailUrldata URI that visually previews the tier. Seepalette-conventions.
After registering: plan your Section shape
Register the component first. Then decide which Section(s) to author in Studio for it.
- Compound component that iterates internally (say
<BlogArticle>doing.map(sections),<CardList>doing.map(related_posts)): build top-down. Author the wrapping Simple Section first, one Section that wraps the whole compound. Studio renders your existing page inside its canvas in ~5 minutes with no code changes. Then decompose one Section per iteration level as you need each to become author-editable. - Atomic component that renders one shape (
<Card>bound to card_ref,<Hero>bound to hero_group): author its Simple Section directly. Compose it later inside a List Section when a parent iteration needs to drop it as a slot's default content.
Both directions produce the same Section chain in the end. Top-down is faster for existing compound-heavy apps. Bottom-up has a cleaner mental model for greenfield.
Use build-section (or build-repeating-section for List Sections) to author each Section. The skill walks the Studio-UI flow. Full worked example with a 4-level nested schema: From components to Studio compositions.
See also
docs/15-build/10-components/20-register-a-component.md: full reference, includingregisterComponents(batch) andregisterLazyComponent(code-split)docs/15-build/10-components/25-component-schema-props-types-and-defaults.md: every prop type with all optionsdocs/30-look-it-up/05-reference/62-data-sources.md: separatewire-component-default-dataskill for advanced default datawire-component-default-data: for components whose defaults aren't representable as staticdefaultValue:literalsuse-section-slot: fill aslot-typed prop on a registered component (thecomponent-slot-propplacement mode), or carve a Section Slot inside onewire-slot-data: carry data the component holds into whatever an author drops into itsslotpropimport-design-tokens: register your design system after registering components