SEO
Métadonnées par page, sitemap construit depuis l'arbre des pages publiées, et fichier robots qui garde la route d'édition hors de l'index.
Les champs SEO sont du contenu : ils appartiennent donc aux éditeurs. Le rôle de votre application est de les lire et de donner à Next.js les bonnes formes.
Métadonnées de page
Chaque page porte seoTitle, seoDescription, seoKeywords et displayName, tous multilingues. Le SDK ne fournit aucun helper de métadonnées : generateMetadata est le code de votre route, et c'est délibéré - hôte canonique, gabarit de titre et stratégie OG sont des décisions sur votre site, pas sur le 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;
// Tel que routé, préfixe compris : le préfixe EST la langue.
return buildPageMetadata(path);
}buildPageMetadata est à vous. Il détache la locale du chemin, lit les champs SEO via public.page.get et renvoie un objet Metadata de Next.js :
// services/seo.ts (abrégé)
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 et pickLocalized sont aussi vos helpers : une quarantaine de lignes dans lib/, partagées avec la route catch-all et le sitemap. SITE_URL est votre propre variable d'environnement ; le CMS ne stocke jamais votre hôte.
Prévoyez les replis : seoTitle, puis displayName, puis le nom du site - ainsi une page inachevée a quand même un titre. L'image Open Graph par défaut vient du branding de public.siteConfig.
Sitemap et robots sont vos routes
cmssy ne fournit pas de helper de sitemap, et c'est le modèle headless qui fonctionne comme prévu. Un sitemap, c'est une requête plus une mise en forme adaptée à votre framework : le CMS n'a rien à faire dans votre fichier de route.
L'arbre des pages publiées est déjà le sitemap. Interrogez-le, transformez-le :
// 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),
}));
}Deux filtres, deux raisons distinctes. publishedAt : list renvoie aussi les brouillons, qui n'ont rien à faire dans un sitemap. notFoundPageId : la page 404 est publiée comme les autres, et la lister invite les crawlers à indexer une erreur - l'espace de travail dit déjà laquelle c'est, via siteConfig.
Gardez-le comme helper, pas comme corps de route
Mettez la requête et la transformation dans services/ et laissez le fichier de route à quatre lignes. Les produits ou catégories issus d'enregistrements ne sont pas des pages : l'arbre ne les connaît pas. Quand vous les ajouterez, vous voudrez une seule fonction propriétaire de la forme des URL et du hreflang, plutôt que deux endroits pouvant diverger sur le domaine.
C'est aussi ce qui rend le motif portable : le même helper, avec une autre forme de retour, alimente une application Astro ou Remix. La requête est la partie réutilisable, la route n'est que l'adaptateur.
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`,
};
}Interdire /cmssy-edit/ n'est pas optionnel. Cette route sert du contenu brouillon et monte l'éditeur. Indexée, elle placerait des textes non publiés dans les résultats de recherche et ferait remonter un doublon de chacune de vos pages.
Les deux routes doivent être dynamiques
export const dynamic = "force-dynamic";Sitemap et robots lisent l'état vivant du CMS. Généré statiquement à la compilation, votre sitemap se fige au jour du déploiement et cesse discrètement de lister tout ce qui a été publié depuis.
Étapes suivantes
- i18n - comment les locales façonnent les URL et le hreflang.
- Routes et pages - où vit
generateMetadata. - Branding - la configuration derrière les images Open Graph.