16 KiB
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. Ten przewodnik zakłada, że
@intecion/ipal-kitjest zainstalowany, i przeprowadza przez konfigurację.Zaczynasz wdrożenie produkcyjne? Najpierw 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
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. Dodaj
zależności współdzielone z Payloadem, których plugin nie zaciąga sam:
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:
"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"
}
}
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:
"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:
// 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
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
// 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.
// 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 },
],
}
// src/blocks/Content/Component.tsx
export function ContentBlockComponent({ heading, body }: { heading?: string; body?: string }) {
return (
<section className="mx-auto max-w-3xl px-4 py-12">
{heading && <h2 className="mb-4 text-2xl font-bold">{heading}</h2>}
{body && <p className="whitespace-pre-line leading-relaxed">{body}</p>}
</section>
)
}
// 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.
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:
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.
pnpm add tailwindcss @tailwindcss/postcss
// postcss.config.mjs (root)
export default { plugins: { '@tailwindcss/postcss': {} } }
/* 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):
:root {
--ipal-primary: #16a34a;
--ipal-radius: 1rem;
}
Pełna lista tokenów + opcja classNames: consent.md.
8. Proxy (routing locale) — NIGDY middleware.ts
Next 16 używa
proxy.ts, NIEmiddleware.ts. Plikproxy.ts, funkcjaproxy.middleware.tsjest 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.
// 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):
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)
// 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
})
// 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<SiteSetting>(await getCachedPayload(), { locale: locale as never, depth: 2 }),
)
NIE twórz
lib/pages.ts(resolvePage) anilib/locales.ts— plugin maresolveRouteigetConfiguredLocales. Duplikaty = rozjazd.
10. Trasy
Usuń starter — (frontend)/layout.tsx i (frontend)/page.tsx. Rootem zostaje
layout locale (bo <html lang> 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.
// 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 (
<html lang={locale}>
<body>
<ConsentProvider texts={texts}>
<main>{children}</main>
<CookieBanner />
<CookieButton />
<Analytics {...analytics} /> {/* WEWNĄTRZ ConsentProvider */}
</ConsentProvider>
</body>
</html>
)
}
export async function generateStaticParams() {
const locales = await getConfiguredLocales()
return locales.map((locale) => ({ locale }))
}
// 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,
})
export async function generateMetadata({ params }): Promise<Metadata> {
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 <RenderBlocks blocks={route.doc.layout as never} components={blockRegistry} />
}
- brak slug (
/pl) → home przez System Pages (nie hardkod slug) - slug (
/pl/o-nas) → resolveRoute po slug w danym locale 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
// 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.
13. Środowisko
# .env
DATABASE_URI=<postgres albo file:./dev.db>
PAYLOAD_SECRET=<losowy-ciąg>
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
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
- Utwórz pierwszego użytkownika (rola admin).
- Site Settings → General — nazwa witryny, tytuł.
- Pages — strona główna. Tytuł w każdym locale (slug per język; pusty
slug EN = 404 na
/en/…). - Site Settings → System Pages — wskaż Homepage (bez tego
/pl→ 404). - Cookie Settings — treść bannera per język.
- Notifications — teksty wyników formularza per język (opcjonalne, ma fallback).
- 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) + 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. Kolekcja + content.config.ts + przypisanie strony-archiwum w System Pages.
Sitemapa i robots
// app/sitemap.ts
export { sitemap as default } from '@/lib/content'
// app/robots.ts
export { robots as default } from '@/lib/content'
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 <html> and <body> |
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 |