Migration Guide
Migrating components to the content/actions pattern - before/after examples, common gotchas, and best practices
Migration Guide
This guide helps you migrate existing components to mdxui's content/actions pattern, which separates AI-generatable content from developer-wired behavior.
Why Migrate?
The content/actions pattern provides:
- AI-friendly props - LLMs can generate content without hallucinating URLs
- Safe spreading -
{...aiContent}won't override behavioral props - Clear separation - Content and behavior are visually distinct
- Type safety - Two schemas (TypeScript and Zod) ensure correctness
The Pattern at a Glance
Before (inconsistent)
// Multiple patterns across different components
type HeroProps = {
title: string
highlightedText: string
primaryButtonText: string
primaryButtonHref: string
}
type FeaturesProps = {
headline: string
cta: { text: string, href: string }
}
type PricingProps = {
name: string
price: string
ctaText: string
ctaUrl: string
}After (consistent)
// All components follow the same pattern
type HeroProps = {
title: ReactNode
subtitle?: ReactNode
callToAction: ReactNode
actions?: {
primary?: string | ActionConfig
secondary?: string | ActionConfig
}
}
type FeaturesProps = {
title: ReactNode
subtitle?: ReactNode
features: Feature[]
actions?: Record<string, string | ActionConfig>
}
type PricingProps = {
name: ReactNode
price: ReactNode
features: ReactNode[]
callToAction: ReactNode
actions?: {
primary?: string | ActionConfig
}
}Migration Steps
Step 1: Separate Content from Actions
Identify which props are content (what to display) vs actions (what to do):
Before:
type CTAProps = {
headline: string
description: string
buttonText: string
buttonUrl: string
onButtonClick?: () => void
}After:
type CTAProps = {
// Content (root level)
title: ReactNode
description: ReactNode
callToAction: ReactNode
// Actions (grouped)
actions?: {
primary?: string | { href: string, onClick?: () => void }
}
}Step 2: Rename Props to Standard Names
Use mdxui naming conventions:
| Old | New | Category |
|---|---|---|
headline, header | title | Content |
subheadline, subheader | subtitle | Content |
desc | description | Content |
cta, ctaText, buttonText | callToAction | Content |
ctaUrl, buttonUrl, href | actions.primary | Action |
secondaryCta | secondaryCallToAction | Content |
secondaryUrl | actions.secondary | Action |
Step 3: Change Types to ReactNode
Content props should accept ReactNode instead of string:
Before:
type HeroProps = {
title: string
description: string
}After:
type HeroProps = {
title: ReactNode
subtitle?: ReactNode // renamed from description
}This allows:
// Strings still work
<Hero title="Ship faster" />
// But now JSX is also supported
<Hero title={<>Ship <Highlight>faster</Highlight></>} />Step 4: Create ContentSchema
Export a Zod schema for AI content generation:
import { z } from 'zod'
export const HeroContentSchema = z.object({
title: z.string().describe('compelling headline for the hero section'),
subtitle: z.string().optional().describe('supporting subheadline'),
callToAction: z.string().describe('action-oriented primary button text'),
secondaryCallToAction: z.string().optional(),
variant: z.enum(['default', 'code-below', 'video-beside']).optional(),
})
export type HeroContent = z.infer<typeof HeroContentSchema>Step 5: Update Component Implementation
Update the component to destructure the new props:
Before:
export function Hero({
title,
highlightedText,
description,
primaryButtonText,
primaryButtonHref,
secondaryButtonText,
secondaryButtonHref,
}: HeroProps) {
return (
<section>
<h1>{title} <span className="highlight">{highlightedText}</span></h1>
<p>{description}</p>
<div className="buttons">
<a href={primaryButtonHref}>{primaryButtonText}</a>
{secondaryButtonText && (
<a href={secondaryButtonHref}>{secondaryButtonText}</a>
)}
</div>
</section>
)
}After:
export function Hero({
title,
subtitle,
callToAction,
secondaryCallToAction,
actions,
}: HeroProps) {
return (
<section>
<h1>{title}</h1>
{subtitle && <p>{subtitle}</p>}
<div className="buttons">
{callToAction && actions?.primary && (
<a href={typeof actions.primary === 'string' ? actions.primary : actions.primary.href}>
{callToAction}
</a>
)}
{secondaryCallToAction && actions?.secondary && (
<a href={typeof actions.secondary === 'string' ? actions.secondary : actions.secondary.href}>
{secondaryCallToAction}
</a>
)}
</div>
</section>
)
}Common Migration Patterns
Pattern 1: Simple Button Props
Before:
{
buttonText: string
buttonUrl: string
}After:
{
callToAction: ReactNode
actions?: {
primary?: string | ActionConfig
}
}Pattern 2: Multiple CTAs
Before:
{
primaryCta: { text: string, url: string }
secondaryCta?: { text: string, url: string }
}After:
{
callToAction: ReactNode
secondaryCallToAction?: ReactNode
actions?: {
primary?: string | ActionConfig
secondary?: string | ActionConfig
}
}Pattern 3: Item Actions
Before:
type Feature = {
title: string
description: string
href: string
}After:
type Feature = {
name: ReactNode
description: ReactNode
}
type FeaturesProps = {
features: Feature[]
actions?: Record<string, string | ActionConfig> // keyed by feature index or id
}Usage:
<Features
features={[
{ name: 'AI Design', description: '...' },
{ name: 'Templates', description: '...' },
]}
actions={{
'0': '/features/ai-design',
'1': '/features/templates',
}}
/>Pattern 4: Nested Objects to Flat Props
Before:
{
hero: {
title: string
cta: { text: string, url: string }
}
}After:
{
title: ReactNode
callToAction: ReactNode
actions?: {
primary?: string | ActionConfig
}
}Flatten nested structures to follow mdxui conventions.
Complete Example: Features Section
Before
type FeaturesSectionProps = {
headline: string
subheadline: string
items: Array<{
icon: string
title: string
desc: string
learnMoreUrl?: string
}>
layout?: 'grid' | 'list'
}
export function FeaturesSection({
headline,
subheadline,
items,
layout = 'grid',
}: FeaturesSectionProps) {
return (
<section>
<h2>{headline}</h2>
<p>{subheadline}</p>
<div className={layout === 'grid' ? 'grid' : 'list'}>
{items.map((item, i) => (
<div key={i}>
<img src={item.icon} alt="" />
<h3>{item.title}</h3>
<p>{item.desc}</p>
{item.learnMoreUrl && (
<a href={item.learnMoreUrl}>Learn more</a>
)}
</div>
))}
</div>
</section>
)
}After
import { z } from 'zod'
import type { ReactNode } from 'react'
// Zod schema for AI generation
export const FeaturesContentSchema = z.object({
title: z.string().describe('section headline'),
subtitle: z.string().optional().describe('supporting subheadline'),
features: z.array(
z.object({
name: z.string().describe('feature name'),
description: z.string().describe('feature description'),
icon: z.string().optional().describe('icon name or emoji'),
})
),
columns: z.number().optional().describe('number of columns in grid layout'),
})
// TypeScript props (full interface)
export type FeaturesProps = {
// Content
title: ReactNode
subtitle?: ReactNode
features: Array<{
name: ReactNode
description: ReactNode
icon?: ReactNode
}>
// Layout
columns?: number
variant?: 'grid' | 'list'
// Actions
actions?: Record<string, string | ActionConfig>
}
export function Features({
title,
subtitle,
features,
columns = 3,
variant = 'grid',
actions,
}: FeaturesProps) {
return (
<section>
<h2>{title}</h2>
{subtitle && <p>{subtitle}</p>}
<div className={variant === 'grid' ? 'grid' : 'list'}>
{features.map((feature, i) => (
<div key={i}>
{feature.icon && <div className="icon">{feature.icon}</div>}
<h3>{feature.name}</h3>
<p>{feature.description}</p>
{actions?.[i] && (
<a href={typeof actions[i] === 'string' ? actions[i] : actions[i].href}>
Learn more
</a>
)}
</div>
))}
</div>
</section>
)
}Usage:
// With AI
<AI schema={FeaturesContentSchema} prompt="Generate 3 features for a code editor">
{(content) => (
<Features
{...content}
actions={{
'0': '/features/intellisense',
'1': '/features/debugging',
'2': '/features/git',
}}
/>
)}
</AI>
// Manual
<Features
title="Why Developers Love Us"
subtitle="Everything you need to ship faster"
features={[
{ name: 'IntelliSense', description: 'Smart code completion', icon: '🧠' },
{ name: 'Debugging', description: 'Visual debugging tools', icon: '🐛' },
{ name: 'Git Integration', description: 'Built-in source control', icon: '🌿' },
]}
actions={{
'0': '/features/intellisense',
'1': '/features/debugging',
'2': '/features/git',
}}
/>Common Gotchas
Gotcha 1: Forgetting to Group Actions
Wrong:
type HeroProps = {
title: ReactNode
primaryAction?: string // ❌ action at root level
}Right:
type HeroProps = {
title: ReactNode
actions?: { // ✅ actions grouped
primary?: string
}
}Gotcha 2: Using String Instead of ReactNode
Wrong:
type HeroProps = {
title: string // ❌ doesn't allow JSX
}Right:
type HeroProps = {
title: ReactNode // ✅ allows strings AND JSX
}Gotcha 3: Including Actions in ContentSchema
Wrong:
export const HeroContentSchema = z.object({
title: z.string(),
primaryUrl: z.string(), // ❌ AI will hallucinate URLs
})Right:
export const HeroContentSchema = z.object({
title: z.string(),
callToAction: z.string(), // ✅ content only, developer wires URLs
})Gotcha 4: Using Acronyms
Wrong:
{
cta: ReactNode // ❌ acronym
}Right:
{
callToAction: ReactNode // ✅ full words
}Gotcha 5: Variant Names Too Generic
Wrong:
variant?: 'variant-1' | 'variant-2' | 'split-left' // ❌ not semanticRight:
variant?: 'default' | 'code-below' | 'video-beside' // ✅ describes content structureTesting After Migration
-
Type check: Ensure no TypeScript errors
pnpm typecheck -
Test with AI: Verify ContentSchema works with AI generation
<AI schema={HeroContentSchema} prompt="Generate a hero"> {(content) => <Hero {...content} actions={{ primary: '/test' }} />} </AI> -
Test with MDX: Verify components work in MDX
<Hero title="Test" callToAction="Click me" actions={{ primary: '/test' }} /> -
Visual regression: Ensure UI hasn't changed
pnpm test:visual
Checklist
- Content props at root level (title, subtitle, description)
- Actions grouped in
actionsobject - Content props use
ReactNodetype - Action props accept
string | ActionConfig - Props use full words (callToAction not cta)
- Variants are semantic (code-below not variant-1)
- ContentSchema exported for AI generation
- ContentSchema uses z.string() not ReactNode
- No actions in ContentSchema
- TypeScript types pass typecheck
- Component works with both AI and manual props
Related
- AI Generation Guide - Using ContentSchemas with AI
- Component Design - Design patterns for mdxui components