Introduction
Basalt is a batteries-included, modular framework for building SaaS applications on Node.js. It is not another HTTP framework — Fastify already does that well. It fills the layer between the server and a finished SaaS product: tenancy, billing, auth, permissions, audit, queues, notifications — integrated with an end-to-end coherence rare on Node.js, and TypeScript inference from the route to the client.
Try it in the browser
No local setup needed — boot a runnable Basalt server in a StackBlitz WebContainer:
⚡Run the playground in StackBlitzWhy Basalt
- Self-hosted, no lock-in. Your data lives in your PostgreSQL, your users authenticate against your database. Gateways like Stripe are drivers, not owners of your state.
- Multi-tenancy as a first-class citizen. Unlike most Node stacks where tenancy is bolted on, the tenant context permeates cache, storage, queue, logger and Prisma natively through
AsyncLocalStorage. - Convention over configuration. A Basalt app runs with zero config; everything is overridable.
- Incremental adoption. Every package works on its own in an existing Fastify app. The full framework is the destination, not the toll to enter.
The 30-second tour
import { createApp } from '@basaltkit/core'
import { fastifyPlugin, FASTIFY, route } from '@basaltkit/fastify'
import { z } from 'zod'
const hello = route({
method: 'GET',
url: '/hello/:name',
params: z.object({ name: z.string() }),
async handler({ params }) {
return { message: `Hello, ${params.name}` }
},
})
const app = await createApp({ plugins: [fastifyPlugin({ routes: [hello] })] }).boot()
await app.container.get(FASTIFY).listen({ port: 3000 })The route's params type is inferred from the Zod schema — the handler is fully typed, and the same schema can feed OpenAPI and the SDK client.
Not on Fastify?
The same route runs unchanged on Express and Hono — swap fastifyPlugin for expressPlugin or honoPlugin. See HTTP Adapters for complete examples.
Zero to running
The fastest path from nothing to a typed, authenticated API is the project scaffolder. It writes a production-shaped app and includes only what you pick — nothing dead ships.
1. Scaffold
pnpm create basalt my-saas # or: npm create basalt my-saasRun it with no name to answer prompts interactively, or pass flags to skip them:
pnpm create basalt my-saas --billing --cli # add subscriptions + the `basalt` CLI
pnpm create basalt my-saas --prisma # PostgreSQL through Prisma, wired end to end
pnpm create basalt my-saas -y # accept every default, no promptsMulti-tenancy and authentication are on by default — opt out with --no-tenancy / --no-auth. In an interactive terminal the scaffolder also installs dependencies and initializes git by default (opt out with --no-install / --no-git); in CI or piped runs it skips both unless you pass --install / --git. The full flag list lives in Installation.
2. Install and configure
cd my-saas
pnpm install
cp .env.example .env.env.example lists the variables under an app-specific prefix derived from the project name — MY_SAAS_PORT, MY_SAAS_HOST, MY_SAAS_LOG_LEVEL and, with auth, a commented-out MY_SAAS_APP_SECRET — plus NODE_ENV, which is never prefixed. All of them are declared and validated in src/env.ts with @basaltkit/env, which passes { prefix: 'MY_SAAS' } to defineEnv: each variable is read as MY_SAAS_<NAME> first and falls back to the bare <NAME>. The code still reads env.PORT — only the names in the environment change.
pnpm dev runs src/dev.ts, which sets NODE_ENV=development (unless it is already set), so the app boots even with an empty environment. APP_SECRET uses secret({ minLength: 32 }): it falls back to a throwaway value only with NODE_ENV=development/test. pnpm start runs src/server.ts directly, where an unset NODE_ENV counts as production, so it refuses to boot until you set a real MY_SAAS_APP_SECRET of at least 32 characters (openssl rand -base64 48).
.env is not loaded for you
defineEnv reads process.env and nothing else — copying the file does not make its values visible. Export the variables, launch with node --env-file=.env (Node 22+), or let your process manager inject them. See Configuration. --env-file never overrides a variable already exported in your shell — which is exactly why the scaffold prefixes the names; see the precedence pitfall.
3. Run
pnpm dev # API on http://localhost:3000src/server.ts boots the app, resolves the Fastify instance and listens — and shuts down cleanly on SIGINT/SIGTERM.
4. First requests
Every generated app exposes a friendly index and a health check:
curl http://localhost:3000/
# { "name": "my-saas", "status": "ok", "endpoints": ["GET /", "GET /health", ...] }
curl http://localhost:3000/health
# { "ok": true, "requestId": "…", "tenant": null }With auth on (the default), authRoutes() and mfaRoutes() are already wired — register, login, refresh, logout, me, email verification, password reset and TOTP enrolment. Register, log in, then call an authenticated route with the returned token:
curl -X POST http://localhost:3000/auth/register \
-H 'content-type: application/json' \
-d '{"email":"ada@example.com","password":"secretpassword1"}'
# → 202 { "ok": true } — the same answer whether or not the email already exists
curl -X POST http://localhost:3000/auth/login \
-H 'content-type: application/json' \
-d '{"email":"ada@example.com","password":"secretpassword1"}'
# → { "user": {…}, "accessToken": "…", "refreshToken": "…" }
curl http://localhost:3000/auth/me \
-H 'authorization: Bearer <accessToken>'
# → the authenticated userRun the included smoke test to confirm everything is wired:
pnpm testAdd a durable store
The scaffold boots on in-memory stores — perfect for dev and CI, but they forget everything on restart. Every store in Basalt is an interface with an in-memory default, so going durable is a swap, not a rewrite.
Start with the database already wired
Scaffold with --prisma and there is nothing to swap: you get prisma/schema.prisma with the models of every domain you enabled, src/db.ts with the tenant-scoped client, the Prisma-backed stores in src/app.ts, a required MY_SAAS_DATABASE_URL, and prismaPlugin({ assertMigrated: true }), which fails the boot when the app is pointed at a database that was never migrated. See PostgreSQL with --prisma.
Open src/app.ts: authPlugin is configured with a MemoryUserSource. Swap it for a durable set of stores backed by Node's built-in SQLite — no ORM, no migration tool, no external service:
pnpm add @basaltkit/auth-sqlite// src/app.ts
import { authPlugin, authRoutes, mfaRoutes } from '@basaltkit/auth'
import { sqliteAuthStores } from '@basaltkit/auth-sqlite'
import { env } from './env.js'
const stores = sqliteAuthStores('./data/auth.db') // ':memory:' by default
authPlugin({
secret: env.APP_SECRET,
users: stores.users,
sessions: stores.sessions,
refreshTokens: stores.refreshTokens,
tokens: stores.tokens, // email verification + password reset
mfa: stores.mfa,
})Now users survive a restart. The same pattern swaps any in-memory store for a durable one — see Persistence & durable stores for the full map (SQLite and Prisma backends for auth, teams, audit, tenancy and more).
Where to next
- Installation — every scaffolder flag, the requirements, the
basaltCLI, and adding Basalt to an existing app. - Configuration —
src/env.ts, fail-closed secrets and the settings repository. - Core Concepts — plugins, the DI container, request context and hooks.
- HTTP Adapters — the same routes on Fastify, Express or Hono.
- Testing — boot the app in-process, impersonate a user or tenant, fake mail and queue, travel through time.
- Web UI & components — a type-safe SDK and admin tables/forms.
- Build a notes SaaS — a complete end-to-end walkthrough.