CMS
Read Sites (the new CMS) and legacy CMS Projects with typed GraphQL functions.
- Use it for: Public website · Server integration
- Reads: public token — works in the browser
- Writes: Bearer (
apiKey) — server-side only - CLI:
uidu projects,uidu sites - Auth: what each token can do and where it may run — Authentication
The CMS is where a workspace keeps its website: pages, the blocks placed on them, and the header
and footer they share. uidu has two CMS generations and @uidu/client reads both; new sites go on
Sites.
Mental model
| Sites (new CMS) | Projects (legacy CMS) | |
|---|---|---|
| Container | Site | Project |
| Page type | a page-kind Model | Template |
| Page | a ModelItem of a page type | Page |
| Block kind | a block-kind Model | Block |
| Block instance | a block ModelItem placed on a page | PageBlock |
| Functions | getSiteByDomain, getSite, getPageBySlug, listSitePages, getSingletonBlock | getPage, listPages, getTemplate |
New sites: use Sites. Projects keep working unchanged for sites already deployed on them, and a Project can be migrated to a Site from the CMS app. How Sites sit on top of Models: Data model.
Sites. A Site is { id, name, shortname, domain, preferences } — preferences holds its theme
settings as JSON. You find it by the domain it is served on (getSiteByDomain) or by id
(getSite, for previews, where there is no domain to go by). Under it:
Site
├── page SitePage: { id, name, slug, publishedAt, status, model, fields, blocks }
│ └── blocks[] SiteBlock: { id, name, model, fields, position, placementId }
└── singleton header, footer, nav — same SitePage shape, fetched by shortnamefieldsis the item's field values keyed by field shortname (page.fields.title), both on a page and on each block.modelis the page type or block kind:{ id, name, kind },kindbeingpage,blockorsingleton.blockscome back sorted byposition.placementIdis the placement itself (the child FieldValue): what reordering or removing a block targets.statusisDraft,PublishedorScheduled.getPageBySlugreturns published pages only, andlistSitePagespublished pages only, unless you passincludeDrafts: true.listSitePagesreturnsSitePageSummary[](the page withoutfieldsandblocks), leaves singletons out, and keeps one page type withpageType— matched against the Model's id or its name. It reads every page of the Site and filters on your side of the call; pages come back in creation order, so sort them yourself.getSingletonBlockfinds a singleton by its Model's shortname (header,footer) and returns the sameSitePageshape, or null.
Block kinds have a stable shortname, but the uidu API does not expose it on
Model yet — key your component map on block.model.name until it does.
<PageBlocks> from @uidu/react renders legacy Project pages only; for a
Site you write the map yourself (below).
Projects (legacy). Project → Template → Page → PageBlock, each PageBlock with
fieldValues[]. getPage returns the page with:
metadata—metaTitle,metaDescription,metaKeywords,metaImage(eachstring | null);pageBlocks[]— each with its ownshortnameandtemplateBlock.block.shortname, the block "type" that<PageBlocks>keys on;fieldValues[]—field.shortnameis the key,contentthe value (JSON), andlinkedRecordthe record a field points at. When that record is a Form, its questions come along (aslinkedRecord.inputs), ready for<DynamicForm>.
listPages returns { id, name, slug } only — cheap, but one Relay page at a time;
getTemplate returns a template with every page that uses it and their blocks (without page
metadata or linked forms). Page and Template are inferred from the live schema, so they
update the next time codegen runs.
Both generations. Every function on this page is server-safe and has no React or framework
dependency — Node, Bun, Deno, the browser, edge runtimes. Every read works with a publicToken
client. A get* returns null when the page doesn't exist or isn't published; a failed request
throws a UiduError (GRAPHQL_ERROR for schema or permission errors, NETWORK_ERROR for
connectivity) — see Errors. Writes (createSite, and createProject,
createPage, createPageBlock, createFieldValue on Projects) need the Bearer client and return
their payload with errors instead of throwing: Writes.
Use cases
Every page of a Site, with its header
Goal: one route renders any page of the Site by slug, pre-rendered at build time, with the header singleton on top.
// src/lib/site.ts — one request per render, shared by layout and page
import { cache } from 'react';
import { getSiteByDomain } from '@uidu/client';
import { uidu } from './uidu';
export const getSite = cache(() => getSiteByDomain(uidu, { domain: 'www.acme.org' }));
// src/app/[slug]/page.tsx
import type { ComponentType } from 'react';
import { notFound } from 'next/navigation';
import {
getPageBySlug,
getSingletonBlock,
listSitePages,
type SiteBlock,
} from '@uidu/client';
import { RichText, toText } from '@uidu/react';
import { getSite } from '@/lib/site';
import { uidu } from '@/lib/uidu';
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 (block.model.name).
const components: Record<string, ComponentType<{ block: SiteBlock }>> = { Hero };
export async function generateStaticParams() {
const site = await getSite();
if (!site) return [];
const pages = await listSitePages(uidu, { siteId: site.id });
return pages.filter((p) => p.slug).map((p) => ({ slug: p.slug! }));
}
export default async function SitePageRoute({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const site = await getSite();
if (!site) notFound();
const [page, header] = await Promise.all([
getPageBySlug(uidu, { siteId: site.id, slug }),
getSingletonBlock(uidu, { siteId: site.id, shortname: 'header' }),
]);
if (!page) notFound();
return (
<>
{header && <header>{toText(header.fields.title)}</header>}
<main>
{page.blocks.map((block) => {
const Component = components[block.model.name ?? ''];
return Component ? <Component key={block.id} block={block} /> : null;
})}
</main>
</>
);
}- A block whose kind isn't in the map renders nothing — add a fallback while you build the map out.
getSiteByDomainreturnsnullfor a domain no Site is served on.cache()makes the layout and the page share one request.- The same steps from an empty folder, with a contact form and events: Public website.
A blog listing, a sitemap and draft previews
Goal: list the posts of one page type, emit a sitemap, and let editors preview unpublished pages.
// src/app/blog/page.tsx
import Link from 'next/link';
import { listSitePages } from '@uidu/client';
import { uidu } from '@/lib/uidu';
import { getSite } from '@/lib/site';
export default async function BlogPage() {
const site = await getSite();
if (!site) return null;
const posts = await listSitePages(uidu, { siteId: site.id, pageType: 'Blog post' });
const newest = [...posts].sort((a, b) =>
(b.publishedAt ?? '').localeCompare(a.publishedAt ?? ''),
);
return (
<ul>
{newest.map((p) => (
<li key={p.id}>
<Link href={`/${p.slug}`}>{p.name}</Link>
</li>
))}
</ul>
);
}
// src/app/sitemap.ts
import type { MetadataRoute } from 'next';
import { listSitePages } from '@uidu/client';
import { uidu } from '@/lib/uidu';
import { getSite } from '@/lib/site';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const site = await getSite();
if (!site?.domain) return [];
const pages = await listSitePages(uidu, { siteId: site.id });
return pages
.filter((p) => p.slug)
.map((p) => ({
url: `https://${site.domain}/${p.slug}`,
lastModified: p.publishedAt ?? undefined,
}));
}
// In the page route: drafts only while Next's draft mode is on.
import { draftMode } from 'next/headers';
const { isEnabled } = await draftMode();
const page = await getPageBySlug(uidu, { siteId: site.id, slug, includeDrafts: isEnabled });pageType: 'Blog post'matches the page type's name; its Model id works too and survives a rename.includeDraftsis not gated by the token — anyone who can read the Site can pass it. Turn it on only behind your own preview check, as above.- In a preview there may be no domain yet: resolve the Site with
getSite(uidu, { id })instead.
A site still on a legacy Project
Goal: render every page of a Project with <PageBlocks>, statically, including a contact form a
block links to.
// src/app/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { getPage, listPages, paginate, ListPagesDocument } from '@uidu/client';
import { PageBlocks } from '@uidu/react';
import { uidu } from '@/lib/uidu';
import { ContactForm, Feature, Hero } from '@/components/blocks';
const projectId = process.env.UIDU_PROJECT_ID!;
export async function generateStaticParams() {
// listPages(uidu, { projectId }) is one page of results; walk them all.
const pages = await paginate(uidu, ListPagesDocument, {
variables: { projectId, first: 100 },
extract: (data) => data.currentWorkspace?.project?.pages,
});
return pages
.filter((p) => p.slug && p.slug !== 'home')
.map((p) => ({ slug: p.slug! }));
}
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const page = await getPage(uidu, { projectId, slug });
return {
title: page?.metadata?.metaTitle ?? page?.name ?? undefined,
description: page?.metadata?.metaDescription ?? undefined,
};
}
export default async function CmsPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const page = await getPage(uidu, { projectId, slug });
if (!page) notFound(); // doesn't exist, or unpublished
return (
<PageBlocks
pageBlocks={page.pageBlocks}
components={{ Header: Hero, feature1: Feature, ModuloContatto: ContactForm }}
/>
);
}
// Inside ContactForm ({ block }): the form a field links to.
const formField = block.fieldValues?.find(
(fv) => fv.linkedRecord && 'inputs' in fv.linkedRecord,
);<PageBlocks>keys each block ontemplateBlock.block.shortname(falling back to the block's ownshortname); inside a block,useFields(block.fieldValues)gives{ shortname: value }. Both: React components.- The project id comes from
uidu projects listorlistProjects. More on this route, ISR and a sitemap: Dynamic CMS routes. getTemplate(uidu, { projectId, templateId })returns the template with all its pages and their blocks in one request — handy to render a section of pages that share a layout.
API at a glance
Every @uidu/client function in CMS — Sites, generated from the source — the full reference has the rest of the SDK.
| Function | Kind | Auth | CLI | Description |
|---|---|---|---|---|
createSite | write | Bearer | sites create | Provisions a new Site in the workspace/account (tenantId). Bearer token (apiKey) — this is workspace admin territory, never a public write. Returns the mutation payload as-is, including errors. |
getPageBySlug | read | public token | — | A page of the Site by slug, with its field values and its ordered blocks. Only published pages unless includeDrafts. |
getSingletonBlock | read | public token | — | A singleton of the Site — the header, the footer, the nav — by its Model's shortname, with its field values and blocks. |
getSite | read | public token | — | A Site by its id — for previews, where there is no domain to go by. |
getSiteByDomain | read | public token | — | The Site served on domain (e.g. www.acme.org), or null. |
listSitePages | read | public token | — | The Site's pages, for navigation and sitemaps: published only unless includeDrafts, singletons (header, footer) left out. |
Types: Site, SiteBlock, SiteFields, SiteModel, SitePage, SitePageSummary
CLI:
| Entity | list | get | create | update | delete |
|---|---|---|---|---|---|
sites | — | — | ✓ | — | — |
API at a glance
Every @uidu/client function in CMS — Projects (legacy), generated from the source — the full reference has the rest of the SDK.
| Function | Kind | Auth | CLI | Description |
|---|---|---|---|---|
createFieldValue | write | Bearer | field create | Create a field value, e.g. set a page block's field (fieldId, content). Returns the payload (with errors) or null; the new value is at fieldValue.node. |
createPage | write | Bearer | page create | Create a legacy CMS page from a templateId, with slug and optional metadataAttributes (there is no projectId attribute). Returns the payload (with errors) or null. |
createPageBlock | write | Bearer | block create | Place a template block on a page (pageId, templateBlockId, both numeric). Returns the payload (with errors) or null. |
createProject | write | Bearer | project create | Create a legacy CMS project (name, slug, domain). Returns the payload (with errors) or null; the new project is at project.node. |
getPage | read | public token | page get | A legacy CMS page by project and slug, with its metadata and ordered pageBlocks (and any linked form's questions), or null. |
getTemplate | read | public token | — | A legacy CMS template by id, with every page it holds and their blocks, or null. |
listPages | read | public token | pages list | The pages of a legacy CMS project (id and slug), one Relay page at a time; use paginate with ListPagesDocument for all of them. |
listProjects | read | public token | projects list | The workspace's CMS projects. A page lives in one, and getPage needs its id. |
Types: Page, Template
CLI:
| Entity | list | get | create | update | delete |
|---|---|---|---|---|---|
projects | ✓ | — | — | — | — |