Renderers are JavaScript modules that provide custom visualizations for turn payloads in the CXDB UI.
When displaying a turn, the UI:
- Checks if a renderer is registered for the turn's
type_id - If found, loads the renderer module (ESM from CDN)
- Passes the typed JSON data to the renderer component
- Renders the output in the turn card
graph LR
Turn[Turn Data] --> Check{Renderer<br/>Registered?}
Check -->|No| Default[Default JSON View]
Check -->|Yes| Load[Load from CDN]
Load --> Render[Renderer Component]
Render --> Display[Display in UI]
Renderers are configured in the UI settings:
// frontend/lib/renderers.ts
export const renderers: RendererConfig[] = [
{
type_id: 'com.example.Chart',
renderer_url: 'https://cdn.example.com/renderers/chart@1.0.0.js',
name: 'Chart Renderer',
version: '1.0.0'
},
{
type_id: 'com.example.Image',
renderer_url: 'https://cdn.example.com/renderers/image@1.0.0.js',
name: 'Image Renderer',
version: '1.0.0'
}
];A renderer is an ES module that exports a default React component:
// chart-renderer.js
export default function ChartRenderer({ data, metadata }) {
return (
<div className="chart-container">
<h3>{data.title}</h3>
<LineChart points={data.points} />
</div>
);
}Renderers receive these props:
interface RendererProps {
// Typed turn data (projected from msgpack)
data: any;
// Turn metadata
metadata: {
turn_id: string;
depth: number;
declared_type: {
type_id: string;
type_version: number;
};
};
// UI theme
theme: 'light' | 'dark';
// Callbacks
onError?: (error: Error) => void;
}The component should return valid React elements (JSX).
// image-renderer.js
export default function ImageRenderer({ data, metadata }) {
// data = { alt: "Diagram", bytes: "base64..." }
return (
<figure>
<img
src={`data:image/png;base64,${data.bytes}`}
alt={data.alt || `Image from turn ${metadata.turn_id}`}
style={{ maxWidth: '100%', borderRadius: '8px' }}
/>
{data.alt && <figcaption>{data.alt}</figcaption>}
</figure>
);
}Type descriptor:
{
"com.example.Image": {
"versions": {
"1": {
"fields": {
"1": { "name": "alt", "type": "string", "optional": true },
"2": { "name": "bytes", "type": "bytes" }
}
}
}
}
}Projection: (with bytes_render=base64)
{
"alt": "Architecture diagram",
"bytes": "iVBORw0KGgoAAAANSUhEUgAA..."
}// chart-renderer.js
import React from 'react';
export default function ChartRenderer({ data, theme }) {
const { title, points } = data;
const maxY = Math.max(...points.map(p => p.y));
const scale = 200 / maxY;
return (
<div style={{
background: theme === 'dark' ? '#1a1a1a' : '#f5f5f5',
padding: '1rem',
borderRadius: '8px'
}}>
<h3>{title}</h3>
<svg width="400" height="200" viewBox="0 0 400 200">
<polyline
points={points.map((p, i) =>
`${i * (400 / points.length)},${200 - p.y * scale}`
).join(' ')}
fill="none"
stroke={theme === 'dark' ? '#00ff00' : '#0066cc'}
strokeWidth="2"
/>
</svg>
</div>
);
}Type descriptor:
{
"com.example.Chart": {
"versions": {
"1": {
"fields": {
"1": { "name": "title", "type": "string" },
"2": { "name": "points", "type": "array", "items": "nested", "nested": "com.example.Point" }
}
}
}
},
"com.example.Point": {
"versions": {
"1": {
"fields": {
"1": { "name": "x", "type": "f64" },
"2": { "name": "y", "type": "f64" }
}
}
}
}
}Renderers can import libraries from CDNs:
// chart-renderer-with-recharts.js
import React from 'react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip } from 'https://esm.sh/recharts@2.10.0';
export default function ChartRenderer({ data }) {
return (
<LineChart width={600} height={300} data={data.points}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="x" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="y" stroke="#8884d8" />
</LineChart>
);
}Supported CDNs:
- esm.sh (recommended)
- unpkg.com
- cdn.jsdelivr.net
- skypack.dev
Note: Configure allowed CDNs via ALLOWED_RENDERER_ORIGINS in the gateway.
mkdir my-renderer
cd my-renderer
npm init -y
npm install vite reactvite.config.js:
import { defineConfig } from 'vite';
export default defineConfig({
build: {
lib: {
entry: 'src/index.jsx',
formats: ['es'],
fileName: 'renderer'
},
rollupOptions: {
external: ['react', 'react/jsx-runtime'],
output: {
globals: {
react: 'React'
}
}
}
}
});src/index.jsx:
import React from 'react';
export default function MyRenderer({ data }) {
return <div>{data.message}</div>;
}Build:
npm run build
# Output: dist/renderer.jsnpm install esbuildbuild.js:
import esbuild from 'esbuild';
esbuild.build({
entryPoints: ['src/index.jsx'],
bundle: true,
format: 'esm',
outfile: 'dist/renderer.js',
external: ['react'],
jsx: 'automatic'
});Upload your built renderer to a CDN or static host:
# AWS S3
aws s3 cp dist/renderer.js s3://my-bucket/renderers/my-renderer@1.0.0.js
aws s3api put-object-acl --bucket my-bucket --key renderers/my-renderer@1.0.0.js --acl public-read
# Cloudflare Pages
npx wrangler pages publish dist
# GitHub Pages
cp dist/renderer.js docs/renderers/my-renderer@1.0.0.js
git add docs && git commit -m "Add renderer" && git pushThen register it:
{
type_id: 'com.mycompany.MyType',
renderer_url: 'https://my-bucket.s3.amazonaws.com/renderers/my-renderer@1.0.0.js',
name: 'My Custom Renderer',
version: '1.0.0'
}The CXDB gateway sets CSP headers to restrict renderer origins:
Content-Security-Policy: script-src 'self' https://cdn.strongdm.ai https://esm.sh https://unpkg.com
Configuring Allowed Origins:
# gateway/.env
ALLOWED_RENDERER_ORIGINS=https://cdn.strongdm.ai,https://your-cdn.comRenderers run in the same browser context as the UI. Follow these best practices:
-
Validate input data
export default function MyRenderer({ data }) { if (!data || typeof data.message !== 'string') { return <div>Invalid data</div>; } // ... }
-
Sanitize HTML
import DOMPurify from 'https://esm.sh/dompurify@3.0.0'; export default function HTMLRenderer({ data }) { const clean = DOMPurify.sanitize(data.html); return <div dangerouslySetInnerHTML={{ __html: clean }} />; }
-
Avoid eval and Function
- Never use
eval()ornew Function() - Don't execute code from turn data
- Never use
-
Limit network access
- Don't make arbitrary fetch() requests
- Only load assets from whitelisted CDNs
Wrap your renderer in error boundaries:
import React from 'react';
class ErrorBoundary extends React.Component {
state = { error: null };
static getDerivedStateFromError(error) {
return { error };
}
render() {
if (this.state.error) {
return (
<div style={{ color: 'red', padding: '1rem' }}>
Renderer error: {this.state.error.message}
</div>
);
}
return this.props.children;
}
}
export default function SafeRenderer({ data, onError }) {
try {
return (
<ErrorBoundary>
<MyActualRenderer data={data} />
</ErrorBoundary>
);
} catch (err) {
onError?.(err);
return <div>Render failed</div>;
}
}Create a test harness:
<!-- test.html -->
<!DOCTYPE html>
<html>
<head>
<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@18",
"react/jsx-runtime": "https://esm.sh/react@18/jsx-runtime"
}
}
</script>
</head>
<body>
<div id="root"></div>
<script type="module">
import React from 'react';
import { createRoot } from 'https://esm.sh/react-dom@18/client';
import Renderer from './dist/renderer.js';
const root = createRoot(document.getElementById('root'));
root.render(
React.createElement(Renderer, {
data: {
message: 'Test data'
},
metadata: {
turn_id: '1',
depth: 1,
declared_type: {
type_id: 'com.example.Test',
type_version: 1
}
},
theme: 'light'
})
);
</script>
</body>
</html>Serve locally:
python3 -m http.server 8000
# Open http://localhost:8000/test.html// renderer.test.jsx
import { render, screen } from '@testing-library/react';
import Renderer from './renderer';
test('renders message', () => {
const data = { message: 'Hello' };
render(<Renderer data={data} metadata={{}} theme="light" />);
expect(screen.getByText('Hello')).toBeInTheDocument();
});For organizations with many custom renderers, create a registry service:
registry.json:
{
"renderers": [
{
"type_id": "com.example.Chart",
"url": "https://cdn.example.com/renderers/chart@1.2.0.js",
"version": "1.2.0",
"checksum": "sha256-abc123..."
},
{
"type_id": "com.example.Image",
"url": "https://cdn.example.com/renderers/image@1.0.0.js",
"version": "1.0.0",
"checksum": "sha256-def456..."
}
]
}Load dynamically in the UI:
// frontend/lib/renderers.ts
export async function loadRendererRegistry() {
const response = await fetch('https://cdn.example.com/registry.json');
const registry = await response.json();
return registry.renderers;
}See the cxdb-renderers repository for example renderers:
- Markdown - Rendered markdown with syntax highlighting
- Mermaid - Diagram rendering
- Code - Syntax-highlighted code blocks
- Table - Sortable, filterable tables
- JSON - Pretty-printed JSON with collapsible sections
- Diff - Side-by-side diff viewer
-
Lazy load heavy dependencies
const Chart = lazy(() => import('https://esm.sh/recharts@2.10.0')); export default function MyRenderer({ data }) { return ( <Suspense fallback={<div>Loading chart...</div>}> <Chart data={data} /> </Suspense> ); }
-
Memoize expensive computations
const processedData = useMemo(() => { return data.points.map(p => ({ ...p, y: p.y * 100 })); }, [data.points]);
-
Virtualize large lists
import { FixedSizeList } from 'https://esm.sh/react-window@1.8.10'; export default function ListRenderer({ data }) { return ( <FixedSizeList height={400} itemCount={data.items.length} itemSize={50} > {({ index, style }) => ( <div style={style}>{data.items[index]}</div> )} </FixedSizeList> ); }
Symptoms: Turn shows "Renderer not found" or default JSON view
Solutions:
- Check that
type_idmatches exactly (case-sensitive) - Verify renderer URL is accessible (try in browser)
- Check browser console for CORS or CSP errors
- Ensure renderer exports a default function
Symptoms: Console error: "Refused to load script from..."
Solution: Add renderer CDN to ALLOWED_RENDERER_ORIGINS:
ALLOWED_RENDERER_ORIGINS=https://cdn.strongdm.ai,https://your-cdn.comSymptoms: "Something went wrong" error in turn card
Solutions:
- Check browser console for error details
- Add error boundary to renderer
- Validate input data shape
- Test with sample data locally
Symptoms: "Failed to resolve module"
Solutions:
- Use full URLs with https:// protocol
- Use ESM-compatible CDN (esm.sh, skypack)
- Check library is available as ESM
- Externalize React:
external: ['react']
- Type Registry - Defining turn types
- HTTP API - Fetching turns with projections
- Architecture - Projection pipeline
- Example Renderers - Sample renderer implementations