# Setup projektu — od zera do wdrożenia
Pełny przewodnik: od pustego katalogu do działającej, wielojęzycznej strony z
blokami, consentem, formularzem i SEO. Łączy szkielet projektu (kolejność
kroków) z wymaganiami frontendu (Tailwind, trasy, bloki, metadata).
> **Instalacja pluginu** (token Gitea, rejestr vs git) jest w głównym
> [README](../README.md). Ten przewodnik zakłada, że `@intecion/ipal-kit` jest
> zainstalowany, i przeprowadza przez konfigurację.
>
> **Zaczynasz wdrożenie produkcyjne?** Najpierw [WDROZENIE-PLAYBOOK.md](./WDROZENIE-PLAYBOOK.md)
> — zasady, procedura, pułapki.
Kolejność jest istotna — kilka kroków zależy od poprzednich (schemat bazy,
importMap, kolejność wpięcia). Zakłada: pnpm, Node 22, Next 16.
---
## 1. Szkielet Payloada
```bash
npx create-payload-app@latest moj-projekt
# → Blank, SQLite (dev) / Postgres (prod)
cd moj-projekt
```
## 2. Plugin i zależności
Zainstaluj `@intecion/ipal-kit` zgodnie z [README](../README.md). Dodaj
zależności współdzielone z Payloadem, których plugin nie zaciąga sam:
```bash
pnpm add @payloadcms/plugin-seo @payloadcms/plugin-form-builder \
nodemailer lucide-react slugify server-only
```
### Spójność wersji @payloadcms/* (KRYTYCZNE)
Wersje `@payloadcms/*` **muszą** zgadzać się z wersją `payload` — inaczej
zagnieżdżone pluginy się nie wpinają (pusty tab SEO, brak kolekcji Forms) albo
projekt się wywala. Wymuś w `package.json`:
```json
"pnpm": {
"overrides": {
"payload": "3.88.0",
"@payloadcms/ui": "3.88.0",
"@payloadcms/next": "3.88.0",
"@payloadcms/db-postgres": "3.88.0",
"@payloadcms/richtext-lexical": "3.88.0",
"@payloadcms/plugin-seo": "3.88.0",
"@payloadcms/plugin-form-builder": "3.88.0"
}
}
```
```bash
rm -rf node_modules pnpm-lock.yaml && pnpm install
```
### Build script z --webpack (Next 16)
Next 16 domyślnie Turbopack, który konfliktuje z withPayload. W `package.json`:
```json
"build": "cross-env NODE_OPTIONS=\"--max-old-space-size=3072\" next build --webpack"
```
## 3. Konfiguracja locale — jedno źródło
Proxy działa przed Payloadem i potrzebuje listy locale synchronicznie, więc nie
może jej czytać z gotowego configu. Wydziel osobny plik, importuj wszędzie:
```ts
// src/i18n.config.ts
export const i18nConfig = {
defaultLocale: 'pl',
locales: [
{ code: 'pl', label: 'Polski' },
{ code: 'en', label: 'English' },
],
} as const // as const — inaczej TS nie uzna locales za niepustą tuple
```
Importuj w: `payload.config` (ipalKit({ i18n: i18nConfig })) i `proxy.ts`.
## 4. payload.config.ts
```ts
import { ipalKit, mailAdapter } from '@intecion/ipal-kit'
import { i18nConfig } from '@/i18n.config'
import { Pages } from '@/collections/Pages'
export default buildConfig({
collections: [Users, Media, Pages],
// Dyspozytor email: czyta transport (SMTP/Graph) z panelu przy każdej wysyłce.
// Bez tego maile form-buildera nie wyjdą (Payload podstawia mocka).
email: mailAdapter(),
plugins: [
ipalKit({
i18n: i18nConfig,
access: { authCollection: 'users' },
pages: { slug: 'pages' },
seo: { collections: ['pages'] },
forms: { redirectRelationships: ['pages'] },
}),
],
})
```
## 5. Kolekcja Pages
```ts
// src/collections/Pages.ts
import type { CollectionConfig } from 'payload'
import { buildSlugField } from '@intecion/ipal-kit'
import { ContentBlock } from '@/blocks/Content/config'
export const Pages: CollectionConfig = {
slug: 'pages',
admin: { useAsTitle: 'title' },
access: { read: () => true },
fields: [
{ name: 'title', type: 'text', required: true, localized: true },
buildSlugField({ from: 'title' }), // NIGDY ręczny slug — plugin to ma
{
name: 'layout',
type: 'blocks',
blocks: [ContentBlock], // NIGDY pusta lista — Payload crashuje
},
],
}
```
## 6. Pierwszy blok
Bloki należą do projektu — plugin ich nie zna i nie stylizuje.
```ts
// src/blocks/Content/config.ts
import type { Block } from 'payload'
export const ContentBlock: Block = {
slug: 'content',
fields: [
{ name: 'heading', type: 'text', localized: true },
{ name: 'body', type: 'textarea', localized: true, required: true },
],
}
```
```tsx
// src/blocks/Content/Component.tsx
export function ContentBlockComponent({ heading, body }: { heading?: string; body?: string }) {
return (
{heading && {heading}
}
{body && {body}
}
)
}
```
```ts
// src/blocks/registry.ts
import type { BlockComponentMap } from '@intecion/ipal-kit/rsc'
import { ContentBlockComponent } from '@/blocks/Content/Component'
export const blockRegistry: BlockComponentMap = {
content: ContentBlockComponent, // klucz = slug bloku
}
```
> **Jak budować treść, żeby klient mógł wszystko edytować** (filozofia
> CMS, kolejność komponent→blok→strona): [architektura-tresci.md](./architektura-tresci.md).
**Puste `blocks: []` crashuje** (traverseFields) — zawsze co najmniej jeden blok.
### enhanceProps — wstrzykiwanie danych server-side do bloków
Bloki NIE importują `lib/*` (cykl importów). Wartości server-side (turnstileSiteKey,
odbiorca formularza) wstrzykuje się przez enhanceProps — bez wiedzy pluginu:
```ts
const enhanceProps = ({ block }) => {
if (block.blockType === 'formBlock') return { turnstileSiteKey, notificationTo }
return {}
}
```
## 7. Tailwind (WYMÓG)
Blank template go nie ma, a komponenty pluginu (banner cookies, Turnstile) są w
czystym Tailwindzie.
```bash
pnpm add tailwindcss @tailwindcss/postcss
```
```js
// postcss.config.mjs (root)
export default { plugins: { '@tailwindcss/postcss': {} } }
```
```css
/* src/app/(frontend)/styles.css — na górze */
@import "tailwindcss";
@source "../../../node_modules/@intecion/ipal-kit/dist/**/*.js";
```
**`@source` jest KONIECZNY** — Tailwind nie skanuje `node_modules`, więc bez
niego klasy komponentów pluginu nie powstaną (banner wyrenderuje się goły).
Ścieżka relatywna do pliku CSS.
### Przestylowanie pod klienta
Komponenty pluginu mają domyślny wygląd. Kolory/zaokrąglenia przez CSS custom
properties (fallbacki wbudowane):
```css
:root {
--ipal-primary: #16a34a;
--ipal-radius: 1rem;
}
```
Pełna lista tokenów + opcja classNames: [consent.md](./consent.md).
## 8. Proxy (routing locale) — NIGDY middleware.ts
> **Next 16 używa `proxy.ts`, NIE `middleware.ts`.** Plik `proxy.ts`, funkcja
> `proxy`. `middleware.ts` jest przestarzały — jeśli istnieje, USUŃ go. Nigdy
> obu naraz. Migracja starego: `npx @next/codemod@canary middleware-to-proxy .`
>
> Import z pluginu zostaje `@intecion/ipal-kit/next/middleware` — to nazwa
> subpath eksportu, NIE nazwa pliku. Nie myl ich.
```ts
// src/proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { createLocaleMiddleware } from '@intecion/ipal-kit/next/middleware'
import { i18nConfig } from '@/i18n.config'
const localeMiddleware = createLocaleMiddleware({ config: i18nConfig })
export function proxy(request: NextRequest) {
const result = localeMiddleware(request)
// Cookie zapisywany w OBU wynikach (redirect na '/' i next przy zmianie
// języka), TYLKO gdy jest zgoda na functional.
const response =
result.type === 'next'
? NextResponse.next()
: NextResponse.redirect(result.location)
if (result.cookie) {
response.cookies.set(result.cookie.name, result.cookie.value)
}
return response
}
// Matcher INLINE (nie import) — Next analizuje statycznie, nie wykonuje importów.
// Import stałej byłby zignorowany → proxy złapałby /admin /_next /api → 500.
// Ten wzorzec łapie root '/' (negocjacja locale), pomija api/admin/_next/pliki.
export const config = {
matcher: ['/((?!api|admin|_next|.*\\..*).*)'],
}
```
### Zlokalizowane ścieżki — getLocalizedSlugs (NIGDY zaszyta mapa)
Do przełącznika języka / budowania ścieżek NIE twórz zaszytej mapy slugów.
Slugi są w bazie (pole `slug` localized):
```ts
import { getLocalizedSlugs, switchLocalePath } from '@intecion/ipal-kit'
const doc = await payload.findByID({ collection: 'pages', id, locale: 'all' })
const slugs = getLocalizedSlugs({ slugField: doc.slug, config: i18nConfig })
switchLocalePath({ slugs, targetLocale: 'en', config: i18nConfig }) // → '/en/about'
```
## 9. Warstwa dostępu do danych — lib/ (jedno źródło)
```ts
// src/lib/content.ts — JEDYNE źródło helperów pluginu
import { createContentHelpers } from '@intecion/ipal-kit'
import payloadConfig from '@/payload.config'
import { i18nConfig } from '@/i18n.config'
export const {
getCachedPayload, getSettings, getConfiguredLocales, resolveRoute, getEntries, robots,
} = createContentHelpers({
config: payloadConfig, // PAYLOAD config (nie i18n!)
content: { collections: [] },
i18n: i18nConfig, // i18n OSOBNO
})
```
```ts
// src/lib/payload.ts — funkcje projektu, typowane
import { cache } from 'react'
import { getSiteSettings } from '@intecion/ipal-kit'
import { getCachedPayload } from './content' // z content, nie osobny getPayload
import type { SiteSetting } from '@/payload-types'
export const getSettings = cache(async (locale: string) =>
getSiteSettings(await getCachedPayload(), { locale: locale as never, depth: 2 }),
)
```
> NIE twórz `lib/pages.ts` (resolvePage) ani `lib/locales.ts` — plugin ma
> `resolveRoute` i `getConfiguredLocales`. Duplikaty = rozjazd.
## 10. Trasy
Usuń starter — `(frontend)/layout.tsx` i `(frontend)/page.tsx`. Rootem zostaje
layout locale (bo `` musi znać język).
```
src/app/(frontend)/
styles.css
[locale]/
layout.tsx # walidacja locale + ConsentProvider + Analytics
[[...slug]]/
page.tsx # render bloków
```
**`[[...slug]]` — PODWÓJNE nawiasy** (opcjonalny catch-all). Pojedyncze `[slug]`
dają string (`slug.join is not a function`) i nie łapią samego `/pl`.
```tsx
// src/app/(frontend)/[locale]/layout.tsx
import { notFound } from 'next/navigation'
import { getConsentTexts, getAnalyticsConfig } from '@intecion/ipal-kit'
import { ConsentProvider, CookieBanner, CookieButton, Analytics } from '@intecion/ipal-kit/client'
import { i18nConfig } from '@/i18n.config'
import { getCachedPayload, getConfiguredLocales } from '@/lib/content'
import { getSettings } from '@/lib/payload'
import '../styles.css'
export default async function LocaleLayout({ children, params }) {
const { locale } = await params
const locales = await getConfiguredLocales()
if (!locales.includes(locale)) notFound()
const payload = await getCachedPayload()
const settings = await getSettings(locale)
const privacyPage = (settings as { privacyPolicy?: unknown }).privacyPolicy
const [texts, analytics] = await Promise.all([
getConsentTexts({
config: i18nConfig, locale, payload,
privacyPolicy: privacyPage && typeof privacyPage === 'object'
? { page: privacyPage, label: 'Polityka prywatności' } : undefined,
}),
getAnalyticsConfig(payload),
])
return (
{/* BEZ jawnego ! Sztywny wypycha metadata do
(canonical/title poza head → crawlery ich nie widzą). Next zarządza
sam; MediaPreconnect w body, React 19 hoistuje link do head. */}
{/* preconnect CDN, jeśli R2 */}
{children}
{/* WEWNĄTRZ ConsentProvider */}
)
}
export async function generateStaticParams() {
const locales = await getConfiguredLocales()
return locales.map((locale) => ({ locale }))
}
```
```tsx
// src/app/(frontend)/[locale]/[[...slug]]/page.tsx
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import { RenderBlocks } from '@intecion/ipal-kit/rsc'
import { createPageMetadata } from '@intecion/ipal-kit'
import { i18nConfig } from '@/i18n.config'
import { blockRegistry } from '@/blocks/registry'
import { getCachedPayload, resolveRoute } from '@/lib/content'
const pageMetadata = createPageMetadata({
config: i18nConfig,
baseUrl: process.env.NEXT_PUBLIC_SERVER_URL,
})
// ISR — cache strony z gotowym . Eliminuje race condition streamingu
// metadata (canonical/title zawsze w head, nie w body). TTFB ~20ms, brak 503.
// Redaktor widzi zmiany po rewalidacji — patrz seo.md (ISR a treść z panelu).
export const revalidate = 3600
export async function generateMetadata({ params }): Promise {
const { locale, slug } = await params
return pageMetadata({ payload: await getCachedPayload(), locale, slug })
}
export default async function Page({ params, searchParams }) {
const { locale, slug } = await params
const { page } = await searchParams
const route = await resolveRoute(locale, slug ?? [], page) // 3 args
if (!route) notFound()
return
}
```
- brak slug (`/pl`) → home przez System Pages (nie hardkod slug)
- slug (`/pl/o-nas`) → resolveRoute po slug w danym locale
- **ISR (`revalidate`)** → metadata zawsze w `` (nie body), szybki TTFB.
KRYTYCZNE dla SEO — patrz seo.md (metadata w head).
- `depth: 2` → relacje w blokach (form) się populują
## 11. Metadata / SEO (szczegóły)
`createPageMetadata` obsługuje hreflang. Kluczowe: resolveDocument pobiera
dokument z **`locale: 'all'`** — wtedy `slug` jest mapą locale→wartość, z której
budują się hreflang alternates. Zwykły fetch (jeden locale) → tylko string,
hreflang nie powstanie.
Bez `NEXT_PUBLIC_SERVER_URL` canonical i hreflang wyjdą względne.
## 12. Nagłówki bezpieczeństwa
```ts
// next.config.ts
import { buildSecurityHeaders } from '@intecion/ipal-kit'
const securityHeaders = buildSecurityHeaders({
hsts: process.env.NODE_ENV === 'production', // off w dev (http)
additional: [ /* CSP projektu — zna swoje domeny */ ],
})
// async headers() { return [{ source: '/:path*', headers: securityHeaders }] }
```
Szczegóły: [security.md](./security.md).
## 13. Środowisko
```bash
# .env
DATABASE_URI=
PAYLOAD_SECRET=
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
# Email przez Graph (opcjonalnie — sekrety agencyjne):
# GRAPH_TENANT_ID=... GRAPH_CLIENT_ID=... GRAPH_CLIENT_SECRET=... GRAPH_SENDER=...
```
## 14. Generowanie i start
```bash
pnpm generate:types
pnpm payload generate:importmap # pola SEO + custom komponenty (MaskedField...)
pnpm dev
```
`generate:importmap` powtarzaj po każdej zmianie dokładającej komponenty admina.
## 15. Konfiguracja w panelu
`http://localhost:3000/admin`
1. **Utwórz pierwszego użytkownika** (rola admin).
2. **Site Settings → General** — nazwa witryny, tytuł.
3. **Pages** — strona główna. Tytuł **w każdym locale** (slug per język; pusty
slug EN = 404 na `/en/…`).
4. **Site Settings → System Pages** — wskaż Homepage (bez tego `/pl` → 404).
5. **Cookie Settings** — treść bannera per język.
6. **Notifications** — teksty wyników formularza per język (opcjonalne, ma fallback).
7. **Site Integrations → SMTP** — transport (SMTP/Graph), From Name, From Address.
Wejdź na `/` — powinno przekierować na `/pl`.
---
## Opcjonalne
### Formularz z Turnstile
Wymaga bloku formularza (patrz [forms.md](./forms.md)) + Site Integrations →
Turnstile (klucze testowe Cloudflare: site `1x00000000000000000000AA`, secret
`1x0000000000000000000000000000000AA`). Maile wysyła form-builder przez
mailAdapter — nie pisze się ich w kodzie. Zgoda RODO: checkbox o nazwie `consent`.
### Blog / archiwum
Pełny opis: [content.md](./content.md). Kolekcja + content.config.ts +
przypisanie strony-archiwum w System Pages.
### Sitemapa i robots
```ts
// app/sitemap.ts
export { sitemap as default } from '@/lib/content'
export const dynamic = 'force-dynamic' // KONIECZNE dla deployu kontenerowego
// app/robots.ts
export { robots as default } from '@/lib/content'
```
`force-dynamic` w sitemap.ts jest wymagane przy deployu w kontenerze (Coolify/
Docker) — bez niego build próbuje prerenderować sitemap i łączy się z bazą,
której kontener budujący nie widzi → build pada. Szczegóły: [deployment.md](./deployment.md).
---
## Kiedy coś nie działa
| Objaw | Przyczyna |
|---|---|
| Pusty tab SEO / brak Forms | rozjazd wersji `@payloadcms/*` — sprawdź `pnpm.overrides` |
| `PayloadComponent not found in importMap` | `pnpm payload generate:importmap` |
| `Cannot destructure property 'config'` (custom pole) | dublet `@payloadcms/ui` — peerDependency (playbook D) |
| Banner bez stylów | brak `@source` na node_modules albo brak Tailwinda |
| `/admin` i `/_next` → 500 | matcher w proxy nie jest inline |
| `slug.join is not a function` | `[slug]` zamiast `[[...slug]]` |
| `/pl` → 404 | Homepage nieustawiony w System Pages |
| `/en/*` → 404, `/pl/*` działa | pusty tytuł/slug w locale EN |
| `Missing and ` | root layout usunięty, a `[locale]/layout.tsx` ich nie ma |
| Zmiany w pluginie nie widać | `rm -rf .next`; sprawdź czy wciągnięto wersję (grep node_modules) |
| Maile nie wychodzą | brak `email: mailAdapter()` albo pusty SMTP/Graph |
| istnieje `middleware.ts` | USUŃ — Next 16 to `proxy.ts` |
| zaszyta mapa `localizedRoutes` | antywzorzec — `getLocalizedSlugs` z bazy |