Files
ipal-kit/docs/seo.md
T

39 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.

Skąd bierze się „Tytuł" (priorytet źródła)

Tytuł strony (część przed nazwą witryny) pochodzi z, w kolejności:

  1. titleOverride — jeśli wypełniony, jest całym tytułem (bez składania).
  2. meta.title — tytuł SEO wpisany w tab SEO.
  3. page.title — nazwa dokumentu (np. „Sprzątanie biur”), gdy meta.title puste.

Punkt 3 (fallback na nazwę strony) działa na dwa sposoby, uzupełniające się:

  • buildAutoFillMetaHook (przy ZAPISIE) — wypełnia puste meta.title z pola dokumentu (title). Jeśli wpięty w kolekcje, meta.title nigdy nie jest puste.
  • pageTitle w buildMetadata (przy RENDEROWANIU) — jeśli meta.title mimo to puste (np. auto-fill niewpięty), używa page.title. Druga linia obrony.

Efekt: strona bez wypełnionego SEO title i tak pokaże swoją nazwę w karcie, nie pusty tytuł ani sam siteName.

Uwaga — „Strona Główna” w tytule: jeśli strona główna ma nazwę dokumentu „Strona Główna” (i auto-fill skopiował ją do meta.title), tytuł wyjdzie „Nazwa – Strona Główna” — bezużyteczne dla SEO. Napraw: wpisz titleOverride dla strony głównej (np. „Firma X – Usługa Miasto”), albo zmień meta.title na coś ze słowami kluczowymi. Fallback page.title nie pomoże, bo problemem jest sama treść nazwy, nie brak tytułu.

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'
export const dynamic = 'force-dynamic' // generuj w runtime, nie w buildzie

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

Deploy kontenerowy (Coolify/Docker/Railway/CI) — WAŻNE: export const dynamic = 'force-dynamic' w app/sitemap.ts jest KONIECZNE. Bez niego Next traktuje sitemap jako statyczny i prerenderuje go w next build — a to wywołuje Payload → bazę. Kontener budujący zwykle nie ma dostępu do sieci Docker, więc połączenie z bazą pada (ENOTFOUND) i build się wywala. Z force-dynamic sitemap generuje się w runtime, gdy baza jest dostępna. (Plugin dodatkowo łapie błąd bazy i zwraca pusty sitemap zamiast wywalić build — ale force-dynamic to właściwe rozwiązanie, nie poleganie na fallbacku.) Opcjonalnie export const revalidate = 3600 — cache sitemap na godzinę.

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

Zasada nadrzędna: brak danych → POMIŃ pole, NIE zaszywaj wartości. Manifest jest ważny bez name? Nie — ale lepszy manifest bez nazwy niż z cudzą nazwą klienta w fallbacku. Fallback z nazwą/kolorem klienta to ukryty hardkod.

// app/manifest.ts
import type { MetadataRoute } from 'next'
import { getCachedPayload } from '@/lib/content'
import { getSiteSettings } from '@intecion/ipal-kit'
import { i18nConfig } from '@/i18n.config'
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: i18nConfig.defaultLocale as never,
  })

  const siteName = settings?.siteName?.trim()

  // Ikona z panelu (favicon → logo). Dla PNG podaj KONKRETNY rozmiar z media
  // (nie 'any' — 'any' jest tylko dla SVG). Bez ikony → pomiń pole icons.
  const icon = settings?.favicon ?? settings?.logo
  const iconEntry =
    typeof icon === 'object' && icon?.url
      ? (() => {
          const isSvg = icon.mimeType === 'image/svg+xml' || icon.url.endsWith('.svg')
          const size =
            typeof icon.width === 'number' && typeof icon.height === 'number'
              ? `${Math.min(icon.width, icon.height)}x${Math.min(icon.width, icon.height)}`
              : '512x512'
          return {
            src: icon.url,
            type: icon.mimeType ?? 'image/png',
            sizes: isSvg ? 'any' : size, // 'any' tylko dla SVG
          }
        })()
      : undefined

  // Buduj TYLKO z tego, co jest. Brak pola → nie ma go w manifeście (zamiast
  // zaszytego fallbacku). start_url z configu, nie zaszyte '/pl'.
  return {
    ...(siteName ? { name: siteName, short_name: siteName } : {}),
    start_url: `/${i18nConfig.defaultLocale}`,
    display: 'standalone',
    ...(iconEntry ? { icons: [iconEntry] } : {}),
    // theme_color / background_color / description — TYLKO jeśli dodasz pola w
    // panelu i je odczytasz. NIE zaszywaj '#0e1e24' ani opisu klienta.
  }
}

