8.3 KiB
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):
"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)
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) alboSite 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ć:
// 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<Metadata> {
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.
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ę:
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
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:
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ą.
// 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,
})
// 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<xhtml:link rel="alternate" hreflang>per URL (to czyta Google przy wielu językach; zwykła sitemapa tego nie ma)lastModifiedz realnegoupdatedAtdokumentu- 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):
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.