Skip to content

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 StackBlitz

Why 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 ​

ts
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 ​

bash
pnpm create basalt my-saas       # or: npm create basalt my-saas

Run it with no name to answer prompts interactively, or pass flags to skip them:

bash
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 prompts

Multi-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 ​

bash
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 ​

bash
pnpm dev        # API on http://localhost:3000

src/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:

bash
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:

bash
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 user

Run the included smoke test to confirm everything is wired:

bash
pnpm test

Add 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:

bash
pnpm add @basaltkit/auth-sqlite
ts
// 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 basalt CLI, 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.

Released under the MIT License.