uidudevelopers
ToolsReact

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 typeOutput
docwrapping <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>
textbare text node, marks wrap inside-out

Supported text marks

Tiptap markOutput
bold / strong<strong>
italic / em<em>
underline<u>
strike / strikethrough<s>
codeinline <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

PropTypeDefaultDescription
formForm—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? }.
includeContactFieldsbooleantrueRender first name, last name, and email above the form questions.
submitLabelstring"Submit"Submit button label.
submittingLabelstring"Submitting…"Submit button label while the action is pending.
renderSuccess(result) => ReactNodedefault green boxCustom success state.
classNames{ form?, input?, button?, … }sensible TailwindPer-element class overrides.

Input type detection

The component inspects each FormQuestion's fieldKind and field.options to pick the right HTML control:

SignalRenders
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} />

On this page