Studio Docs

Register Components

Deciding what to register? Read Design a component library that composes, not sprawls first. It’s the atomic + layout primitive pattern (aligned with Tailwind + shadcn/ui) that keeps your library small and your design system enforced.

⚠️ Deciding how each component should be shaped to bind cleanly? Read Component shape rules, the five-rule tactical checklist. Skipping it leads to silent blank renders (Rule 1), unreachable child bindings (Rule 2), compound monoliths (Rule 3), misaligned positional remaps (Rule 4), and per-Template rebuilds (Rule 5). This page covers the API mechanics. Those pages cover the what and the how.

Three register APIs, picked by use case.

APIWhen to use
registerComponent(config)One component at a time. Simplest.
registerComponents(configs)Many components at once, all eagerly registered.
registerLazyComponent(config, loader)When you want code-splitting: the schema ships eagerly, the component code loads on demand.

All three live in @contentstack/studio-react.

Do It With a Skill

curl -fsSL https://studio-documentation.contentstackapps.com/install.sh | sh

Then: register-component, or say “register my Button component as a Studio component”. The LLM inspects your component, infers a prop schema (or asks if it can’t), and writes the registry entry.

Use registerComponent for one component

import { registerComponent } from "@contentstack/studio-react";
import { Button } from "@/components/Button";
import buttonIcon from "@/assets/icons/button.svg";

registerComponent({
  type:        "Button",
  displayName: "Button",
  description: "Primary call-to-action button",
  thumbnailUrl: buttonIcon,
  component:   Button,
  props: {
    label: { type: "string", defaultValue: "Click me" },
    href:  { type: "href",   defaultValue: "#" },
    size:  {
      type:         "choice",
      options:      ["small", "medium", "large"],
      defaultValue: ["medium"],
    },
  },
});

What each field does:

FieldTypeRequiredPurpose
typestringUnique identifier for this component. Use the React component’s display name or a clear slug. Don’t change it after publishing, composition records reference it.
componentReact componentYour actual React component. Studio mounts this at render time.
displayNamestringLabel shown in the palette and Layers panel. Falls back to type if omitted.
descriptionstringTooltip / docs hover in the palette.
thumbnailUrlstring (URL, imported asset, or data: URI)Palette tile thumbnail. SVG works best. Inline data:image/svg+xml,… URIs avoid separate asset files and ship in the eager bundle. Falls back to a generic placeholder if omitted, so all your tiles end up looking the same.
layerIconstring (inline SVG, URL, or servable path)Icon shown in the Layers tree. Best sourced from a .svg file via a ?raw import (inline, themeable, sanitized). See Layers-tree icons.
sectionsstring | string[]Which left-panel category this appears under. Defaults to “Registered Components”.
wrapboolean | stringIf true, Studio wraps the component in a <div> for selection / edit handles. If a string, wraps in that tag. Default: false, you opt in if your component doesn’t render a single editable root.
propsobjectThe prop schema. See Component schema for every prop type.
stylesobjectDesign Panel groups. See Design tokens.
hideFromContentCreatorsbooleanHides from authors but keeps in dev mode.
hideFromComponentListbooleanHides from the palette entirely.
metadataobjectFree-form storage for custom flows.

Design Palette Thumbnails

Studio renders the value of thumbnailUrl as the tile in the Registered Components section of the palette. Without one, every tile falls back to the same default placeholder, useful as a sanity check, useless once you have more than two components.

Two practical patterns:

A. Bundled SVG files

import buttonIcon from "@/assets/icons/button.svg";

registerComponent({
  type: "Button",
  thumbnailUrl: buttonIcon,
  // …
});

Your bundler (Vite, Next, Webpack with SVG-loader) inlines the SVG as a URL. Cleanest when you already have an icon set.

B. Inline data: URIs

When you don’t want a separate asset file, encode the SVG inline:

