16 KiB
Nowy projekt — krok po kroku
Instalacja pluginu (token Gitea, rejestr vs git) jest opisana w głównym README. Ten przewodnik zakłada, że
@intecion/ipal-kitjest już zainstalowany, i przeprowadza przez konfigurację projektu.
Od pustego katalogu do działającej, wielojęzycznej strony z blokami, consentem i formularzem. Kolejność jest istotna: kilka kroków zależy od poprzednich (schemat bazy, importMap, kolejność wpięcia).
Zakłada: pnpm, Node 20+, SQLite (dla Postgres zmienia się tylko adapter).
1. Szkielet Payloada
npx create-payload-app@latest moj-projekt
# → Blank, SQLite
cd moj-projekt
2. Plugin i zależności
Zainstaluj @intecion/ipal-kit zgodnie z README (rejestr Gitea
albo bezpośrednio z repozytorium — wymaga tokenu). Następnie dodaj zależności
współdzielone z Payloadem, których plugin nie zaciąga sam:
pnpm add @payloadcms/[email protected] @payloadcms/[email protected] \
nodemailer lucide-react slugify server-only
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.84.1",
"@payloadcms/ui": "3.84.1",
"@payloadcms/next": "3.84.1",
"@payloadcms/db-sqlite": "3.84.1",
"@payloadcms/richtext-lexical": "3.84.1",
"@payloadcms/plugin-seo": "3.84.1",
"@payloadcms/plugin-form-builder": "3.84.1"
}
}
rm -rf node_modules pnpm-lock.yaml && pnpm install
3. Konfiguracja locale — jedno źródło
Middleware działa przed Payloadem i potrzebuje listy locale synchronicznie, więc nie może jej czytać z gotowego configu. Wydziel osobny plik i importuj w obu miejscach:
// src/i18n.config.ts
export const i18nConfig = {
defaultLocale: 'pl',
locales: [
{ code: 'pl', label: 'Polski' },
{ code: 'en', label: 'English' },
],
} as const
as const jest konieczne — bez niego TS nie uzna locales za niepustą listę.
4. payload.config.ts
import { ipalKit, panelSmtpAdapter } from '@intecion/ipal-kit'
import { i18nConfig } from '@/i18n.config'
import { Pages } from '@/collections/Pages'
export default buildConfig({
// …reszta z template'u
collections: [Users, Media, Pages],
// SMTP z panelu zamiast env — czyta Site Integrations przy każdym wysłaniu.
// Bez tego maile form-buildera nie wyjdą (Payload podstawia mocka).
email: panelSmtpAdapter(),
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' }),
{
name: 'layout',
type: 'blocks',
blocks: [ContentBlock], // NIGDY pusta lista — Payload się wywala
},
],
}
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 w rejestrze = slug bloku.
7. Tailwind
Blank template go nie ma, a komponenty pluginu (banner cookies) są w 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ą i banner wyrenderuje się goły.
Ścieżka jest relatywna do pliku CSS.
8. Proxy (dawniej middleware)
Next 16: konwencja
middleware.tsjest przestarzała — nazwa pliku to terazproxy.ts, a funkcjaproxyzamiastmiddleware. Logika pluginu bez zmian:createLocaleMiddlewaredziała tak samo. Migracja jednej komendy:npx @next/codemod@canary middleware-to-proxy .
// 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)
if (result.type === 'next') return NextResponse.next()
const response = NextResponse.redirect(result.location)
// cookie tylko gdy jest zgoda na kategorię functional — inaczej undefined
if (result.cookie) response.cookies.set(result.cookie.name, result.cookie.value)
return response
}
// INLINE, nie import — Next analizuje ten obiekt statycznie i nie wykonuje
// importów. Importowana stała zostanie zignorowana, proxy złapie /admin
// i /_next, i wszystko zwróci 500.
export const config = {
matcher: ['/((?!api|admin|_next|.*\\..*).*)'],
}
Import z pluginu zostaje
@intecion/ipal-kit/next/middleware— to nazwa subpath eksportu w pakiecie, niezależna od tego, czy plik projektu nazywa sięmiddleware.tsczyproxy.ts.
9. Warstwa dostępu do danych
Next uruchamia generateMetadata i komponent strony niezależnie — cache()
sprawia, że nie pytają bazy dwa razy o to samo.
// src/lib/payload.ts
import { cache } from 'react'
import { getPayload } from 'payload'
import config from '@/payload.config'
export const getCachedPayload = cache(async () => getPayload({ config: await config }))
export const getSettings = cache(async (locale: string) =>
(await getCachedPayload()).findGlobal({
slug: 'site-settings',
locale: locale as 'pl' | 'en',
depth: 2,
}),
)
// src/lib/locales.ts
import { cache } from 'react'
import config from '@/payload.config'
export const getConfiguredLocales = cache(async (): Promise<string[]> => {
const payloadConfig = await config
return payloadConfig.localization ? payloadConfig.localization.locales.map((l) => l.code) : []
})
// src/lib/pages.ts
import { cache } from 'react'
import type { Page, SiteSetting } from '@/payload-types'
import { getCachedPayload, getSettings } from './payload'
export const resolvePage = cache(
async (locale: string, slugPath: string | null): Promise<Page | null> => {
if (!slugPath) {
// Strona główna z System Pages — edytor może ją zmienić bez zmiany kodu.
const settings = (await getSettings(locale)) as SiteSetting
const homepage = settings.homepage
return homepage && typeof homepage === 'object' ? homepage : null
}
const payload = await getCachedPayload()
const result = await payload.find({
collection: 'pages',
where: { slug: { equals: slugPath } },
locale: locale as 'pl' | 'en',
depth: 2,
limit: 1,
})
return result.docs[0] ?? null
},
)
10. Trasy
Usuń starter — (frontend)/layout.tsx i (frontend)/page.tsx. Rootem zostaje
layout locale, bo <html lang> musi znać język, a (frontend) jest ponad
segmentem [locale]. Każdy trafia na ścieżkę z locale — middleware przekierowuje.
src/app/(frontend)/
styles.css
[locale]/
layout.tsx
[[...slug]]/
page.tsx
[[...slug]] — podwójne nawiasy. Pojedyncze [slug] dają string zamiast
tablicy (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, getSettings } from '@/lib/payload'
import { getConfiguredLocales } from '@/lib/locales'
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} />
</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 } from '@/lib/payload'
import { resolvePage } from '@/lib/pages'
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 }) {
const { locale, slug } = await params
const page = await resolvePage(locale, slug?.length ? slug.join('/') : null)
if (!page) notFound()
return <RenderBlocks blocks={page.layout as never} components={blockRegistry} />
}
11. Środowisko
# .env
DATABASE_URL=file:./moj-projekt.db
PAYLOAD_SECRET=<losowy-ciąg>
NEXT_PUBLIC_SERVER_URL=http://localhost:3000
Bez NEXT_PUBLIC_SERVER_URL canonical i hreflang wyjdą względne.
12. Generowanie i start
pnpm generate:types
pnpm payload generate:importmap # pola SEO to komponenty admina
pnpm dev
generate:importmap powtarzaj po każdej zmianie, która dokłada komponenty
admina.
13. Konfiguracja w panelu
http://localhost:3000/admin
- Utwórz pierwszego użytkownika (dostanie rolę admin).
- Site Settings → General — nazwa witryny, kolejność i separator tytułu.
- Pages — utwórz stronę główną. Wypełnij tytuł w każdym locale
(przełącznik u góry) — slug generuje się per język, a pusty slug w EN oznacza
404 na
/en/…. - Site Settings → System Pages — wskaż Homepage. Bez tego
/plda 404. - Cookie Settings — treść bannera (bez tego lecą angielskie domyślne).
Wejdź na / — powinno przekierować na /pl i pokazać stronę.
Rzeczy opcjonalne
Formularz z Turnstile
Wymaga bloku formularza w projekcie (patrz forms.md) oraz:
- Site Integrations → Turnstile — site key i secret. Klucze testowe
Cloudflare (zawsze przechodzą): site
1x00000000000000000000AA, secret1x0000000000000000000000000000000AA. - Site Integrations → SMTP — host, port, user, hasło, adres nadawcy.
- Forms → dany formularz → Emails — odbiorca, temat, treść (
{{*:table}}wypisze wszystkie pola tabelką). Maile wysyła form-builder przezpanelSmtpAdapter— nie pisze się ich w kodzie.
Blog / archiwum (kolekcja pod stroną-archiwum)
Pełny opis: content.md. W skrócie:
-
Kolekcja
src/collections/Posts.ts— tytuł (localized),buildSlugField, pola, bloki. Dodaj ją docollectionsw payload.config. -
content.config.ts obok i18n.config.ts:
import type { ContentOption } from '@intecion/ipal-kit'
export const contentConfig: ContentOption = {
collections: [{ slug: 'posts', label: 'Artykuły', perPage: 10 }],
}
-
payload.config —
content: contentConfig, pluspostswseo.collections. -
Front —
createContentHelperswsrc/lib/content.ts,resolveRoutew page.tsx (obsługa typów page/archive/entry), blok listy (EntriesList). -
Baza + typy — nowa kolekcja to nowy schemat:
rm -f *.db *.db-shm *.db-wal && pnpm generate:types && pnpm dev
- W panelu — utwórz stronę „Artykuły" (w każdym locale!), dodaj do niej blok listy, w System Pages przypisz ją jako archiwum kolekcji posts. Dodaj wpisy.
Adres wpisów = slug strony-archiwum. Zmiana tytułu strony przenosi sekcję. Kolejny typ treści (realizacje) = kolejna kolekcja + kolejna pozycja w content.config.
Sitemapa i robots.txt
createContentHelpers oddaje gotowe handlery — dodaj i18n i baseUrl do jego
argumentów (patrz seo.md), potem dwa pliki po jednej linii:
// app/sitemap.ts
export { sitemap as default } from '@/lib/content'
// app/robots.ts
export { robots as default } from '@/lib/content'
Sitemapa z hreflangiem per URL, lastmod, wpisami bloga; pomija drafty i noindex.
Analytics
Site Integrations → GA4 Measurement ID albo GTM Container ID. Tagi ładują się z Consent Mode: nic nie zapisze ciasteczek, dopóki odwiedzający nie zaakceptuje kategorii Analytics.
Przestylowanie pod klienta
/* styles.css */
:root {
--ipal-primary: #16a34a;
--ipal-radius: 1rem;
}
Pełna lista tokenów: consent.md.
Kiedy coś nie działa
| Objaw | Przyczyna |
|---|---|
| Pusty tab SEO / brak kolekcji Forms | rozjazd wersji @payloadcms/* — sprawdź pnpm.overrides |
PayloadComponent not found in importMap |
pnpm payload generate:importmap |
| Banner bez stylów | brak @source na node_modules/@intecion/ipal-kit albo brak Tailwinda |
/admin i /_next zwracają 500 |
matcher w middleware nie jest inline |
slug.join is not a function |
katalog [slug] zamiast [[...slug]] |
/pl → 404 |
Homepage nieustawiony w System Pages |
/en/cokolwiek → 404, /pl/cokolwiek działa |
pusty tytuł (a więc i slug) w locale EN |
Missing <html> and <body> |
root layout usunięty, a [locale]/layout.tsx ich nie ma |
SQLITE_ERROR: index … already exists |
zmiana schematu — usuń *.db *.db-shm *.db-wal |
| Zmiany w pluginie nie widać | Turbopack cache — rm -rf .next |
| Maile nie wychodzą | brak email: panelSmtpAdapter() w configu albo pusty SMTP w panelu |
GTM ładuje się, brak _ga |
pusty kontener — GTM sam nie ustawia ciasteczek, potrzebny opublikowany tag GA4 |
/pl/artykuly → 404 |
strona nieprzypisana jako archiwum w System Pages |
| brak pola „archive page" w panelu | brak content w configu albo generate:importmap po dodaniu |
| wpis 404 mimo że istnieje | slug pusty w tym locale — wypełnij tytuł w danym języku |