mdxui
Guides

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:

  1. AI-friendly props - LLMs can generate content without hallucinating URLs
  2. Safe spreading - {...aiContent} won't override behavioral props
  3. Clear separation - Content and behavior are visually distinct
  4. 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:

OldNewCategory
headline, headertitleContent
subheadline, subheadersubtitleContent
descdescriptionContent
cta, ctaText, buttonTextcallToActionContent
ctaUrl, buttonUrl, hrefactions.primaryAction
secondaryCtasecondaryCallToActionContent
secondaryUrlactions.secondaryAction

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 semantic

Right:

variant?: 'default' | 'code-below' | 'video-beside'  // ✅ describes content structure

Testing After Migration

  1. Type check: Ensure no TypeScript errors

    pnpm typecheck
  2. Test with AI: Verify ContentSchema works with AI generation

    <AI schema={HeroContentSchema} prompt="Generate a hero">
      {(content) => <Hero {...content} actions={{ primary: '/test' }} />}
    </AI>
  3. Test with MDX: Verify components work in MDX

    <Hero
      title="Test"
      callToAction="Click me"
      actions={{ primary: '/test' }}
    />
  4. Visual regression: Ensure UI hasn't changed

    pnpm test:visual

Checklist

  • Content props at root level (title, subtitle, description)
  • Actions grouped in actions object
  • Content props use ReactNode type
  • 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

On this page