uidu SDK
Paths

Public website

Build a public site on uidu, from an empty folder to CMS pages, a contact form and an events section, rendered statically.

Use this path when you're building something anyone can visit: a landing page, an organisation's site, a blog, an events calendar. The pages read public content with the public token. The one thing a visitor writes, a contact form, goes through a server action that holds the workspace's Bearer token.

By the end you'll have a Next.js site that renders pages from a uidu Site, takes contact-form submissions, lists events with a page for each one, and pre-renders all of it at build time.

Scaffold the app

npm create uidu-app@latest my-site

The default minimal template gives you Next.js 16 (App Router), Tailwind, a configured src/lib/uidu.ts and an .env.local filled in from the prompts. Its home page reads a legacy CMS Project with <PageBlocks> and asks for UIDU_PROJECT_ID and UIDU_LANDING_SLUG. Step 3 replaces that page with one that reads a Site, so you can leave those two empty. Other templates and every flag: create-uidu-app.

Already have a Next.js app? Add the packages instead: npm install @uidu/client @uidu/react swr (see Installation), then write the file in step 2 yourself.

Create the client

One client for the whole site. It reads with the public token and writes with the API key, so import it only from server code: Server Components, server actions, generateStaticParams.

// src/lib/uidu.ts
import { createClient } from '@uidu/client';

export const uidu = createClient({
  workspace: process.env.UIDU_WORKSPACE ?? '',
  publicToken: process.env.UIDU_PUBLIC_TOKEN,
  apiKey: process.env.UIDU_API_KEY, // server-only
});

Which token does what, where you get each one, and why apiKey never reaches the browser: Authentication.

Render a page from your Site

A Site holds your pages. Each page has its own field values and an ordered list of blocks, and each block has a kind (a block-kind Model) and its own fields, keyed by field shortname. Find the Site by the domain it's served on, get a page by slug, and render each block with the component for its kind. You write that component map yourself: <PageBlocks> from @uidu/react reads legacy Project pages, not Sites.

// src/lib/site.ts
import { cache } from 'react';
import { getSiteByDomain } from '@uidu/client';
import { uidu } from './uidu';

// The domain the Site is served on, as set in uidu.
export const getSite = cache(() => getSiteByDomain(uidu, { domain: 'www.acme.org' }));

// src/components/site-blocks.tsx
import type { ComponentType } from 'react';
import type { SiteBlock } from '@uidu/client';
import { RichText, toText } from '@uidu/react';

function Hero({ block }: { block: SiteBlock }) {
  return (
    <section>
      <h1>{toText(block.fields.title)}</h1>
      <RichText doc={block.fields.body} />
    </section>
  );
}

// Keyed by the block kind's name in uidu.
const components: Record<string, ComponentType<{ block: SiteBlock }>> = { Hero };

export function SiteBlocks({ blocks }: { blocks: SiteBlock[] }) {
  return blocks.map((block) => {
    const Component = components[block.model.name ?? ''];
    return Component ? <Component key={block.id} block={block} /> : null;
  });
}

// src/app/page.tsx
import { getPageBySlug } from '@uidu/client';
import { notFound } from 'next/navigation';
import { SiteBlocks } from '@/components/site-blocks';
import { getSite } from '@/lib/site';
import { uidu } from '@/lib/uidu';

export default async function HomePage() {
  const site = await getSite();
  if (!site) notFound();

  const page = await getPageBySlug(uidu, { siteId: site.id, slug: 'home' });
  if (!page) notFound();

  return <SiteBlocks blocks={page.blocks} />;
}

The block map is keyed on block.model.name because the API doesn't expose a block kind's shortname yet. Pages, singletons such as the header and footer, drafts and previews: CMS.

Add a contact form

Build the form in uidu, then get it with getForm and render it with <DynamicForm>, which picks an input for each question and hands the answers to your action. The action is a server action: like every write, createFormResponse needs the client with the API key. Every answer's content is { value }.

// src/app/contact/page.tsx
import { createFormResponse, getForm } from '@uidu/client';
import { DynamicForm, type DynamicFormResult, type DynamicFormValues } from '@uidu/react';
import { notFound } from 'next/navigation';
import { uidu } from '@/lib/uidu';

const FORM_ID = 'Z2lkOi8v…'; // from the Forms app, or `uidu forms list`