Kluczowe różnice od częstego błędu agenta:

  • Brak fallbacku z nazwą klienta — siteName puste → pomijamy name, nie wstawiamy „Kancelaria X" na sztywno. Cudza nazwa w fallbacku = hardkod.
  • PNG dostaje konkretny sizes z wymiarów media (nie sizes: 'any' — to ten sam błąd co przy favicon; any tylko dla SVG).
  • Brak bloku catch z hardkodami — jeśli boisz się błędu, opakuj samo getSiteSettings i przy błędzie zwróć minimalny manifest (start_url + display), BEZ zaszytej nazwy/kolorów.
  • start_url z i18nConfig, nie zaszyte /pl.

Kontrast — czego NIE robić (realne błędy z projektów):

// ŹLE — hardkod jawny (rcustomcars)
let name = 'R Custom Cars'; short_name: 'RCC'
background_color: '#08080a', theme_color: '#d4af37'
icons: [{ src: '/logo/rcc-logo.svg' }]              // statyczna ścieżka

// ŹLE — hardkod UKRYTY w fallbacku (kancelaria)
siteName || 'Kancelaria Adwokacka Adwokat Romuald Kędzierski'   // cudza nazwa w ||
sizes: 'any', type: mimeType                        // 'any' na PNG = źle
catch { return { name: 'Kancelaria...', theme_color: '#0e1e24' } }  // hardkod w catch

Fallback || 'Nazwa Klienta' wygląda niewinnie, ale to hardkod — inny projekt skopiuje i pokaże cudzą nazwę, gdy panel zawiedzie. Brak danych → pomiń pole.

Jeśli klient potrzebuje kolorów motywu / opisu w manifeście — dodaj pola themeColor, manifestDescription w SiteSettings (przez opcje pluginu SiteSettingsFields) i czytaj z panelu. Wtedy redaktor je zmienia, 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)

KRYTYCZNE: metadata w <head> dla Google

Największa pułapka SEO w Next.js — dotyczy KAŻDEGO projektu. Dla dynamicznie renderowanych stron (SSR) Next.js streamuje metadata do <body>, nie <head>, i przenosi ją do head skryptem JS. Skutek: canonical, hreflang, title, favicon lądują w body w surowym HTML. Crawlery bez JS (Screaming Frog, część botów) widzą je poza head → ignorują → utrata SEO.

To wyścig czasowy (race condition): gdy baza odpowie szybko, metadata zdąży do head; gdy wolniej (albo crawler odpytuje wiele stron naraz, obciążając bazę), Next zamyka </head> i dokleja metadata w <body>. Dlatego pojedynczy curl może pokazać head OK, a test 10 zapytań — 5/10 w body. Testuj wielokrotnie.

Rozwiązanie GŁÓWNE — ISR (revalidate) w stronach

Najskuteczniejsze: cache całej strony (ISR). Strona generowana raz z gotowym <head>, kolejne żądania serwują cache — zero zapytań do bazy przy renderowaniu, więc race condition ZNIKA (metadata zawsze w head). Bonus: TTFB spada z ~500ms do ~20ms, znikają sporadyczne 503 (cold start).

