28 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'
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'wapp/sitemap.tsjest KONIECZNE. Bez niego Next traktuje sitemap jako statyczny i prerenderuje go wnext 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. Zforce-dynamicsitemap generuje się w runtime, gdy baza jest dostępna. (Plugin dodatkowo łapie błąd bazy i zwraca pusty sitemap zamiast wywalić build — aleforce-dynamicto właściwe rozwiązanie, nie poleganie na fallbacku.) Opcjonalnieexport 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)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.
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
- Otwórz stronę → DevTools → Elements →
<head>→ sprawdź<link rel="icon">z poprawnym URL. - Otwórz sam URL favicon w przeglądarce — obraz się pokazuje, ≥48×48.
- Rich Results Test (Google) — wklej URL strony, sprawdź Organization.
- 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 —
siteNamepuste → pomijamyname, nie wstawiamy „Kancelaria X" na sztywno. Cudza nazwa w fallbacku = hardkod. - PNG dostaje konkretny
sizesz wymiarów media (niesizes: 'any'— to ten sam błąd co przy favicon;anytylko dla SVG). - Brak bloku
catchz hardkodami — jeśli boisz się błędu, opakuj samogetSiteSettingsi 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 (htmlLimitedBots)
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, które nie wykonują JS (Screaming Frog,
część botów), widzą je poza head → ignorują → utrata SEO.
Google twierdzi, że wykonuje JS i widzi przeniesione tagi, ale praktyka (i audyty) pokazują realne problemy z indeksacją canonical. Bezpieczniej wymusić metadata do head dla crawlerów.
Rozwiązanie — htmlLimitedBots w next.config
// next.config.ts
const nextConfig: NextConfig = {
// Wymusza blocking metadata (canonical, hreflang, title, favicon) w <head>
// dla crawlerów SEO — zamiast streamingu do <body>.
htmlLimitedBots:
/Googlebot|Google-InspectionTool|Storebot-Google|Bingbot|Yandex|DuckDuckBot|Baiduspider|Screaming Frog|AhrefsBot|SemrushBot/i,
// ...reszta
}
htmlLimitedBots mówi Next: dla tych User-Agentów wyłącz streaming, wstaw
metadata do <head> w surowym HTML (blocking). Użytkownicy dalej dostają
streaming (szybkie ładowanie); crawlery dostają poprawny head.
Objawy (że masz ten problem)
- Screaming Frog: „canonical/hreflang/title outside <head>"
- Search Console: „brak canonical", favicon nie pokazuje się (glob)
- W surowym HTML canonical/title są PO
</head>, na końcu body, ze skryptemdocument.querySelectorAll('body link[rel=icon]')...appendChild
Weryfikacja
# jako Googlebot — metadata MUSI być w <head>
curl -A "Googlebot" https://twojadomena.pl/pl/strona | grep -o '<head>.*</head>' | grep canonical
# jako user — streaming (metadata w body — OK dla ludzi wykonujących JS)
curl -A "Mozilla/5.0" https://twojadomena.pl/pl/strona
Bez htmlLimitedBots ten sam problem dotknie favicon (glob w Google), canonical („User-declared canonical: None"), hreflang i title. Jedna linia w config naprawia wszystko naraz.
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: enalbo 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
- Search Console → Inspekcja URL dla
/— zobacz, na co Google przekierowuje i co indeksuje. - Sprawdź, czy
/pli/ensą indeksowane osobno (nie jako duplikaty). - Rich Results / źródło strony → potwierdź
<link rel="alternate" hreflang="...">z wpisami per locale +hreflang="x-default". - 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ą.
noindexna/plprzez 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.
Sitelinks i structured data (branding w wynikach Google)
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)
- Ranking na 1. stronie Google — bez tego sitelinków nie ma. To robota SEO (treść, linki), nie kodu.
- Czysta struktura + jasne tytuły — logiczna hierarchia stron, opisowe title (nie „Strona 1"). Patrz fundamenty-projektu.md.
- Linkowanie wewnętrzne — ważne strony podlinkowane z głównej.
- Structured data (poniżej) — sygnał pomocniczy, nie przełącznik.
- 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:
buildWebSiteJsonLdw root layout (search tylko jeśli jest wyszukiwarka)buildOrganizationJsonLdw root layout (logo, nazwa)buildBreadcrumbJsonLdna podstronach (z realnej ścieżki)buildSiteNavigationJsonLdz pozycji menu (jeśli jest header nav)app/robots.tsiapp/sitemap.tswystawione (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">.