@csirt-cms/widget-react
v0.1.1
Published
React and Next.js runtime renderers for the CSIRT CMS widget system.
Maintainers
Readme
@csirt-cms/widget-react
React and Next.js runtime renderers for the CSIRT CMS widget system. Takes the
{ component, props } sections the Public API returns and renders them through a
registry your Page Builder never has to know about.
npm install @csirt-cms/widget-reactPeers: react >= 18, zod ^4. Framer Motion ships as a dependency and is only
pulled into the bundle by widgets that animate — today, the hero carousel.
Quick start
// app/widgets.ts — import this from any route that renders CMS content
import { builtInWidgets, registerWidgets } from "@csirt-cms/widget-react";
registerWidgets(builtInWidgets);
export {};// app/layout.tsx
import "@csirt-cms/widget-react/styles.css";// app/[slug]/page.tsx — a Server Component
import { PageRenderer, createCmsClient } from "@csirt-cms/widget-react";
import "../widgets";
const client = createCmsClient({
baseUrl: process.env.CMS_URL!,
site: "csirt",
});
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const page = await client.getPage(slug, { next: { revalidate: 60 } });
return <PageRenderer sections={page.sections} />;
}Register in a module both the server and the client import — not inside a component — so the two registries stay in step.
Server Components
PageRenderer carries no "use client" directive. It resolves widget keys to
components and renders them, all of which works on the server. Only the widgets
that need browser state mark themselves as client entry points:
| Renders on the server, ships no JS | Hydrates on the client |
| --- | --- |
| feature-grid, timeline, html, markdown | hero-carousel, faq, statistics, gallery, announcement, contact |
A page built only from the left column ships zero JavaScript. Mixed pages hydrate only the interactive widgets.
The package is emitted unbundled by tsc, which preserves each file's directive
prologue, so the "use client" markers survive into dist and React Server
Components treat those widgets as proper client entry points.
Markdown and HTML are rendered and sanitized during the server render, so no Markdown parser or sanitizer reaches the browser.
PageRenderer
<PageRenderer
sections={page.sections} // PublicSection[] or SectionState[]
device="mobile" // evaluates device visibility rules
preview={false} // true also renders DRAFT and SCHEDULED
now={new Date()} // fixes "now" for scheduling rules
fallback={(key) => <Missing widgetKey={key} />}
wrapper={(section, children) => <Outline section={section}>{children}</Outline>}
/>It accepts the Public API's { component, props } pairs directly, or the fuller
SectionState the Page Builder holds.
For each section it migrates stored props to the widget's current version, fills
gaps from defaults, and validates. A section that still fails validation renders
its empty state rather than taking the page down. An unregistered key warns in
development and renders nothing, unless you pass fallback.
Adding a widget
Nothing in the Page Builder changes — it renders whatever is registered.
npm run generate:widget -- testimonial --name "Testimonial" --category MarketingThat scaffolds the definition in widget-core and a renderer in both framework
packages, and wires them into each barrel. Or by hand:
import { defineWidget, registerWidget } from "@csirt-cms/widget-react";
import { testimonialDefinition } from "./definition";
function TestimonialRenderer({ quote, author }: WidgetRendererProps<TestimonialProps>) {
return <blockquote className="cms-w cms-w-testimonial">{quote} — {author}</blockquote>;
}
registerWidget(defineWidget(testimonialDefinition, { renderer: TestimonialRenderer }));Add "use client"; as the first line of the renderer only if it needs state,
effects or event handlers.
Registry
import {
getWidgets, getWidgetsByCategory, getWidgetCategories, searchWidgets,
} from "@csirt-cms/widget-react";
getWidgetCategories(); // populated categories, canonical order
getWidgetsByCategory("Hero");
searchWidgets("carousel"); // name, key, description, category, tagsThese are what a Widget Picker is built from — each widget carries icon,
thumbnail, description and version.
Live preview
import { WidgetPreview } from "@csirt-cms/widget-react";
<WidgetPreview widgetKey="hero-carousel" props={draftProps} device="mobile" />Renders the widget inside a device frame at desktop, tablet or mobile width. Props are defaulted and validated first, so a half-finished draft still previews.
Client-side data hooks
Prefer fetching in a Server Component. These exist for SPAs (Vite, CRA) and for content that genuinely has to refresh in the browser:
"use client";
const { data, loading, error, reload } = useCmsPage(client, "beranda");Also useCmsNavigation, useCmsAnnouncements, useCmsTheme.
Styling
One stylesheet drives every widget, in this package and the Vue one. Every value is a CSS custom property with a fallback, so a site's theme can override any of it without touching the file:
import { themeToCssText } from "@csirt-cms/widget-react";
const theme = await client.getTheme();
<style dangerouslySetInnerHTML={{ __html: themeToCssText(theme) }} />Server-rendering the theme this way avoids the flash of unthemed content an
effect-based approach causes. Dark mode follows the OS, or is forced with
data-cms-theme="dark" on <html>.
Security
The html and markdown widgets sanitize before rendering. The built-in
sanitizer is an allowlist tokenizer suited to trusted CMS editors — for
untrusted input, pass your own:
import DOMPurify from "isomorphic-dompurify";
<HtmlRenderer {...props} sanitizer={(html) => DOMPurify.sanitize(html)} />See the @csirt-cms/widget-core README for the full threat-model note.
License
MIT
