Files
ipal-kit/docs/getting-started.md
T

15 KiB

Nowy projekt — krok po kroku

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. Instalacja IPAL

pnpm add ipal-kit
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 '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 '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 '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/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. Middleware

// src/middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { createLocaleMiddleware } from 'ipal-kit/next/middleware'
import { i18nConfig } from '@/i18n.config'

const localeMiddleware = createLocaleMiddleware({ config: i18nConfig })

export function middleware(request: NextRequest) {
  const result = localeMiddleware(request)
  if (result.type === 'next') return NextResponse.next()

  const response = NextResponse.redirect(result.location)
  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, middleware złapie /admin
// i /_next, i wszystko zwróci 500.
export const config = {
  matcher: ['/((?!api|admin|_next|.*\\..*).*)'],
}

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 'ipal-kit'
import { ConsentProvider, CookieBanner, CookieButton, Analytics } from '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 'ipal-kit/rsc'
import { createPageMetadata } from '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

  1. Utwórz pierwszego użytkownika (dostanie rolę admin).
  2. Site Settings → General — nazwa witryny, kolejność i separator tytułu.
  3. 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/….
  4. Site Settings → System Pages — wskaż Homepage. Bez tego /pl da 404.
  5. 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, secret 1x0000000000000000000000000000000AA.
  • 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 przez panelSmtpAdapter — nie pisze się ich w kodzie.

Blog / archiwum (kolekcja pod stroną-archiwum)

Pełny opis: content.md. W skrócie:

  1. Kolekcja src/collections/Posts.ts — tytuł (localized), buildSlugField, pola, bloki. Dodaj ją do collections w payload.config.

  2. content.config.ts obok i18n.config.ts:

import type { ContentOption } from 'ipal-kit'
export const contentConfig: ContentOption = {
  collections: [{ slug: 'posts', label: 'Artykuły', perPage: 10 }],
}
  1. payload.config — content: contentConfig, plus posts w seo.collections.

  2. Front — createContentHelpers w src/lib/content.ts, resolveRoute w page.tsx (obsługa typów page/archive/entry), blok listy (EntriesList).

  3. Baza + typy — nowa kolekcja to nowy schemat:

rm -f *.db *.db-shm *.db-wal && pnpm generate:types && pnpm dev
  1. 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/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