Skip to content
Don’tPanic

Boilerplate SaaS · NestJS · Next.js · Prisma · Postgres

The security decisions an AI gets silently wrong are already made, documented and tested.

Pick what your system needs. Get one command. The code arrives with your project’s name in everything — packages, database, environment variables — and with the hard choices already made the right way.

Ten questions in plain language. You can skip any of them.

Command for the default preset
npx create-dontpanic 'Acme Corp'

Needs Node 24 and pnpm.

78,533
lines of TypeScript that compile, pass lint and pass the test suite
5
resources swappable by environment variable, without touching logic
2 min
from npx to pnpm dev, database migrated and admin seeded

Mistakes that pass review

The proof

None of this is hypothetical. These are mistakes that produce code which compiles, passes the tests and passes code review — and shows up months later, in a user who is not you. Each one is already decided in the boilerplate, with the reasoning beside the decision and the test named underneath.

01 · Social login

A social identity matched by email address

apps/api/src/modules/auth/oauth/oauth.service.ts
// callback do provedor: quem é esta pessoa? const user = await prisma.user.findUnique({ where: { email: profile.email }, }); if (user) return issueSession(user);

What happens

Corporate addresses get recycled. Ana leaves, HR hands ana@company.com to the next hire, he signs in with Google and inherits Ana’s account: history, permissions, everything. Nobody broke in — the system did exactly what was written, and the test, which had a single user, passed.

In DontPanic

The identity key is the immutable providerAccountIdsub on Google and Apple, the numeric id on GitHub — with @@unique([provider, providerAccountId]). The email column on oauth_accounts is for display and may be stale. And an address the provider did not mark as verified links nothing: the callback returns unverified_email.

covered by tests · 46 casesoauth.service.spec.ts

02 · Second factor

A session issued in the OAuth callback without checking the second factor

apps/api/src/modules/auth/oauth/oauth.service.ts
const account = await findLinkedAccount( provider, profile.sub, ); return issueSession(account.userId);

What happens

Anyone who deliberately turned on the six-digit code discovers that “sign in with Google” never asks for it. Social login becomes strictly weaker than typing the password, and the second factor turns optional for whoever knows which button to click. The TwoFactorGateGuard does not catch it: it verifies that 2FA is enabled, never that this session went through it.

In DontPanic

If twoFactorEnabled, the callback does not issue a session: it creates the same ticket POST /auth/login would create, hands it over in a five-minute single-use cookie, and redirects to /login?twofactor=1. A cookie and not a query string — a query string lands in browser history, in the Referer header and in the logs of every proxy along the way.

covered by tests · 46 casesoauth.service.spec.ts

03 · Isolation

Isolation between companies left to the application’s where

apps/api/src/modules/records/records.service.ts
// todo método repete o filtro, para sempre findOne(id: string, tenantId: string) { return prisma.record.findFirst({ where: { id, tenantId }, }); } // e então alguém escreve este: byId(id: string) { return prisma.record.findUnique({ where: { id } }); }

What happens

The guarantee has become human discipline, repeated in every query, by everyone who joins the team after you. The first findUnique({ where: { id } }) by primary key — written in a hurry, or by an agent that did not know the rule — returns another company’s row. And it does not fail: it returns data, with status 200.

In DontPanic

Isolation belongs to Postgres, not to the application: Row Level Security, with the scope declared by SET LOCAL inside the request transaction. With no scope at all, current_setting(…, true) returns NULL and the policy never matches — forgetting the scope yields an empty result, never the wrong company’s row. The application-level filter is still there, as a convenience; the guarantee is the one underneath.

covered by tests · 19 casesprisma.service.spec.ts

04 · Sessions

A password reset that does not end the open sessions

apps/api/src/modules/auth/services/auth.service.ts
// "senha trocada, problema resolvido" await prisma.user.update({ where: { id: record.userId }, data: { passwordHash }, }); return { message: 'Password updated.' };

What happens

