Jetzt mit KI-gestütztem Page Building über den MCP-Server

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. buildCmssyMetadata macht daraus ein Next.js-Metadata-Objekt:

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

export async function generateMetadata({ params }) {
  const { path } = await params;
  // Pfad wie geroutet übergeben, samt Präfix: das Präfix IST die Sprache.
  return buildCmssyMetadata(cmssy, path);
}

Es löst das Locale aus dem Pfad auf, holt die SEO-Felder der Seite und füllt Canonical-URL, hreflang-Alternativen, Open Graph und Twitter-Card aus dem Branding deiner Site-Config. Leer gelassene Felder fallen auf den Display-Namen zurück, dann auf den Site-Namen - eine Seite hat also immer einen Titel, auch eine halbfertige.

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

Zwei Details wiegen schwer. filter(page => page.publishedAt) - nur Veröffentlichtes gehört in eine Sitemap. Und schließe deine 404-Seite aus: eine Nicht-gefunden-Route für Crawler zu listen ist eine Einladung, einen Fehler zu indexieren.

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