Multi Surface Render
Multi-surface rendering with json-render — same JSON spec produces React web, Next.js apps, React Native, Ink terminal UIs, PDFs, emails, Remotion videos, OG images, and 3D scenes. Covers renderer target selection, registry mapping, and platform-specific APIs (renderToBuffer, renderToStream, renderToFile). Use when generating output for multiple platforms, creating PDF reports, email templates, demo videos, or social media images from a single component spec.
Auto-activated — this skill loads automatically when Claude detects matching context.
Multi-Surface Rendering with json-render
Define once, render everywhere. A single json-render catalog and spec can produce React web UIs, PDF reports, HTML emails, Remotion demo videos, and OG images — each surface gets its own registry that maps catalog types to platform-native components.
Quick Reference
| Category | Rules | Impact | When to Use |
|---|---|---|---|
| Target Selection | 1 | HIGH | Choosing which renderer for your use case |
| React Renderer | 1 | MEDIUM | Web apps, SPAs, dashboards |
| PDF & Email Renderer | 1 | HIGH | Reports, documents, notifications |
| Video & Image Renderer | 1 | MEDIUM | Demo videos, OG images, social cards |
| Registry Mapping | 1 | HIGH | Platform-specific component implementations |
Total: 5 rules across 5 categories
How Multi-Surface Rendering Works
- One catalog — Zod-typed component definitions shared across all surfaces
- One spec — flat-tree JSON/YAML describing the UI structure
- Many registries — each surface maps catalog types to its own component implementations
- Many renderers — each package renders the spec using its registry
The catalog is the contract. The spec is the data. The registry is the platform-specific implementation.
Quick Start — Same Catalog, Different Renderers
Shared Catalog (used by all surfaces)
import { defineCatalog } from '@json-render/core'
import { z } from 'zod'
export const catalog = defineCatalog({
Heading: {
props: z.object({
text: z.string(),
level: z.enum(['h1', 'h2', 'h3']),
}),
children: false,
},
Paragraph: {
props: z.object({ text: z.string() }),
children: false,
},
StatCard: {
props: z.object({
label: z.string(),
value: z.string(),
trend: z.enum(['up', 'down', 'flat']).optional(),
}),
children: false,
},
})Render to Web (React)
import { Renderer } from '@json-render/react'
import { catalog } from './catalog'
import { webRegistry } from './registries/web'
export const Dashboard = ({ spec }) => (
<Renderer spec={spec} catalog={catalog} registry={webRegistry} />
)Render to PDF
import { renderToBuffer, renderToFile } from '@json-render/react-pdf'
import { catalog } from './catalog'
import { pdfRegistry } from './registries/pdf'
// Buffer for HTTP response
const buffer = await renderToBuffer(spec, { catalog, registry: pdfRegistry })
// Direct file output
await renderToFile(spec, './output/report.pdf', { catalog, registry: pdfRegistry })Render to Email
import { renderToHtml } from '@json-render/react-email'
import { catalog } from './catalog'
import { emailRegistry } from './registries/email'
const html = await renderToHtml(spec, { catalog, registry: emailRegistry })
await sendEmail({ to: user.email, subject: 'Weekly Report', html })Render to OG Image (Satori)
import { renderToSvg, renderToPng } from '@json-render/image'
import { catalog } from './catalog'
import { imageRegistry } from './registries/image'
const png = await renderToPng(spec, {
catalog,
registry: imageRegistry,
width: 1200,
height: 630,
})Render to Video (Remotion)
import { JsonRenderComposition } from '@json-render/remotion'
import { catalog } from './catalog'
import { remotionRegistry } from './registries/remotion'
export const DemoVideo = () => (
<JsonRenderComposition
spec={spec}
catalog={catalog}
registry={remotionRegistry}
fps={30}
durationInFrames={150}
/>
)Render to Terminal (Ink, 0.15+)
import { render } from 'ink'
import { InkRenderer } from '@json-render/ink'
import { catalog } from './catalog'
import { inkRegistry } from './registries/ink'
render(<InkRenderer spec={spec} catalog={catalog} registry={inkRegistry} />)Useful for /ork:* CLI dashboards and streaming agent chat interfaces — ships 20+ Ink-native components (Box, Text, Spinner, Table, Markdown, Progress, etc.).
Render to Next.js App (0.16+)
import { generateNextApp } from '@json-render/next'
await generateNextApp(spec, {
catalog,
registry: webRegistry,
outDir: './out',
// generates routes, layouts, SSR handlers, and metadata
})Output is a full Next.js App Router project — specs describe route trees, not just components.
Decision Matrix — When to Use Each Target
| Target | Package | When to Use | Output |
|---|---|---|---|
| React | @json-render/react | Web apps, SPAs | JSX |
| Next.js | @json-render/next (0.16+) | Full apps: routes, layouts, SSR, metadata | Next.js app |
| Vue | @json-render/vue | Vue projects | Vue components |
| Svelte | @json-render/svelte | Svelte projects | Svelte components |
| Svelte+shadcn | @json-render/shadcn-svelte (0.16+) | 36-component Svelte 5 catalog | Svelte + Tailwind |
| React Native | @json-render/react-native | Mobile apps (25+ components) | Native views |
| Terminal | @json-render/ink (0.15+) | CLI UIs, TUIs, streaming chat | Ink (terminal) |
@json-render/react-pdf | Reports, documents | PDF buffer/file | |
@json-render/react-email | Notifications, digests | HTML string | |
| Remotion | @json-render/remotion | Demo videos, marketing | MP4/WebM |
| Image | @json-render/image | OG images, social cards | SVG/PNG (Satori) |
| YAML | @json-render/yaml (0.14+) | Token optimization, streaming parser | YAML string |
| MCP | @json-render/mcp | Claude/Cursor/ChatGPT conversations | Sandboxed iframe |
| 3D | @json-render/react-three-fiber | 3D scenes (20 components, incl. GaussianSplat in 0.17) | Three.js canvas |
| Codegen | @json-render/codegen | Source code from specs | TypeScript/JSX |
Load rules/target-selection.md for detailed selection criteria and trade-offs.
PDF Renderer — Reports and Documents
The @json-render/react-pdf package renders specs to PDF using react-pdf under the hood. Three output modes: buffer, file, and stream.
import { renderToBuffer, renderToFile, renderToStream } from '@json-render/react-pdf'
// In-memory buffer (for HTTP responses, S3 upload)
const buffer = await renderToBuffer(spec, { catalog, registry: pdfRegistry })
res.setHeader('Content-Type', 'application/pdf')
res.send(buffer)
// Direct file write
await renderToFile(spec, './output/report.pdf', { catalog, registry: pdfRegistry })
// Streaming (for large documents)
const stream = await renderToStream(spec, { catalog, registry: pdfRegistry })
stream.pipe(res)Load rules/pdf-email-renderer.md for PDF registry patterns and email rendering.
Image Renderer — OG Images and Social Cards
The @json-render/image package uses Satori to convert specs to SVG, then optionally to PNG. Designed for server-side generation of social media images.
import { renderToSvg, renderToPng } from '@json-render/image'
// SVG output (smaller, scalable)
const svg = await renderToSvg(spec, {
catalog,
registry: imageRegistry,
width: 1200,
height: 630,
})
// PNG output (universal compatibility)
const png = await renderToPng(spec, {
catalog,
registry: imageRegistry,
width: 1200,
height: 630,
})Load rules/video-image-renderer.md for Satori constraints and Remotion composition patterns.
Registry Mapping — Same Catalog, Platform-Specific Components
Each surface needs its own registry. The registry maps catalog types to platform-specific component implementations while the catalog and spec stay identical.
// Web registry — uses HTML elements
const webRegistry = {
Heading: ({ text, level }) => {
const Tag = level // h1, h2, h3
return <Tag className="font-bold">{text}</Tag>
},
StatCard: ({ label, value, trend }) => (
<div className="rounded border p-4">
<span className="text-sm text-gray-500">{label}</span>
<strong className="text-2xl">{value}</strong>
</div>
),
}
// PDF registry — uses react-pdf primitives
import { Text, View } from '@react-pdf/renderer'
const pdfRegistry = {
Heading: ({ text, level }) => (
<Text style={{ fontSize: level === 'h1' ? 24 : level === 'h2' ? 18 : 14 }}>
{text}
</Text>
),
StatCard: ({ label, value }) => (
<View style={{ border: '1pt solid #ccc', padding: 8 }}>
<Text style={{ fontSize: 10, color: '#666' }}>{label}</Text>
<Text style={{ fontSize: 18, fontWeight: 'bold' }}>{value}</Text>
</View>
),
}Load rules/registry-mapping.md for registry creation patterns and type safety.
Rule Details
Target Selection
Decision criteria for choosing the right renderer target.
| Rule | File | Key Pattern |
|---|---|---|
| Target Selection | rules/target-selection.md | Use case mapping, output format constraints |
React Renderer
Web rendering with the <Renderer> component.
| Rule | File | Key Pattern |
|---|---|---|
| React Renderer | rules/react-renderer.md | <Renderer> component, streaming, error boundaries |
PDF & Email Renderer
Server-side rendering to PDF buffers/files and HTML email strings.
| Rule | File | Key Pattern |
|---|---|---|
| PDF & Email | rules/pdf-email-renderer.md | renderToBuffer, renderToFile, renderToHtml |
Video & Image Renderer
Remotion compositions and Satori image generation.
| Rule | File | Key Pattern |
|---|---|---|
| Video & Image | rules/video-image-renderer.md | JsonRenderComposition, renderToPng, renderToSvg |
Registry Mapping
Creating platform-specific registries for a shared catalog.
| Rule | File | Key Pattern |
|---|---|---|
| Registry Mapping | rules/registry-mapping.md | Per-platform registries, type-safe mapping |
Key Decisions
| Decision | Recommendation |
|---|---|
| PDF library | Use @json-render/react-pdf (react-pdf), not Puppeteer screenshots |
| Email rendering | Use @json-render/react-email (react-email), not MJML or custom HTML |
| OG images | Use @json-render/image (Satori), not Puppeteer or canvas |
| Video | Use @json-render/remotion (Remotion), not FFmpeg scripts |
| Registry per platform | Always separate registries; never one registry for all surfaces |
| Catalog sharing | One catalog definition shared via import across all registries |
Common Mistakes
- Building separate component trees for each surface — defeats the purpose; share the catalog and spec
- Using Puppeteer to screenshot React for PDF generation — slow, fragile; use native react-pdf rendering
- One giant registry covering all platforms — impossible since PDF uses
<View>/<Text>, web uses<div>/<span> - Forgetting Satori limitations — no CSS grid, limited flexbox; design image registries with these constraints
- Duplicating catalog definitions per surface — one catalog, many registries; the catalog is the contract
Related Skills
ork:json-render-catalog— Catalog definition patterns with Zod, shadcn componentsork:demo-producer— Video production pipeline using Remotionork:presentation-builder— Slide deck generationork:mcp-visual-output— Rendering specs in Claude/Cursor via MCP
Rules (5)
Use native react-pdf and react-email renderers instead of browser-based workarounds — HIGH
PDF & Email Renderer
@json-render/react-pdf renders specs to PDF using react-pdf primitives (View, Text, Image). @json-render/react-email renders specs to HTML email strings using react-email components. Both validate against the same catalog.
Incorrect — using Puppeteer to screenshot React for PDF:
// WRONG: Launches a browser, takes a screenshot, converts to PDF
import puppeteer from 'puppeteer'
async function generatePdf(spec) {
const browser = await puppeteer.launch()
const page = await browser.newPage()
await page.setContent(renderToString(<Dashboard spec={spec} />))
const pdf = await page.pdf({ format: 'A4' })
await browser.close()
return pdf // slow, ~2s startup, CSS rendering differences, no catalog validation
}Correct — native PDF rendering via react-pdf:
import { renderToBuffer, renderToFile, renderToStream } from '@json-render/react-pdf'
import { catalog } from './catalog'
import { pdfRegistry } from './registries/pdf'
// Buffer — for HTTP responses, S3 upload, attachments
const buffer = await renderToBuffer(spec, { catalog, registry: pdfRegistry })
// File — direct disk write
await renderToFile(spec, './output/report.pdf', { catalog, registry: pdfRegistry })
// Stream — for large documents, pipe to HTTP response
const stream = await renderToStream(spec, { catalog, registry: pdfRegistry })
res.setHeader('Content-Type', 'application/pdf')
stream.pipe(res)Incorrect — manual HTML string for email:
// WRONG: Manual HTML, no validation, rendering inconsistencies
const html = `<table><tr><td>${data.title}</td></tr></table>`Correct — react-email rendering:
import { renderToHtml } from '@json-render/react-email'
import { catalog } from './catalog'
import { emailRegistry } from './registries/email'
const html = await renderToHtml(spec, { catalog, registry: emailRegistry })
await transporter.sendMail({ to: user.email, subject: 'Report', html })Key rules:
- Use
renderToBufferfor in-memory PDF (HTTP responses, email attachments, cloud storage upload) - Use
renderToFilefor disk output (batch report generation, CI artifacts) - Use
renderToStreamfor large documents to avoid buffering the entire PDF in memory - PDF registry components must use react-pdf primitives (
View,Text,Image) — not HTML elements - Email registry components must use react-email primitives (
Section,Text,Heading) — not arbitrary HTML - Both renderers validate specs against the catalog — invalid types or props throw at render time
PDF Registry Pattern
import { View, Text, StyleSheet } from '@react-pdf/renderer'
const styles = StyleSheet.create({
heading: { fontSize: 24, fontWeight: 'bold', marginBottom: 8 },
card: { border: '1pt solid #e5e7eb', padding: 12, borderRadius: 4 },
})
export const pdfRegistry = {
Heading: ({ text, level }) => (
<Text style={{ ...styles.heading, fontSize: level === 'h1' ? 24 : level === 'h2' ? 18 : 14 }}>
{text}
</Text>
),
StatCard: ({ label, value }) => (
<View style={styles.card}>
<Text style={{ fontSize: 10, color: '#6b7280' }}>{label}</Text>
<Text style={{ fontSize: 18, fontWeight: 'bold' }}>{value}</Text>
</View>
),
}Use the Renderer component with catalog validation for web rendering — MEDIUM
React Renderer
The <Renderer> component from @json-render/react validates specs against the catalog at runtime and renders each element using the registry. Never parse specs manually.
Incorrect — parsing the spec manually:
// WRONG: Manual parsing, no validation, no streaming
function Dashboard({ spec }) {
return (
<div>
{Object.entries(spec.elements).map(([id, el]) => {
const Component = components[el.type] // no catalog validation
return <Component key={id} {...el.props} />
})}
</div>
)
}Correct — using the Renderer component:
import { Renderer } from '@json-render/react'
import { catalog } from './catalog'
import { webRegistry } from './registries/web'
function Dashboard({ spec }) {
return (
<Renderer
spec={spec}
catalog={catalog}
registry={webRegistry}
fallback={<LoadingSkeleton />}
onError={(err) => console.error('Render error:', err)}
/>
)
}Key rules:
- Always pass
catalogto<Renderer>— it validates that spec types exist in the catalog and props match Zod schemas - Use
fallbackprop for loading states during progressive streaming - Use
onErrorcallback or wrap in an error boundary for graceful degradation - For streaming specs (AI generating in real-time), the Renderer updates progressively as elements arrive
- The registry maps catalog types to React components — keep it separate from the catalog definition
Progressive Streaming Pattern
import { Renderer, useStreamingSpec } from '@json-render/react'
function StreamingDashboard({ specStream }) {
const spec = useStreamingSpec(specStream) // updates as patches arrive
return (
<Renderer
spec={spec}
catalog={catalog}
registry={webRegistry}
fallback={<Skeleton />}
/>
)
}Elements render as soon as their props are complete — the user sees the UI building in real-time.
Create separate registries per platform sharing a single catalog — HIGH
Registry Mapping
A registry maps each catalog type to a platform-specific component implementation. The catalog (Zod schemas) and spec (flat-tree data) stay identical across surfaces. Only the registry changes.
Incorrect — one giant registry trying to cover all platforms:
// WRONG: Impossible — PDF needs View/Text, web needs div/span
const universalRegistry = {
Heading: ({ text, level, platform }) => {
if (platform === 'pdf') return <Text style={...}>{text}</Text>
if (platform === 'email') return <Heading as={level}>{text}</Heading>
return <h1>{text}</h1> // web fallback
},
}Correct — separate registries per platform, same catalog:
import { catalog } from './catalog' // SHARED — one definition
// Web registry
export const webRegistry = {
Heading: ({ text, level }) => {
const Tag = level
return <Tag className="font-bold tracking-tight">{text}</Tag>
},
StatCard: ({ label, value, trend }) => (
<div className="rounded-lg border p-4 shadow-sm">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="text-2xl font-bold">{value}</p>
{trend && <TrendIcon direction={trend} />}
</div>
),
}
// PDF registry
import { View, Text } from '@react-pdf/renderer'
export const pdfRegistry = {
Heading: ({ text, level }) => (
<Text style={{ fontSize: level === 'h1' ? 24 : 18, fontWeight: 'bold' }}>
{text}
</Text>
),
StatCard: ({ label, value }) => (
<View style={{ border: '1pt solid #ccc', padding: 8 }}>
<Text style={{ fontSize: 10, color: '#666' }}>{label}</Text>
<Text style={{ fontSize: 18 }}>{value}</Text>
</View>
),
}
// Email registry
import { Section, Text as EmailText, Heading as EmailHeading } from '@react-email/components'
export const emailRegistry = {
Heading: ({ text, level }) => (
<EmailHeading as={level}>{text}</EmailHeading>
),
StatCard: ({ label, value }) => (
<Section style={{ border: '1px solid #e5e7eb', padding: '12px' }}>
<EmailText style={{ fontSize: '12px', color: '#6b7280' }}>{label}</EmailText>
<EmailText style={{ fontSize: '20px', fontWeight: 'bold' }}>{value}</EmailText>
</Section>
),
}Key rules:
- One catalog, many registries — the catalog defines WHAT can be rendered, registries define HOW
- Every catalog type must have an entry in each registry — missing entries throw at render time
- Registry components receive the same props defined in the catalog Zod schema
- Never add platform-specific props to the catalog — the catalog is platform-agnostic
- Organize registries in
./registries/web.ts,./registries/pdf.ts,./registries/email.ts - Use
CatalogComponents<typeof catalog>type to ensure registries match the catalog
Type-Safe Registry Pattern
import type { CatalogComponents } from '@json-render/react'
import type { catalog } from './catalog'
// TypeScript ensures every catalog type is implemented
export const webRegistry: CatalogComponents<typeof catalog> = {
Heading: ({ text, level }) => { /* ... */ },
StatCard: ({ label, value, trend }) => { /* ... */ },
// Missing type → TypeScript error
}File Organization
src/
catalog.ts # Shared catalog (Zod schemas)
registries/
web.ts # React/HTML components
pdf.ts # react-pdf View/Text components
email.ts # react-email Section/Text components
image.ts # Satori-compatible inline-style components
remotion.ts # Remotion-animated componentsSelect renderer target based on output format and platform constraints — HIGH
Target Selection
Choose the renderer target based on what the output is, not what framework you use. Each target maps to a specific @json-render/* package with its own rendering pipeline.
Incorrect — building separate templates for each surface:
// WRONG: Separate template systems, no shared catalog
const webDashboard = buildReactComponents(data)
const pdfReport = buildPdfWithPuppeteer(data) // puppeteer screenshot
const emailDigest = buildMjmlEmail(data) // separate MJML templates
const ogImage = buildCanvasImage(data) // manual canvas drawingCorrect — one catalog, one spec, multiple registries:
import { catalog } from './catalog'
// Same spec, different renderers
import { Renderer } from '@json-render/react' // web
import { renderToBuffer } from '@json-render/react-pdf' // pdf
import { renderToHtml } from '@json-render/react-email' // email
import { renderToPng } from '@json-render/image' // og image
// Each renderer uses the same catalog + spec, different registry
const webUi = <Renderer spec={spec} catalog={catalog} registry={webRegistry} />
const pdf = await renderToBuffer(spec, { catalog, registry: pdfRegistry })
const html = await renderToHtml(spec, { catalog, registry: emailRegistry })
const png = await renderToPng(spec, { catalog, registry: imageRegistry, width: 1200, height: 630 })Key rules:
- Match target to output format: PDF document =
react-pdf, HTML email =react-email, image =image - Never use Puppeteer/Playwright to screenshot a React page for PDF — use native
@json-render/react-pdf - Never build custom MJML/HTML templates when
@json-render/react-emailexists - If output is a file (PDF, PNG, MP4), use the server-side renderer — not the React
<Renderer>component - Multiple targets in one project is the normal case — that is the entire point of json-render
Selection Checklist
| Need | Target | Package |
|---|---|---|
| Interactive web UI | React | @json-render/react |
| Downloadable document | @json-render/react-pdf | |
| Transactional email | @json-render/react-email | |
| Social preview card | Image | @json-render/image |
| Marketing video | Video | @json-render/remotion |
| Mobile app screen | React Native | @json-render/react-native |
| AI conversation output | MCP | @json-render/mcp |
| Source code generation | Codegen | @json-render/codegen |
Use Remotion compositions and Satori for video and image generation from specs — MEDIUM
Video & Image Renderer
@json-render/remotion wraps specs into Remotion compositions for MP4/WebM video. @json-render/image uses Satori to render specs as SVG, then optionally converts to PNG for OG images and social cards.
Incorrect — manually creating Remotion timelines:
// WRONG: Manual timeline, no catalog validation, duplicated rendering logic
export const MyVideo = () => (
<Composition
id="demo"
component={() => (
<div>
<h1>{data.title}</h1>
<p>{data.description}</p>
</div>
)}
durationInFrames={150}
fps={30}
width={1920}
height={1080}
/>
)Correct — JsonRenderComposition from spec:
import { JsonRenderComposition } from '@json-render/remotion'
import { catalog } from './catalog'
import { remotionRegistry } from './registries/remotion'
export const DemoVideo = () => (
<JsonRenderComposition
spec={spec}
catalog={catalog}
registry={remotionRegistry}
fps={30}
durationInFrames={150}
width={1920}
height={1080}
/>
)Incorrect — using Puppeteer for OG images:
// WRONG: Launches browser, screenshots a page, saves as PNG
const browser = await puppeteer.launch()
const page = await browser.newPage()
await page.setViewport({ width: 1200, height: 630 })
await page.setContent(`<div style="...">${title}</div>`)
const png = await page.screenshot({ type: 'png' })Correct — Satori-based image rendering:
import { renderToSvg, renderToPng } from '@json-render/image'
import { catalog } from './catalog'
import { imageRegistry } from './registries/image'
// SVG (smaller file size, scalable)
const svg = await renderToSvg(spec, {
catalog,
registry: imageRegistry,
width: 1200,
height: 630,
})
// PNG (universal compatibility)
const png = await renderToPng(spec, {
catalog,
registry: imageRegistry,
width: 1200,
height: 630,
})Key rules:
- Use
JsonRenderCompositionfor Remotion videos — it validates specs against the catalog - Use
renderToPngfor OG images (1200x630 standard) — Satori is server-side, no browser needed - Satori has CSS limitations: no CSS grid, limited flexbox, no
position: absolutenesting — design image registries accordingly - Image registries must use inline styles only — Satori does not support className or CSS files
- For Remotion, the registry can use Remotion animation primitives (
useCurrentFrame,interpolate,spring)
Satori CSS Constraints
| Supported | Not Supported |
|---|---|
| Flexbox (basic) | CSS Grid |
border, borderRadius | box-shadow |
padding, margin | position: absolute (limited) |
fontSize, fontWeight | External CSS, className |
color, backgroundColor | CSS animations |
width, height | Media queries |
Common OG Image Dimensions
| Platform | Width | Height | Ratio |
|---|---|---|---|
| Open Graph (general) | 1200 | 630 | 1.91:1 |
| Twitter card | 1200 | 628 | 1.91:1 |
| LinkedIn share | 1200 | 627 | 1.91:1 |
| Facebook share | 1200 | 630 | 1.91:1 |
References (6)
Renderer Api
Renderer API Reference
@json-render/react
<Renderer> Component
import { Renderer } from '@json-render/react'
<Renderer
spec={spec} // JsonRenderSpec — flat-tree JSON/YAML
catalog={catalog} // Catalog from defineCatalog()
registry={registry} // CatalogComponents<typeof catalog>
fallback={<Loading />} // Optional: shown during streaming
onError={(err) => log(err)} // Optional: error callback
/>useStreamingSpec Hook
import { useStreamingSpec } from '@json-render/react'
const spec = useStreamingSpec(stream) // ReadableStream of JSON Patch ops@json-render/react-pdf
renderToBuffer(spec, options): Promise<Buffer>
Renders spec to an in-memory PDF buffer.
import { renderToBuffer } from '@json-render/react-pdf'
const buffer = await renderToBuffer(spec, {
catalog,
registry: pdfRegistry,
pageSize: 'A4', // Optional: 'A4' | 'LETTER' | { width, height }
orientation: 'portrait', // Optional: 'portrait' | 'landscape'
margins: { top: 40, bottom: 40, left: 40, right: 40 }, // Optional
})renderToFile(spec, path, options): Promise<void>
Renders spec directly to a PDF file on disk.
import { renderToFile } from '@json-render/react-pdf'
await renderToFile(spec, './output/report.pdf', {
catalog,
registry: pdfRegistry,
pageSize: 'A4',
})renderToStream(spec, options): Promise<ReadableStream>
Renders spec to a readable stream for piping to HTTP responses.
import { renderToStream } from '@json-render/react-pdf'
const stream = await renderToStream(spec, { catalog, registry: pdfRegistry })
res.setHeader('Content-Type', 'application/pdf')
res.setHeader('Content-Disposition', 'attachment; filename="report.pdf"')
stream.pipe(res)@json-render/react-email
renderToHtml(spec, options): Promise<string>
Renders spec to an HTML string optimized for email clients.
import { renderToHtml } from '@json-render/react-email'
const html = await renderToHtml(spec, {
catalog,
registry: emailRegistry,
preview: 'Your weekly report is ready', // Optional: email preview text
theme: 'light', // Optional: 'light' | 'dark'
})renderToPlainText(spec, options): Promise<string>
Renders spec to plain text (for text/plain multipart emails).
import { renderToPlainText } from '@json-render/react-email'
const text = await renderToPlainText(spec, { catalog, registry: emailRegistry })@json-render/image
renderToSvg(spec, options): Promise<string>
Renders spec to an SVG string using Satori.
import { renderToSvg } from '@json-render/image'
const svg = await renderToSvg(spec, {
catalog,
registry: imageRegistry,
width: 1200, // Required
height: 630, // Required
fonts: [{ // Optional: custom fonts
name: 'Inter',
data: fontBuffer,
weight: 400,
style: 'normal',
}],
})renderToPng(spec, options): Promise<Buffer>
Renders spec to a PNG buffer (SVG -> PNG via Resvg).
import { renderToPng } from '@json-render/image'
const png = await renderToPng(spec, {
catalog,
registry: imageRegistry,
width: 1200,
height: 630,
})@json-render/remotion
<JsonRenderComposition> Component
import { JsonRenderComposition } from '@json-render/remotion'
<JsonRenderComposition
spec={spec} // JsonRenderSpec
catalog={catalog} // Catalog from defineCatalog()
registry={remotionRegistry} // Registry with Remotion animations
fps={30} // Frames per second
durationInFrames={150} // Total duration
width={1920} // Video width
height={1080} // Video height
/>Remotion Registry with Animations
import { useCurrentFrame, interpolate, spring, useVideoConfig } from 'remotion'
const remotionRegistry = {
Heading: ({ text, level }) => {
const frame = useCurrentFrame()
const opacity = interpolate(frame, [0, 20], [0, 1], { extrapolateRight: 'clamp' })
return <h1 style={{ opacity, fontSize: level === 'h1' ? 48 : 36 }}>{text}</h1>
},
}Common Options (all renderers)
| Option | Type | Description |
|---|---|---|
catalog | Catalog | Required. Zod-typed component definitions |
registry | CatalogComponents | Required. Platform-specific component map |
spec | JsonRenderSpec | Required. Flat-tree JSON/YAML spec |
onError | (err: Error) => void | Optional. Error handler |
Target Comparison
Target Comparison Matrix
All Targets — Capabilities Overview
| Target | Package | Output | Server-Side | Streaming | File Output | Sizing |
|---|---|---|---|---|---|---|
| React | @json-render/react | JSX | No (client) | Yes (progressive) | No | Responsive |
| Vue | @json-render/vue | Vue components | SSR supported | Yes | No | Responsive |
| Svelte | @json-render/svelte | Svelte components | SSR supported | Yes | No | Responsive |
| React Native | @json-render/react-native | Native views | No | Yes | No | Flex-based |
@json-render/react-pdf | Buffer/File/Stream | Yes | Via stream | Yes | Fixed (A4, Letter) | |
@json-render/react-email | HTML string | Yes | No | No | 600px max-width | |
| Remotion | @json-render/remotion | MP4/WebM | Yes (render) | No | Yes | Fixed (px) |
| Image | @json-render/image | SVG/PNG | Yes | No | Yes (buffer) | Fixed (px) |
| YAML | @json-render/yaml | YAML string | Yes | No | No | N/A |
| MCP | @json-render/mcp | Sandboxed iframe | Yes | Yes | No | Constrained |
| 3D | @json-render/react-three-fiber | Three.js canvas | No | No | No | Canvas-based |
| Codegen | @json-render/codegen | TypeScript/JSX | Yes | No | Yes | N/A |
Registry Component Primitives by Target
| Target | Base Elements | Styling | Layout |
|---|---|---|---|
| React | div, span, h1-h6, p, img | className, CSS modules, Tailwind | Flexbox, Grid, any CSS |
View, Text, Image (react-pdf) | StyleSheet.create() | Flexbox only | |
Section, Text, Heading, Button (react-email) | Inline styles | Table-based (email compat) | |
| Image | div, span, img (Satori subset) | Inline styles only | Flexbox only (limited) |
| Remotion | div, span + Remotion primitives | Inline styles, CSS | Flexbox, absolute positioning |
| React Native | View, Text, Image (RN) | StyleSheet.create() | Flexbox only |
Limitations by Target
PDF (@json-render/react-pdf)
- No CSS grid — flexbox only
- No className — use
StyleSheet.create() - No
<div>/<span>— must use<View>/<Text> - Font embedding required for custom fonts
- No interactive elements (buttons, links are display-only)
Email (@json-render/react-email)
- 600px max-width (email client constraint)
- Table-based layout for Outlook compatibility
- Limited CSS support (no flexbox in Outlook, no grid)
- Inline styles only for maximum compatibility
- No JavaScript interactivity
Image (@json-render/image / Satori)
- No CSS grid
- Limited flexbox (no
flex-wrap, limitedalign-items) - No
box-shadow - No CSS animations or transitions
- No external stylesheets — inline styles only
position: absolutehas limited nesting support- Custom fonts must be loaded as ArrayBuffer
Remotion (@json-render/remotion)
- No user interaction (pre-rendered video)
- Rendering is CPU-intensive — use cloud rendering for production
- Must specify exact
durationInFramesandfps - All animations must be frame-based (
useCurrentFrame)
React Native (@json-render/react-native)
- No
div/span— useView/Text - No CSS grid — flexbox only
- No className — use
StyleSheet.create() - Platform-specific behavior (iOS vs Android)
Performance Characteristics
| Target | Render Time | Memory | CPU |
|---|---|---|---|
| React | <50ms (client) | Low | Low |
| PDF (buffer) | 200-500ms | Medium | Medium |
| PDF (stream) | 100-300ms start | Low | Medium |
| 50-100ms | Low | Low | |
| Image (SVG) | 100-200ms | Low | Low |
| Image (PNG) | 200-400ms | Medium | Medium |
| Remotion | 10-60s (full render) | High | High |
| Codegen | 50-100ms | Low | Low |
When to Use Multiple Targets
Common multi-target combinations:
| Use Case | Targets | Example |
|---|---|---|
| Dashboard + PDF export | React + PDF | Web dashboard with "Download PDF" button |
| Dashboard + email digest | React + Email | Web view + weekly email summary |
| Blog + social sharing | React + Image | Blog post + OG image preview |
| Product page + demo | React + Remotion | Landing page + demo video |
| Full marketing suite | React + PDF + Email + Image + Remotion | All surfaces from one spec |
Upstream Email
<!-- SYNCED from vercel-labs/json-render (skills/react-email/SKILL.md) --> <!-- Hash: 8b376b37da07dd68944e72e0e322a272571f39271e5b08b6efd437db201470c9 --> <!-- Re-sync: bash scripts/sync-vercel-skills.sh -->
@json-render/react-email
React Email renderer that converts JSON specs into HTML or plain-text email output.
Quick Start
import { renderToHtml } from "@json-render/react-email";
import { schema, standardComponentDefinitions } from "@json-render/react-email";
import { defineCatalog } from "@json-render/core";
const catalog = defineCatalog(schema, {
components: standardComponentDefinitions,
});
const spec = {
root: "html-1",
elements: {
"html-1": { type: "Html", props: { lang: "en", dir: "ltr" }, children: ["head-1", "body-1"] },
"head-1": { type: "Head", props: {}, children: [] },
"body-1": {
type: "Body",
props: { style: { backgroundColor: "#f6f9fc" } },
children: ["container-1"],
},
"container-1": {
type: "Container",
props: { style: { maxWidth: "600px", margin: "0 auto", padding: "20px" } },
children: ["heading-1", "text-1"],
},
"heading-1": { type: "Heading", props: { text: "Welcome" }, children: [] },
"text-1": { type: "Text", props: { text: "Thanks for signing up." }, children: [] },
},
};
const html = await renderToHtml(spec);Spec Structure (Element Tree)
Same flat element tree as @json-render/react: root key plus elements map. Root must be Html; children of Html should be Head and Body. Use Container (e.g. max-width 600px) inside Body for client-safe layout.
Creating a Catalog and Registry
import { defineCatalog } from "@json-render/core";
import { schema, defineRegistry, renderToHtml } from "@json-render/react-email";
import { standardComponentDefinitions } from "@json-render/react-email/catalog";
import { Container, Heading, Text } from "@react-email/components";
import { z } from "zod";
const catalog = defineCatalog(schema, {
components: {
...standardComponentDefinitions,
Alert: {
props: z.object({
message: z.string(),
variant: z.enum(["info", "success", "warning"]).nullable(),
}),
slots: [],
description: "A highlighted message block",
},
},
actions: {},
});
const { registry } = defineRegistry(catalog, {
components: {
Alert: ({ props }) => (
<Container style={{ padding: 16, backgroundColor: "#eff6ff", borderRadius: 8 }}>
<Text style={{ margin: 0 }}>{props.message}</Text>
</Container>
),
},
});
const html = await renderToHtml(spec, { registry });Server-Side Render APIs
| Function | Purpose |
|---|---|
renderToHtml(spec, options?) | Render spec to HTML email string |
renderToPlainText(spec, options?) | Render spec to plain-text email string |
RenderOptions: registry, includeStandard (default true), state (for $state / $cond).
Visibility and State
Supports visible conditions, $state, $cond, repeat (repeat.statePath), and the same expression syntax as @json-render/react. Use state in RenderOptions when rendering server-side so expressions resolve.
Server-Safe Import
Import schema and catalog without React or @react-email/components:
import { schema, standardComponentDefinitions } from "@json-render/react-email/server";Key Exports
| Export | Purpose |
|---|---|
defineRegistry | Create type-safe component registry from catalog |
Renderer | Render spec in browser (e.g. preview); use with JSONUIProvider for state/actions |
createRenderer | Standalone renderer component with state/actions/validation |
renderToHtml | Server: spec to HTML string |
renderToPlainText | Server: spec to plain-text string |
schema | Email element schema |
standardComponents | Pre-built component implementations |
standardComponentDefinitions | Catalog definitions (Zod props) |
Sub-path Exports
| Path | Purpose |
|---|---|
@json-render/react-email | Full package |
@json-render/react-email/server | Schema and catalog only (no React) |
@json-render/react-email/catalog | Standard component definitions and types |
@json-render/react-email/render | Render functions only |
Standard Components
All components accept a style prop (object) for inline styles. Use inline styles for email client compatibility; avoid external CSS.
Document structure
| Component | Description |
|---|---|
Html | Root wrapper (lang, dir). Children: Head, Body. |
Head | Email head section. |
Body | Body wrapper; use style for background. |
Layout
| Component | Description |
|---|---|
Container | Constrain width (e.g. max-width 600px). |
Section | Group content; table-based for compatibility. |
Row | Horizontal row. |
Column | Column in a Row; set width via style. |
Content
| Component | Description |
|---|---|
Heading | Heading text (as: h1–h6). |
Text | Body text. |
Link | Hyperlink (text, href). |
Button | CTA link styled as button (text, href). |
Image | Image from URL (src, alt, width, height). |
Hr | Horizontal rule. |
Utility
| Component | Description |
|---|---|
Preview | Inbox preview text (inside Html). |
Markdown | Markdown content as email-safe HTML. |
Email Best Practices
- Keep width constrained (e.g. Container max-width 600px).
- Use inline styles or React Email's style props; many clients strip
<style>blocks. - Prefer table-based layout (Section, Row, Column) for broad client support.
- Use absolute URLs for images; many clients block relative or cid: references in some contexts.
- Test in multiple clients (Gmail, Outlook, Apple Mail); use a preview tool or Litmus-like service when possible.
Upstream Image
<!-- SYNCED from vercel-labs/json-render (skills/image/SKILL.md) --> <!-- Hash: fc6469e1592a86d4d92058b81704023394ab8cbdd9f422d541ad3a752f2a0e42 --> <!-- Re-sync: bash scripts/sync-vercel-skills.sh -->
@json-render/image
Image renderer that converts JSON specs into SVG and PNG images using Satori.
Quick Start
import { renderToPng } from "@json-render/image/render";
import type { Spec } from "@json-render/core";
const spec: Spec = {
root: "frame",
elements: {
frame: {
type: "Frame",
props: { width: 1200, height: 630, backgroundColor: "#1a1a2e" },
children: ["heading"],
},
heading: {
type: "Heading",
props: { text: "Hello World", level: "h1", color: "#ffffff" },
children: [],
},
},
};
const png = await renderToPng(spec, {
fonts: [{ name: "Inter", data: fontData, weight: 400, style: "normal" }],
});Using Standard Components
import { defineCatalog } from "@json-render/core";
import { schema, standardComponentDefinitions } from "@json-render/image";
export const imageCatalog = defineCatalog(schema, {
components: standardComponentDefinitions,
});Adding Custom Components
import { z } from "zod";
const catalog = defineCatalog(schema, {
components: {
...standardComponentDefinitions,
Badge: {
props: z.object({ label: z.string(), color: z.string().nullable() }),
slots: [],
description: "A colored badge label",
},
},
});Standard Components
| Component | Category | Description |
|---|---|---|
Frame | Root | Root container. Defines width, height, background. Must be root. |
Box | Layout | Container with padding, margin, border, absolute positioning |
Row | Layout | Horizontal flex layout |
Column | Layout | Vertical flex layout |
Heading | Content | h1-h4 heading text |
Text | Content | Body text with full styling |
Image | Content | Image from URL |
Divider | Decorative | Horizontal line separator |
Spacer | Decorative | Empty vertical space |
Key Exports
| Export | Purpose |
|---|---|
renderToSvg | Render spec to SVG string |
renderToPng | Render spec to PNG buffer (requires @resvg/resvg-js) |
schema | Image element schema |
standardComponents | Pre-built component registry |
standardComponentDefinitions | Catalog definitions for AI prompts |
Sub-path Exports
| Export | Description |
|---|---|
@json-render/image | Full package: schema, components, render functions |
@json-render/image/server | Schema and catalog definitions only (no React/Satori) |
@json-render/image/catalog | Standard component definitions and types |
@json-render/image/render | Render functions only |
Upstream Pdf
<!-- SYNCED from vercel-labs/json-render (skills/react-pdf/SKILL.md) --> <!-- Hash: 42d7d378733e0c1e6ff10ba788dc77c32b36b2f28841f1d6aa671e44ed9ad0be --> <!-- Re-sync: bash scripts/sync-vercel-skills.sh -->
@json-render/react-pdf
React PDF renderer that generates PDF documents from JSON specs using @react-pdf/renderer.
Installation
npm install @json-render/core @json-render/react-pdfQuick Start
import { renderToBuffer } from "@json-render/react-pdf";
import type { Spec } from "@json-render/core";
const spec: Spec = {
root: "doc",
elements: {
doc: { type: "Document", props: { title: "Invoice" }, children: ["page"] },
page: {
type: "Page",
props: { size: "A4" },
children: ["heading", "table"],
},
heading: {
type: "Heading",
props: { text: "Invoice #1234", level: "h1" },
children: [],
},
table: {
type: "Table",
props: {
columns: [
{ header: "Item", width: "60%" },
{ header: "Price", width: "40%", align: "right" },
],
rows: [
["Widget A", "$10.00"],
["Widget B", "$25.00"],
],
},
children: [],
},
},
};
const buffer = await renderToBuffer(spec);Render APIs
import { renderToBuffer, renderToStream, renderToFile } from "@json-render/react-pdf";
// In-memory buffer
const buffer = await renderToBuffer(spec);
// Readable stream (pipe to HTTP response)
const stream = await renderToStream(spec);
stream.pipe(res);
// Direct to file
await renderToFile(spec, "./output.pdf");All render functions accept an optional second argument: \{ registry?, state?, handlers? \}.
Standard Components
| Component | Description |
|---|---|
Document | Top-level PDF wrapper (must be root) |
Page | Page with size (A4, LETTER), orientation, margins |
View | Generic container (padding, margin, background, border) |
Row, Column | Flex layout with gap, align, justify |
Heading | h1-h4 heading text |
Text | Body text (fontSize, color, weight, alignment) |
Image | Image from URL or base64 |
Link | Hyperlink with text and href |
Table | Data table with typed columns and rows |
List | Ordered or unordered list |
Divider | Horizontal line separator |
Spacer | Empty vertical space |
PageNumber | Current page number and total pages |
Custom Catalog
import { defineCatalog } from "@json-render/core";
import { schema, defineRegistry, renderToBuffer } from "@json-render/react-pdf";
import { standardComponentDefinitions } from "@json-render/react-pdf/catalog";
import { z } from "zod";
const catalog = defineCatalog(schema, {
components: {
...standardComponentDefinitions,
Badge: {
props: z.object({ label: z.string(), color: z.string().nullable() }),
slots: [],
description: "A colored badge label",
},
},
actions: {},
});
const { registry } = defineRegistry(catalog, {
components: {
Badge: ({ props }) => (
<View style={{ backgroundColor: props.color ?? "#e5e7eb", padding: 4 }}>
<Text>{props.label}</Text>
</View>
),
},
});
const buffer = await renderToBuffer(spec, { registry });External Store (Controlled Mode)
Pass a StateStore for full control over state:
import { createStateStore } from "@json-render/react-pdf";
const store = createStateStore({ invoice: { total: 100 } });
store.set("/invoice/total", 200);Server-Safe Import
Import schema and catalog without pulling in React:
import { schema, standardComponentDefinitions } from "@json-render/react-pdf/server";Upstream Remotion
<!-- SYNCED from vercel-labs/json-render (skills/remotion/SKILL.md) --> <!-- Hash: 59d63ac06729795f0981fe0cfadf6c1c34454be0329a34f2d36c9be2391fbbe9 --> <!-- Re-sync: bash scripts/sync-vercel-skills.sh -->
@json-render/remotion
Remotion renderer that converts JSON timeline specs into video compositions.
Quick Start
import { Player } from "@remotion/player";
import { Renderer, type TimelineSpec } from "@json-render/remotion";
function VideoPlayer({ spec }: { spec: TimelineSpec }) {
return (
<Player
component={Renderer}
inputProps={{ spec }}
durationInFrames={spec.composition.durationInFrames}
fps={spec.composition.fps}
compositionWidth={spec.composition.width}
compositionHeight={spec.composition.height}
controls
/>
);
}Using Standard Components
import { defineCatalog } from "@json-render/core";
import {
schema,
standardComponentDefinitions,
standardTransitionDefinitions,
standardEffectDefinitions,
} from "@json-render/remotion";
export const videoCatalog = defineCatalog(schema, {
components: standardComponentDefinitions,
transitions: standardTransitionDefinitions,
effects: standardEffectDefinitions,
});Adding Custom Components
import { z } from "zod";
const catalog = defineCatalog(schema, {
components: {
...standardComponentDefinitions,
MyCustomClip: {
props: z.object({ text: z.string() }),
type: "scene",
defaultDuration: 90,
description: "My custom video clip",
},
},
});
// Pass custom component to Renderer
<Player
component={Renderer}
inputProps={{
spec,
components: { MyCustomClip: MyCustomComponent },
}}
/>Timeline Spec Structure
{
"composition": { "id": "video", "fps": 30, "width": 1920, "height": 1080, "durationInFrames": 300 },
"tracks": [{ "id": "main", "name": "Main", "type": "video", "enabled": true }],
"clips": [
{ "id": "clip-1", "trackId": "main", "component": "TitleCard", "props": { "title": "Hello" }, "from": 0, "durationInFrames": 90 }
],
"audio": { "tracks": [] }
}Standard Components
| Component | Type | Description |
|---|---|---|
TitleCard | scene | Full-screen title with subtitle |
TypingText | scene | Terminal-style typing animation |
ImageSlide | image | Full-screen image display |
SplitScreen | scene | Two-panel comparison |
QuoteCard | scene | Quote with attribution |
StatCard | scene | Animated statistic display |
TextOverlay | overlay | Text overlay |
LowerThird | overlay | Name/title overlay |
Key Exports
| Export | Purpose |
|---|---|
Renderer | Render spec to Remotion composition |
schema | Timeline schema |
standardComponents | Pre-built component registry |
standardComponentDefinitions | Catalog definitions |
useTransition | Transition animation hook |
ClipWrapper | Wrap clips with transitions |
Monitoring Observability
Monitoring and observability patterns for Prometheus metrics, Grafana dashboards, Langfuse v4 LLM tracing (as_type, score_current_span, should_export_span, LangfuseMedia), and drift detection. Use when adding logging, metrics, distributed tracing, LLM cost tracking, or quality drift monitoring.
Multimodal Llm
Vision, audio, video generation, and multimodal LLM integration patterns. Use when processing images, transcribing audio, generating speech, generating AI video (Kling v3, Sora 2, Veo 3.1 std/lite/fast, Runway Gen-4.5 via `gen4_turbo`), or building multimodal AI pipelines.
Last updated on