uidudevelopers
Domains

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)
ContainerSiteProject
Page typea page-kind ModelTemplate
Pagea ModelItem of a page typePage
Block kinda block-kind ModelBlock
Block instancea block ModelItem placed on a pagePageBlock
FunctionsgetSiteByDomain, getSite, getPageBySlug, listSitePages, getSingletonBlockgetPage, 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 shortname
  • fields is the item's field values keyed by field shortname (page.fields.title), both on a page and on each block.
  • model is the page type or block kind: { id, name, kind }, kind being page, block or singleton.
  • blocks come back sorted by position. placementId is the placement itself (the child FieldValue): what reordering or removing a block targets.
  • status is Draft, Published or Scheduled. getPageBySlug returns published pages only, and listSitePages published pages only, unless you pass includeDrafts: true.
  • listSitePages returns SitePageSummary[] (the page without fields and blocks), leaves singletons out, and keeps one page type with pageType — 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.
  • getSingletonBlock finds a singleton by its Model's shortname (header, footer) and returns the same SitePage shape, 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 (each string | null);
  • pageBlocks[] — each with its own shortname and templateBlock.block.shortname, the block "type" that <PageBlocks> keys on;
  • fieldValues[] — field.shortname is the key, content the value (JSON), and linkedRecord the record a field points at. When that record is a Form, its questions come along (as linkedRecord.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.
  • getSiteByDomain returns null for 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.
  • includeDrafts is 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 on templateBlock.block.shortname (falling back to the block's own shortname); inside a block, useFields(block.fieldValues) gives { shortname: value }. Both: React components.
  • The project id comes from uidu projects list or listProjects. 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.

FunctionKindAuthCLIDescription
createSitewriteBearersites createProvisions 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.
getPageBySlugreadpublic token—A page of the Site by slug, with its field values and its ordered blocks. Only published pages unless includeDrafts.
getSingletonBlockreadpublic token—A singleton of the Site — the header, the footer, the nav — by its Model's shortname, with its field values and blocks.
getSitereadpublic token—A Site by its id — for previews, where there is no domain to go by.
getSiteByDomainreadpublic token—The Site served on domain (e.g. www.acme.org), or null.
listSitePagesreadpublic 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:

Entitylistgetcreateupdatedelete
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.

FunctionKindAuthCLIDescription
createFieldValuewriteBearerfield createCreate 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.
createPagewriteBearerpage createCreate a legacy CMS page from a templateId, with slug and optional metadataAttributes (there is no projectId attribute). Returns the payload (with errors) or null.
createPageBlockwriteBearerblock createPlace a template block on a page (pageId, templateBlockId, both numeric). Returns the payload (with errors) or null.
createProjectwriteBearerproject createCreate a legacy CMS project (name, slug, domain). Returns the payload (with errors) or null; the new project is at project.node.
getPagereadpublic tokenpage getA legacy CMS page by project and slug, with its metadata and ordered pageBlocks (and any linked form's questions), or null.
getTemplatereadpublic token—A legacy CMS template by id, with every page it holds and their blocks, or null.
listPagesreadpublic tokenpages listThe pages of a legacy CMS project (id and slug), one Relay page at a time; use paginate with ListPagesDocument for all of them.
listProjectsreadpublic tokenprojects listThe workspace's CMS projects. A page lives in one, and getPage needs its id.

Types: Page, Template

CLI:

Entitylistgetcreateupdatedelete
projects✓————

On this page