// app/(frontend)/[locale]/[[...slug]]/page.tsx
export const revalidate = 3600   // cache 1h, regeneracja w tle

UWAGA — ISR a treść z panelu: strona cache'owana revalidate sekund NIE pokaże zmian redaktora od razu (czeka do rewalidacji). Dla treści zmienianej rzadko OK. Jeśli redaktor ma widzieć zmiany natychmiast — użyj on-demand revalidation: hook afterChange w kolekcji → revalidatePath(path) (patrz HOOKS.md). Albo krótszy revalidate (np. 300 = 5 min). NIE łącz ISR z force-dynamic — wykluczają się.

Rozwiązanie DRUGIE — usuń jawny <head> z layoutu

Sztywny <head> w layoucie App Router wymusza przedwczesne zamknięcie head — zanim strona wygeneruje metadane. To pcha metadata do body. NIE deklaruj <head>:

// ŹLE — jawny <head> zamyka head za wcześnie
<html lang={locale}>
  <head><MediaPreconnect /></head>
  <body>{children}</body>
</html>

// DOBRZE — MediaPreconnect w body, React 19 hoistuje link do head
<html lang={locale}>
  <body>
    <MediaPreconnect />
    {children}
  </body>
</html>

React 19 sam przenosi <link rel="preconnect"> do head. Jawny <head> jest zbędny i szkodliwy (wymusza wczesne zamknięcie).

Rozwiązanie TRZECIE — htmlLimitedBots (uzupełnienie)

Dodatkowo można wymusić blocking metadata dla crawlerów (przydatne, gdy strona z jakiegoś powodu nie może być ISR):

// next.config.ts
const nextConfig: NextConfig = {
  htmlLimitedBots:
    /Googlebot|Google-InspectionTool|Storebot-Google|Bingbot|Yandex|DuckDuckBot|Baiduspider|Screaming Frog|AhrefsBot|SemrushBot/i,
}

htmlLimitedBots wyłącza streaming dla tych User-Agentów (metadata w head). ALE to słabsze niż ISR — bo strona dalej renderuje dynamicznie (zapytanie do bazy → wolniej, ryzyko race). ISR eliminuje przyczynę, htmlLimitedBots łagodzi objaw. Najlepiej: ISR + brak jawnego head. htmlLimitedBots jako dodatkowa warstwa.

Objawy (że masz ten problem)

  • Screaming Frog: „canonical/hreflang/title outside <head>" (na wielu podstronach)
  • Search Console: „brak canonical", favicon glob
  • Surowy HTML: canonical/title PO </head>, na końcu body, ze skryptem appendChild
  • Test 10 zapytań: część w head, część w body (race condition)

Weryfikacja — TESTUJ WIELOKROTNIE (nie pojedynczo)

Pojedynczy curl może trafić w „szczęśliwy" timing. Testuj 10 razy:

# 10 zapytań jako Googlebot — ile ma canonical w <head>
for i in $(seq 1 10); do
  curl -s -A "Googlebot" https://twojadomena.pl/pl/strona | \
  python3 -c "import sys; h=sys.stdin.read(); e=h.find('</head>'); c=h.find('rel=\"canonical\"'); print('head' if 0<c<e else 'BODY')"
done
# Cel: 10x 'head'. Jeśli część 'BODY' → race condition, dodaj ISR.

Testuj też PODSTRONY (nie tylko główną) — race częściej dotyka podstron. DevTools (F12) NIE nadaje się do testu — hoistuje tagi do head automatycznie, pokazując fałszywie poprawny head. Używaj curl / Ctrl+U (surowe źródło).

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.

noindex per strona (strony prawne, cienkie, wyniki wyszukiwania)

Niektóre strony NIE powinny być w indeksie Google: polityki/regulamin (kanibalizują frazy), strony z parametrami, wyniki wyszukiwania. Plugin wspiera to przez pole noindex w meta SEO.

// w danych strony (meta): noindex: true
// buildMetadata automatycznie doda robots: { index: false, follow: true }

