Component Schema Prop Types
Every registered component declares its prop schema. Studio uses it to render the right-panel form, the data picker, and the in-canvas validation.
This page is the reference for prop types. For the component-level fields (type, component, displayName, thumbnailUrl, etc.), see Registering components. TypeScript types for props and schema shapes are available from @contentstack/studio-react: import StudioAttributes, RegisterComponentConfig, and related types from there rather than reaching into internal packages.
Schema Shape
{
type: "Button",
// …
props: {
label: { type: "string", defaultValue: "Click me" },
href: { type: "href" },
size: {
type: "choice",
options: ["small", "medium", "large"],
defaultValue: "medium",
},
},
}Each prop is an object with a type plus type-specific options.
Fields Every Prop Supports
| Field | Type | Purpose |
|---|---|---|
| type | string | The prop type (see table below). Required. |
| displayName | string | Label in the right panel. Defaults to the prop key. |
| defaultValue | matches the prop's value type | What renders if no value is set. See Default data. |
| defaultValueHint | string | Hint text describing the expected default; shown when no value is set. Distinct from placeholder (string / href only), which is the input placeholder. |
| helpText | string | Inline help text under the form field. |
| validate | (value) => boolean \| string \| void | Custom validation. Return a string to show as an error message; return true / nothing to accept. |
Prop Types
| type | Renders in the right panel as | Use it for |
|---|---|---|
| "string" | Text input | Labels, headings, body copy |
| "boolean" | Toggle | Show/hide flags, on/off switches |
| "number" | Number input (or slider) | Counts, sizes, percentages |
| "choice" | Radio group or dropdown | Variants like size, intent, tone. Set multiSelect: true for tag/category-style multi-select. |
| "href" | Link picker | URLs, with internal link / external link distinction |
| "imageurl" | Image picker | Media asset URLs from the stack or external |
| "datestring" | Date or date-time picker | Publication dates, scheduling |
| "array" | Editable list | Repeating items where the item type matters |
| "object" | Nested form (a group of sub-props) | Structured props like { title, subtitle, link } |
| "slot" | Drop target on the canvas | Child components, giving you a Section-Slot-like placeholder |
| "any" | Raw editor | Last resort for anything that doesn't fit above |
| "json_rte" | Rich-text editor | Structured rich text (Contentstack's JSON RTE format) |
Type-Specific Options
string
title: {
type: "string",
displayName: "Title",
defaultValue: "Hello",
placeholder: "Enter a heading",
control: "default", // or "large" for textarea, "markdown" for markdown editor
}| Option | Values |
|---|---|
| control | "default" (single-line input) · "large" (textarea) · "markdown" (markdown editor) |
| placeholder | Placeholder text |
number
columns: {
type: "number",
displayName: "Columns",
defaultValue: 3,
min: 1,
max: 12,
step: 1,
control: "slider", // or "default" for a number input
}choice
size: {
type: "choice",
options: ["small", "medium", "large"],
defaultValue: ["medium"],
control: "dropdown", // or "radio" for radio buttons
multiSelect: false,
}Or label/value pairs when the option labels differ from values:
intent: {
type: "choice",
options: [
{ value: "primary", label: "Primary" },
{ value: "secondary", label: "Secondary" },
{ value: "danger", label: "Danger" },
],
defaultValue: ["primary"],
}| Option | Values |
|---|---|
| options | string[] or { value, label }[] |
| multiSelect | true for multi-select, default false |
| control | "radio" (single, default) · "dropdown" (single or multi) |
href
href: {
type: "href",
displayName: "Link",
defaultValue: "#",
placeholder: "https://…",
}The right-panel control is Studio's link picker, which supports both internal (entry / composition reference) and external links.
imageurl
src: {
type: "imageurl",
displayName: "Image",
}The right panel shows an asset picker. Your component receives a URL string at render time.
datestring
publishedAt: {
type: "datestring",
displayName: "Published",
control: "datetime", // or "date" for date-only
}array
features: {
type: "array",
displayName: "Features",
items: { type: "string" }, // each item is a string
}The items field is itself a prop config: nest any prop type.
object
cta: {
type: "object",
displayName: "Call to action",
properties: {
label: { type: "string", defaultValue: "Sign up" },
href: { type: "href", defaultValue: "#" },
},
}object props render as a nested form group in the right panel.
slot
The most powerful prop type: it lets authors drop child components.
children: {
type: "slot",
displayName: "Content",
}A slot prop receives an array of children at render time. Your component renders them somewhere inside its tree:
function Card({ children }) {
return <div className="card">{children}</div>;
}You can also drive slot count from a number prop, useful for "this card has N columns" patterns:
columns: {
type: "number",
defaultValue: 3,
},
items: {
type: "slot",
countProp: "columns", // children auto-grow / shrink with `columns`
itemFactory: () => ({ // template node for new children
type: "Text",
props: {}, // Studio fills static defaults from the registered schema
}),
},boolean
disabled: {
type: "boolean",
defaultValue: false,
displayName: "Disabled",
}json_rte
body: {
type: "json_rte",
displayName: "Body",
}Renders Contentstack's JSON Rich Text Editor. The right panel shows a rich-text editor control. Your component receives a JSON RTE document object at render time and must serialize it using @contentstack/json-rte-serializer or equivalent.
⚠ Don't use string for an RTE-bound prop. RTE fields deliver a JSON RTE document object (not a pre-serialized string). Binding one to a string prop tries to render the structured object as text: you get [object Object] or stringified JSON in the page. Always use json_rte for RTE-backed bindings. The component receives the JSON RTE object and must:
- Serialize the JSON RTE document to HTML via @contentstack/json-rte-serializer
- Sanitize the HTML (DOMPurify or equivalent)
- Render with dangerouslySetInnerHTML
import { jsonToHtml } from "@contentstack/json-rte-serializer"; import DOMPurify from "isomorphic-dompurify"; function BlogBody({ body }: { body: object | undefined }) { if (!body) return null; const html = jsonToHtml(body); // JSON RTE doc → HTML string const safe = DOMPurify.sanitize(html); // sanitize editor-supplied content return <article dangerouslySetInnerHTML={{ __html: safe }} />; }Verified against the SDK: JsonRTEProp = PropBase<NodePropTypes["json_rte"], object> in composable-studio-sdk/packages/studio-registry/src/component-registry/component-props.type.ts: the prop value is object, not string.
any
config: {
type: "any",
displayName: "Config",
}Use sparingly. Studio shows a raw JSON editor. No type checking, no nice form controls. Useful for prototypes and developer-only props.
Selection and Edit Handles: wrap vs studioAttributes
Studio needs a DOM element to attach selection handles, hover overlays, and inline-edit anchors to. Every registered component contracts with the SDK in one of two ways for that, declared via the wrap option on registerComponent:
| wrap | Behaviour | Use when… |
|---|---|---|
| false (default) | The SDK injects a studioAttributes prop into your component. Your component's root element must spread it ({...studioAttributes}) so the builder can attach handles. | Your component renders a single root element. Recommended path: no extra wrapping in the DOM. |
| true | The SDK wraps your component in its own <div> automatically. You don't receive studioAttributes. | Your component renders multiple top-level siblings (fragment) OR doesn't have a stable single root. Adds an extra <div> to your DOM. |
Quick decision rule: Start with wrap: false. Switch to wrap: true only if your component's render output is a React fragment or returns sibling elements with no shared root, and be aware the extra <div> can break CSS rules that rely on parent selectors or direct-child combinators.
wrap: false: the canonical pattern
import type { StudioAttributes } from "@contentstack/studio-react";
export function Hero({
headline,
subhead,
studioAttributes, // injected by Studio
}: HeroProps & StudioAttributes) {
return (
<section {...studioAttributes} className="hero"> // spread on the root
<h1>{headline}</h1>
<p>{subhead}</p>
</section>
);
}Register with:
registerComponent({
type: "site-hero",
displayName: "Hero",
component: Hero,
wrap: false, // default — could be omitted
props: { /* … */ },
});The StudioAttributes type comes from @contentstack/studio-react ({ studioAttributes?: BuilderNodeInternalAttributes }). Extending your props type with it gives the consumer a typed contract without coupling to internal SDK structure.
wrap: true: when your component has no single root
// Two sibling elements — no clean root to spread on
export function FeatureCallout({ icon, label }: Props) {
return (
<>
<span className="icon">{icon}</span>
<strong>{label}</strong>
</>
);
}registerComponent({
type: "feature-callout",
displayName: "Feature Callout",
component: FeatureCallout,
wrap: true, // SDK adds a wrapper <div> automatically
props: { /* … */ },
});Common pitfalls
- wrap: false but not spreading studioAttributes: the canvas can't select the component. Selection handles, hover overlays, and inline-edit anchors all silently fail.
- wrap: false on a fragment-rooted component: same problem. Either refactor to a single root or use wrap: true.
- wrap: true when the component already has a clean root: adds an unnecessary <div> to the DOM and can break CSS that targets parent selectors.
Validation
The validate function runs whenever the prop value changes:
slug: {
type: "string",
displayName: "Slug",
validate: (value) => {
if (!value) return "Slug is required.";
if (!/^[a-z0-9-]+$/.test(value)) return "Lowercase letters, numbers, and hyphens only.";
// return true or nothing to accept
},
}Return: - A string: shown as an error in the right panel; the value is rejected - false: rejected without an error message (use a string instead for UX) - true or undefined: accepted
A Complete Worked Example
registerComponent({
type: "Card",
displayName: "Product Card",
description: "Product tile with image, title, price, and CTA",
thumbnailUrl: cardIcon,
component: ProductCard,
props: {
title: {
type: "string",
displayName: "Title",
defaultValue: "Product name",
placeholder: "Enter the product name",
},
image: {
type: "imageurl",
displayName: "Cover image",
helpText: "Recommended: 800×800 square crop",
},
price: {
type: "number",
displayName: "Price",
min: 0,
step: 0.01,
},
badge: {
type: "choice",
options: [
{ value: "new", label: "New" },
{ value: "sale", label: "On sale" },
{ value: "exclusive", label: "Exclusive" },
{ value: "", label: "None" },
],
defaultValue: [""],
},
cta: {
type: "object",
displayName: "Call to action",
properties: {
label: { type: "string", defaultValue: "View details" },
href: { type: "href", defaultValue: "#" },
},
},
featured: {
type: "boolean",
displayName: "Featured",
defaultValue: false,
helpText: "Adds a highlighted border",
},
},
});Next
- Default data: when defaults render
- Design tokens: make controls match your brand
- Optimizing load: code-split big components