844 lines
34 KiB
Markdown
844 lines
34 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`.
|
||
|
||
### 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ć:
|
||
|
||
```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'
|
||
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):
|
||
|
||
```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
|
||
|
||
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.
|
||
|
||
```ts
|
||
// 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):
|
||
|
||
```ts
|
||
// Ź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).
|
||
|
||
```ts
|
||
// 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>`:
|
||
|
||
```tsx
|
||
// Ź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):
|
||
|
||
```ts
|
||
// 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:
|
||
|
||
```bash
|
||
# 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.
|
||
|
||
## 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.
|
||
|
||
## 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.
|
||
|
||
```ts
|
||
// 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:
|
||
|
||
```ts
|
||
// 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:
|
||
```ts
|
||
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:
|
||
```ts
|
||
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:
|
||
```ts
|
||
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 — <url> 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:
|
||
|
||
```ts
|
||
// 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):
|
||
|
||
```ts
|
||
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. |