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.