Components
Prebuilt React components for rendering uidu CMS content.
<PageBlocks pageBlocks components fallback? />
Walks a page's pageBlocks array and renders each block using the component registered for its shortname. Returns null when pageBlocks is empty.
import { getPage } from '@uidu/client';
import { PageBlocks } from '@uidu/react';
import { uidu, config } from '@/lib/uidu';
import { Hero, Feature, ContactForm } from '@/components/blocks';
export default async function HomePage() {
const page = await getPage(uidu, {
projectId: config.projectId,
slug: 'home',
});
if (!page) return null;
return (
<PageBlocks
pageBlocks={page.pageBlocks}
components={{
Header: Hero,
feature1: Feature,
ModuloContatto: ContactForm,
}}
/>
);
}How block components receive props
Each component receives the raw PageBlock as props.block:
import type { Page } from '@uidu/client';
import { useFields, toText } from '@uidu/react';
type PageBlock = NonNullable<Page['pageBlocks']>[number];
export function Hero({ block }: { block: PageBlock }) {
const fields = useFields(block.fieldValues);
return <h1>{toText(fields.Titolo)}</h1>;
}The block's shortname is derived from block.templateBlock.block.shortname first (the canonical block type), falling back to block.shortname.
Handling unknown blocks
Pass a fallback to render a placeholder for blocks whose shortname isn't in your map:
<PageBlocks
pageBlocks={page.pageBlocks}
components={blockComponents}
fallback={({ shortname }) => (
<div className="rounded border border-dashed p-3 text-xs">
Unknown block: <code>{shortname}</code>
</div>
)}
/>Without a fallback, unknown blocks are silently skipped.
<BlockRenderer pageBlocks shortname components />
Render exactly one block by its shortname. Useful when blocks need to be placed in specific spots in your layout rather than rendered sequentially.
<header>
<BlockRenderer
pageBlocks={page.pageBlocks}
shortname="Header"
components={{ Header: Hero }}
/>
</header>
<main>
{/* other content */}
</main>
<aside>
<BlockRenderer
pageBlocks={page.pageBlocks}
shortname="Sidebar"
components={{ Sidebar: SidebarBlock }}
/>
</aside>If no block matches the shortname, returns null.
<RichText doc />
Render a Tiptap/ProseMirror JSON document as React elements. Used by uidu CMS rich-text fields (Event.body, Call.body, page block rich-text fields, etc.).
import { getEvent } from '@uidu/client';
import { RichText } from '@uidu/react';
import { uidu } from '@/lib/uidu';
export default async function EventPage({ params }) {
const event = await getEvent(uidu, { id: params.id });
return (
<article>
<h1>{event?.name}</h1>
<RichText doc={event?.body} />
</article>
);
}Supported nodes
| Tiptap type | Output |
|---|---|
doc | wrapping <div> |
paragraph | <p> |
heading (level 1–6) | <h1>–<h6>, scaled type |
bulletList / orderedList | <ul> / <ol> |
listItem | <li> |
blockquote | <blockquote> with left border |
codeBlock | <pre><code> with dark background |
image | <img> (src, alt, title from attrs) |
horizontalRule | <hr> |
hardBreak | <br> |
text | bare text node, marks wrap inside-out |
Supported text marks
| Tiptap mark | Output |
|---|---|
bold / strong | <strong> |
italic / em | <em> |
underline | <u> |
strike / strikethrough | <s> |
code | inline <code> with pill styling |
link | <a> with href, target, rel |
Graceful degradation
Unknown node types render their children inside a plain <div> — content survives even if your CMS uses custom nodes the renderer doesn't recognize.
If doc is a string (some CMSes store rendered HTML), it falls back to dangerouslySetInnerHTML. If doc is null, undefined, or not an object, the component renders nothing.
Types
import type { RichTextNode, RichTextProps } from '@uidu/react';
// RichTextNode — the recursive Tiptap node shape: { type, content?, text?, marks?, attrs? }<DynamicForm form action />
Render any uidu Form schema as inputs, collect the values, and call your Server Action. Built on useTransition, no SWR or react-hook-form dependency. Used in the demo for contact forms, RSVPs, job applications, donations, and standalone form submissions — same component, different action.
'use client';
import { DynamicForm } from '@uidu/react';
import { submitContactForm } from '@/lib/actions';
export function ContactForm({ form }) {
return (
<DynamicForm
form={form}
action={submitContactForm}
submitLabel="Send message"
renderSuccess={(result) => (
<p>Thanks! Reference: <code>{String(result.meta?.responseId)}</code></p>
)}
/>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
form | Form | — | The schema from getForm, call.form, or an embedded page block. |
action | (values: DynamicFormValues) => Promise<DynamicFormResult> | — | Server Action wrapping createFormResponse. Receives parsed values, returns { ok, errors? }. |
includeContactFields | boolean | true | Render first name, last name, and email above the form questions. |
submitLabel | string | "Submit" | Submit button label. |
submittingLabel | string | "Submitting…" | Submit button label while the action is pending. |
renderSuccess | (result) => ReactNode | default green box | Custom success state. |
classNames | { form?, input?, button?, … } | sensible Tailwind | Per-element class overrides. |
Input type detection
The component inspects each FormQuestion's fieldKind and field.options to pick the right HTML control:
| Signal | Renders |
|---|---|
field.options is non-empty | <select> |
fieldKind: 'checkbox' or 'boolean' | <input type="checkbox"> |
fieldKind: 'textarea', 'longtext', 'multiline' | <textarea> |
fieldKind: 'email' | <input type="email"> |
fieldKind: 'number', 'integer' | <input type="number"> |
fieldKind: 'date' | <input type="date"> |
fieldKind: 'tel', 'phone' | <input type="tel"> |
fieldKind: 'url' | <input type="url"> |
| anything else | <input type="text"> |
Hidden questions (hidden: true) and system fields (field.type === 'system') are filtered out automatically — the latter are handled by includeContactFields.
The action contract
Your action receives parsed DynamicFormValues:
{
contact?: { firstName, lastName, email }, // only if includeContactFields
fieldValues: [{ fieldId, questionId, value }, …], // one per non-system question
formData: FormData, // raw, in case you need extras
}A typical submitForm Server Action looks like:
'use server';
import { createFormResponse } from '@uidu/client';
import { uidu } from './uidu';
export async function submitForm(formId, values) {
const result = await createFormResponse(uidu, {
input: {
attributes: {
formId,
fieldValuesAttributes: values.fieldValues.map((fv) => ({
fieldId: fv.fieldId,
content: { value: fv.value },
})),
contactAttributes: values.contact && {
email: values.contact.email,
contactableAttributes: {
kind: 'person',
firstName: values.contact.firstName,
lastName: values.contact.lastName || undefined,
},
},
},
event: 'complete!',
},
});
return result?.errors?.length
? { ok: false as const, errors: result.errors }
: { ok: true as const, meta: { responseId: result?.formResponse?.id } };
}The same action shape works for contact forms, RSVPs (with a different mutation), donations (different mutation again), and job applications.
Styling
Defaults are reasonable Tailwind classes. Override per element with the classNames prop:
<DynamicForm
form={form}
action={action}
classNames={{
input: 'rounded-xl border-emerald-200 focus:border-emerald-500',
button: 'rounded-xl bg-emerald-600 hover:bg-emerald-700',
}}
/>For deeper customization (custom Input components, field-level adornments, complex layout), drop down to rendering the questions yourself — form.inputs.edges gives you everything <DynamicForm> uses internally.
Helper exports
import { getBlockShortname, type BlockComponent } from '@uidu/react';
// getBlockShortname(block) — extracts the canonical shortname from a PageBlock.
// Tries block.templateBlock.block.shortname first, then block.shortname.
//
// BlockComponent — type alias for ComponentType<{ block: PageBlock }>.
// Use it to type your own block component registries.Patterns
Per-route component maps
Different pages can use different block components. Keep the registry close to the page:
// app/blog/[slug]/page.tsx
const blogBlocks = {
Header: BlogHero,
Body: ProseBody,
CTA: NewsletterSignup,
};
export default async function BlogPost({ params }) {
const page = await getPage(uidu, { projectId, slug: params.slug });
return <PageBlocks pageBlocks={page?.pageBlocks} components={blogBlocks} />;
}Shared registry across pages
For consistency, hoist the registry into lib/:
// lib/blocks.ts
export const siteBlocks = {
Header: Hero,
feature1: Feature,
ModuloContatto: ContactForm,
/* ... */
};// any page
import { siteBlocks } from '@/lib/blocks';
<PageBlocks pageBlocks={page.pageBlocks} components={siteBlocks} />