noindex, follow — strona wypada z indeksu, ale linki dalej przekazują moc (follow). Ustaw dla:

  • polityka prywatności, regulamin, polityka cookies
  • strony z parametrami kalkulatorów, filtrów
  • wyniki wewnętrznej wyszukiwarki

Redaktor zaznacza noindex w panelu (pole SEO strony), plugin generuje tag. Alternatywnie: dodaj noindex do System Pages o rolach prawnych automatycznie.

robots.txt — blokada parametrów (crawl budget)

URL-e z parametrami (?meter=101-120m2, ?s=fraza) marnują budżet indeksowania — Google skanuje dziesiątki pustych wariantów. Zablokuj je w robots:

// app/robots.ts
import { buildRobots } from '@intecion/ipal-kit'
export default function robots() {
  return buildRobots({
    baseUrl: process.env.NEXT_PUBLIC_SERVER_URL!,
    disallow: ['/admin', '/api', '/*?meter=*', '/*?s=*'],  // + parametry
  })
}

Wzorce /*?param=* odcinają parametryzowane URL-e. Realne z audytu: 55 niezindeksowanych stron kalkulatora — blokada w robots by temu zapobiegła.

Local SEO — LocalBusiness, Service, FAQPage (structured data)

Dla firm lokalnych (usługi + miasto) — trzy schematy zwiększające widoczność w wynikach lokalnych i rich results.

LocalBusiness (map pack, wyniki lokalne) — RAZ w root layout, z globala company:

import { buildLocalBusinessJsonLd } from '@intecion/ipal-kit'
const jsonLd = buildLocalBusinessJsonLd({
  name: company.name, url: baseUrl, telephone: company.phone,
  address: company.address, openingHours: company.hours,
  geo: company.geo, priceRange: '$$',
})

Najważniejsze dla „usługa + miasto". Dla konkretnego typu (Dentist, Plumber) nadpisz @type.

Service (co strona oferuje) — per strona usługowa:

import { buildServiceJsonLd } from '@intecion/ipal-kit'
const jsonLd = buildServiceJsonLd({
  name: 'Sprzątanie biur', providerName: company.name,
  url: pageUrl, areaServed: 'Wrocław',
})

FAQPage (rich results FAQ) — per strona z FAQ, z bloku FAQ w panelu:

import { buildFaqJsonLd } from '@intecion/ipal-kit'
const jsonLd = buildFaqJsonLd(
  faqBlock.items.map(i => ({ question: i.question, answer: i.answer }))
)

WAŻNE: Q&A musi odpowiadać widocznej treści strony (Google flaguje rozbieżność). Nie wymyślaj pytań, których nie ma na stronie.

Wszystkie: dane z panelu (company, bloki), jako <script type="application/ld+json">.

Wielojęzyczna strona główna — homeSlug per język (naprawione 1.2)

Gdy strona główna ma RÓŻNE slugi per język (pl: strona-glowna, de: startseite), plugin obsługuje mapę homeSlug — każdy język zwija się do swojego roota (/pl, /de), a hreflang wskazuje poprawnie (nie /de/startseite).

  • readSiteMetaConfig czyta slug homepage per język (locale: 'all') → mapa.
  • buildLocalizedPath przyjmuje homeSlug: string | Record<string, string>.
  • hreflang dostaje mapę → poprawne return tags (koniec błędu GSC „Missing return tags”).

Działa automatycznie przez createPageMetadata. Warunek: strona główna musi mieć slug wypełniony w KAŻDYM języku (panel, per locale).

Sitemap wielojęzyczny — per język (naprawione 1.2)

Sitemap emituje osobny <url> dla KAŻDEGO języka (nie tylko domyślnego). Zgodnie z wymogiem Google: każda wersja językowa = osobny <loc> + alternates. GSC liczy teraz wszystkie wersje (8×3 = 24, nie 8). Działa automatycznie w buildSitemapEntries.

Aliasy strony głównej — 301 zamiast 200 (do wpięcia w projekcie)

PROBLEM: /pl/strona-glowna (pełny slug home) zwraca 200, tak jak /pl (root). Google widzi duplikat („Duplikat bez URL kanonicznego”).

ROZWIĄZANIE (projekt): w page.tsx, jeśli slug odpowiada slugowi strony głównej w danym języku, zrób redirect 301 na root:

// page.tsx — po resolveRoute
const homeSlugForLocale = /* slug home w tym języku, z settings */
if (slug?.length === 1 && slug[0] === homeSlugForLocale) {
  redirect(`/${locale}`)   // 301 do roota, nie serwuj duplikatu
}

