SEO
Metadatos por página, un sitemap construido desde el árbol de páginas publicadas y un robots que mantiene la ruta de edición fuera del índice.
Los campos SEO son contenido, así que pertenecen a los editores. El trabajo de tu app es leerlos y darle a Next.js las formas correctas.
Metadatos de página
Cada página lleva seoTitle, seoDescription, seoKeywords y displayName, todos multilingües. El SDK no trae helper de metadatos: generateMetadata es código de tu ruta, y es deliberado - host canónico, plantilla de título y estrategia OG son decisiones sobre tu sitio, no sobre el 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;
// Tal como se enrutó, con prefijo: el prefijo ES el idioma.
return buildPageMetadata(path);
}buildPageMetadata es tuyo. Separa el locale de la ruta, lee los campos SEO vía public.page.get y devuelve un objeto Metadata de Next.js:
// services/seo.ts (abreviado)
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 y pickLocalized también son helpers tuyos: unas cuarenta líneas en lib/, compartidas con la ruta catch-all y el sitemap. SITE_URL es tu propia variable de entorno; el CMS nunca guarda tu host.
Cae de vuelta a propósito: seoTitle, luego displayName, luego el nombre del sitio, para que una página a medias tenga título igualmente. La imagen Open Graph por defecto viene del branding de public.siteConfig.
El sitemap y robots son tus rutas
cmssy no trae un helper de sitemap, y así es como el modelo headless debe funcionar. Un sitemap es una consulta más un mapeo a la forma que pide tu framework: el CMS no pinta nada en tu archivo de ruta.
El árbol de páginas publicadas ya es el sitemap. Consulta y mapea:
// 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),
}));
}Dos filtros, dos motivos distintos. publishedAt: list devuelve también borradores, que no pintan nada en un sitemap. notFoundPageId: la página 404 está publicada como cualquier otra, y listarla invita a los crawlers a indexar un error; el workspace ya dice cuál es, vía siteConfig.
Mantenlo como helper, no como cuerpo de la ruta
Pon la consulta y el mapeo en services/ y deja el archivo de ruta en cuatro líneas. Los productos o categorías de registros de modelos no son páginas, así que el árbol no los conoce: cuando los añadas querrás una sola función dueña de la forma de las URL y del hreflang, no dos sitios que puedan discrepar sobre el dominio.
Eso mismo hace el patrón portable: el mismo helper, con otra forma de retorno, alimenta una app de Astro o Remix. La consulta es la parte reutilizable; la ruta es solo el adaptador.
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`,
};
}Bloquear /cmssy-edit/ no es opcional. Esa ruta sirve contenido borrador y monta el editor. Indexada, metería textos sin publicar en los resultados de búsqueda y posicionaría un duplicado de cada página que tienes.
Ambas rutas deben ser dinámicas
export const dynamic = "force-dynamic";Sitemap y robots leen el estado vivo del CMS. Generados estáticamente en el build, tu sitemap se congela el día del despliegue y deja de listar en silencio todo lo publicado desde entonces.
Siguientes pasos
- i18n: cómo los locales dan forma a URL y hreflang.
- Rutas y páginas: dónde vive
generateMetadata. - Branding: la configuración tras las imágenes de Open Graph.