A fullstack boilerplate that already works. Next.js web + admin console, NestJS API, in a Turborepo monorepo. Auth, RBAC, audit logging, file uploads and transactional email are done — there is no domain code to delete, just one example feature to copy.
gh repo create my-app --template dfansoo/groundwork --private --clone- Auth — email/password + Google OAuth, ES256 JWTs, refresh rotation, session listing and revocation, password reset, password change
- RBAC — five roles, seven permissions, enforced by guards on every route, not merely hidden in the UI
- Brute-force defence — rate limits per IP and per (IP, account), plus an account lockout after ten consecutive failures, which is the only part that survives an attacker rotating IPs
- Admin console — items, staff (grant and revoke roles) and the audit log, all paginated and permission-gated
- Audit log — every mutation recorded against the acting user, filterable, with a CSV export
- File uploads — presigned uploads, an orphan sweep, and a disk driver, so you need no AWS account to run it
- Transactional email — Handlebars templates; renders to the server log in dev, Brevo in prod
- Typed API client — generated from the backend's OpenAPI contract, so frontend types cannot drift from the API
- Docker — three images, built from the repo root, built on every CI run so they cannot rot
- Tests that mean something — the e2e and integration suites run against the real backend and a real database, not a mock
apps/
web/ public client :3000
admin/ admin console :3001
backend/ API + Prisma :9000 (Swagger at /doc)
packages/
ui/ @workspace/ui — shared shadcn components + theme
api-client/ typed client, generated from the API's OpenAPI contract
eslint-config/ shared lint config
typescript-config/ shared tsconfigs
You need Bun 1.3+, Node 20+, and PostgreSQL (natively, or docker compose up -d db from the repo root).
bun install
# 1. Backend
cd apps/backend
cp .env.example .env # set DATABASE_URL, and DATA_ENCRYPTION_KEY (openssl rand -base64 32)
bun run keys:generate # EC P-256 keypair for signing tokens
bun run db:migrate # create the schema
bun run db:seed # SUPER_ADMIN + two example items
# 2. Frontends
cd ../web && cp .env.example .env.local # set AUTH_SECRET (openssl rand -base64 32)
cd ../admin && cp .env.example .env.local # set AUTH_SECRET
# 3. Run everything
cd ../.. && bun devSign in at http://localhost:3001 with the seeded admin — admin@example.com / ChangeMe123! by default (ADMIN_EMAIL / ADMIN_PASSWORD in apps/backend/.env).
No cloud account is needed. FILES_DRIVER=local writes uploads to disk, and MAIL_TRANSPORT=log renders emails into the server log. Switch to s3 and brevo for production.
cp .env.example .env # three secrets: openssl rand -base64 32
cd apps/backend && bun run keys:generate # mounted into the container, never baked in
cd .. && docker compose up --buildEvery image builds from the repo root — a Bun workspace only resolves from there:
docker build -f apps/backend/Dockerfile -t groundwork-backend .The signing keys are mounted, not copied in. A key inside an image layer is a key held by everyone who can pull the image, and rebuilding it would invalidate every live token. Migrations and seeding are opt-in (RUN_MIGRATIONS, RUN_SEED) so that N replicas do not race the same schema change on boot.
items is the example, threaded end to end so every layer is visible at once:
| Layer | Path |
|---|---|
| API | apps/backend/src/items/ + the Item model in prisma/schema.prisma |
| Admin UI | apps/admin/features/items/ + app/(dashboard)/items/ |
| Public UI | apps/web/app/items/ |
Copy those, rename, delete the originals. Between them they demonstrate validated DTOs, a slug derived from the title, RBAC at the controller, an audit-log entry on every mutation, soft deletes, and file attachment.
Adding a resource usually means: a Prisma model → a backend module → a *_READ / *_WRITE pair in src/types/permission.enum.ts (and its mirror in apps/admin/lib/permissions.ts) → a nav entry → a features/ directory.
The backend is the identity source of truth. NestJS owns users, password hashing, ES256 JWTs, refresh rotation, session revocation and OAuth provider linking. NextAuth exists in the frontends only to run the Google redirect dance and to hold the backend's tokens in an encrypted cookie. Web, admin, and any future mobile client all authenticate the same way.
Better Auth was considered and rejected: it is an auth server, so putting it in the frontends would stand a second one in front of the real one, with two session stores that can disagree. The reasoning is in the design spec.
Types cannot drift from the API. The backend emits openapi.json; @workspace/api-client generates its types from it. Turbo runs backend#openapi before the client builds, so changing a route breaks the frontend build rather than production.
Permissions are enforced server-side. apps/admin/lib/permissions.ts mirrors the backend's map, but only to decide which nav entries and buttons to render. Every call is authorized by PermissionsGuard — a tampered client gets a 403, not access.
Nothing is trusted until it has been run. Unit tests mock the repository, which means they cannot tell you whether a guard is actually attached to a route: a controller that lost its @UseGuards would pass all of them. apps/backend/test/ drives the real HTTP stack — router, pipes, guards, Prisma, Postgres — and asserts that a VIEWER really does get a 403 from POST /v1/admin/items. CI builds the Docker images for the same reason.
bun dev |
everything, in parallel |
bun run build |
build all (regenerates the API client first) |
bun run test |
Vitest + Jest |
bun run test:int (in apps/backend) |
HTTP-level tests against a real database — needs Postgres |
bunx turbo test:e2e |
Playwright — needs the backend running and seeded |
bun run lint · bun run typecheck |
across the workspace |
bun run format |
Prettier; CI fails on anything unformatted |
Backend-only: db:migrate, db:seed, keys:generate, openapi.
.env* (except .env.example), keys/ and storage/ are git-ignored. Nothing secret ships in this template — generate your own with keys:generate and openssl rand -base64 32.