SEO
Per-page metadata, a sitemap built from the published page tree, and a robots file that keeps the edit route out of the index.
SEO fields are content, so editors own them. Your app's job is to read them and hand Next.js the right shapes.
Page metadata
Every page carries seoTitle, seoDescription, seoKeywords and displayName, each multilingual. The SDK ships no metadata helper - generateMetadata is your route's own code, and that is deliberate: canonical host, title template and OG strategy are decisions about your site, not about the CMS.
// app/[[...path]]/page.tsx
import type { Metadata } from "next";
import { buildPageMetadata } from "@/services/seo";
type PageProps = { params: Promise<{ path?: string[] }> };
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { path } = await params;
// As routed, prefix and all: the prefix IS the language.
return buildPageMetadata(path);
}buildPageMetadata is yours. It splits the locale off the path, reads the SEO fields through public.page.get, and returns a Next.js Metadata object:
// services/seo.ts (abridged)
const { locale, path: rest } = splitLocaleFromPath(path, { defaultLocale, locales });
const slug = "/" + (rest ?? []).join("/");
const meta = (await publicRequest(PAGE_META_QUERY, { workspaceSlug, slug })).public.page.get;
const title =
pickLocalized(meta?.seoTitle, locale, defaultLocale) ||
pickLocalized(meta?.displayName, locale, defaultLocale) ||
siteName;
return {
title,
description: pickLocalized(meta?.seoDescription, locale, defaultLocale) || undefined,
keywords: meta?.seoKeywords?.length ? meta.seoKeywords : undefined,
alternates: {
canonical: `${SITE_URL}${localizedPath(slug, locale, defaultLocale)}`,
languages: Object.fromEntries(
locales.map((l) => [l, `${SITE_URL}${localizedPath(slug, l, defaultLocale)}`]),
),
},
openGraph: { title, images: siteConfig?.branding?.ogImageUrl },
};splitLocaleFromPath, localizedPath and pickLocalized are your app's helpers too - forty lines in lib/, shared with the catch-all route and the sitemap. SITE_URL is your own env var; the CMS never stores your host.
Fall back deliberately: seoTitle, then displayName, then the site name - so a half-finished page still has a title. The Open Graph default comes from public.siteConfig's branding.
Sitemap and robots are your routes
cmssy does not ship a sitemap helper, and that is the headless model working as intended. A sitemap is a query plus a mapping to whatever shape your framework wants - the CMS has no business owning your route file.
The published page tree already is the sitemap. Query it, map it:
// app/sitemap.ts
import { listPublicPages } from "@/services/pages";
import { fetchSiteConfig, resolveSiteLocales } from "@/services/site";
import { localizedPath } from "@/lib/locale-path";
export const dynamic = "force-dynamic";
export default async function sitemap() {
const [{ defaultLocale, locales }, pages, siteConfig] = await Promise.all([
resolveSiteLocales(),
listPublicPages(),
fetchSiteConfig(),
]);
const notFoundPageId = siteConfig?.notFoundPageId ?? null;
return pages
.filter((page) => page.publishedAt && page.id !== notFoundPageId)
.map((page) => ({
url: `${SITE_URL}${localizedPath(page.slug, defaultLocale, defaultLocale)}`,
lastModified: new Date(page.updatedAt ?? page.publishedAt),
alternates: {
languages: Object.fromEntries(
locales.map((l) => [l, `${SITE_URL}${localizedPath(page.slug, l, defaultLocale)}`]),
),
},
}));
}Two filters, two different reasons. publishedAt - public.page.list returns drafts too, and they have no business in a sitemap. notFoundPageId - the 404 page is published like any other, and listing it invites crawlers to index an error; the workspace already says which page it is, through siteConfig.
Keep it a helper, not a route body
Put the querying and mapping in services/ and let the route file stay four lines. Products or categories from model records are not pages, so the page tree does not know about them - when you add them, you want one function that owns URL shape and hreflang for every entry, not two places that can disagree about the domain.
This is also what makes the pattern portable. The same helper, with a different return shape, feeds an Astro or Remix app - the query is the reusable part, the route is the adapter.
Robots
// app/robots.ts
export const dynamic = "force-dynamic";
export default function robots() {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/cmssy-edit/", "/api/"],
},
sitemap: `${SITE_URL}/sitemap.xml`,
};
}Disallowing /cmssy-edit/ is not optional. That route serves draft content and mounts the editor. Indexed, it would put unpublished copy in search results and rank a duplicate of every page you have.
Both routes must be dynamic
export const dynamic = "force-dynamic";Sitemaps and robots read live CMS state. Statically generated at build time, your sitemap freezes on the day you deployed and quietly stops listing everything published since.
Next steps
- i18n - how locales shape URLs and hreflang.
- Routes and pages - where
generateMetadatalives. - Branding - the site config behind Open Graph images.