Skip to Content
React WidgetExamples

Ahead of the release

npm install @evanion/react-widget gives you 0.3.0. These pages document main, which has changes that release does not.

Examples

A CMS-driven page

The case the package was built for: the component map is the contract between the CMS and the front end, validateItems is the gate at ingestion, and the page itself is a Server Component.

// widgets.ts import { createWidgets } from '@evanion/react-widget'; import { Hero } from './blocks/hero'; import { RichText } from './blocks/rich-text'; import { ProductGrid } from './blocks/product-grid'; export const { Widgets, validateItems } = createWidgets({ components: { hero: Hero, richText: RichText, productGrid: ProductGrid }, });
// app/[slug]/page.tsx import { Widgets } from '../widgets'; export default async function Page({ params }: { params: { slug: string } }) { const page = await cms.getPage(params.slug); return <Widgets items={page.blocks} ctx={{ locale: page.locale }} />; }
// scripts/check-content.ts — run in CI, or on the CMS webhook import { validateItems } from '../widgets'; const problems = validateItems(await cms.getPage(slug).then((p) => p.blocks)); if (problems.length) { for (const problem of problems) { console.error( `block ${problem.index} (${problem.type}): ${problem.message}`, ); } process.exit(1); }

page.blocks arrives as unknown from the CMS, so nothing about it is checked at compile time. validateItems is the runtime check for that path.

A dashboard grid

meta carries placement; the widgets stay ignorant of where they sit.

import type { WidgetItemComponent } from '@evanion/react-widget'; type GridMeta = { column: number; span?: number; row?: number }; const GridCell: WidgetItemComponent<GridMeta> = ({ children, meta, ...rest }) => ( <div {...rest} style={{ gridColumn: `${meta?.column ?? 'auto'} / span ${meta?.span ?? 1}`, gridRow: meta?.row, }} > {children} </div> ); const { Widgets, defineItems } = createWidgets({ components: { stat: StatCard, chart: Chart, table: LedgerTable }, chrome: { wrapper: ({ children }) => <div className="dashboard-grid">{children}</div>, item: GridCell, suspenseFallback: <div className="tile-skeleton" />, }, }); const layout = defineItems([ { id: 'revenue', type: 'stat', props: { title: 'Revenue', value: '$45,678', trend: '+8%' }, meta: { column: 1 }, }, { id: 'users', type: 'stat', props: { title: 'Total users', value: '1,234', trend: '+12%' }, meta: { column: 2 }, }, { id: 'trend', type: 'chart', props: { metric: 'revenue', window: '90d' }, meta: { column: 1, span: 2 }, }, ]);

meta: { colum: 1 } does not compile, because GridCell is annotated with the vocabulary it reads. Without that annotation it would compile and the tile would land wherever the ?? 'auto' fallback put it.

Widgets that fetch their own data

A widget can be an async Server Component. It gets its own Suspense boundary by default, so a slow one does not hold up its siblings.

async function AsyncReport({ reportId }: { reportId: string }) { const rows = await db.report(reportId); return <ReportTable rows={rows} />; } const { Widgets } = createWidgets({ components: { report: AsyncReport, stat: StatCard }, chrome: { suspenseFallback: <Skeleton /> }, }); <Widgets items={[ { id: 'q3', type: 'report', props: { reportId: 'q3-2026' } }, { id: 'q4', type: 'report', props: { reportId: 'q4-2026' } }, ]} />;

Nothing about the widget changes to make this work. There is no loader, no registration step, and no 'use client' anywhere in the chain.

Nesting

Nested items become the parent’s children:

const Card = ({ title, children }: PropsWithChildren<{ title: string }>) => ( <section className="card"> <h3>{title}</h3> {children} </section> ); const Text = ({ content }: { content: string }) => <p>{content}</p>; const Image = ({ src, alt }: { src: string; alt: string }) => ( <img src={src} alt={alt} /> ); const { Widgets } = createWidgets({ components: { card: Card, text: Text, image: Image }, }); <Widgets items={[ { id: 'c1', type: 'card', props: { title: 'My card' }, children: [ { id: 't1', type: 'text', props: { content: 'Nested text' } }, { id: 'i1', type: 'image', props: { src: '/x.jpg', alt: 'Nested' } }, ], }, ]} />;

Text and Image declare no children, so giving either one nested items is a compile error rather than a subtree that disappears at runtime.

One region, two presentations

Per-instance components overrides let the same data render differently on different pages:

const { Widgets } = createWidgets({ components: { listing: ListingCard } }); function StorefrontPage() { return ( <Widgets items={shelfItems} components={{ listing: FeaturedListing }} /> ); } function BackOfficePage() { return <Widgets items={shelfItems} />; }

FeaturedListing has to accept ListingCard’s props — the items were checked against the factory’s map, and an override is a Partial<C>, so its entries are checked against the same component types.

A synchronous table

A long region of widgets that never suspend should say so, or every row past the first chunk boundary is streamed out of order into a trailing <div hidden>:

const { Widgets } = createWidgets({ components: { row: LedgerRow }, chrome: { suspense: 'none' }, });

See Suspense for the measurements.

Mixed valid and invalid data

The renderer is defensive, so a stale type in a payload costs one skipped item and one console warning rather than a blank page:

const { Widgets } = createWidgets({ components: { valid: ValidWidget } }); <Widgets items={ [ { id: 'a', type: 'gone-in-v2', props: {} }, // skipped, warned once { id: 'b', type: 'valid', props: { content: 'renders' } }, ] as never // only reachable from untyped data } />;

The as never is needed because that array does not typecheck against this map. It is what a JSON payload looks like after JSON.parse.

Last updated on