Generates React components for Optimizely CMS content types and display templates, mapping properties to React props and following SDK patterns.
How this skill is triggered — by the user, by Claude, or both
Slash command
/optimizely-cms-skills:optimizely-model-reactThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
This skill generates React components for Optimizely CMS content types and display templates, following SDK patterns and best practices.
This skill generates React components for Optimizely CMS content types and display templates, following SDK patterns and best practices.
Use this skill when the user wants to:
Before generating a React component, you need:
optimizely-model skill)optimizely.config.mjs file to determine the components directory@optimizely/cms-sdk installedFirst, find where the content type or display template is defined:
pattern: "export const.*ContentType = contentType"
or
pattern: "export const.*DisplayTemplate = displayTemplate"
tag field value - this exact value will be used as the key in the component registryChoose where to add the React component:
Strategy A: Add to existing file (preferred if content type is already in a .tsx file)
Article.tsx)Strategy B: Create new file
Before generating components, check for existing styles to maintain consistency. See references/styling-strategy.md for detailed guidance on integrating CSS/SCSS classes, CSS Modules, Tailwind, and handling projects without styles.
Use these patterns based on the content type structure:
import { ContentProps } from '@optimizely/cms-sdk';
import { getPreviewUtils } from '@optimizely/cms-sdk/react/server';
type Props = {
content: ContentProps<typeof XYZContentType>;
};
export default function XYZ({ content }: Props) {
const { pa } = getPreviewUtils(content);
return (
<div>
<h1 {...pa('heading')}>{content.heading}</h1>
</div>
);
}
1. Preview Attributes Always use preview attributes for editable properties. This enables in-context editing in the CMS:
const { pa } = getPreviewUtils(content);
// For simple properties
<h1 {...pa('heading')}>{content.heading}</h1>
<p {...pa('subtitle')}>{content.subtitle}</p>
// For nested properties (embedded components)
<div {...pa('hero.heading')}>{content.hero?.heading}</div>
2. RichText Properties
For richText type properties, use the RichText component:
import { RichText } from '@optimizely/cms-sdk/react/richText';
// Basic usage
<RichText content={content.body?.json} />
// With custom element renderers (optional)
<RichText
content={content.body?.json}
elements={{
'heading-two': (props) => <h1 style={{ color: 'blue' }}>{props.text}</h1>
}}
/>
// Or use HTML rendering (simpler but less customizable)
<div {...pa('body')} dangerouslySetInnerHTML={{ __html: content.body?.html ?? '' }} />
3. Image Properties For image content references, use damAssets and the src utility:
import { damAssets } from '@optimizely/cms-sdk';
const { pa, src } = getPreviewUtils(content);
const { getSrcset, getAlt } = damAssets(content);
const imageUrl = src(content.image);
{content.image && imageUrl && (
<img
src={imageUrl}
srcSet={getSrcset(content.image)}
sizes="(max-width: 768px) 100vw, 50vw"
alt={getAlt(content.image, 'Default alt text')}
/>
)}
For Next.js projects, prefer using the Image component:
import Image from 'next/image';
const imageUrl = src(content.background);
{imageUrl && <Image src={imageUrl} alt="" fill={true} />}
3a. URL Properties
CRITICAL: URL properties (type: 'url') return an InferredUrl object, NOT a string. Always access .default for the href value.
// ✅ CORRECT: Access .default from InferredUrl object
{content.websiteUrl && (
<a href={content.websiteUrl.default ?? undefined} {...pa('websiteUrl')}>
Visit Website
</a>
)}
// ❌ WRONG: Using URL directly as string causes TypeScript error
{content.websiteUrl && (
<a href={content.websiteUrl}>Visit</a>
)}
// Error: Type 'InferredUrl' is not assignable to type 'string'
3b. Link Properties
CRITICAL: Link properties (type: 'link') return a LinkItem object with nullable fields. TypeScript requires string | undefined for HTML attributes, NOT string | null. Always use ?? undefined to convert null to undefined.
// ✅ CORRECT: Convert null to undefined with ?? undefined
{content.ctaButton && (
<a
href={content.ctaButton.url ?? undefined}
title={content.ctaButton.title ?? undefined}
target={content.ctaButton.target ?? undefined}
rel={content.ctaButton.target === '_blank' ? 'noopener noreferrer' : undefined}
{...pa('ctaButton')}
>
{content.ctaButton.text}
</a>
)}
// ❌ WRONG: Passing null to attributes causes TypeScript error
{content.ctaButton && (
<a
title={content.ctaButton.title}
target={content.ctaButton.target}
>
// Error: Type 'string | null' is not assignable to type 'string | undefined'
For detailed patterns on URL and link rendering, see references/react-patterns.md.
4. ContentReference Properties
ContentReference properties return InferredContentReference which contains { key, url, item } but NOT __typename. They cannot be used with OptimizelyComponent.
For single contentReference:
{content.relatedPage?.url && (
<a href={content.relatedPage.url.default ?? ''}>
{content.relatedPage.key}
</a>
)}
For array of contentReferences:
{content.relatedPages?.map((ref, i) => (
ref?.url && (
<a key={i} href={ref.url.default ?? ''}>
{ref.key}
</a>
)
))}
5. Array Properties (Content Type Arrays) For arrays of content types (not contentReferences), use OptimizelyComponent:
import { OptimizelyComponent } from '@optimizely/cms-sdk/react/server';
<div {...pa('sections')}>
{content.sections?.map((section, index) => (
<OptimizelyComponent key={index} content={section} />
))}
</div>
// With optional chaining for safety
<div {...pa('articles')}>
{(content.articles ?? []).map((article, i) => (
<OptimizelyComponent key={i} content={article} />
))}
</div>
Note: Use OptimizelyComponent for type: 'content' arrays, NOT for type: 'contentReference' arrays.
6. Embedded Component Properties
For type: 'component' properties, access the nested data directly:
// The property is embedded inline, not a reference
{content.hero && (
<header {...pa('hero')}>
<h1 {...pa('hero.heading')}>{content.hero.heading}</h1>
<p {...pa('hero.summary')}>{content.hero.summary}</p>
</header>
)}
7. Experience Types with Composition Experiences need OptimizelyComposition to render the visual builder nodes:
import {
OptimizelyComposition,
ComponentContainerProps,
getPreviewUtils
} from '@optimizely/cms-sdk/react/server';
function ComponentWrapper({ children, node }: ComponentContainerProps) {
const { pa } = getPreviewUtils(node);
return <div {...pa(node)}>{children}</div>;
}
export default function MyExperience({ content }: Props) {
const { pa } = getPreviewUtils(content);
return (
<main>
{/* Your content properties */}
<h1 {...pa('title')}>{content.title}</h1>
{/* Visual builder composition */}
<OptimizelyComposition
nodes={content.composition.nodes ?? []}
ComponentWrapper={ComponentWrapper}
/>
</main>
);
}
8. Display Templates For display template components, accept displaySettings as a prop:
import { ContentProps } from '@optimizely/cms-sdk';
export const SquareDisplayTemplate = displayTemplate({
key: 'SquareDisplayTemplate',
displayName: 'Square Display',
baseType: '_component',
settings: {
color: {
editor: 'select',
displayName: 'Color',
choices: {
red: { displayName: 'Red' },
blue: { displayName: 'Blue' },
},
},
},
tag: 'Square', // CRITICAL: This tag value must match the key in initReactComponentRegistry tags object
});
type Props = {
content: ContentProps<typeof TileContentType>;
displaySettings?: ContentProps<typeof SquareDisplayTemplate>;
};
export function SquareTile({ content, displaySettings }: Props) {
const { pa } = getPreviewUtils(content);
return (
<div style={{ backgroundColor: displaySettings?.color }}>
<h1 {...pa('title')}>{content.title}</h1>
</div>
);
}
CRITICAL: The tag field in the display template definition is the exact key that MUST be used in the initReactComponentRegistry tags object. If the display template has tag: 'Square', then the registration MUST use tags: { Square: SquareTile }.
Use appropriate semantic HTML based on the base type:
_page → wrap in <main>_component with sectionEnabled → wrap in <section> or <article>_component with elementEnabled → use <div>, <span>, or specific elements like <a>, <button>_experience → wrap in <main>Organize imports logically:
// SDK imports
import { contentType, ContentProps, damAssets } from '@optimizely/cms-sdk';
import { RichText } from '@optimizely/cms-sdk/react/richText';
import {
getPreviewUtils,
OptimizelyComponent,
OptimizelyComposition
} from '@optimizely/cms-sdk/react/server';
// Framework imports (Next.js, etc.)
import Image from 'next/image';
// Local imports
import { HeroContentType } from './Hero';
After creating the component, automatically register it in both initContentTypeRegistry and initReactComponentRegistry.
CRITICAL WORKFLOW: Import path errors are the #1 issue when registering components. Follow this exact sequence:
npx tsc --noEmit before finalizingDo NOT skip Step 2 (reading tsconfig.json). Assuming path aliases without verification leads to broken imports.
initReactComponentRegistry:
grep -r "initReactComponentRegistry" --include="*.tsx" --include="*.ts"
app/layout.tsx or src/app/layout.tsxCRITICAL: Before adding imports, you MUST check tsconfig.json to understand the path alias configuration. This is the most common source of import errors.
ALWAYS read tsconfig.json first before generating any imports:
# Read the tsconfig.json file
cat tsconfig.json
Look for these critical fields:
compilerOptions.baseUrl - REQUIRED for path aliases to work (usually "." or "./")compilerOptions.paths - Defines the alias mappingsCommon configurations:
"@/*": ["./src/*"] → Alias @/ maps to src/ directory"@/*": ["./*"] → Alias @/ maps to project root"~/*": ["./src/*"] → Alternative alias conventionpaths field → Path aliases not configured, use relative importsIf baseUrl is missing, path aliases won't work regardless of the paths configuration. In this case, use relative imports.
Given the component file location and tsconfig configuration, calculate the import path:
Example: Component at src/components/Article.tsx
| tsconfig paths | baseUrl | Import as | Why |
|---|---|---|---|
"@/*": ["./src/*"] | "." | @/components/Article | Alias strips ./src/, add @/ |
"@/*": ["./*"] | "." | @/src/components/Article | Alias is at root, keep full path |
"~/*": ["./src/*"] | "." | ~/components/Article | Different alias symbol |
| No paths | "./src" | ./components/Article | Relative from baseUrl |
| No paths, no baseUrl | N/A | ../src/components/Article | Relative from layout.tsx |
Step-by-step calculation for "@/*": ["./*"] (root mapping):
src/components/Article.tsx@/* maps to ./* (project root)src/components/Article.tsx./ with @/ → @/src/components/Article.tsx@/src/components/ArticleResult: import Article from '@/src/components/Article'
Step-by-step calculation for "@/*": ["./src/*"] (src mapping):
src/components/Article.tsx@/* maps to ./src/*./src/ prefix: components/Article.tsx@/ → @/components/Article.tsx@/components/ArticleResult: import Article from '@/components/Article'
The most reliable method is to copy the exact pattern from existing imports in the registration file.
Find the registration file:
grep -r "initReactComponentRegistry" --include="*.tsx" --include="*.ts"
Open the file and examine existing imports:
// Example: app/layout.tsx
import HomePage from '@/components/HomePage'; // ← Existing pattern
import Hero from '@/src/components/Hero'; // ← Different project might use this
Copy the exact pattern:
@/components/..., use @/components/Article@/src/components/..., use @/src/components/Article../, calculate the relative pathThis approach is safer than calculating from tsconfig because:
After determining the import path, verify it before finalizing:
Add the import temporarily:
import Article, { ArticleContentType } from '@/components/Article';
Run TypeScript type checking:
npx tsc --noEmit
Expected output:
Common fixes for import errors:
Error: "Cannot find module '@/...'"
tsconfig.json for baseUrl field../src/components/ArticleError: Module resolves but path seems wrong (e.g., @/components/Article when file is at src/components/Article.tsx and tsconfig has "@/*": ["./*"])
src/ when alias maps to root@/src/components/Articletsconfig.json has "@/*": ["./*"] (maps to root, not src)Error: "Module has no default export"
export defaultexport default function Article() {...}Error: "Module has no exported member 'ArticleContentType'"
export const ArticleContentType = contentType({...})Test in development server:
npm run dev # or yarn dev, pnpm dev
Check for module resolution errors in the console. If the dev server fails to start with import errors, the path is incorrect.
If path aliases fail or are unclear, relative imports always work:
// From: app/layout.tsx
// To: src/components/Article.tsx
// Relative path: ../src/components/Article
import Article, { ArticleContentType } from '../src/components/Article';
When to use relative imports:
tsconfig.json is missing baseUrl or pathsCalculate relative path:
app/layout.tsxsrc/components/Article.tsx../../src/components/Article.tsx../src/components/ArticleIMPORTANT: The import path in these examples is @/src/components/Article because we assume tsconfig.json has "@/*": ["./*"] (maps to root). If your tsconfig has "@/*": ["./src/*"], use @/components/Article instead.
For regular components (most common case):
import { initContentTypeRegistry } from '@optimizely/cms-sdk';
import { initReactComponentRegistry } from '@optimizely/cms-sdk/react/server';
// Example: tsconfig.json has "@/*": ["./*"] (maps to project root)
// Component file: src/components/Article.tsx
// Import path: @/src/components/Article
import Article, { ArticleContentType } from '@/src/components/Article';
// If your tsconfig.json has "@/*": ["./src/*"] instead (maps to src directory)
// Use this import instead:
// import Article, { ArticleContentType } from '@/components/Article';
// Register the content type
initContentTypeRegistry([
ArticleContentType,
// ... other content types
]);
// Register the React component
initReactComponentRegistry({
resolver: {
Article,
// ... other components
},
});
Key points:
initContentTypeRegistry from @optimizely/cms-sdk (NOT from react/server)initContentTypeRegistry takes an array of content typesArticleContentType if you're not registering itCommon error pattern to avoid:
// ❌ WRONG: Assuming "@/" maps to src when it actually maps to root
// tsconfig.json: "@/*": ["./*"]
// Component file: src/components/Article.tsx
import Article from '@/components/Article'; // ❌ Module not found!
// ✅ CORRECT: Include src/ because @/ maps to project root
import Article from '@/src/components/Article'; // ✅ Works!
For components with display template variants:
When a content type has multiple display templates with different tag values, you MUST:
STEP 1: Read the display template definition to find the tag field value
STEP 2: Use that EXACT tag value as the key in the tags object
Example: If the display template is defined as:
export const SquareDisplayTemplate = displayTemplate({
key: 'SquareDisplayTemplate',
displayName: 'Square Display',
tag: 'Square', // ← This is the value you need
// ...
});
Then the registration MUST use 'Square' as the key:
import { initContentTypeRegistry, initDisplayTemplateRegistry } from '@optimizely/cms-sdk';
import { initReactComponentRegistry } from '@optimizely/cms-sdk/react/server';
import Tile, {
SquareTile,
TileContentType,
SquareDisplayTemplate
} from '@/src/components/Tile'; // Adjust path based on tsconfig
initContentTypeRegistry([
TileContentType,
// ... other content types
]);
initDisplayTemplateRegistry([
SquareDisplayTemplate,
// ... other display templates
]);
initReactComponentRegistry({
resolver: {
Tile: {
default: Tile, // Default component (no display template)
tags: {
Square: SquareTile, // ← Key MUST match display template's tag field
},
},
// ... other components
},
});
CRITICAL POINTS:
tags object MUST match the tag field from the display template definitiontags: { SquareTile: SquareTile } ❌ - component name doesn't match tagtags: { Square: SquareTile } ✅ - matches the tag fieldThe registry matches components using:
tag setting in the contenttags[tagName] componentdefault componentBefore reporting completion, verify the import actually works:
Run type checking:
npx tsc --noEmit
Expected: No errors related to module resolution
Start dev server (if not running):
npm run dev
Expected: Server starts without import errors
Check console for errors:
If verification fails, do NOT report completion. Instead:
After successfully creating, registering, AND verifying the component:
Show a summary of what was created:
✓ Created React component `Article` in src/components/Article.tsx
✓ Registered component in app/layout.tsx
✓ Verified import resolution (no errors)
Mention next steps:
npx @optimizely/cms-cli@latest config pushIf display templates were involved, remind about registering them:
initDisplayTemplateRegistry as welloptimizely-model skill for display template registrationThis example shows the complete workflow for a fresh Next.js project with the common "@/*": ["./*"] configuration.
src/components/ directory"@/*": ["./*"] (maps to project root, NOT src)BlankExperienceStep 1: Read tsconfig.json
cat tsconfig.json
Output:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"] // ← Maps to project root
}
}
}
Analysis: The alias @/ maps to project root (./*), NOT to src/. This means imports must include the src/ prefix.
Step 2: Find registration file
grep -r "initReactComponentRegistry" --include="*.tsx" --include="*.ts"
Output:
app/layout.tsx:import { initReactComponentRegistry } from '@optimizely/cms-sdk/react/server';
Step 3: Check existing imports
// app/layout.tsx (before adding BlankExperience)
import { initReactComponentRegistry } from '@optimizely/cms-sdk/react/server';
// No existing component imports yet (first component)
Step 4: Calculate import path
Component file location: src/components/experiences/BlankExperience.tsx
tsconfig alias: "@/*" maps to "./*" (project root)
Calculation:
1. Full path from root: src/components/experiences/BlankExperience.tsx
2. Apply alias: Replace "./" with "@/" → @/src/components/experiences/BlankExperience.tsx
3. Remove extension: @/src/components/experiences/BlankExperience
Result: import BlankExperience from '@/src/components/experiences/BlankExperience'
Step 5: Add import and registration
// app/layout.tsx
import { initContentTypeRegistry } from '@optimizely/cms-sdk';
import { initReactComponentRegistry } from '@optimizely/cms-sdk/react/server';
import BlankExperience, { BlankExperienceContentType } from '@/src/components/experiences/BlankExperience';
initContentTypeRegistry([
BlankExperienceContentType,
]);
initReactComponentRegistry({
resolver: {
BlankExperience,
},
});
Step 6: Verify
npx tsc --noEmit
Expected: No errors ✅
Common mistake for this scenario:
// ❌ WRONG: Forgot that @/ maps to root, not src
import BlankExperience from '@/components/experiences/BlankExperience';
// Error: Cannot find module '@/components/experiences/BlankExperience'
// ✅ CORRECT: Include src/ because @/ maps to project root
import BlankExperience from '@/src/components/experiences/BlankExperience';
For detailed React patterns for rendering different property types (content references, booleans, enums, dates, links), see references/react-patterns.md.
Always use optional chaining, provide null fallbacks, use stable keys for arrays, and put preview attributes on array containers. See references/edge-cases.md for detailed guidance.
For solutions to type errors, preview issues, component rendering problems, and image loading issues, see references/troubleshooting.md.
For detailed path resolution verification and debugging, see references/path-resolution-workflow.md.
The workflow for this skill:
npx tsc --noEmit before finalizingAlways prioritize:
npx tsc --noEmit before reporting successFor detailed information, consult:
references/styling-strategy.md - CSS/SCSS integration guide with patterns for CSS Modules, Tailwind, and styling best practicesreferences/react-patterns.md - React patterns for rendering different property types (content references, booleans, dates, links, etc.)references/edge-cases.md - Edge cases and considerations including optional chaining, null fallbacks, keys, and preview attributesreferences/troubleshooting.md - Solutions to common issues with types, preview mode, component rendering, and imagesreferences/path-resolution-workflow.md - Complete guide to verifying tsconfig.json path aliases and debugging import errorsnpx claudepluginhub episerver/content-js-sdk --plugin optimizely-cms-skillsCreates content type definitions, display templates, and contracts for Optimizely CMS. Use when modeling pages, components, or reusable property sets.
Converts designs and API contracts into React/Next.js component scaffolds with TailwindCSS and typed API hooks.
Builds React components and Next.js pages with server-side rendering, state management, and performance optimization. Activates automatically when React, JSX, or frontend build issues arise.