SEO

Metadaten pro Seite, eine Sitemap aus dem veröffentlichten Seitenbaum und eine robots-Datei, die die Edit-Route aus dem Index hält.

SEO-Felder sind Inhalt, gehören also der Redaktion. Die Aufgabe deiner App ist, sie zu lesen und Next.js die richtigen Formen zu geben.

Seiten-Metadaten

Jede Seite trägt seoTitle, seoDescription, seoKeywords und displayName, jeweils mehrsprachig. Das SDK liefert keinen Metadaten-Helper - generateMetadata ist der Code deiner Route, und das mit Absicht: kanonischer Host, Titel-Template und OG-Strategie sind Entscheidungen über deine Site, nicht über das 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;
  // Wie geroutet, samt Präfix: das Präfix IST die Sprache.
  return buildPageMetadata(path);
}

buildPageMetadata gehört dir. Es trennt das Locale vom Pfad, liest die SEO-Felder über public.page.get und gibt ein Next.js-Metadata-Objekt zurück:

// services/seo.ts (gekürzt)
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 und pickLocalized sind ebenfalls deine Helper - vierzig Zeilen in lib/, geteilt mit der Catch-all-Route und der Sitemap. SITE_URL ist deine eigene Env-Variable; das CMS speichert deinen Host nie.

Falle bewusst zurück: seoTitle, dann displayName, dann der Site-Name - so hat auch eine halbfertige Seite einen Titel. Das Open-Graph-Standardbild kommt aus dem Branding in public.siteConfig.

Sitemap und Robots sind deine Routen

cmssy liefert keinen Sitemap-Helper - und genau so soll das Headless-Modell funktionieren. Eine Sitemap ist eine Query plus eine Abbildung auf die Form, die dein Framework will. Das CMS hat in deiner Route-Datei nichts verloren.

Der veröffentlichte Seitenbaum ist bereits die Sitemap. Abfragen, abbilden:

// 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)}`]),
        ),
      },
    }));
}

Zwei Filter, zwei verschiedene Gründe. publishedAt - public.page.list liefert auch Entwürfe, und die haben in einer Sitemap nichts verloren. notFoundPageId - die 404-Seite ist veröffentlicht wie jede andere, und sie zu listen lädt Crawler ein, einen Fehler zu indexieren; welche Seite das ist, sagt der Workspace bereits über siteConfig.

Halte es als Helper, nicht als Route-Body

Leg Abfrage und Abbildung nach services/ und lass die Route-Datei vier Zeilen bleiben. Produkte oder Kategorien aus Model-Records sind keine Seiten, der Seitenbaum kennt sie also nicht - kommen sie dazu, willst du eine Funktion, der URL-Form und hreflang für alle Einträge gehören, statt zweier Stellen, die sich über die Domain uneins sein können.

Genau das macht das Muster portabel. Derselbe Helper mit anderer Rückgabeform speist eine Astro- oder Remix-App - die Query ist der wiederverwendbare Teil, die Route ist der 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`,
  };
}

/cmssy-edit/ zu sperren ist nicht optional. Diese Route liefert Entwurfsinhalte und mountet den Editor. Indexiert würde sie unveröffentlichte Texte in Suchergebnisse bringen und ein Duplikat jeder deiner Seiten ranken.

Beide Routen müssen dynamisch sein

export const dynamic = "force-dynamic";

Sitemap und Robots lesen den Live-Zustand des CMS. Zur Build-Zeit statisch erzeugt friert deine Sitemap auf dem Deploy-Tag ein und listet stillschweigend nichts mehr, was seither veröffentlicht wurde.

Nächste Schritte