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.