Files
ipal-kit/docs/seo.md
T

11 KiB
Raw Permalink Blame History

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) 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ć:

// 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)
  • 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):

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 — dlaczego się nie pokazywał

Google ma twarde wymogi: <link rel="icon"> w <head>, 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:

// 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<Metadata> {
  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.

// 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:
<script
  type="application/ld+json"
  dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>

Dane z panelu (siteName, logo) — nic na sztywno.

Po wdrożeniu — cierpliwość z Google

Google cache'uje favicon osobno i wolno (dni, czasem tygodnie). Po poprawnym wpięciu favicon nie pojawi się natychmiast — Googlebot musi ponownie odwiedzić stronę główną. Przyspieszenie: Search Console → prośba o ponowne indeksowanie strony głównej. Sprawdź też, czy / nie blokuje Googlebota (robots) i czy favicon URL jest publiczny (nie za auth).

Weryfikacja

  1. Otwórz stronę → DevTools → Elements → <head> → sprawdź <link rel="icon"> z poprawnym URL.
  2. Otwórz sam URL favicon w przeglądarce — obraz się pokazuje, ≥48×48.
  3. Rich Results Test (Google) — wklej URL strony, sprawdź Organization.
  4. Search Console → poproś o ponowne indeksowanie strony głównej.