Now with AI-powered page building via MCP Server

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. buildCmssyMetadata turns them into a Next.js Metadata object:

// app/[[...path]]/page.tsx
import { buildCmssyMetadata } from "@cmssy/next/server";
import { cmssy } from "@/cmssy.config";

export async function generateMetadata({ params }) {
  const { path } = await params;
  // Pass the path as routed, prefix and all: the prefix IS the language.
  return buildCmssyMetadata(cmssy, path);
}

It resolves the locale from the path, fetches the page's SEO fields, and fills in the canonical URL, hreflang alternates, Open Graph and Twitter card from your site config's branding. Fields the editor left empty fall back to the display name, then to the site name - so a page always has a title, even a half-finished one.

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 { resolveSiteLocales } from "@/services/site";
import { localizedPath } from "@/lib/locale-path";

export const dynamic = "force-dynamic";

export default async function sitemap() {
  const [{ defaultLocale, locales }, pages] = await Promise.all([
    resolveSiteLocales(),
    listPublicPages(),
  ]);

  return pages
    .filter((page) => page.publishedAt)
    .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 details carry weight. filter(page => page.publishedAt) - only published entries belong in a sitemap. And exclude your 404 page: a not-found route listed for crawlers is an invitation to index an error.

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 generateMetadata lives.
  • Branding - the site config behind Open Graph images.