Ahora con creación de páginas por IA vía el servidor MCP

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.