Bare Payload v4 + TanStack Start starter. Boots standalone: placeholder /, admin at /admin, REST at /api/*. Users-only auth, plain lexical editor, Postgres push:false.
Development-only UI showcase: open /ui to exercise the installed shadcn components. Examples and removal instructions live in src/features/ui-showcase; production requests return 404.
bun install
cp .env.example .env # fill PAYLOAD_SECRET, DATABASE_URL, PAYLOAD_PUBLIC_SERVER_URL
bun run db:up # local Postgres via compose.yaml (demo/[email protected]:5434/template_demo)
bun run db:migrate
bun run generate:types
bun run generate:importmap
bun run generate-routes # regenerate src/routeTree.gen.ts after adding routes
bun run dev # http://localhost:3000First admin user: bunx payload create-first-user (Payload CLI), then sign in at /admin.
| Script | Purpose |
|---|---|
dev |
Vite dev server on :3000 |
build |
admin CSS → types → importmap → vite build (Dockerfile calls this chain; never repeat it elsewhere) |
start |
Run Nitro output for the active preset (scripts/start.ts: node-server → .output/server/index.mjs, default vercel → .vercel/output/functions/__server.func) |
generate:types / generate:importmap |
Regenerate src/payload/payload-types.ts / src/payload/payload-import-map.ts (both gitignored) |
generate-routes |
Regenerate src/routeTree.gen.ts after adding/removing route files |
generate:payload-admin-css |
Regenerate src/payload/styles/payload-admin-generated.css (runs inside build) |
db:migrate |
Apply committed migrations (src/payload/migrations/) |
db:up |
Start local Postgres (compose.yaml) matching .env |
smoke |
Boot-contract check against a running server (bun run smoke [baseUrl]): / 200, /admin redirect, /api/users/me, /api/live, /api/ready |
typecheck |
bun check --no-script (Bun 1.4.4+ native checker, ~100ms) |
format / format:check |
bun fmt / bun fmt --check (Bun 1.4.4+; config in bunfig.toml) |
lint / lint:fix |
Biome lint + import assist (formatter disabled) |
check |
format:check → lint → typecheck (what CI runs) |
sync:kernel |
Pull canonical src/server/ from the blank template (manifest in scripts/kernel-sync.json; CI runs this weekly and opens a PR) |
compose.yaml local Postgres matching .env (bun run db:up)
src/routes/ __root (withPayloadRoot + ThemeProvider + Base-UI Toaster), index placeholder,
_payload (admin shell), _payload.admin.{index,$} (loaders),
api.live (liveness), api.ready (readiness), api.$ (REST catch-all)
src/lib/ site-not-found.tsx (defaultNotFoundComponent), site-error.tsx (defaultErrorComponent)
src/payload/ Payload CMS: everything Payload-related lives here
payload.config.ts users collection, postgres push:false + prodMigrations, plain lexical
collections/ one file per collection (users.ts), registered in payload.config.ts
components/ React components: payload-layout (admin shell)
lib/ admin bridge: server-fns, admin-render, import-map
migrations/ 20261011_initial-users-schema (users + payload internals)
security/ payload-server-url.ts (prod origin guard, rejects loopback in production)
styles/ admin CSS: generated sheet + theme
payload-shutdown.ts Nitro close hook: drain, log, exit (srvx owns SIGINT/SIGTERM)
payload-types.ts generated, gitignored
payload-import-map.ts generated, gitignored
src/server/ env.ts (3 keys),
http/validation.ts (zod → 400, JSON body + query helpers), modules/health (live + ready)
tests/ unit suites per module + live users integration
vendor/ vendored RSC-independent Payload Vite plugins + 1 shim
public/ favicon.svg, robots.txt (admin/api disallowed)
scripts/ smoke.ts (boot-contract check), assets/ (admin CSS generator)
.github/workflows/ci.yml format + lint + typecheck + migrate + test + node-server build + smoke vs live server
.bun-version single Bun source (Dockerfile ARG + CI bun-version-file)
Two paths: TanStack add-ons via the CLI, or a hand-built route (the common case here).
For existing projects (CLI reference):
bunx @tanstack/cli add tanstack-query
bunx @tanstack/cli add tanstack-formList what's available first: bunx @tanstack/cli create --list-add-ons, details with bunx @tanstack/cli create --addon-details <id>. Add-ons drop files under src/integrations/ (wired here via src/router.tsx, e.g. tanstack-query/root-provider) plus demo routes and deps.
Caveat for this template: the CLI doesn't know about the Payload/Nitro wiring (vite.config.ts plugin order, withPayloadRoot, payloadParseSearch/payloadStringifySearch, src/payload/payload-shutdown.ts). Review every file the add-on touches before accepting — keep the boot contract in AGENTS.md intact, then bun run typecheck && bun test && bun run dev.
Query needs no provider here: setupRouterSsrQueryIntegration in src/router.tsx already wraps the router with QueryClientProvider, so useSuspenseQuery works with zero provider code. Never add an explicit QueryClientProvider (double-wrap; only with wrapQueryClient: false).
Client-only form, colocated in the route component:
import { useForm } from '@tanstack/react-form'
function ContactForm() {
const form = useForm({
defaultValues: { name: '' },
onSubmit: async ({ value }) => {
await myServerFn({ data: value })
},
})
return (
<form
onSubmit={(e) => {
e.preventDefault()
form.handleSubmit()
}}
>
<form.Field name="name">
{(field) => <input value={field.state.value} onChange={(e) => field.handleChange(e.target.value)} />}
</form.Field>
<button type="submit">Send</button>
</form>
)
}Server-validated forms: share a formOptions shape, validate with createServerValidate from @tanstack/react-form-start in a createServerFn POST, and read back with getFormData. Validators: inline functions or the bundled zod.
At feature module scope (client-only), subscribe in components:
import { createStore, useStore } from '@tanstack/react-store'
export const uiStore = createStore({ sidebarOpen: false })
function SidebarToggle() {
const open = useStore(uiStore, (s) => s.sidebarOpen)
return <button onClick={() => uiStore.setState((s) => ({ ...s, sidebarOpen: !s.sidebarOpen }))}>{open ? 'Close' : 'Open'}</button>
}SSR caution: a module-scope store is shared across requests during server render — seed per-request via the route loader, never mutate at module scope on the server.
File-based routing: add src/routes/<name>.tsx, then regenerate and verify:
bun run generate-routes # refresh src/routeTree.gen.ts (never hand-edit)
bun run typecheck && bun test
bun run dev # check the new route rendersConventions: @/ imports only (no ../), server work goes in createServerFn handlers under src/lib/ (see src/payload/lib/server-fns.ts), shared types in the same module. If the feature needs stored data, follow "Adding a collection" below instead of inventing a parallel store.
Template stays users-only; this is the recipe consumers follow per collection:
- Create
src/payload/collections/<name>.tsand register it insrc/payload/payload.config.tscollections(fields,admin.useAsTitle,access— local-API tests needoverrideAccess: trueunless the collection is public). - Create the migration — never dev auto-push (
push: false):bunx payload migrate:create # then split value/type imports per AGENTS.md trap bun run db:migrate bun run generate:types # refresh payload-types.ts
- Verify in
/admin(collection appears, create/edit round-trips) andbun test(extendtests/users.integration.test.tspattern for the new slug).
Business tables that Payload never owns (no admin UI, no auth): skip collections entirely — add a src/server/modules/<name>/ slice on getBusinessSql() / getBusinessDb() instead (see src/server/README.md).
Only 3 keys: PAYLOAD_SECRET (min 32 random chars), DATABASE_URL (postgres), PAYLOAD_PUBLIC_SERVER_URL (public origin; loopback rejected when NODE_ENV=production).
- Vercel: default Nitro preset; run
db:migratebefore/with build. - Docker:
docker build .— build layer uses dummy secrets + real non-loopback origin; runtime env supplies real values. See Dockerfile comments.