Plugin nie robi tego automatycznie (redirect to decyzja projektu — Next redirect w page.tsx). Ale warto wpiąć, żeby nie mieć duplikatów w GSC. Alternatywnie: canonical strony /pl/strona-glowna wskazujący na /pl (mniej czyste niż 301).

Article/BlogPosting JSON-LD (blog)

Dla wpisów blogowych — buildArticleJsonLd generuje Article/BlogPosting rich results (headline, data, autor, obraz):

import { buildArticleJsonLd } from '@intecion/ipal-kit'
const jsonLd = buildArticleJsonLd({
  headline: post.title, url: postUrl, description: post.excerpt,
  image: post.coverImage, datePublished: post.publishedAt,
  author: post.author, publisherName: settings.siteName,
  publisherLogo: settings.logo, type: 'BlogPosting',
})

Dane z dokumentu/panelu. Google wymaga headline + dat dla rich result.

Fallback meta description (siteDescription)

Strona bez meta.description → plugin używa globalnego siteDescription z SiteSettings. Lepsze niż brak description (Google losowo wyciąga tekst ze strony).

  • Pole siteDescription w SiteSettings → General (localized, textarea)
  • buildMetadata: meta.description || siteDescription
  • Redaktor wypełnia raz globalnie; strony bez własnego opisu dziedziczą

llms.txt — opis strony dla agentów AI (GEO)

buildLlmsTxt generuje /llms.txt (standard llmstxt.org) — plik Markdown opisujący stronę dla agentów AI / crawlerów LLM. Na wzór buildRobots/sitemap: plugin zna nazwę, opis i strony, więc generuje automatycznie.

// app/llms.txt/route.ts
import { buildLlmsTxt } from '@intecion/ipal-kit'
import { getCachedPayload } from '@/lib/content'
import { i18nConfig } from '@/i18n.config'

export const dynamic = 'force-dynamic'

export async function GET() {
  const body = await buildLlmsTxt({
    payload: await getCachedPayload(),
    config: i18nConfig,
    baseUrl: process.env.NEXT_PUBLIC_SERVER_URL!,
  })
  return new Response(body, {
    headers: { 'Content-Type': 'text/plain; charset=utf-8' },
  })
}

Struktura: H1 (nazwa firmy), blockquote (siteDescription), lista stron jako linki Markdown z opisami. Dane z panelu (siteName, siteDescription, strony), pomija drafty i noindex. Poprawia widoczność w wyszukiwaniach AI (GEO — Generative Engine Optimization) i audytach „Agentic Browsing”.

Wymaga wypełnionego siteDescription i sensownych meta.description stron (inaczej llms.txt będzie ubogi).

Sitemap czytelny w przeglądarce (czysty XML)

Domyślny /sitemap.xml (Next MetadataRoute) działa dla Google, ale w przeglądarce bywa nieczytelny. Możesz serwować go jako czysty, sformatowany XML przez buildSitemapXml — przeglądarka pokaże wbudowane drzewo XML (wcięcia, zwijanie, kolorowanie składni), bez żadnej transformacji.

