Files
ipal-kit/docs/seo.md
T

20 KiB
Raw 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 — 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: <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.

Ręczne rozszerzenia SEO/PWA (manifest itp.) — z panelu, NIE hardkod

Niektóre rzeczy SEO/PWA są na tyle projekt-specyficzne i jednorazowe, że plugin ich nie dostarcza (byłoby przeinżynierowaniem). Robisz je w projekcie — ALE poprawnie: czytając z panelu/env, nie zaszywając wartości klienta.

Zasada: nawet gdy coś robisz ręcznie w projekcie, dane (nazwa, kolory, opis, logo) czytaj z panelu (SiteSettings) albo env. Zaszyta nazwa/kolor klienta = antywzorzec (patrz standardy-kodu.md). Manifest „R Custom Cars" z hardkodem zadziała tylko dla jednego klienta.

Web App Manifest (PWA) — jak zrobić DOBRZE

// app/manifest.ts
import type { MetadataRoute } from 'next'
import { getCachedPayload } from '@/lib/content'
import { getSiteSettings } from '@intecion/ipal-kit'
import type { SiteSetting } from '@/payload-types'

export default async function manifest(): Promise<MetadataRoute.Manifest> {
  const payload = await getCachedPayload()
  const settings = await getSiteSettings<SiteSetting>(payload, { locale: 'pl' as never })

  // Wszystko z panelu — zero hardkodu. Ikona z pola logo/favicon (upload),
  // nie ze statycznej ścieżki.
  const iconUrl =
    typeof settings.logo === 'object' && settings.logo?.url ? settings.logo.url : undefined

  return {
    name: settings.siteName ?? '',
    short_name: settings.siteName ?? '',          // albo osobne pole, jeśli dodasz
    start_url: '/',
    display: 'standalone',
    ...(iconUrl
      ? { icons: [{ src: iconUrl, sizes: 'any', type: 'image/svg+xml' }] }
      : {}),
    // description / theme_color / background_color:
    // jeśli klient ich potrzebuje, DODAJ POLA w SiteSettings i czytaj stąd —
    // NIE wpisuj '#d4af37' na sztywno. Bez pól — pomiń (manifest działa bez nich).
  }
}

Kontrast — czego NIE robić (realny błąd z sesji):

// ŹLE — wszystko zaszyte, zadziała tylko dla jednego klienta
let name = 'R Custom Cars'                          // hardkod nazwy
short_name: 'RCC',                                  // hardkod
description: 'Custom car styling...',               // hardkod
background_color: '#08080a', theme_color: '#d4af37', // hardkod kolorów
icons: [{ src: '/logo/rcc-logo.svg' }]              // statyczna ścieżka, nie panel

Jeśli klient potrzebuje kolorów motywu / opisu w manifeście — dodaj pola themeColor, manifestDescription w SiteSettings (SiteSettingsFields przez opcje pluginu) i czytaj z panelu. Wtedy redaktor je zmienia, i nie są zaszyte.

Inne ręczne rozszerzenia — ta sama zasada

Cokolwiek dodajesz ręcznie (dodatkowe meta tagi, structured data konkretnego typu, itp.):

  • dane z panelu (SiteSettings / pola strony) albo env
  • nic zaszytego per klient (nazwa, kolor, adres, domena)
  • jeśli to uniwersalne i powtarzalne → rozważ zgłoszenie do pluginu zamiast ręcznie (patrz ANTIGRAVITY-ZASADY-AGENT.md A0)

SEO wielojęzyczne — hreflang, x-default, redirect roota

Przekierowanie / → /pl (negocjacja locale) może wpływać na SEO. Kluczowe: plugin generuje hreflang, więc Google rozumie, że /pl i /en to wersje językowe (nie duplikaty). Ale są niuanse.

hreflang + x-default (generowane przez plugin)

buildHreflangAlternates generuje alternates.languages z wpisami per locale ORAZ x-default wskazujący na defaultLocale. x-default mówi Google: „gdy język/region użytkownika nie pasuje do żadnej wersji, użyj TEJ" — co pokrywa sytuację roota (Googlebot bez preferencji językowej). Bez x-default Google zgadywałby; z nim dostaje jasną wskazówkę (domyślnie pl).

Działa automatycznie przez createPageMetadata (canonical + hreflang + x-default).

Redirect roota — na co uważać

  • 307 (temporary) na / → /pl — plugin tak robi. Dla warunkowego redirectu (zależnego od negocjacji) to obronne. Google i tak podąża.
  • Negocjacja Accept-Language — Googlebot bywa z Accept-Language: en albo bez. Może trafić na /en. x-default (→ pl) łagodzi to: Google wie, że domyślna wersja to polska.
  • Root nie ma własnej treści — cała moc idzie przez redirect na locale. To normalne dla i18n stron, hreflang to obsługuje.

Weryfikacja SEO wielojęzycznego

  1. Search Console → Inspekcja URL dla / — zobacz, na co Google przekierowuje i co indeksuje.
  2. Sprawdź, czy /pl i /en są indeksowane osobno (nie jako duplikaty).
  3. Rich Results / źródło strony → potwierdź <link rel="alternate" hreflang="..."> z wpisami per locale + hreflang="x-default".
  4. Search Console → raport Międzynarodowe targetowanie (jeśli dostępny) — błędy hreflang.