People reset their password precisely because they suspect someone got in. The new hash invalidates nothing: the intruder’s refresh token keeps renewing itself, and he stays inside the account long after the change — indefinitely, as long as he keeps using the system.

In DontPanic

resetPassword writes the new password and burns the reset token in one transaction and then, after the commit, calls revokeAllForUser — every existing session dies, recorded in the audit trail as a deliberate logout. Rotating refresh closes the rest: an old token presented again revokes the whole family.

covered by tests · 6 casesauth.service.spec.ts

05 · Database

DATABASE_URL pointing at the database owner

.env
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/app"

What happens

A SUPERUSER — and any role with BYPASSRLS — ignores Row Level Security even with FORCE ROW LEVEL SECURITY. Every policy becomes decoration, and isolation goes back to depending on no query ever forgetting a where. Worse: your isolation tests pass, because they exercise the application filter, which is there and is correct.

In DontPanic

The application connects as a restricted role, created NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOREPLICATION NOBYPASSRLS; the owner lives only in DATABASE_ADMIN_URL, for migrate and seed. The API refuses to boot in production if it detects a superuser. And the e2e suite runs under the restricted role — which is what makes the isolation test prove something instead of restating the code’s intent.

covered by tests · 5 casestenant-isolation.e2e-spec.ts

Five more, same shape

  • Turning on trustProxy: true to make a spurious 429 go away. Trusting every hop means accepting any X-Forwarded-For — and the browser can set it, because it is not on the fetch forbidden-headers list: a fresh rate-limit bucket on every request. Here the IP is counted from the right, with CLIENT_IP_TRUSTED_HOPS, and the BFF strips every forwarding header coming from the browser.
  • Reading the database in a guard, before the tenant scope exists. Nest runs guards before interceptors, so the RLS policy returns zero rows, the guard concludes “this user has no 2FA” and lets the request through — no error, no log. Here, a guard that reads the database opens its own scope and fails closed.
  • Sending the invitation email inside the transaction. A rollback hands out a valid link pointing at a company that does not exist, and leaves no record for support to find. Here, issue() writes to the caller’s tx and the email goes out after the commit.
  • Answering “this account uses social login” on a password login. That is an oracle: you can enumerate, by timing the form, exactly which addresses have no password. Here the error is the same generic one and pays the same Argon2 cost — verifyPassword(null, …) verifies against a hash of something nobody knows before returning false.
  • Counting seats before writing the user. Two concurrent requests both read “one left” and both create: counting locks nothing. Here pg_advisory_xact_lock, per company and per resource, sits inside the same transaction as the write.

Ten questions. One command at the end.

One question per screen, in plain language, with what it changes in the system written underneath. No wall of fourteen switches.

no sign-up · you can go back at any step

How it works

Four steps, and the fourth is pnpm dev.

  1. 01

    Answer the questions

    Right here, one at a time. Each one says what changes in the code if you answer yes or no. You can skip with “use the recommended”.

  2. 02

    Copy the command

    The last screen shows a single command with your choices inside it. There is a shareable link, if you want to discuss the configuration with your team first.

  3. 03

    Run it in the terminal

    It downloads the code, renames everything to your project — packages, database, variables, containers —, brings Postgres and Redis up in Docker and seeds the database.

  4. 04

    pnpm dev

    API on :4201, web on :4200, email caught by Mailpit on :4207. The seeded admin login is in the README.

The rename is proven, not reviewed

The project name shows up in places no human review covers. The gate is mechanical: CI generates with a test name, runs grep -ri demanding zero occurrences of the old name, and only then installs, typechecks and runs the whole suite, e2e included.

  • 531 occurrences across 199 files, in three different cases.
  • Inside the SQL that creates the restricted Postgres role, where a partial replacement yields a role with no GRANT — and the symptom is “zero rows”, not an error.
  • In database and bucket names at once, where SQL rejects hyphens and S3 rejects underscores.

What's inside

The template is the real DontPanic repository, at the tag the generator declares. It is not a demo build: it is the code that runs its own CI.

The stack

