Teraz z AI - twórz strony przez serwer MCP

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. buildCmssyMetadata zamienia je w obiekt Metadata Next.js:

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

export async function generateMetadata({ params }) {
  const { path } = await params;
  // Przekaż ścieżkę tak jak w routingu, z prefiksem: prefiks JEST językiem.
  return buildCmssyMetadata(cmssy, path);
}

Rozwiązuje locale ze ścieżki, pobiera pola SEO strony i uzupełnia kanoniczny URL, alternatywy hreflang, Open Graph i kartę Twittera z brandingu z konfiguracji serwisu. Pola, które redaktor zostawił puste, spadają do display name, potem do nazwy serwisu - więc strona zawsze ma tytuł, nawet ta niedokończona.

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

Dwa szczegóły mają wagę. filter(page => page.publishedAt) - w sitemapie miejsce mają tylko opublikowane wpisy. I wyklucz swoją stronę 404: route "nie znaleziono" podany crawlerom to zaproszenie do zaindeksowania błędu.

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.