1.2.0: local SEO structured data (LocalBusiness, Service, FAQPage), noindex per page

This commit is contained in:
2026-09-08 12:49:49 +02:00
parent e39e2a361a
commit 068415849f
34 changed files with 1030 additions and 104 deletions
+198 -24
View File
@@ -217,6 +217,7 @@ export const { /* ... */, sitemap, robots } = createContentHelpers({
```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'
@@ -227,6 +228,16 @@ całkiem w pluginie — Next tworzy te trasy wyłącznie z plików w `app/`, ska
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>`
@@ -354,51 +365,88 @@ poprawnie: czytając z panelu/env, nie zaszywając wartości 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: 'pl' as never })
const settings = await getSiteSettings<SiteSetting>(payload, {
locale: i18nConfig.defaultLocale 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
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 {
name: settings.siteName ?? '',
short_name: settings.siteName ?? '', // albo osobne pole, jeśli dodasz
start_url: '/',
...(siteName ? { name: siteName, short_name: siteName } : {}),
start_url: `/${i18nConfig.defaultLocale}`,
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).
...(iconEntry ? { icons: [iconEntry] } : {}),
// theme_color / background_color / description — TYLKO jeśli dodasz pola w
// panelu i je odczytasz. NIE zaszywaj '#0e1e24' ani opisu klienta.
}
}
```
**Kontrast — czego NIE robić** (realny błąd z sesji):
**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 — 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
// Ź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 (SiteSettingsFields przez
opcje pluginu) i czytaj z panelu. Wtedy redaktor je zmienia, i nie są zaszyte.
`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
@@ -409,6 +457,55 @@ typu, itp.):
- 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
```ts
// 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 skryptem
`document.querySelectorAll('body link[rel=icon]')...appendChild`
### Weryfikacja
```bash
# 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:
@@ -536,4 +633,81 @@ Plugin dostarcza helpery — projekt MUSI je wpiąć i podać dane z panelu:
- [ ] 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.
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">`.