TechnologyWhat it solves
NestJS + FastifyAPI, with Fastify underneath
Next.js (App Router)Web, with the BFF that talks to the API instead of the browser
Prisma 7 + PostgreSQLDatabase, with driver adapters and Row Level Security
ZodRequest and response contracts, shared between API and web
Argon2 + JWTPassword and session, with rotating refresh and reuse detection
BullMQ + RedisDurable queue, with the worker in a separate process
Jest + Vitest + Testing LibraryTests: unit, component and e2e
Turborepo + pnpmMonorepo, with build caching

Out of the box

Access and session

Argon2 passwords, session in an httpOnly cookie, rotating refresh with reuse detection — a stolen token takes down the whole family. Changing the password ends the other sessions.

Isolation in the database

Row Level Security in Postgres, with the scope declared per request. A new table with tenantId protects itself: SELECT app.apply_tenant_rls(); at the end of the migration.

Invitations and onboarding

Token stored only as a hash, at most one pending invitation per email (partial unique index), and the email going out after the commit — never inside the transaction.

Five swaps by variable

Storage, email, cache, queue and captcha behind interfaces: STORAGE_DRIVER, MAIL_DRIVER, CACHE_DRIVER, QUEUE_DRIVER, CAPTCHA_DRIVER.

Background work

BullMQ on Redis, with the worker in a separate process and the tenant travelling along with the job. Without it, the job would see an empty database and report success.

Tests that prove it

Unit tests with the database mocked, e2e against a real Postgres under the restricted role, and the web UI kit in Vitest.

The part nobody writes

Every security decision has a file in docs/decisions/ and a section in CLAUDE.md, with the reasoning and what happens if someone undoes it. It is what an agent reads before writing — and what you read six months later, when you cannot remember why it is like that.

The numbers

78,533
lines of TypeScript
~99%
statement coverage on the API, with thresholds enforced in CI
100%
statement coverage on the web UI kit
531
name occurrences replaced across 199 files, proven by grep

Questions

The ones worth an honest answer before you run the command.

What exactly is tested?

The preset matrix, in full: CI generates a project from each preset, demands zero occurrences of the old name, and runs install, typecheck, unit and e2e. Plus all-on, all-off, and each feature turned off individually on top of the SaaS preset. Fourteen boolean features are 16,384 combinations, and CI does not test 16,384 projects: combinations outside that matrix are allowed and untested — and the CLI says so, in one line, without drama. A boilerplate that promises guarantees it does not verify is worse than one that states the limit.

What if I do not want multi-tenancy?

--no-multi-tenant hides it, it does not tear it out. The project starts with one fixed tenant created in the seed, the scope always open on it, and the company switcher, the /platform panel and SUPERADMIN out of the UI. Row Level Security stays, and stays proven by tenant-isolation.e2e-spec.ts; the cost is one indexed column and a predicate Postgres resolves to a constant. Tearing it out would mean maintaining two versions of all data access — and the version without RLS is precisely the one we cannot prove safe.

Can I update later?

The generated project is yours, not a dependency: there is no pnpm update that pulls DontPanic changes into it, and that is deliberate — you will be editing this code on day one. What you do get is reproducibility: the same recipe on the same template version generates the same project today and in two years, so you can generate again and compare diffs when you want to adopt something from upstream.

What about the licence?

MIT, on the generator and on the template. What comes out of the npx is yours: no required attribution, no royalty, no clause that changes value if your product grows. You can close the source of what you generate.

Do I need Docker?

To run the test suite, no: the memory, console and local adapters exist exactly so it runs with nothing else up. To develop properly you need a Postgres — and the project’s docker compose brings up Postgres, Redis, MinIO and Mailpit on ports that will not collide with yours. If you already have those services, point the .env at them and generate with --no-docker.

Does it work with Claude Code, Cursor and friends?

The generated project ships a CLAUDE.md pruned to the features you chose — only the sections that exist in your code. That is where the security decisions live, each with its reasoning, in the form an agent reads before writing. The side effect is probably what brought you here: context goes into your product instead of rediscovering how refresh token rotation works.