1.2.0: local SEO structured data (LocalBusiness, Service, FAQPage), noindex per page
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
# Deployment — zmienne środowiskowe i produkcja
|
||||
|
||||
Jedno źródło prawdy o zmiennych środowiskowych (wszystkie, co znaczą, wymagane
|
||||
czy nie) oraz jak wdrożyć projekt na produkcję spójnie. Env jest częścią
|
||||
deploymentu — te same zmienne w dev (.env) i na produkcji (runtime hostingu).
|
||||
|
||||
Powiązane: [getting-started.md](./getting-started.md), [storage.md](./storage.md)
|
||||
(R2), [email.md](./email.md) (Graph), [security.md](./security.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. ZMIENNE ŚRODOWISKOWE — pełna lista
|
||||
|
||||
### Rdzeń (WYMAGANE — projekt bez nich nie wstanie)
|
||||
|
||||
```bash
|
||||
# Baza danych (Mongo albo Postgres — zależnie od projektu)
|
||||
DATABASE_URI=mongodb://... # albo postgres://... / file:./dev.db (dev)
|
||||
|
||||
# Sekret Payload (podpisywanie sesji/tokenów) — losowy, długi
|
||||
PAYLOAD_SECRET=<losowy-ciąg-min-32-znaki>
|
||||
|
||||
# Publiczny URL serwisu (canonical, hreflang, OG, manifest)
|
||||
NEXT_PUBLIC_SERVER_URL=https://klient.pl # dev: http://localhost:3000
|
||||
```
|
||||
|
||||
### Email — Graph (OPCJONALNE, agencyjne, gdy transport = Graph)
|
||||
|
||||
```bash
|
||||
GRAPH_TENANT_ID=<azure-tenant-id>
|
||||
GRAPH_CLIENT_ID=<azure-app-client-id>
|
||||
GRAPH_CLIENT_SECRET=<azure-app-secret>
|
||||
GRAPH_SENDER=[email protected] # wspólna skrzynka
|
||||
```
|
||||
Bez nich transport Graph nie zadziała (fallback SMTP). Patrz email.md.
|
||||
|
||||
### Storage — R2 (OPCJONALNE, gdy media w R2)
|
||||
|
||||
```bash
|
||||
R2_BUCKET=<nazwa-bucketa>
|
||||
R2_ENDPOINT=https://<ACCOUNT_ID>.r2.cloudflarestorage.com
|
||||
R2_ACCESS_KEY_ID=<access-key>
|
||||
R2_SECRET_ACCESS_KEY=<secret-key>
|
||||
R2_PUBLIC_URL=https://media.klient.pl # custom domena (obrazy publiczne)
|
||||
```
|
||||
Brak → fallback na lokalny dysk. Patrz storage.md.
|
||||
|
||||
### Tabela — wszystkie zmienne
|
||||
|
||||
| Zmienna | Wymagana | Warstwa | Opis |
|
||||
|---|---|---|---|
|
||||
| `DATABASE_URI` | ✅ | infra | połączenie z bazą |
|
||||
| `PAYLOAD_SECRET` | ✅ | infra | sekret Payload |
|
||||
| `NEXT_PUBLIC_SERVER_URL` | ✅ | infra | publiczny URL (canonical, OG) |
|
||||
| `GRAPH_TENANT_ID` | ⬜ | email | Azure tenant (Graph) |
|
||||
| `GRAPH_CLIENT_ID` | ⬜ | email | Azure app id |
|
||||
| `GRAPH_CLIENT_SECRET` | ⬜ | email | Azure secret |
|
||||
| `GRAPH_SENDER` | ⬜ | email | skrzynka nadawcza |
|
||||
| `R2_BUCKET` | ⬜ | storage | bucket R2 |
|
||||
| `R2_ENDPOINT` | ⬜ | storage | endpoint S3 R2 |
|
||||
| `R2_ACCESS_KEY_ID` | ⬜ | storage | klucz R2 |
|
||||
| `R2_SECRET_ACCESS_KEY` | ⬜ | storage | sekret R2 |
|
||||
| `R2_PUBLIC_URL` | ⬜ | storage | custom domena mediów |
|
||||
|
||||
**Zasada:** wszystkie sekrety to zmienne agencyjne/infrastrukturalne — w `.env`
|
||||
(dev) i runtime hostingu (prod), NIGDY w repo. Dane per-projekt edytowalne przez
|
||||
redaktora idą do PANELU, nie do env (patrz architektura-tresci.md).
|
||||
|
||||
### .env.example — zawsze w repo
|
||||
|
||||
Każdy projekt ma `.env.example` z listą zmiennych (bez wartości/sekretów) —
|
||||
szablon dla następnej osoby. Commituj go; `.env` (z wartościami) NIGDY.
|
||||
|
||||
---
|
||||
|
||||
## 2. PRZED DEPLOYEM — checklist
|
||||
|
||||
- [ ] `pnpm build --webpack` przechodzi LOKALNIE (nie tylko dev)
|
||||
- [ ] Wszystkie wymagane env ustawione na hostingu (runtime)
|
||||
- [ ] `NEXT_PUBLIC_SERVER_URL` = produkcyjny URL (nie localhost)
|
||||
- [ ] `PAYLOAD_SECRET` inny niż w dev (produkcyjny sekret)
|
||||
- [ ] Baza produkcyjna (nie dev/SQLite)
|
||||
- [ ] HSTS włączony (buildSecurityHeaders hsts: production)
|
||||
- [ ] Media: R2 z custom domeną (jeśli używane) — obrazy publiczne
|
||||
- [ ] Migracja mediów lokalne→R2 (jeśli przełączasz)
|
||||
- [ ] Strony polityk + baner cookies (patrz wymagania-prawne.md)
|
||||
|
||||
---
|
||||
|
||||
## 3. BUDOWANIE NA PRODUKCJĘ
|
||||
|
||||
### Build script (Next 16 + Payload)
|
||||
|
||||
```json
|
||||
// package.json — --webpack KONIECZNE (Turbopack konfliktuje z withPayload)
|
||||
"build": "cross-env NODE_OPTIONS=\"--max-old-space-size=3072\" next build --webpack"
|
||||
```
|
||||
|
||||
`--max-old-space-size` — Payload + Next bywają pamięciożerne przy buildzie;
|
||||
3072 MB zapobiega OOM na mniejszych maszynach.
|
||||
|
||||
### Kolejność build → migracje → start
|
||||
|
||||
```bash
|
||||
pnpm install --frozen-lockfile # dokładnie z lockfile (powtarzalny build)
|
||||
pnpm generate:types # typy z kolekcji
|
||||
pnpm build # --webpack
|
||||
# migracje bazy (jeśli Postgres z migracjami):
|
||||
pnpm payload migrate
|
||||
pnpm start # produkcyjny serwer
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3a. Pułapka: metadata w <body> zamiast <head> (htmlLimitedBots)
|
||||
|
||||
Next 16 streamuje metadata dynamicznych stron do `<body>` (przenosi do head
|
||||
skryptem JS). Crawlery bez JS widzą canonical/hreflang/title/favicon poza head →
|
||||
utrata SEO. **Każdy projekt** tego potrzebuje w next.config:
|
||||
|
||||
```ts
|
||||
const nextConfig: NextConfig = {
|
||||
htmlLimitedBots:
|
||||
/Googlebot|Google-InspectionTool|Bingbot|Yandex|DuckDuckBot|Screaming Frog|AhrefsBot|SemrushBot/i,
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
Weryfikacja: `curl -A "Googlebot" URL | grep canonical` — musi być w `<head>`.
|
||||
Szczegóły i objawy: seo.md (sekcja htmlLimitedBots).
|
||||
|
||||
## 3b. Pułapka: prerender tras zależnych od bazy (KONIECZNE)
|
||||
|
||||
Next domyślnie **prerenderuje** trasy typu `sitemap.ts` w czasie `next build` —
|
||||
traktuje je jako statyczne. Jeśli taka trasa czyta bazę (sitemap → Payload →
|
||||
Mongo/Postgres), build **próbuje połączyć się z bazą**. A kontener budujący
|
||||
(Coolify/Docker/Railway/CI) zwykle NIE ma dostępu do sieci bazy → połączenie
|
||||
pada (`ENOTFOUND`, `MongooseServerSelectionError`) → **build się wywala**.
|
||||
|
||||
**Rozwiązanie — `force-dynamic` na trasach zależnych od bazy:**
|
||||
```ts
|
||||
// app/sitemap.ts
|
||||
export { sitemap as default } from '@/lib/content'
|
||||
export const dynamic = 'force-dynamic' // generuj w runtime, nie w buildzie
|
||||
```
|
||||
|
||||
To mówi Next: nie prerenderuj w buildzie, generuj w runtime (gdy baza jest
|
||||
dostępna). Dotyczy KAŻDEJ trasy czytającej bazę podczas renderowania:
|
||||
- `app/sitemap.ts` → `force-dynamic`
|
||||
- inne trasy/strony czytające bazę w prerenderze → rozważ `force-dynamic` albo
|
||||
obsłuż błąd bazy (try/catch z fallbackiem)
|
||||
|
||||
Plugin dodatkowo zabezpiecza handler sitemap (łapie błąd bazy, zwraca pustą
|
||||
mapę), więc build nie padnie nawet bez `force-dynamic` — ale to siatka
|
||||
bezpieczeństwa, nie właściwe rozwiązanie. Zawsze dodawaj `force-dynamic`.
|
||||
|
||||
**Strona 404** (`not-found.tsx`) czytająca ustawienia z bazy — ten sam problem.
|
||||
Opakuj `getCachedPayload()` w try/catch, żeby brak bazy w buildzie nie wywalił
|
||||
prerenderu 404 (fallback na statyczne teksty).
|
||||
|
||||
**Weryfikacja lokalna** (symuluj brak bazy):
|
||||
```bash
|
||||
DATABASE_URI=mongodb://invalid-host:27017/test pnpm build
|
||||
# build musi przejść (kod 0), mimo niedostępnej bazy
|
||||
```
|
||||
|
||||
## 4. HOSTING (Coolify / Docker)
|
||||
|
||||
### Zmienne runtime, nie build
|
||||
|
||||
Zmienne środowiskowe ustaw w **runtime** hostingu (Coolify → Environment
|
||||
Variables), nie zapiekaj w build. `NEXT_PUBLIC_*` są wyjątkiem — wchodzą w build
|
||||
(bo publiczne, w bundlu klienta), więc muszą być dostępne PODCZAS buildu.
|
||||
|
||||
### Persystencja mediów
|
||||
|
||||
Jeśli media lokalne (nie R2) — potrzebują **wolumenu** (inaczej znikną przy
|
||||
redeployu). Dlatego R2 jest zalecane na produkcji: media poza kontenerem,
|
||||
przetrwają redeploy. Patrz storage.md.
|
||||
|
||||
### Health check
|
||||
|
||||
Payload wystawia panel pod `/admin` — health check może pingować stronę główną
|
||||
albo `/admin`. Nie ustawiaj health check na endpoint wymagający bazy, jeśli
|
||||
baza wstaje wolniej niż app.
|
||||
|
||||
---
|
||||
|
||||
## 5. PO DEPLOYU — weryfikacja
|
||||
|
||||
- [ ] Strona główna `/` przekierowuje na locale (`/pl`)
|
||||
- [ ] Panel `/admin` działa, logowanie OK
|
||||
- [ ] Formularz wysyła (test przez panel: Send test)
|
||||
- [ ] Media się wyświetlają (jeśli R2 — custom domena działa, nie 403)
|
||||
- [ ] Favicon w `<head>` (patrz seo.md — Google cache'uje wolno)
|
||||
- [ ] HTTPS + nagłówki bezpieczeństwa (sprawdź np. securityheaders.com)
|
||||
- [ ] Sitemap `/sitemap.xml` i `/robots.txt` odpowiadają
|
||||
|
||||
---
|
||||
|
||||
## DLACZEGO TO WAŻNE
|
||||
|
||||
- **Jedna lista env** — nikt nie zgaduje, czego brakuje
|
||||
- **Powtarzalny deploy** — frozen-lockfile, ta sama kolejność, każdy projekt tak samo
|
||||
- **Sekrety bezpieczne** — env/runtime, nigdy repo
|
||||
- **Media przetrwają** — R2 albo wolumen, nie znikają przy redeployu
|
||||
@@ -55,6 +55,21 @@ buildLocalizedPath({ slugs, locale: 'en', config }) // '/en/about'
|
||||
buildLocalizedPath({ slugs: { en: 'home' }, locale: 'en', config }) // '/en'
|
||||
```
|
||||
|
||||
> **Pułapka typu (TypeScript):** przy `locale: 'all'` Payload w RUNTIME zwraca
|
||||
> zlokalizowane pole jako obiekt `{ pl, en }`, ale wygenerowane typy Payloada
|
||||
> deklarują `doc.slug` jako `string` (typ nie odróżnia trybu `all`). `tsc`
|
||||
> zgłosi więc niezgodność. Rozwiązanie — czyste rzutowanie na oczekiwany przez
|
||||
> helper typ:
|
||||
> ```ts
|
||||
> const slugs = getLocalizedSlugs({
|
||||
> slugField: doc.slug as unknown as Record<string, unknown>,
|
||||
> config,
|
||||
> })
|
||||
> ```
|
||||
> To nie hack — to pomost między statycznym typem (string) a rzeczywistym
|
||||
> kształtem runtime (obiekt), którego generator typów Payloada nie modeluje.
|
||||
> `as unknown as` jest tu poprawne, bo TS nie zna trybu `all`.
|
||||
|
||||
### Przełącznik języka (bez 404)
|
||||
|
||||
```ts
|
||||
|
||||
+198
-24
@@ -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">`.
|
||||
Reference in New Issue
Block a user