NIE używaj XSLT. Przeglądarki (Chrome i in.) WYCOFUJĄ XSLT — arkusz <?xml-stylesheet?> pokazuje ostrzeżenie i wkrótce przestanie działać. Rozwiązanie: serwuj czysty XML z poprawnym Content-Type; przeglądarka renderuje swój natywny widok drzewa XML sama.

// app/sitemap.xml/route.ts  (zamiast app/sitemap.ts)
import { buildSitemapXml } from '@intecion/ipal-kit'
import { sitemap } from '@/lib/content'

export const dynamic = 'force-dynamic'

export async function GET() {
  const entries = await sitemap()
  const xml = buildSitemapXml(entries)
  return new Response(xml, {
    headers: { 'Content-Type': 'application/xml; charset=utf-8' },
  })
}

buildSitemapXml zwraca wcięty XML. Dwa tryby wyświetlania:

Bez cssUrl → przeglądarka pokazuje natywny widok drzewa XML (wcięcia, zwijanie).

Z cssUrl → stylujesz XML własnym CSS (kafelki, etykiety). To W3C standard „Associating Style Sheets with XML" — type="text/css", NIE wycofywane (w przeciwieństwie do XSLT/text/xsl). Zero ostrzeżenia, ładny wygląd, bezpieczne dla Google (crawlery ignorują dyrektywę).

const xml = buildSitemapXml(entries, { cssUrl: '/sitemap.css' })

Starter CSS (public/sitemap.css)

Plugin dostarcza gotowy plik — skopiuj do projektu:

cp node_modules/@intecion/ipal-kit/dist/modules/seo/assets/sitemap.css public/sitemap.css

Albo skopiuj poniższy starter i dostosuj do designu. Selektory to bezpośrednio nazwy tagów XML:

/* public/sitemap.css */
urlset {
  display: block;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  background: #090d16; color: #f1f5f9;
  padding: 2rem 1.5rem; max-width: 1200px; margin: 0 auto;
}
url {                    /* każdy adres jako kafelek */
  display: block;
  background: #111827; border: 1px solid #1e293b; border-radius: 8px;
  padding: 1rem 1.25rem; margin-bottom: 0.75rem;
}
loc {                    /* adres URL */
  display: block; font-size: 0.95rem; font-weight: 600;
  color: #f97316; margin-bottom: 0.5rem; word-break: break-all;
}
lastmod, changefreq, priority {
  display: inline-block; font-size: 0.8rem; color: #94a3b8; margin-right: 1.5rem;
}
lastmod::before   { content: 'Ostatnia modyfikacja: '; color: #64748b; }
changefreq::before { content: 'Częstotliwość: '; color: #64748b; }
priority::before  { content: 'Priorytet: '; color: #64748b; }
link {                   /* tagi hreflang */
  display: inline-block; font-size: 0.75rem;
  background: #1e293b; color: #38bdf8; border: 1px solid #334155;
  padding: 0.15rem 0.45rem; border-radius: 4px; margin: 0.4rem 0.35rem 0 0;
}

Kluczowe: display: block na <url>/<loc> zamienia „ścianę tekstu" w kafelki. Etykiety („Ostatnia modyfikacja:") przez ::before. Dostosuj kolory do projektu.

Ograniczenie: <loc> to tag XML, nie <a href> — CSS nie zrobi z niego klikalnego linku (niektóre przeglądarki autodetektują URL). Zysk to czytelność i organizacja, nie klikalność. Dla sitemap (głównie dla robotów) to akceptowalne.

WAŻNE — jeden sitemap, nie dwa

Jeśli używasz app/sitemap.xml/route.ts, USUŃ app/sitemap.ts (MetadataRoute). Dwa sitemapy pod różnymi ścieżkami mylą crawlery. Wybierz jeden:

  • route.ts + buildSitemapXml — czytelny XML w przeglądarce
  • sitemap.ts (reeksport z lib/content) — mniej kodu, mniej czytelny w przeglądarce

Oba tak samo dobre dla Google — to kwestia czytelności dla człowieka, nie SEO.