Introdução
O Basalt é uma framework modular "baterias incluídas" para construir aplicações SaaS em Node.js. Não é mais uma framework HTTP — o Fastify já faz isso bem. Preenche a camada entre o servidor e um produto SaaS acabado: tenancy, faturação, autenticação, permissões, auditoria, filas, notificações — integrados com uma coerência ponta-a-ponta rara em Node.js, e com inferência de TypeScript da rota até ao cliente.
Experimenta no browser
Sem configuração local — arranca um servidor Basalt executável num WebContainer do StackBlitz:
⚡Correr o playground no StackBlitzPorquê o Basalt
- Self-hosted, sem lock-in. Os teus dados vivem no teu PostgreSQL, os teus utilizadores autenticam-se contra a tua base de dados. Gateways como o Stripe são drivers, não donos do teu estado.
- Multi-tenancy como cidadão de primeira classe. Ao contrário da maioria das stacks Node onde a tenancy é aparafusada por cima, o contexto do tenant permeia cache, storage, queue, logger e Prisma nativamente através do
AsyncLocalStorage. - Convenção acima de configuração. Uma app Basalt corre com zero configuração; tudo é substituível.
- Adoção incremental. Cada pacote funciona sozinho numa app Fastify existente. A framework completa é o destino, não a portagem para entrar.
A visita de 30 segundos
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 })O tipo de params da rota é inferido a partir do schema Zod — o handler fica totalmente tipado, e o mesmo schema pode alimentar o OpenAPI e o cliente SDK.
Dica: Não estás em Fastify?
A mesma route corre sem alterações em Express e Hono — troca fastifyPlugin por expressPlugin ou honoPlugin. Vê Adaptadores HTTP para exemplos completos.
Do zero a correr
O caminho mais rápido do nada até uma API tipada e autenticada é o scaffolder de projeto. Escreve uma app com forma de produção e inclui apenas o que escolheres — nada de código morto é enviado.
1. Scaffold
pnpm create basalt my-saas # ou: npm create basalt my-saasCorre-o sem nome para responderes às perguntas interativamente, ou passa flags para as saltar:
pnpm create basalt my-saas --billing --cli # adiciona subscrições + a CLI `basalt`
pnpm create basalt my-saas --prisma # PostgreSQL via Prisma, ligado de ponta a ponta
pnpm create basalt my-saas -y # aceita todos os defaults, sem perguntasMulti-tenancy e autenticação estão ativos por defeito — desativa com --no-tenancy / --no-auth. Num terminal interativo o scaffolder também instala dependências e inicializa o git por defeito (desativa com --no-install / --no-git); em CI ou execuções piped salta ambos a menos que passes --install / --git. A lista completa de flags vive em Instalação.
2. Instalar e configurar
cd my-saas
pnpm install
cp .env.example .envO .env.example lista as variáveis sob um prefixo próprio da app derivado do nome do projeto — MY_SAAS_PORT, MY_SAAS_HOST, MY_SAAS_LOG_LEVEL e, com auth, um MY_SAAS_APP_SECRET comentado — mais o NODE_ENV, que nunca leva prefixo. Todas são declaradas e validadas em src/env.ts com o @basaltkit/env, que passa { prefix: 'MY_SAAS' } ao defineEnv: cada variável é lida primeiro como MY_SAAS_<NOME> e recua para o nome simples <NOME>. O código continua a ler env.PORT — só mudam os nomes no ambiente.
O pnpm dev corre o src/dev.ts, que define NODE_ENV=development (se ainda não estiver definido), por isso a app arranca mesmo com um ambiente vazio. O APP_SECRET usa secret({ minLength: 32 }): recai num valor descartável apenas com NODE_ENV=development/test. O pnpm start corre o src/server.ts diretamente, onde um NODE_ENV não definido conta como produção, por isso recusa arrancar enquanto não definires um MY_SAAS_APP_SECRET a sério com pelo menos 32 caracteres (openssl rand -base64 48).
Dica: o .env não é carregado por ti
O defineEnv lê o process.env e mais nada — copiar o ficheiro não torna os seus valores visíveis. Exporta as variáveis, arranca com node --env-file=.env (Node 22+), ou deixa o teu gestor de processos injetá-las. Vê Configuração. O --env-file nunca sobrepõe uma variável já exportada na tua shell — e é exatamente por isso que o scaffold usa nomes com prefixo; vê a armadilha de precedência.
3. Correr
pnpm dev # API em http://localhost:3000O src/server.ts arranca a app, resolve a instância Fastify e escuta — e encerra de forma limpa em SIGINT/SIGTERM.
4. Primeiros pedidos
Cada app gerada expõe um índice amigável e um 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 }Com auth ativa (o default), o authRoutes() e o mfaRoutes() já estão ligados — registo, login, refresh, logout, me, verificação de email, reposição de password e inscrição TOTP. Regista-te, inicia sessão e depois chama uma rota autenticada com o token devolvido:
curl -X POST http://localhost:3000/auth/register \
-H 'content-type: application/json' \
-d '{"email":"ada@example.com","password":"secretpassword1"}'
# → 202 { "ok": true } — a mesma resposta quer o email já exista quer não
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>'
# → o utilizador autenticadoCorre o smoke test incluído para confirmar que tudo está ligado:
pnpm testAdicionar um store durável
O scaffold arranca com stores em memória — perfeitos para dev e CI, mas esquecem tudo ao reiniciar. Cada store no Basalt é uma interface com um default em memória, por isso tornar-se durável é uma troca, não uma reescrita.
Começar já com a base de dados ligada
Com o --prisma não há nada para trocar: recebes o prisma/schema.prisma com os modelos de cada domínio que ativaste, o src/db.ts com o cliente com escopo de tenant, as stores Prisma no src/app.ts, um MY_SAAS_DATABASE_URL obrigatório e o prismaPlugin({ assertMigrated: true }), que falha o arranque quando a app é apontada a uma base de dados que nunca foi migrada. Vê PostgreSQL com --prisma.
Abre src/app.ts: o authPlugin está configurado com um MemoryUserSource. Troca-o por um conjunto durável de stores suportado pelo SQLite embutido do Node — sem ORM, sem ferramenta de migração, sem serviço externo:
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:' por defeito
authPlugin({
secret: env.APP_SECRET,
users: stores.users,
sessions: stores.sessions,
refreshTokens: stores.refreshTokens,
tokens: stores.tokens, // verificação de email + reposição de password
mfa: stores.mfa,
})Agora os utilizadores sobrevivem a um reinício. O mesmo padrão troca qualquer store em memória por um durável — vê Persistência e stores duráveis para o mapa completo (backends SQLite e Prisma para auth, teams, auditoria, tenancy e mais).
Para onde a seguir
- Instalação — todas as flags do scaffolder, os requisitos, a CLI
basalte como adicionar o Basalt a uma app existente. - Configuração — o
src/env.ts, segredos que falham fechados e o repositório de definições. - Conceitos Fundamentais — plugins, o container de DI, contexto de pedido e hooks.
- Adaptadores HTTP — as mesmas rotas em Fastify, Express ou Hono.
- Testes — arranca a app no processo, personifica um utilizador ou tenant, faz fake de mail e fila, viaja no tempo.
- Web UI e componentes — um SDK type-safe e tabelas/formulários de admin.
- Construir um SaaS de notas — um passo-a-passo completo ponta-a-ponta.