function svg(body: string): string {
  const wrapped = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><g transform="translate(8 8)">${body}</g></svg>`;
  return `data:image/svg+xml,${encodeURIComponent(wrapped)}`;
}

registerComponent({
  type: "Button",
  thumbnailUrl: svg('<path d="M5 4 L17 13.5 L11 14 L13.5 19 L11 20 L8.5 15 L5 18 Z" fill="currentColor" stroke="none"/>'),
  // …
});

Why this shape:

  • viewBox="0 0 40 40" + <g transform="translate(8 8)">: Studio scales the SVG to a fixed tile size. A larger viewBox shrinks the visible ink. The translate(8 8) re-centers it. Without these the icon either fills the entire tile or sits in the corner.
  • stroke="currentColor": tiles inherit Studio’s palette theme (works in light + dark mode). Avoid hard-coded hex.
  • encodeURIComponent: quotes and # characters break naive data: URIs in some browsers. Encode the whole body once.

Verify visually before relying on it: open the project, drop into a composition, confirm every tile is visually distinct, not the placeholder.

Layers-tree icons: layerIcon

thumbnailUrl drives the palette tile only. The Layers tree (left panel → Layers tab) reads a different field, layerIcon, a public field that accepts your own icon as an inline SVG string, an absolute URL, or a servable path. (This is separate from the internal built-in icon field covered below.)

The cleanest pattern is to keep each icon in its own .svg file and import it with the bundler’s ?raw query, which returns the file’s markup as a string:

// hero.svg lives next to your registration, editable in any SVG tool.
import heroLayerIcon from "@/assets/layer-icons/hero.svg?raw";

registerComponent({
  type: "Hero",
  displayName: "Hero",
  thumbnailUrl: heroIcon,
  component: Hero,
  layerIcon: heroLayerIcon, // file markup as a string → inline, themeable, sanitized
  // …
});

Why ?raw and not a plain import:

  • import x from "./x.svg?raw" → the file’s markup as a string. Studio treats it as inline SVG: themeable via currentColor (recolors on hover/selection), DOMPurify-sanitized, and inlined into the bundle: no separate asset request, and no production path breakage.
  • import x from "./x.svg" (no ?raw) → a URL, rendered as a static <img>. Fine for thumbnailUrl, but not themeable and the wrong choice for layerIcon.
  • Use currentColor (not hard-coded hex) in the SVG so the glyph follows the Layers-tree theme in light + dark mode.
  • In TypeScript, ?raw imports are typed as string by vite/client (Vite) or your bundler’s SVG type declarations, no cast needed.

You can also pass an inline SVG string directly (skip the file), an absolute https://… URL, or a public/-root-relative path like /icons/hero.svg. Raw string source-tree paths ("./icons/hero.svg") are not supported. They break in production once the bundler hashes assets. Use ?raw, a public/ path, or an imported URL instead.

Legacy: the built-in icon field

Before layerIcon was public, the only way to set a Layers-tree glyph was the internal icon field, typed as internal and stripped from the public API, opted into via a spread-cast. Prefer layerIcon above. Reach for icon only if you specifically want one of Studio’s own built-in glyphs:

registerComponent({
  type: "Hero",
  displayName: "Hero",
  component: Hero,
  // Built-in glyph — typescript-stripped, runtime-honoured.
  ...({ icon: "ComponentHeader" } as any),
  // …
});

Only built-in BuilderIcon names are accepted: the closed set lives in the Renderer switch in composable-studio (look for case "ComponentX":). Useful ones:

If your component is…Try icon
Anything button-shaped"ComponentButton"
A content card / tile"ComponentCard"
A header / banner / hero"ComponentHeader"
Text-heavy / quote / paragraph"ComponentText"
Image-prominent"ComponentImage"
Video"ComponentVideo"
A box / generic container"ComponentBox"

Arbitrary SVG / URLs are not accepted in icon. The lookup is a switch on the name string (that’s what layerIcon is for). If you omit both fields, Layers falls back to "ComponentDefault" (4 small squares).

Use registerComponents for many at once

The bulk form. Same schema per entry. Ergonomic when you ship a whole library.

import { registerComponents } from "@contentstack/studio-react";
import { Button, Card, Hero } from "@/components";
import { buttonIcon, cardIcon, heroIcon } from "@/assets/icons";

registerComponents([
  {
    type: "Button",
    displayName: "Button",
    thumbnailUrl: buttonIcon,
    component: Button,
    props: {
      label: { type: "string", defaultValue: "Click me" },
      href:  { type: "href",   defaultValue: "#" },
    },
  },
  {
    type: "Card",
    displayName: "Card",
    thumbnailUrl: cardIcon,
    component: Card,
    props: {
      title: { type: "string", defaultValue: "Card title" },
      image: { type: "imageurl" },
      body:  { type: "string", control: "large" },
    },
  },
  {
    type: "Hero",
    displayName: "Hero",
    thumbnailUrl: heroIcon,
    component: Hero,
    props: {
      headline: { type: "string", defaultValue: "Welcome" },
      cover:    { type: "imageurl" },
    },
  },
]);

Whether you call registerComponent thrice or registerComponents once is purely stylistic. The result is identical.

Use registerLazyComponent to code-split a component

import { registerLazyComponent } from "@contentstack/studio-react";
import heroIcon from "@/assets/icons/hero.svg";

registerLazyComponent(
  {
    type:        "Hero",
    displayName: "Hero",
    thumbnailUrl: heroIcon,
    description: "Above-the-fold hero with image and CTA",
    props: {
      headline:  { type: "string",   defaultValue: "Welcome" },
      cover:     { type: "imageurl" },
      ctaLabel:  { type: "string",   defaultValue: "Get started" },
      ctaHref:   { type: "href",     defaultValue: "#" },
    },
  },
  () => import("@/components/Hero").then((m) => m.Hero),
);

The first argument is the same schema as registerComponent, except you omit component. The second is a loader that resolves to the component.

Studio wraps the loader in React.lazy + <Suspense fallback={null}>. The schema (icon, label, props) ships eagerly so the palette renders the tile. The component code loads only when the component is mounted, either when an author drops it on the canvas or when a visitor’s page renders it.

When to use lazy:

  • You have a large component library and want the schema-only initial bundle
  • A component pulls heavy dependencies (chart libs, rich-text editors) you want code-split
  • You ship to multiple Studio projects and want per-project tree-shaking

Eager registerComponent is the right default: reach for lazy when bundle size becomes a real concern. See Optimizing load for the full pattern + framework-specific notes.

Place the Registration Call

The safest place is a shared module that’s imported at app boot, alongside @/lib/contentstack:

// src/lib/studio-components.ts
import { registerComponents, registerDesignTokens } from "@contentstack/studio-react";
import { Button, Card, Hero } from "@/components";

// Export a function — DO NOT register at module load.
// registerDesignTokens throws if the SDK is not yet initialized, and ES `import`
// is hoisted (an `import "./studio-components"` written below `studioSdk.init(...)`
// will actually run BEFORE init). Calling registerStudio() explicitly after init
// is the only order-safe pattern.
export function registerStudio() {
  registerDesignTokens({ /* your tokens */ });
  registerComponents([
    { type: "Button", component: Button, /* … */ },
    { type: "Card",   component: Card,   /* … */ },
    { type: "Hero",   component: Hero,   /* … */ },
  ]);
}
// src/lib/contentstack.ts
import { studioSdk } from "@contentstack/studio-react";
import { registerStudio } from "./studio-components";

export const csStudio = studioSdk.init({ stackSdk, contentTypeUid: "compositions" });
registerStudio();   // explicit call — guaranteed to run AFTER init

Then in your app shell:

import "@/lib/contentstack";  // runs init + registration once

⚠ ES imports hoist, so an import './studio-components' below studioSdk.init() runs before it: always use an exported register() function called explicitly after init.

Understand Registration Order

Registration runs synchronously at module load. The canvas reads the global registry when the iframe loads.

Registration must run before Studio’s canvas iframe loads. Otherwise the palette comes up empty for your components and authors only see the defaults.

Three ways this breaks in practice:

  1. Side-effect import after init in the same file: ES hoisting moves the import above the init call (see ⚠ above). Use an exported register() function instead.
  2. Registering inside a route component: only runs when that route mounts, which may be after the canvas. Move it to app boot.
  3. Conditional registration based on user state: same problem. Register all components upfront. Use hideFromContentCreators if you want to keep some out of the palette for certain audiences.

Handle Next.js App Router: Register in Both Module Graphs

App Router apps have two module graphs: a server graph (RSC pages that call csStudio.fetchCompositionData) and a client graph (the bundle that hydrates). The component registry is a globalThis-backed singleton, but it still needs to be populated in whichever graph reaches it first. If only one side registers, the other renders with an empty palette / unknown component errors.

Recommended layout:

// src/lib/studio-components.ts — PLAIN module (no "use client")
export function registerStudio() { /* registerComponents + registerDesignTokens */ }

Then import + call it from both entry points:

// src/lib/contentstack.ts — server-side import path
import { registerStudio } from "./studio-components";
export const csStudio = studioSdk.init({ /* … */ });
registerStudio();
// src/components/StudioRender.tsx — "use client" client wrapper
"use client";
import { registerStudio } from "@/lib/studio-components";
import { StudioComponent } from "@contentstack/studio-react";
registerStudio();   // safe — registration is idempotent (first wins)
export function StudioRender(props) { return <StudioComponent {...props} />; }

This double-call is safe because registerComponents is idempotent by default: duplicates are silently skipped (first wins). The SDK was designed this way exactly so HMR, dual-graph, and worker re-evaluation don’t break a working integration.

Verify the Registration

After registering, open Studio, go to the Components palette on the left, and expand Registered Components. You should see your tiles with the icons + display names you provided.

If they don’t appear, see Troubleshoot: “Registered components don’t appear in Studio’s palette”.

Next