export default async function ContactPage() {
  const form = await getForm(uidu, { id: FORM_ID });
  if (!form) notFound();

  async function submit(values: DynamicFormValues): Promise<DynamicFormResult> {
    'use server';
    const result = await createFormResponse(uidu, {
      input: {
        attributes: {
          formId: FORM_ID,
          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!',
      },
    });

    if (result?.errors?.length) return { ok: false, errors: result.errors };
    return { ok: true, meta: { responseId: result?.formResponse?.id } };
  }

  return <DynamicForm form={form} action={submit} submitLabel="Send" />;
}

Validation failures come back in errors, not as a throw; <DynamicForm> shows them above the button. The input shape and drafts: Forms. The component's props: React components. Errors on every write: Writes.

List events, with a page for each

A listing with listEvents and a detail page with getEvent. Put the detail route under a scope path, /event/[id], not at the root: a root-level /[id] also catches requests like /favicon.ico and turns them into failed queries.

// src/app/events/page.tsx
import { listEvents } from '@uidu/client';
import Link from 'next/link';
import { uidu } from '@/lib/uidu';

export default async function EventsPage() {
  const events = await listEvents(uidu, { first: 50 });

  return (
    <ul>
      {events.map((event) => (
        <li key={event.id}>
          <Link href={`/event/${encodeURIComponent(event.id)}`}>{event.name}</Link>
        </li>
      ))}
    </ul>
  );
}

// src/app/event/[id]/page.tsx
import { getEvent } from '@uidu/client';
import { RichText } from '@uidu/react';
import { notFound } from 'next/navigation';
import { uidu } from '@/lib/uidu';

export default async function EventPage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;
  const event = await getEvent(uidu, { id: decodeURIComponent(id) });
  if (!event) notFound();

  return (
    <article>
      <h1>{event.name}</h1>
      {event.instance?.beginsAt && (
        <time>{new Date(event.instance.beginsAt as string).toLocaleString()}</time>
      )}
      <RichText doc={event.body} />
    </article>
  );
}

Instances, addresses, calendar links and RSVPs with createAttendance: Events.

Render every page at build time

generateStaticParams tells Next.js which pages to pre-render. listSitePages already returns all of a Site's published pages; events come one page at a time, so walk them all with paginate and the generated ListEventsDocument.

// src/app/[slug]/page.tsx — every Site page except the home page
import { getPageBySlug, listSitePages } from '@uidu/client';
import { notFound } from 'next/navigation';
import { SiteBlocks } from '@/components/site-blocks';
import { getSite } from '@/lib/site';
import { uidu } from '@/lib/uidu';

export const dynamicParams = false; // only the slugs below; anything else is a 404

export async function generateStaticParams() {
  const site = await getSite();
  if (!site) return [];
  const pages = await listSitePages(uidu, { siteId: site.id });
  return pages.flatMap((p) => (p.slug && p.slug !== 'home' ? [{ slug: p.slug }] : []));
}

export default async function SitePage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const site = await getSite();
  const page = site && (await getPageBySlug(uidu, { siteId: site.id, slug }));
  if (!page) notFound();
  return <SiteBlocks blocks={page.blocks} />;
}

// src/app/event/[id]/page.tsx — add next to the page component from step 5
import { paginate, ListEventsDocument } from '@uidu/client';

export async function generateStaticParams() {
  const events = await paginate(uidu, ListEventsDocument, {
    variables: { first: 100 },
    extract: (data) => data.currentWorkspace?.events,
  });
  return events.map((event) => ({ id: event.id }));
}

/ stays with app/page.tsx from step 3. With dynamicParams = false the root [slug] route only answers the slugs it was built with, so it doesn't catch stray requests. Events keep the default: one published after the build still renders on its first visit. How paginate walks a connection: Pagination & field values. A sitemap and revalidation on top of the same route: Dynamic CMS routes.

Deploy

Set the same three variables wherever the site builds and runs:

UIDU_WORKSPACE=acme
UIDU_PUBLIC_TOKEN=...   # public reads
UIDU_API_KEY=...        # the contact form's server action; server-only

They're needed at build time too, because generateStaticParams and the static pages query uidu during the build. Keep UIDU_API_KEY a plain server variable: never prefix it NEXT_PUBLIC_.

Where to go next

On this page