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.