Częste błędy (nie rób tak)

  • Brak hreflang → Google traktuje wersje jako duplikaty (plugin to ma, nie usuwaj).
  • Zaszyta mapa ścieżek zamiast getLocalizedSlugs → hreflang się rozjedzie z bazą.
  • noindex na /pl przez pomyłkę → wypada z indeksu. Sprawdź robots meta.
  • Redirect roota na twardo 301 do jednego języka → tracisz negocjację i drugą wersję. Zostaw negocjację + hreflang.

Cel: żeby wyszukanie marki („rcustomcars") pokazało stronę główną + podlinki (sitelinks) z opisami. Ważne — sitelinków NIE DA SIĘ wymusić. Google generuje je algorytmicznie ze struktury strony, linkowania wewnętrznego, jasnych tytułów i rankingu. Żaden kod ich nie włączy. Plugin dostarcza SYGNAŁY, które zwiększają szansę — nie gwarancję.

Co realnie wpływa na sitelinki (kolejność wg wagi)

  1. Ranking na 1. stronie Google — bez tego sitelinków nie ma. To robota SEO (treść, linki), nie kodu.
  2. Czysta struktura + jasne tytuły — logiczna hierarchia stron, opisowe title (nie „Strona 1"). Patrz fundamenty-projektu.md.
  3. Linkowanie wewnętrzne — ważne strony podlinkowane z głównej.
  4. Structured data (poniżej) — sygnał pomocniczy, nie przełącznik.
  5. Sitemap + robots — żeby Google w ogóle widział wszystkie strony (patrz niżej — to fundament, sprawdź czy działa!).

Structured data z pluginu — 3 helpery

Wszystkie emitowane jako <script type="application/ld+json">, dane z panelu.

1. WebSite + SearchAction (największy realny efekt) — może dać sitelinks searchbox (pole wyszukiwania pod wynikiem marki). RAZ w root layout:

import { buildWebSiteJsonLd } from '@intecion/ipal-kit'
const jsonLd = buildWebSiteJsonLd({
  name: settings.siteName,
  url: baseUrl,
  // TYLKO jeśli masz działającą stronę wyszukiwania:
  search: { target: `${baseUrl}/szukaj?q={search_term_string}` },
})

Pomiń search, jeśli nie ma realnej wyszukiwarki — SearchAction wskazujący na nieistniejącą stronę szkodzi.

2. BreadcrumbList (realny efekt) — okruszki w wynikach (Dom › Usługi › Detailing) + Google rozumie hierarchię. PER STRONA, z pozycji strony:

import { buildBreadcrumbJsonLd } from '@intecion/ipal-kit'
const jsonLd = buildBreadcrumbJsonLd([
  { name: 'Strona główna', url: `${base}/pl` },
  { name: 'Usługi', url: `${base}/pl/uslugi` },
  { name: 'Detailing', url: `${base}/pl/uslugi/detailing` },
])

Okruszki buduj z RZECZYWISTEJ pozycji strony (resolveRoute / ścieżka URL), NIE z zaszytej listy.

3. SiteNavigationElement (słabszy, tani) — nawigacja jako dane. RAZ, z tych samych pozycji co menu w headerze:

import { buildSiteNavigationJsonLd } from '@intecion/ipal-kit'
const jsonLd = buildSiteNavigationJsonLd(
  navItems.map(i => ({ name: i.label, url: `${base}${i.href}` }))
)

Dane z tego samego źródła co widoczne menu — nie osobna zaszyta lista.

Realne oczekiwania (ważne)

  • Structured data nie gwarantuje sitelinków — to sygnał wśród wielu.
  • Efekt (jeśli będzie) pojawia się po tygodniach, gdy Google przecrawluje i strona rankuje.
  • Największy wpływ ma ranking + struktura + linkowanie, nie schema. Schema pomaga Google zrozumieć, ale nie zastąpi bycia na 1. stronie.
  • Weryfikuj: Google Rich Results Test (czy schema poprawna) + Search Console (co Google pokazuje dla marki).

To, co ZALEŻY OD PROJEKTU (obowiązki wpięcia)

Plugin dostarcza helpery — projekt MUSI je wpiąć i podać dane z panelu:

  • buildWebSiteJsonLd w root layout (search tylko jeśli jest wyszukiwarka)
  • buildOrganizationJsonLd w root layout (logo, nazwa)
  • buildBreadcrumbJsonLd na podstronach (z realnej ścieżki)
  • buildSiteNavigationJsonLd z pozycji menu (jeśli jest header nav)
  • app/robots.ts i app/sitemap.ts wystawione (patrz niżej — bez tego Google nie widzi stron!)
  • Jasne, opisowe tytuły stron (nie generyczne)
  • Logiczna hierarchia + linkowanie wewnętrzne z głównej

Dane WSZĘDZIE z panelu (siteName, nav, logo), nigdy zaszyte.