SEO

Metadane per strona, sitemapa budowana z drzewa opublikowanych stron i robots trzymający route edycyjny poza indeksem.

Pola SEO to treść, więc należą do redaktorów. Zadaniem Twojej aplikacji jest je odczytać i podać Next.js właściwe kształty.

Metadane strony

Każda strona niesie seoTitle, seoDescription, seoKeywords i displayName, każde wielojęzyczne. SDK nie dostarcza helpera do metadanych - generateMetadata to kod Twojego route'a i tak ma być: kanoniczny host, szablon tytułu i strategia OG to decyzje o Twoim serwisie, nie o CMS-ie.

// 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;
  // Tak jak w routingu, z prefiksem: prefiks JEST językiem.
  return buildPageMetadata(path);
}

buildPageMetadata jest Twoje. Odcina locale od ścieżki, czyta pola SEO przez public.page.get i zwraca obiekt Metadata Next.js:

// services/seo.ts (skrócone)
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 i pickLocalized też są Twoje - czterdzieści linijek w lib/, współdzielone z route'em catch-all i sitemapą. SITE_URL to Twoja własna zmienna środowiskowa; CMS nigdy nie przechowuje Twojego hosta.

Fallbackuj świadomie: seoTitle, potem displayName, potem nazwa serwisu - dzięki temu niedokończona strona też ma tytuł. Domyślny obrazek Open Graph pochodzi z brandingu w public.siteConfig.

Sitemapa i robots to Twoje route'y

cmssy nie dostarcza helpera do sitemapy i tak ma być - to model headless działający zgodnie z założeniem. Sitemapa to zapytanie plus zmapowanie na kształt, którego chce Twój framework. CMS nie ma czego szukać w Twoim pliku route'a.

Drzewo opublikowanych stron już jest sitemapą. Odpytaj je i zmapuj:

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

Dwa filtry, dwa różne powody. publishedAt - public.page.list zwraca też szkice, a te nie mają czego szukać w sitemapie. notFoundPageId - strona 404 jest opublikowana jak każda inna, a wypisanie jej to zaproszenie crawlerów do zaindeksowania błędu; workspace już mówi, która to strona, przez siteConfig.

Trzymaj to jako helper, nie jako ciało route'a

Odpytywanie i mapowanie włóż do services/, a plik route'a niech zostanie czterolinijkowy. Produkty czy kategorie z rekordów modeli nie są stronami, więc drzewo stron o nich nie wie - gdy je dodasz, chcesz jednej funkcji władającej kształtem URL-i i hreflangiem dla wszystkich wpisów, a nie dwóch miejsc, które mogą się nie zgodzić co do domeny.

To też czyni ten wzorzec przenośnym. Ten sam helper, z innym kształtem zwracanym, zasila aplikację w Astro albo Remiksie - zapytanie jest częścią wielokrotnego użytku, route jest adapterem.

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

Zablokowanie /cmssy-edit/ nie jest opcjonalne. Ten route serwuje treść roboczą i montuje edytor. Zaindeksowany, wrzuciłby nieopublikowane teksty do wyników wyszukiwania i wypozycjonował duplikat każdej Twojej strony.

Oba route'y muszą być dynamiczne

export const dynamic = "force-dynamic";

Sitemapa i robots czytają żywy stan CMS-a. Wygenerowane statycznie przy buildzie, Twoja sitemapa zamarza na dniu deployu i po cichu przestaje wymieniać wszystko, co opublikowano później.

Następne kroki

  • i18n - jak locale kształtują URL-e i hreflang.
  • Route'y i strony - gdzie żyje generateMetadata.
  • Branding - konfiguracja serwisu stojąca za obrazkami Open Graph.