# seo Wpina `@payloadcms/plugin-seo` (pola meta w kolekcjach) i dodaje warstwę logiki: składanie tytułów, budowanie `Metadata` dla Next.js z canonical i hreflang, auto-fill pustych meta z treści dokumentu. ## Zależność Dodaj do `dependencies` (pin do wersji payload): ```json "dependencies": { "@payloadcms/plugin-seo": "3.84.1" } ``` Po wpięciu uruchom `pnpm payload generate:importmap` — pola SEO to komponenty admina i bez importMap się nie wyrenderują. ## Config (payload.config.ts) ```ts ipalKit({ seo: { collections: ['pages', 'posts'], // które kolekcje dostają meta // generateTitle: ({ doc }) => `${doc.title}`, // opcjonalne // generateDescription: ({ doc }) => doc.excerpt ?? '', // fields: [...], // extra pola w grupie SEO // autoFill: { title: 'title', description: 'excerpt' }, // mapowanie auto-fill // autoFill: false, // wyłącz auto-fill }, }) ``` Dodaje tab **SEO** do wskazanych kolekcji: title, description, image, titleOverride. Auto-fill: hook `beforeChange` wypełnia puste `meta.title` z pola dokumentu (domyślnie `title`). Nigdy nie nadpisuje tego, co edytor wpisał ręcznie. Tab budowany jest przez `injectSeoTabs`, nie przez `tabbedUI` plugin-seo — tamten scala taby patrząc na pierwsze pole kolekcji i psuje się, gdy są tam już inne pola (slug, role), zostawiając pusty tab SEO. ## Tytuły — konfiguracja z panelu Bez kodu, per witryna (Site Settings → General): - **Title Order** — `Page first` (O nas | Acme) albo `Site first` (Acme | O nas) - **Title Separator** — `|` `–` `-` `·` `/` Per strona (tab SEO): - **Title Override** — wpisany tekst trafia do karty dosłownie, ignorując oba powyższe. Do strony głównej, gdzie składanie dałoby "Acme | Acme". Domyślnie: `Tytuł | Nazwa witryny`. ## Front — createPageMetadata Dla zwykłych stron. Zna konwencje pluginu (kolekcja pages, SiteSettings, System Pages, grupa meta), więc nie trzeba mu tego opisywać: ```ts // app/(frontend)/[locale]/[[...slug]]/page.tsx import { createPageMetadata } from '@intecion/ipal-kit' import { i18nConfig } from '@/i18n.config' const pageMetadata = createPageMetadata({ config: i18nConfig, baseUrl: process.env.NEXT_PUBLIC_SERVER_URL, // collection: 'pages', // domyślne // settingsSlug: 'site-settings', // domyślne // siteNameField: 'siteName', // domyślne }) export async function generateMetadata({ params }): Promise { const { locale, slug } = await params return pageMetadata({ payload: await getPayload({ config }), locale, slug }) } ``` Wrapper jest konieczny: Next woła `generateMetadata({ params })` bez payloada, a plugin nigdy nie wywołuje `getPayload` sam. Ogarnia: tytuł (order/separator/override z panelu), description, canonical, hreflang dla wszystkich locale, OG, home zwinięty do `/pl` (slug home czytany z System Pages). Bez `NEXT_PUBLIC_SERVER_URL` canonical i hreflang będą względne (`/pl` zamiast `https://…/pl`). ## Front — createMetadataGenerator Dla tras spoza konwencji: inna kolekcja (blog), własna logika obrazka, inny global. Klient dostarcza resolvery. ```ts import { createMetadataGenerator } from '@intecion/ipal-kit' const generate = createMetadataGenerator({ config: i18nConfig, baseUrl: process.env.NEXT_PUBLIC_SERVER_URL, homeSlug: 'homepage', resolveDocument: async ({ payload, params, locale }) => { const slug = (params.slug as string[])?.join('/') // meta MUSI przyjść w konkretnym locale (stringi), a slug jako mapa // locale→wartość (hreflang) — to dwa różne odczyty. const found = await payload.find({ collection: 'posts', where: { slug: { equals: slug } }, locale, depth: 1, limit: 1, }) const doc = found.docs[0] if (!doc) return null const allLocales = await payload.findByID({ collection: 'posts', id: doc.id, locale: 'all', depth: 0, }) return { ...doc, slug: allLocales.slug } }, resolveSiteName: async ({ payload, locale }) => (await getSiteSettings(payload, { locale })).siteName ?? null, resolveImageUrl: async ({ doc }) => doc.meta?.image?.url ?? null, }) export async function generateMetadata({ params }) { const { locale, slug } = await params return generate({ payload: await getPayload({ config }), params: { slug }, locale }) } ``` **Pułapka:** pojedynczy odczyt z `locale: 'all'` wygląda kusząco, ale wtedy **każde** zlokalizowane pole jest mapą — `meta.title` też. Składanie tytułu dostaje obiekt zamiast stringa i leci `pageTitle?.trim is not a function`. Next uruchamia `generateMetadata` i komponent strony niezależnie — bez React `cache()` wokół tych odczytów każde żądanie pyta bazę dwa razy. ## Front — buildMetadata bezpośrednio Gdy chcesz pełną kontrolę: ```ts import { buildMetadata, getLocalizedSlugs } from '@intecion/ipal-kit' return buildMetadata({ meta: doc.meta, // z plugin-seo siteName: settings.siteName, imageUrl: '/og.png', locale: 'pl', slugs: getLocalizedSlugs({ slugField: docAllLocales.slug, config }), config, baseUrl: 'https://example.com', separator: ' – ', // opcjonalne order: 'site-first', // opcjonalne homeSlug: 'homepage', }) // → { title, description, openGraph, alternates: { canonical, languages } } ``` ## Pomocnicze ```ts import { composeTitle, buildHreflangAlternates } from '@intecion/ipal-kit' composeTitle({ pageTitle: 'O nas', siteName: 'Acme' }) // 'O nas | Acme' composeTitle({ pageTitle: 'O nas', siteName: 'Acme', separator: ' – ', order: 'site-first' }) // 'Acme – O nas' buildHreflangAlternates({ slugs, config, baseUrl }) // { pl: '...', en: '...' } ``` ## Pomocnicze niższego poziomu `createPageMetadata` składa się z mniejszych, testowalnych kawałków — eksportowanych, gdybyś budował własny generator metadanych: ```ts import { readSiteMetaConfig, slugsAcrossLocales } from '@intecion/ipal-kit' // nazwa witryny, separator (dopełniony), kolejność, homeSlug — z SiteSettings const site = await readSiteMetaConfig({ payload, locale }) // slug dokumentu we wszystkich locale — mapa dla hreflang const slugs = await slugsAcrossLocales({ payload, collection: 'pages', id, config }) ``` `slugsAcrossLocales` robi osobne zapytanie z `locale: 'all'` — bo ten tryb zamienia KAŻDE zlokalizowane pole w mapę, co jest dobre dla sluga i złe dla reszty (mapa w title rozłożyłaby składanie tytułu). Dlatego czyta tylko slug, depth 0. ## Sitemapa i robots.txt Plugin składa sitemapę z hreflangiem per URL — te same buildery co metadane, więc nie rozjedzie się z tym, co strony serwują. Handlery są gotowe w `createContentHelpers`; klient re-eksportuje je jedną linią. ```ts // src/lib/content.ts export const { /* ... */, sitemap, robots } = createContentHelpers({ config, content: contentConfig, i18n: i18nConfig, // wymagane dla sitemap (hreflang) baseUrl: process.env.NEXT_PUBLIC_SERVER_URL, }) ``` ```ts // app/sitemap.ts export { sitemap as default } from '@/lib/content' // app/robots.ts export { robots as default } from '@/lib/content' ``` Next generuje z tego `/sitemap.xml` i `/robots.txt`. Nie da się tego schować całkiem w pluginie — Next tworzy te trasy wyłącznie z plików w `app/`, skanuje katalog projektu, nie node_modules. Ale re-eksport to maksimum redukcji: cała logika jest w pluginie. Co zawiera sitemapa: - każdą stronę i wpis bloga, URL w domyślnym locale - `alternates.languages` → Next renderuje `` per URL (to czyta Google przy wielu językach; zwykła sitemapa tego nie ma) - `lastModified` z realnego `updatedAt` dokumentu - wpisy bloga z prefiksem archiwum (`/pl/artykuly/moj-post`) Pomija: drafty (`_status !== 'published'`) i dokumenty z `meta.noindex`. Niskopoziomowo (własna trasa zamiast handlera z fabryki): ```ts import { buildSitemapEntries, buildRobots } from '@intecion/ipal-kit' const entries = await buildSitemapEntries({ payload, config: i18nConfig, baseUrl, content: contentConfig, }) ``` Przy dziesiątkach tysięcy URL-i Next ma `generateSitemaps` do dzielenia na części — dodasz, gdyby realnie było ich tyle. Jedna sitemapa wystarcza do ~50k. ## Favicon w Google + Organization (branding w wyszukiwarce) Favicon i structured data wpływają na to, jak strona wygląda w wynikach Google. Plugin generuje jedno i drugie z panelu — projekt tylko wpina w root layout. ### Favicon — format: PNG, nie SVG (ważne) **Dla Google użyj PNG (≥48×48), nie SVG.** Zweryfikowane: Google niezawodnie wspiera PNG i ICO, ale **SVG w wynikach Google jest zawodny** — często pokazuje glob mimo że w karcie przeglądarki favicon renderuje się dobrze. Oficjalna dokumentacja Google nie wymienia SVG. Jeśli zależy Ci na faviconie w wyszukiwarce — wgraj PNG. - **PNG ≥48×48** (idealnie 96 lub 192), kwadratowy → działa w Google ✓ - **SVG** → działa w przeglądarce, ale w Google glob (zawodne) ✗ - Walidacja pola favicon OSTRZEGA, gdy wgrasz SVG (żebyś wiedział, że dla search potrzebny PNG). ### Favicon — dlaczego się nie pokazywał Google ma twarde wymogi: `` w ``, kwadratowy, **≥48×48px**, stały URL. Gdy projekt renderował favicon „po swojemu", często był za mały, źle otagowany albo nieobecny w head → Google go nie pokazywał. Plugin robi to teraz poprawnie. ### Wpięcie favicon (root layout) Favicon jest GLOBALNY (ten sam wszędzie) — wpina się RAZ w root layout, nie per strona: ```tsx // app/(frontend)/[locale]/layout.tsx import type { Metadata } from 'next' import { buildIconsMetadata } from '@intecion/ipal-kit' import { getSettings } from '@/lib/payload' export async function generateMetadata({ params }): Promise { const { locale } = await params const settings = await getSettings(locale) return buildIconsMetadata(settings.favicon) // z pola favicon (panel) } ``` `buildIconsMetadata` generuje poprawne `icons` (favicon + apple-touch) z pola favicon. SVG → skaluje się; PNG → powinien być ≥48×48 (walidacja ostrzega, patrz niżej). ### Wymuszenie rozmiaru (walidacja) Pole favicon w SiteSettings ma walidację `validateFaviconField` — ostrzega redaktora przy zapisie, jeśli favicon jest <48×48 albo nie kwadratowy. Redaktor widzi ostrzeżenie, zamiast po cichu wgrać favicon, którego Google nie pokaże. ### Organization JSON-LD (branding) Pomaga Google powiązać stronę z marką (nazwa, logo) — lepsze wyświetlanie w wynikach, logo w knowledge panel. ```tsx // root layout — RAZ (Organization jest globalny) import { buildOrganizationJsonLd } from '@intecion/ipal-kit' const jsonLd = buildOrganizationJsonLd({ name: settings.siteName, url: process.env.NEXT_PUBLIC_SERVER_URL!, logo: settings.logo, sameAs: settings.socialLinks, // opcjonalne: profile społecznościowe }) // w JSX layoutu: