Production considerations

Two things every real deployment needs to get right that a local spike doesn't: who can edit, and where data lives.

Access control

Payload does not restrict reads/writes by default just because a collection has auth: true or versions.drafts enabled - every collection in @olgax.com/payload-preset and @olgax.com/multi-tenancy explicitly defines access.create/update/delete requiring a logged-in user (Boolean(req.user)), and Users additionally allows anonymous create only when zero users exist yet (the bootstrap/first-admin flow).

The Local API (used by apps/demo's own server actions and routes) bypasses access control by default (overrideAccess: true). Write paths that matter - lib/actions.ts's saveDraftPageData/publishPageData - explicitly check for a logged-in user first and pass overrideAccess: false so Payload's own access control is the actual enforcement, not just a UI-level check. The /[slug]/edit route itself also redirects anonymous visitors to /admin/login rather than rendering the editor at all.

Database

apps/demo's payload.config.ts picks a database adapter based on DATABASE_URL: SQLite (zero external services) if it's a file: path, Postgres if it starts with postgres:// or postgresql://.

# Local dev (default)
DATABASE_URL=file:./payload.db

# Production - any managed Postgres works (Neon, Supabase, Railway, Vercel Postgres, ...)
DATABASE_URL=postgres://user:password@host:5432/dbname

SQLite stays the default so create-olgax-site keeps its under-2-minute, zero-setup goal - swapping to Postgres for production is a one-line env var change, not a code change.

Migrations

payload.config.ts sets an explicit migrationDir (apps/demo/migrations) so migrations land in the same place regardless of which database adapter is active. Three scripts wrap Payload's CLI:

pnpm migrate:create   # write a new migration from the current schema diff
pnpm migrate          # run any pending migrations
pnpm migrate:status   # list applied/pending migrations

Local dev never needs these - Payload's dev-mode schema push (the “Pulling schema from database” prompt) applies changes automatically, which is fine for disposable local data. A real deployment should use migrations instead: run pnpm migrate:create after a schema-affecting change, commit the generated file, and run pnpm migrate as part of deploying - never rely on the dev-mode push (or accept its “DATA LOSS WARNING” prompt) against a database with real content.

Marking an existing field localized: true (as Pages.title/data and Sections.content are, see packages/payload-preset) is the schema change most likely to trigger that warning - it changes how the column is stored, and accepting the prompt against a populated table drops the old column. Write a migration that moves existing values into the new localized structure first, or add localization from the start on a fresh project instead of retrofitting it onto live content.

Analytics

apps/demo records basic page-view counts out of the box (no setup, no external service - see the Analytics tab in /dashboard). For broader indicators - countries, session duration, devices, browsers, referrers, UTM campaigns - it integrates with a self-hosted Umami instance instead of reimplementing any of that. Umami is entirely optional: with no env vars set, no tracker script loads and nothing changes from today's zero-config default.

Run docker compose -f docker-compose.umami.yml up -d (bundled in apps/demo), create a website in Umami's own UI, then set NEXT_PUBLIC_UMAMI_SCRIPT_URL, NEXT_PUBLIC_UMAMI_WEBSITE_ID, and NEXT_PUBLIC_UMAMI_DASHBOARD_URL in .env - see .env.example for the exact values. Both the built-in counter and the Umami tracker skip a logged-in editor's own visits, so previewing your own work never inflates the numbers.

Still worth doing before a real launch