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-siteThe 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-onlyThey'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
- More public content ā Stories for a blog, Donations for campaigns and a donate form, Help center for an FAQ, Jobs for a careers page.
- Recipes ā Multi-step forms, File upload, Search as you type, and the demo app, a full Next.js site built this way.
- Reference ā every function in the client reference, every component and hook in the React reference.