539 lines
20 KiB
Markdown
539 lines
20 KiB
Markdown
# 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):
|
||
|
||
```json
|
||
"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)
|
||
|
||
```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ć:
|
||
|
||
```ts
|
||
// 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.
|
||
|
||
```ts
|
||
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ę:
|
||
|
||
```ts
|
||
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
|
||
|
||
```ts
|
||
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:
|
||
|
||
```ts
|
||
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ą.
|
||
|
||
```ts
|
||
// 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,
|
||
})
|
||
```
|
||
|
||
```ts
|
||
// 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):
|
||
|
||
```ts
|
||
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:
|
||
|
||
```tsx
|
||
// 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.
|
||
|
||
```tsx
|
||
// 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
|
||
|
||
```ts
|
||
// 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):
|
||
|
||
```ts
|
||
// Ź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.
|
||
|
||
## 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)
|
||
|
||
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:
|
||
```tsx
|
||
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:
|
||
```tsx
|
||
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:
|
||
```tsx
|
||
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. |