Express 5 · TypeScript · Next.js

Everything you need to ship an API.

Chassis gives you NestJS-style controller ergonomics on plain Express 5 — in a handful of small files you can actually read. Pick a database, an auth provider and an optional Next.js front end; the scaffolder ships only what you chose.

Overview

#🏎️ Chassis

A lightweight, decorator-driven Express + TypeScript backend starter. Clone, run, ship.

📖 Documentation · Getting started · create-chassis on npm

Chassis gives you NestJS-style controller ergonomics on plain Express 5 — in a handful of small files you can actually read. Zero configuration required: the server boots standalone, and every integration switches on only when you add its environment variable. Scaffold with a preset or pick à la carte — a database (Mongo, Postgres, or SQLite, ORM included), an auth provider (Auth0, JWT, or Clerk), an optional Next.js front end, Sentry, an MCP server, and x402 payments — and the CLI ships only what you chose.

ts
export class UserController extends Routable {
  constructor() {
    super('/users');
  }

  @route('get', '/:id')
  async show(req: Request) {
    const user = await findUser(req.params.id);
    if (!user) throw new AppError(ERROR_CODES.NOT_FOUND, 'User not found');
    return req.resHandler.ok(user);
  }

  @protectedRoute('post', '/', [validate({ body: createUserSchema })])
  async create(req: Request) {
    return req.resHandler.created(await createUser(req.body));
  }
}

Export the class from src/controllers/index.ts — that's the whole wiring.

#Quick start

bash
npm create chassis my-api -- --yes                      # zero prompts: Postgres + JWT + Sentry + Docker
npm create chassis my-app -- --preset fullstack --yes   # the same, plus a Next.js front end
npm create chassis my-api                               # interactive — pick a preset
npm create chassis my-api -- --db postgres --auth jwt --mcp   # à la carte
npm create chassis my-api -- --bare                     # nothing — standalone build

Or use the template directly:

bash
git clone https://github.com/dvd90/chassis.git my-api
cd my-api && npm install && npm run dev

That's it — no database, no env file, no accounts needed. Open http://localhost:8000/status.

New here? Follow the step-by-step getting-started guide — zero to a tested API in ~10 minutes.

#For AI agents

Every path is non-interactive: --yes and --bare never prompt, and the CLI skips prompts automatically whenever stdin isn't a TTY. One command produces a project that already typechecks, lints and tests green.

  • llms.txt — the project, its conventions and its docs index, in one fetch
  • llms-full.txt — every documentation page, concatenated
  • AGENTS.md — the conventions to follow when writing code in a Chassis project, and the definition of done

Generated projects carry AGENTS.md, CLAUDE.md, llms.txt and an add-resource skill, so whichever agent opens one writes code that matches the rest of the codebase rather than fighting it.

#Features

  • TypeScript 5 + Express 5 — strict types, async errors caught automatically
  • Decorator routing@route / @protectedRoute on controller methods, controllers auto-mount
  • Consistent responsesreq.resHandler.ok() / .notFound() / .validation() with structured logging
  • Request correlation — every request gets a callId (or propagates x-call-id), echoed in responses and logs
  • Typed, validated config — zod-checked environment via src/config; the app refuses to boot on bad config
  • Zod input validationvalidate({ body, query, params }) middleware with structured 400s
  • Pick-your-stack scaffolder — presets or à la carte: database + ORM (Mongo/Postgres/SQLite), auth (Auth0/JWT/Clerk), a Next.js front end, Sentry, MCP, x402 — the CLI prunes everything else so package.json carries only what you chose
  • Opt-in integrations — every module enables by env var, never required
  • Payment-gated routes@paidRoute('get', '/report', '$0.01') via the x402 protocol (opt-in)
  • Optional Next.js front end--web adds an App Router app and makes the project an npm-workspaces monorepo (apps/api + apps/web); the auth provider you picked is wired on both sides
  • MCP server — expose your API to AI agents as MCP tools (npm run mcp, opt-in)
  • Health endpoints/healthz (liveness) and /readyz (readiness, checks enabled integrations)
  • Graceful shutdown — drains connections and closes integrations on SIGTERM/SIGINT
  • Vitest + supertest — fast tests against the pure app factory, no server or DB needed
  • DB-aware code generatornpm run gen user scaffolds a controller + test wired to your ORM (Drizzle or Mongoose)
  • Production Docker — multi-stage build, non-root user, plus docker-compose with your database for dev
  • CI + Renovate — GitHub Actions verify pipeline and automated dependency updates
  • AI-agent ready — ships AGENTS.md, CLAUDE.md, llms.txt, and an add-resource skill so agents write code that matches the conventions (see below)

#AI-agent ready

Most people scaffolding a backend today have an AI agent in the loop. Chassis is built so that agent-written code reads like hand-written code — because the framework gives agents rails and a verifiable finish line:

  • AGENTS.md + CLAUDE.md ship in every project — Claude Code, Cursor, Copilot, and Codex pick them up automatically and follow the conventions (thin controllers, resHandler responses, throw AppError, config in one place).
  • One obvious place for everything means agent output converges on the same shape a maintainer would write — that's what keeps it readable.
  • npm run verify (strict TypeScript + ESLint + tests) is a deterministic quality gate agents iterate against until green.
  • .claude/skills/add-resource turns "add a books resource" into one consistent, checklisted operation.
  • llms.txt gives doc-fetching tools a compact map of the conventions.

Nothing to install — it's all in the scaffold. See AGENTS.md.

#Scripts

Command What it does
npm run dev Start with hot reload (tsx watch)
npm test / npm run test:watch Run the vitest suite
npm run verify Typecheck + lint + test (CI runs this)
npm run build / npm start Compile to dist/ and run production build
npm run gen <Name> Generate a controller + test
npm run lint / npm run format ESLint / Prettier

#Enabling integrations

Copy .env.example to .env. Each integration turns on when its variables are set — and stays completely dormant otherwise:

Integration Enable by setting What you get
MongoDB MONGODB_URI Mongoose connection, readiness check, graceful disconnect
Auth0 AUTH0_DOMAIN + AUTH0_AUDIENCE JWT verification on every @protectedRoute
Sentry SENTRY_DSN Automatic error reporting from the central error handler

Using a different IdP? Call setAuthProvider([...yourMiddleware]) at boot and @protectedRoute uses it — see src/core/auth.ts.

#Project structure

code
src/
├── config/          # zod-validated env → typed config + feature flags
├── core/            # the framework: Routable, decorators, responses, errors, validation
├── middleware/      # callId correlation, dev request logging
├── integrations/    # opt-in modules: mongo, auth0, sentry
├── controllers/     # your endpoints — exported classes auto-mount
├── __tests__/       # vitest + supertest
├── app.ts           # pure app factory (no I/O — trivially testable)
└── server.ts        # boot: integrations → listen → graceful shutdown

#Documentation

Read them at dvd90.github.io/chassis — searchable, one page. The source lives in docs/ and the site is generated from it, so the two can never disagree:

#Docker

bash
docker compose up --build     # API + MongoDB
docker build -t my-api .      # production image only

#License

MIT

Guides

#Getting started

From nothing to a running, tested API. Total time: about 10 minutes.

#Prerequisites

  • Node.js 20 or newer (node -v)
  • That's it. No database, no accounts, no Docker required to start.

#Step 1 — Create your project

Option A — the scaffolder (recommended):

bash
npm create chassis my-api

Answer the prompts — each module you decline is completely removed from the generated project (files, dependencies, config):

code
🏎️  create-chassis — Express + TypeScript, ready to drive

Include MongoDB (Mongoose)? (Y/n)
Include Auth0 JWT auth? (Y/n)
Include Sentry error reporting? (Y/n)
Include Docker (Dockerfile + compose)? (Y/n)
Initialize a git repository? (Y/n)
Run npm install now? (Y/n)

Shortcuts: --yes accepts everything, --bare declines every optional module for the smallest possible starting point.

Option B — use the template directly:

bash
git clone https://github.com/dvd90/chassis.git my-api
cd my-api
rm -rf .git && git init
npm install

#Step 2 — Run it

bash
cd my-api
npm run dev
code
info No integrations configured — running standalone
info 🏎️  Chassis running on http://localhost:8000

Check it's alive:

bash
curl http://localhost:8000/status
# {"status":"up","uptime":2.14}

Note there was no configuration step. Chassis boots with zero env vars; integrations activate later, when you add theirs (step 7).

#Step 3 — Add your first endpoint

bash
npm run gen todo

This creates src/controllers/Todo.controller.ts (a CRUD skeleton), a matching test, and wires the export into src/controllers/index.ts. The dev server picks it up on save:

bash
curl http://localhost:8000/todos
# {"items":[]}

#Step 4 — Understand what you're looking at

Open the generated controller:

ts
export class TodoController extends Routable {
  constructor() {
    super('/todos'); // base path — every route below is relative to it
  }

  @route('get', '/')
  async index(req: Request): Promise<Response> {
    return req.resHandler.ok({ items: [] });
  }

  @route('post', '/', [validate({ body: createTodoSchema })])
  async create(req: Request): Promise<Response> {
    return req.resHandler.created({ item: req.body });
  }
}

Three ideas carry the whole framework:

  1. @route declares endpoints. Export the class from src/controllers/index.ts and it auto-mounts. No router files.
  2. req.resHandler sends responses. ok, created, notFound, validation, … — every response is logged with a correlation id.
  3. Just throw for errors. throw new AppError(ERROR_CODES.NOT_FOUND, '...') anywhere; the central handler maps it to a clean JSON response.

More depth: Architecture · Core API.

#Step 5 — Run the tests

bash
npm test

Tests hit the app factory directly with supertest — no port, no database, so they run in about a second. Try npm run test:watch while developing.

#Step 6 — Verify like CI does

bash
npm run verify   # typecheck + lint + test

The pre-commit hook and the GitHub Actions workflow both run exactly this, so green locally means green in CI.

#Step 7 — Enable an integration (when you need it)

bash
cp .env.example .env

Set a variable, restart, done:

You set You get
MONGODB_URI=mongodb://localhost:27017/my-api Mongoose connected, /readyz checks it, graceful disconnect
AUTH0_DOMAIN + AUTH0_AUDIENCE JWT verification on every @protectedRoute
SENTRY_DSN Unexpected errors reported to Sentry

No MongoDB running? docker compose up mongo starts one (if you kept the Docker module). Full walkthroughs: Building an API and Authentication.

#Step 8 — Build for production

bash
npm run build    # compiles to dist/
npm start        # runs dist/server.js

Or build the production Docker image — multi-stage, non-root, ~150 MB:

bash
docker build -t my-api .
docker run -p 8000:8000 my-api

#Where to go next

Guides

#Building an API

This guide builds a complete books resource: Mongoose model, validated input, proper errors, and tests. It assumes you've read Getting started.

#1. Start MongoDB and enable the integration

bash
docker compose up -d mongo          # or any MongoDB you have around
echo 'MONGODB_URI=mongodb://localhost:27017/my-api' >> .env
npm run dev
code
info ✅ MongoDB connected
info Integrations enabled: mongo

/readyz now reports the connection state — try stopping Mongo and watching it flip to 503.

#2. Define the model

Create src/models/Book.model.ts:

ts
import { model, Schema } from 'mongoose';

export interface Book {
  title: string;
  author: string;
  year?: number;
}

const bookSchema = new Schema<Book>(
  {
    title: { type: String, required: true },
    author: { type: String, required: true },
    year: { type: Number }
  },
  { timestamps: true }
);

export const BookModel = model<Book>('Book', bookSchema);

#3. Define the input schema

Zod schemas double as validation and TypeScript types. Create src/schemas/book.schema.ts:

ts
import { z } from 'zod';

export const createBookSchema = z.object({
  title: z.string().min(1),
  author: z.string().min(1),
  year: z.coerce.number().int().min(0).optional()
});

export type CreateBookInput = z.infer<typeof createBookSchema>;

#4. Write the controller

Create src/controllers/Book.controller.ts (or start from npm run gen book and edit):

ts
import { Request, Response } from 'express';
import { AppError, ERROR_CODES, Routable, route, validate } from '../core';
import { BookModel } from '../models/Book.model';
import { createBookSchema } from '../schemas/book.schema';

export class BookController extends Routable {
  constructor() {
    super('/books');
  }

  @route('get', '/')
  async index(req: Request): Promise<Response> {
    const books = await BookModel.find().sort({ createdAt: -1 });
    return req.resHandler.ok({ items: books });
  }

  @route('get', '/:id')
  async show(req: Request): Promise<Response> {
    const book = await BookModel.findById(req.params.id);
    if (!book) {
      throw new AppError(ERROR_CODES.NOT_FOUND, 'Book not found');
    }
    return req.resHandler.ok(book);
  }

  @route('post', '/', [validate({ body: createBookSchema })])
  async create(req: Request): Promise<Response> {
    const book = await BookModel.create(req.body);
    return req.resHandler.created(book);
  }

  @route('delete', '/:id')
  async destroy(req: Request): Promise<Response> {
    const deleted = await BookModel.findByIdAndDelete(req.params.id);
    if (!deleted) {
      throw new AppError(ERROR_CODES.NOT_FOUND, 'Book not found');
    }
    return req.resHandler.noContent();
  }
}

Export it from src/controllers/index.ts:

ts
export * from './Book.controller';

That's the entire wiring. Notice what you did not write: no router file, no try/catch (thrown errors — including Mongoose failures — land in the central error handler), no manual 400 handling (the validate middleware answers with a structured issue list).

#5. Try it

bash
curl -X POST localhost:8000/books \
  -H 'content-type: application/json' \
  -d '{"title":"Dune","author":"Frank Herbert","year":1965}'
# 201 {"_id":"...","title":"Dune",...}

curl -X POST localhost:8000/books -H 'content-type: application/json' -d '{}'
# 400 {"statusReason":"Validation Failed","issues":[
#   {"part":"body","path":"title","message":"Required"},
#   {"part":"body","path":"author","message":"Required"}],...}

curl localhost:8000/books/000000000000000000000000
# 404 {"statusReason":"Not Found","message":"Book not found",...}

Every response carries an x-call-id header, and every log line carries the same id — grep your logs by it when debugging.

#6. Test it

Controllers are testable without a running server. For DB-backed tests, either point MONGODB_URI at a throwaway database, use mongodb-memory-server, or keep controller logic thin and unit-test your services instead.

A pattern that needs no database — mount a throwaway controller through the factory's test seam (extraRoutables) — is demonstrated in src/__tests__/app.test.ts.

#7. Keep growing

  • Business logic getting thick? Add src/services/book.service.ts and keep controllers as thin translation layers.
  • Need auth on the write paths? Change @route to @protectedRoute — see Authentication.
  • Need query-string filtering? validate({ query: listBooksSchema }) works the same way as body.
Guides

#Database

Chassis ships one database at a time — you pick it when scaffolding (--db mongo|postgres|sqlite, or a preset). The ORM comes with the choice: Mongoose for Mongo, Drizzle for Postgres and SQLite. Like every integration it stays off until its env var is set, so the app boots standalone in tests.

#Postgres / SQLite (Drizzle)

Set the connection string:

bash
# .env
DATABASE_URL=postgres://postgres:postgres@localhost:5432/chassis   # postgres
# or
SQLITE_PATH=chassis.db                                             # sqlite

src/db/<engine>/ holds the Drizzle client (db) and your schema.ts. Add tables there, or let the generator do it:

bash
npm run gen Widget

This appends a widgets table to schema.ts and generates a controller wired to Drizzle (db.select().from(widgets), insert().values().returning(), …). Then create and run the migration:

bash
npx drizzle-kit generate --config src/db/<engine>/drizzle.config.ts
npx drizzle-kit migrate  --config src/db/<engine>/drizzle.config.ts

/readyz pings the database so orchestrators only route traffic once it's reachable. With Docker enabled, docker compose up -d starts a matching Postgres.

#MongoDB (Mongoose)

bash
# .env
MONGODB_URI=mongodb://localhost:27017/chassis

Mongoose has no shared db handle — models register themselves on import. npm run gen Widget writes src/db/mongo/widget.model.ts and a controller using WidgetModel. No migration step.

#Switching or adding a database

The three are parallel integrations. To swap after scaffolding, follow the module contract: add src/integrations/<db>.ts, an env var + features.<db> flag in src/config, and a guarded init in src/integrations/index.ts. Prisma isn't shipped (its codegen fights the everything-installed template), but drops in the same way if you prefer it.

Guides

#Authentication

@protectedRoute marks an endpoint as requiring authentication. Who verifies the request is pluggable: Chassis ships an Auth0 integration, and swapping in any other provider is one function call.

Until a provider is configured, protected routes answer 501 with a message explaining exactly what to set — the route is never silently open.

#Option A — Auth0 (built in)

#1. Create an API in Auth0

  1. Auth0 Dashboard → Applications → APIs → Create API
  2. Pick any name; set the Identifier (this becomes your audience), e.g. https://my-api.example.com
  3. Note your tenant domain, e.g. my-tenant.eu.auth0.com

#2. Configure Chassis

bash
# .env
AUTH0_DOMAIN=my-tenant.eu.auth0.com
AUTH0_AUDIENCE=https://my-api.example.com

Restart — you should see ✅ Auth0 authentication enabled. Both variables must be set; with either missing the integration stays off.

#3. Protect a route

ts
import { protectedRoute } from '../core';

@protectedRoute('post', '/', [validate({ body: createBookSchema })])
async create(req: Request): Promise<Response> {
  return req.resHandler.created(await BookModel.create(req.body));
}

Extra middlewares run after auth, so they can trust the request.

#4. Call it with a token

Get a test token (Auth0 Dashboard → your API → Test tab, or client credentials):

bash
TOKEN=$(curl -s https://my-tenant.eu.auth0.com/oauth/token \
  -H 'content-type: application/json' \
  -d '{"client_id":"...","client_secret":"...","audience":"https://my-api.example.com","grant_type":"client_credentials"}' \
  | jq -r .access_token)

curl localhost:8000/books -H "authorization: Bearer $TOKEN" \
  -X POST -H 'content-type: application/json' \
  -d '{"title":"Dune","author":"Frank Herbert"}'

Missing/invalid tokens get a structured 401 from the central error handler. To read the token's claims in a handler, use the auth property that express-oauth2-jwt-bearer sets on the request (req.auth?.payload.sub, etc.).

#Option B — Local JWT (built in)

Pick --auth jwt for self-issued Bearer tokens with no third party. Set a secret:

bash
# .env
JWT_SECRET=a-long-random-string

Unlike the hosted providers, this one has no user directory behind it — so Chassis ships the missing half: src/controllers/Auth.controller.ts mints tokens that src/integrations/jwt.ts then verifies (HS256, via jose).

bash
curl localhost:8000/auth/register -H 'content-type: application/json' \
  -d '{"email":"dev@example.com","password":"correct-horse-42"}'
# → 201 { "user": { "id": "1", "email": "dev@example.com" }, "token": "eyJ..." }

curl localhost:8000/auth/login -H 'content-type: application/json' \
  -d '{"email":"dev@example.com","password":"correct-horse-42"}'
# → 200 { "user": {...}, "token": "eyJ..." }

Passwords are hashed with scrypt from Node's node:crypto — a memory-hard KDF in the standard library, so there is no argon2/bcrypt dependency and no native build (src/utils/password.ts).

#Where users are stored

src/db/users.ts resolves the store the same way integrations resolve themselves — by feature flag, at call time:

Configured database Store
SQLite / Postgres Drizzle users table (src/db/<db>/users.ts)
MongoDB Mongoose User model (src/db/mongo/users.ts)
none in-memory, seeded from AUTH_DEV_EMAIL / AUTH_DEV_PASSWORD

Pick a database and the store follows it — no code change. The in-memory fallback exists so --auth jwt --db none still boots and logs in during development; it is process-local and forgets everything on restart. Add a database before putting local JWT in front of real users.

With Drizzle, generate the migration for the users table before first use:

bash
npx drizzle-kit generate --config src/db/sqlite/drizzle.config.ts

Tokens are access tokens only, valid for one hour — there is no refresh rotation. Clients re-authenticate when a token expires; add a refresh endpoint alongside /auth/login if you need sessions to outlive that without a password prompt.

#Option C — Clerk (built in)

Pick --auth clerk to use Clerk. Set the key (Clerk reads it from the env itself):

bash
# .env
CLERK_SECRET_KEY=sk_test_...

src/integrations/clerk.ts registers Clerk's middleware; read the session in a handler with getAuth(req) from @clerk/express.

#Option D — any other provider

@protectedRoute delegates to whatever middleware chain was registered with setAuthProvider() (see src/core/auth.ts). To use your own IdP, API keys, sessions — anything — register a chain at boot.

Example: simple API-key auth. Create src/integrations/apiKey.ts:

ts
import { RequestHandler } from 'express';
import { setAuthProvider } from '../core/auth';

const checkApiKey: RequestHandler = (req, res, next) => {
  if (req.header('x-api-key') !== process.env.API_KEY) {
    return req.resHandler.wrongToken('Invalid API key');
  }
  next();
};

export function initApiKeyAuth(): void {
  setAuthProvider([checkApiKey]);
}

Then register it in src/integrations/index.ts behind a feature flag, exactly like the built-ins (the pattern is documented in Modules). Every @protectedRoute in the app now uses your provider — controllers don't change at all.

#From the browser

If you scaffolded the Next.js front end (--web), the same three providers have a matching web half in apps/web/auth/providers/. All of them do one job — hand apiFetch a bearer token for the API — behind one function, getAccessToken():

Provider Web package How the token is obtained
jwt (none) sign-in form → /api/session → API /auth/loginhttpOnly cookie
auth0 @auth0/nextjs-auth0 hosted login → SDK session → getAccessToken()
clerk @clerk/nextjs <SignIn/>auth().getToken()

Two notes that cost people hours:

  • Auth0 needs an audience. apps/web/auth/providers/auth0.shared.ts passes authorizationParameters.audience. Without it Auth0 returns an ID token rather than an API access token, and every @protectedRoute call is rejected. It must match AUTH0_AUDIENCE on the API exactly.
  • The local-JWT token never touches client JavaScript. The form posts to a Next route handler, which calls the API server-side and returns the token only as an httpOnly cookie — so an injected script cannot read it.

See Web front end for the full picture.

#How it works (and why 501)

Route decorators run at class-definition time, before any integration has initialized. requireAuth() therefore resolves the provider per request, not at decoration time. If nothing registered a provider, the request is answered with 501 Not Implemented and a hint — a loud, obvious failure instead of an accidentally-public endpoint.

Guides

#Web front end

--web adds a Next.js 15 App Router front end and turns the project into an npm-workspaces monorepo. Without the flag nothing changes: the project stays a single package, exactly as it is today.

bash
npm create chassis my-app -- --preset fullstack     # Postgres + JWT + web
npm create chassis my-app -- --web --auth clerk     # à la carte

#Layout

code
my-app/
  package.json          # workspaces root: dev / build / verify / format
  apps/api/             # the Chassis backend — src, scripts, Dockerfile
  apps/web/             # the Next.js app
  docs/  README.md  docker-compose.yml  .github/

npm run dev at the root starts both (API on :8000, web on :3000). npm run verify runs each workspace's own verify. npm run gen <Name> still scaffolds a resource — it forwards to apps/api.

Nothing forces you into npm workspaces beyond the root package.json: there is no monorepo tool, no build graph, no plugin versions to keep in step. Two apps do not need a task orchestrator; if the repo grows to the point where one earns its keep, adding it later is a package.json edit.

#Talking to the API

apps/web/lib/api.ts is the whole client:

ts
const status = await apiFetch<Status>('/status');

It runs server-side only — it reads the session to attach Authorization: Bearer <token>, and a token must never be exposed to the browser. Call it from server components and route handlers. Point API_URL at the backend (.env.local, defaults to http://localhost:8000).

#Swapping auth providers

Every provider lives in apps/web/auth/providers/ and exports the same five things. The app never names one directly — it imports from auth/active.ts, which is a single re-export line:

ts
// apps/web/auth/active.ts
export * from './providers/jwt';

That line (and its twin in active-middleware.ts, kept separate so the middleware bundle stays free of React and next/headers) is what create-chassis rewrites for --auth. Changing provider afterwards means editing it, installing the new SDK, and swapping the env vars.

The generated project keeps only the provider you chose. In the Chassis repo itself all four coexist, and apps/web/auth/providers/conformance.ts typechecks each one against the AuthModule contract — so a provider that drifts out of shape fails CI here rather than in someone's generated project. That file is template-only and never ships.

#Pages

Route What it demonstrates
/ server component calling the API, with the token if there is one
/sign-in the active provider's SignInPanel
/account protected: redirects when signed out, then makes an authed call

/account is guarded twice on purpose — middleware redirects the obvious cases, and the page re-checks. Middleware alone is never the guard.

#Docker

docker-compose.yml builds the API from apps/api. The Dockerfile there uses npm install rather than npm ci, because the workspace lockfile lives at the repo root and is not part of that build context. The web app has no compose service — deploy it wherever you deploy Next.

Guides

#MCP server

There are two, and they do opposite things:

What it exposes
--mcp module (this page) your API, as tools an agent can call
chassis-mcp Chassis itself — an agent calls it to create a project

If you want an agent to scaffold Chassis projects for you, that's the second one; point your client at npx -y chassis-mcp and it gains list_chassis_options, create_chassis_project and chassis_conventions. The rest of this page is about the first.


The MCP module (--mcp) exposes your API to AI agents as Model Context Protocol tools. It runs as a separate stdio process — the way agent clients (Claude Desktop, etc.) launch tool servers — not mounted in the HTTP app. That keeps the ESM-only MCP SDK out of the compiled dist build; it runs via tsx.

#Run it

bash
npm run mcp        # starts the stdio server

Point your agent client at that command. MCP_API_URL (default http://localhost:8000) tells the server where the running API is.

#Add tools

Tools live in src/mcp/tools.ts. The example proxies the API's health probe; add your own with server.registerTool:

ts
server.registerTool(
  'list_widgets',
  { description: 'List all widgets', inputSchema: {} },
  async () => {
    const res = await fetch(`${config.mcp.apiUrl}/widgets`);
    return { content: [{ type: 'text', text: await res.text() }] };
  }
);

Give a tool inputs with a Zod raw shape as its inputSchema ({ id: z.string() }), and the handler receives typed args.

Tools that call your own endpoints keep a single source of truth (the HTTP API); tools can equally call service functions directly.

Guides

#Payments (x402)

The x402 module (--x402) gates routes behind an HTTP 402 payment using the x402 protocol — pay-per-request in stablecoins, settled by a facilitator. It mirrors auth: a @paidRoute decorator plus a pluggable gate, so payment verification never lives in your controllers.

#Configure

bash
# .env
X402_PAY_TO=0xYourWalletAddress
X402_NETWORK=base-sepolia          # testnet default; use `base` for mainnet

With X402_PAY_TO set you'll see ✅ x402 payments enabled at boot. Unset, a @paidRoute answers 501 with a hint — never accidentally free.

#Gate a route

ts
import { paidRoute } from '../core';

@paidRoute('get', '/report', '$0.01')
async report(req: Request): Promise<Response> {
  return req.resHandler.ok({ report: buildReport() });
}

A caller without a valid payment gets 402 Payment Required with the payment details; the x402 client library on their side handles the settlement and retries. The price string ('$0.01') is USDC.

#How it works

@paidRoute uses requirePayment(price), which resolves the registered gate per request (see src/core/payments.ts) — so decorators can run before the integration boots. The x402 integration registers a gate backed by x402-express's paymentMiddleware, keyed to each route's path and price. Swap in any other provider by calling setPaymentGate() from your own integration; @paidRoute doesn't care who settles the payment.

Guides

#Deployment

#Build once, run anywhere

bash
npm run build     # TypeScript → dist/ (tests excluded)
npm start         # node dist/server.js

The compiled output has no build-time dependencies — a production install (npm ci --omit=dev) plus dist/ is a complete deployment.

#Docker

The included Dockerfile is production-shaped out of the box:

  • Multi-stage — dev dependencies never reach the final image
  • Non-root — runs as the node user
  • npm ci — reproducible installs from the lockfile
bash
docker build -t my-api .
docker run -p 8000:8000 --env-file .env my-api

#docker-compose (local prod-like stack)

bash
docker compose up --build

Starts the API plus MongoDB (if you kept the Mongo module), wired together — the compose file sets MONGODB_URI to the internal hostname.

#Health probes

Chassis exposes the two endpoints orchestrators expect:

Endpoint Meaning Use as
GET /healthz Process is up Liveness probe
GET /readyz Enabled integrations are ready (e.g. Mongo connected); 503 otherwise Readiness probe

Kubernetes example:

yaml
livenessProbe:
  httpGet: { path: /healthz, port: 8000 }
readinessProbe:
  httpGet: { path: /readyz, port: 8000 }

#Graceful shutdown

On SIGTERM/SIGINT the server stops accepting connections, drains in-flight requests, disconnects integrations, then exits (with a 10s force-exit failsafe). This is exactly what rolling deploys on Kubernetes, ECS, Fly, Railway, Render, etc. need — no special handling required on your side.

#PaaS notes

Any platform that runs a Node server or a Dockerfile works:

  • Build command: npm run build · Start command: npm start (or just point the platform at the Dockerfile)
  • Set PORT if the platform injects its own (Chassis reads it)
  • Set NODE_ENV=production

#Production checklist

  • NODE_ENV=production — enables JSON logs, hides stack traces from responses
  • CORS_ORIGINS=https://your-frontend.com — unset means allow all, which you want in dev but not in prod
  • Secrets (MONGODB_URI, SENTRY_DSN, …) come from the platform's secret store, not a committed file — .env is gitignored, keep it that way
  • SENTRY_DSN set if you want error reporting (recommended)
  • Point probes at /healthz and /readyz
  • CI is green (npm run verify — the included GitHub Actions workflow runs it on Node 20 and 22)
Concepts

#Architecture

Chassis is deliberately small: the "framework" is ~7 files in src/core that you can read in one sitting. This page explains how a request flows through it and why the pieces are shaped the way they are.

#Request lifecycle

code
request
  │
  ├─ helmet, cors                      security headers, CORS policy
  ├─ callIdMiddleware                  req.callId = x-call-id header or new UUID
  ├─ ResponseHandler.middleware        req.resHandler = per-request response helper
  ├─ express.json()                    body parsing (after resHandler, so parse
  │                                    errors still get structured responses)
  ├─ requestLogger                     dev only: method/path/status/duration
  │
  ├─ controller route                  your @route handler
  │     └─ throws? Express 5 forwards rejected promises automatically
  │
  ├─ 404 handler                       structured JSON for unmatched routes
  └─ central error handler             AppError → mapped status
                                       401/403 → wrongToken
                                       400 → badRequest
                                       anything else → 500 (+ Sentry if enabled)

#The decorator flow

  1. Class definition time@route('get', '/:id') runs and pushes a RouteDefinition (method, path, handler name, middlewares) onto a symbol-keyed array on the controller's prototype. No router exists yet.
  2. Boot timecreateApp() iterates every class exported from src/controllers, instantiates it, and calls registerToRouter(app). That builds a fresh express.Router, binds each handler to the instance, and mounts it at the controller's base path.

Because decorators only record metadata, ordering problems disappear: @protectedRoute can be evaluated long before the auth integration initializes — requireAuth() resolves the provider per request.

#Errors

Two intentional paths:

  • Expected failures — return them: req.resHandler.notFound('...'), or throw new AppError(ERROR_CODES.CONFLICT, '...') from anywhere (controller, service, model hook). The central handler maps it.
  • Unexpected failures — just let them throw. Express 5 catches rejected promises; the central handler logs the stack (with callId), reports to Sentry when enabled, and returns a sanitized 500 (stack traces are only included outside production).

#Why an app factory?

createApp() performs no I/O — integrations boot separately in server.ts. Tests build the app synchronously and hit it with supertest; no port, no database, no mocks of the framework itself. The extraRoutables option lets tests (or plugins) mount throwaway controllers to exercise any code path — see src/__tests__/app.test.ts.

#Configuration

src/config is the only file that touches process.env. The zod schema validates at import time and the process exits with a readable message on bad config. Feature flags (config.features.*) are derived from which variables are present — that's the entire opt-in mechanism.

Concepts

#Integrations & modules

Every integration in Chassis follows the same contract, which is what makes the template safe to strip down (the create-chassis CLI does exactly that) and easy to extend.

#The contract

An integration is:

  1. One file in src/integrations/<name>.ts exporting init<Name>() (plus optional close<Name>() / readiness helpers).
  2. One feature flag in src/config derived from its env vars: features.<name> = Boolean(env.SOME_VAR).
  3. Single-line hooks where it touches shared code — registration in src/integrations/index.ts, optional lines in the health controller, error handler, .env.example, and docker-compose.yml.

Nothing else in the codebase may import an integration directly (the health controller's readiness check is the one sanctioned exception).

#Built-in integrations

Module Env vars Hooks
mongo MONGODB_URI connect at boot, /readyz check, graceful disconnect, compose service
postgres DATABASE_URL Drizzle client, /readyz ping, graceful close, compose service
sqlite SQLITE_PATH Drizzle + better-sqlite3, /readyz ping, graceful close
auth0 AUTH0_DOMAIN, AUTH0_AUDIENCE registers the auth provider used by @protectedRoute
jwt JWT_SECRET registers a jose Bearer-token provider for @protectedRoute
clerk CLERK_SECRET_KEY registers Clerk's middleware as the @protectedRoute provider
sentry SENTRY_DSN Sentry.init at boot, captureException in the error handler
x402 X402_PAY_TO registers the payment gate used by @paidRoute
mcp (none — separate stdio process) npm run mcp server exposing the API as agent tools
web (none — a separate app) Next.js front end; restructures the project into a workspaces monorepo

#Choice groups

Two concerns are pick-exactly-one groups rather than independent toggles, because their options are mutually exclusive:

  • Databasenone / mongo / postgres / sqlite. The ORM follows the choice (Mongoose or Drizzle). See Database.
  • Authnone / auth0 / jwt / clerk, all sharing the setAuthProvider() seam. See Authentication. Each variant also has a web half (web/auth/providers/<name>) behind the equivalent front-end seam — one re-export line in web/auth/active.ts. Local JWT additionally ships the piece the hosted providers don't need: a register/login controller and a user store that follows the database choice. See Web front end.

Mechanically a group variant is just a module in the chassis:<name> namespace: choosing Postgres declines mongo and sqlite, which prune exactly like a declined toggle. The template ships every variant installed together; the CLI keeps only the one you pick.

@protectedRoute (auth) and @paidRoute (x402) live in src/core and are always present — with no provider configured they answer 501, never open.

#The chassis:<name> markers

Lines that exist only for a specific integration carry a trailing // chassis:<name> (or # chassis:<name>) marker:

ts
import { initMongo, closeMongo } from './mongo'; // chassis:mongo

The create-chassis CLI uses these markers to prune declined modules: it deletes the module's file, drops every marked line, and removes the dependency from package.json — leaving a project that compiles and tests green as if the module never existed.

Rule of thumb: keep every marked construct on a single line.

#Adding your own integration

  1. Create src/integrations/redis.ts with initRedis() / closeRedis().
  2. Add REDIS_URL to the schema in src/config and features.redis: Boolean(env.REDIS_URL).
  3. Register it in src/integrations/index.ts: if (config.features.redis) await initRedis();
  4. Optionally add a /readyz check and an .env.example entry.

Swap rather than add: to replace Auth0 with any other IdP, call setAuthProvider([...yourMiddleware]) from your own integration — @protectedRoute doesn't know or care who verifies the token.

Reference

#CLI & scripts reference

#create-chassis

bash
npm create chassis my-api                    # interactive — pick a preset
npm create chassis my-api -- --preset lite   # SQLite + JWT, no infrastructure
npm create chassis my-api -- --yes           # Recommended API preset, no prompts
npm create chassis my-api -- --bare          # nothing — standalone build
npm create chassis my-api -- --preset fullstack              # + Next.js front end
npm create chassis my-api -- --db postgres --auth jwt --mcp   # à la carte

The interactive flow asks for one preset, then (only if you pick Custom) walks you through each choice. Every choice is also a flag, so nothing is interactive-only. Run npm create chassis -- --help for the generated list of presets, choices, and toggles.

#Presets (--preset <name>)

Preset Stack
api Postgres + JWT + Sentry + Docker (the default)
fullstack api + a Next.js front end (workspaces monorepo)
lite SQLite + JWT — zero external infrastructure
minimal No database, no auth — standalone

#Choices — pick exactly one of each

Group Values
--db <name> none · mongo · postgres · sqlite
--auth <name> none · auth0 · jwt · clerk

Choosing a database brings its ORM: mongo → Mongoose, postgres/sqlite → Drizzle.

#Toggles — independent on/off

Flag Module
--sentry Sentry error reporting
--mcp MCP server exposing the API as agent tools
--x402 x402 payment-gating via @paidRoute
--web Next.js front end — see Web front end
--docker Dockerfile + docker-compose

#Other flags

Flag Effect
--yes, -y No prompts; use the api preset (or --preset)
--bare No prompts; the minimal preset, no Docker
--no-install Skip npm install
--no-git Skip git init
(no TTY) Behaves like --yes — safe in CI

#What it does, in order

  1. Downloads the template (GitHub tarball; requires tar on PATH)
  2. Resolves your selection (preset → prompts → flags, flags win) and shows a confirmation summary
  3. Prunes everything not chosen — deletes each declined module's files and directories, strips every line carrying its chassis:<name> marker, and removes its dependencies, devDependencies, and npm scripts from package.json. A dependency shared by a kept module (e.g. drizzle-orm, used by both Postgres and SQLite) is never removed.
  4. Renames the package, resets the version, git-inits (optional), installs (optional)
  5. Makes the project the user's own — rewrites the LICENSE copyright and removes the maintainer-only docs/maintainers.md

The generated project passes npm run verify and npm run build, and its package.json carries only the dependencies the chosen modules need — no dead weight.

For developing the CLI itself, scaffold from a local checkout instead of the network:

bash
CHASSIS_TEMPLATE=/path/to/template node cli/index.mjs /tmp/test-app --bare

#npm run gen <Name>

Scaffolds a resource:

bash
npm run gen user        # → UserController at /users
npm run gen BlogPost    # → BlogPostController at /blog-posts

Creates src/controllers/<Name>.controller.ts, a smoke test in src/__tests__/, and appends the export to src/controllers/index.ts. It is database-aware: it detects the installed DB and generates matching persistence code —

  • Postgres / SQLite — a controller wired to Drizzle, plus a table appended to src/db/<engine>/schema.ts. Generate the migration with npx drizzle-kit generate --config src/db/<engine>/drizzle.config.ts.
  • Mongo — a Mongoose model in src/db/mongo/ and a controller using it.
  • No database — an in-memory skeleton.

Refuses to overwrite an existing controller.

#npm scripts

Script What it does
npm run dev Dev server with hot reload (tsx watch)
npm run build Compile to dist/ (tests + src/mcp excluded)
npm start Run the compiled server (node dist/server.js)
npm test Run the vitest suite once
npm run test:watch Vitest in watch mode
npm run typecheck tsc --noEmit
npm run lint ESLint over the repo
npm run format Prettier --write
npm run verify typecheck + lint + test — what CI and the pre-commit hook run
npm run gen <Name> Generate a controller (above)
npm run mcp Start the MCP server over stdio (only with the MCP module)

With the Next.js front end (--web) the project is a workspaces monorepo, so these live in apps/api and the root gains orchestration instead: npm run dev starts both apps, npm run build and npm run verify fan out to every workspace, and npm run gen <Name> forwards to apps/api. See Web front end.

#Git hooks

Husky runs npm run verify on pre-commit. Bypass in an emergency with git commit --no-verify (CI will still catch you).

Reference

#Configuration reference

All configuration is environment variables, declared and validated with zod in src/config/index.ts — the only file that reads process.env. On invalid config the process prints each problem and exits before binding a port.

.env files are loaded automatically in development (via dotenv); in production, inject real environment variables instead.

#Variables

Variable Type / default Purpose
NODE_ENV development | test | production — default development Log format (pretty vs JSON), stack-trace exposure, dev request logging
PORT positive integer — default 8000 HTTP port
CORS_ORIGINS comma-separated list — default (empty = allow all) Allowed CORS origins, e.g. https://app.example.com,https://admin.example.com
MONGODB_URI string — optional Enables the Mongo module. Standard connection string
DATABASE_URL string — optional Enables the Postgres module (Drizzle). Postgres connection string
SQLITE_PATH string — optional Enables the SQLite module (Drizzle). File path, or :memory:
AUTH0_DOMAIN string — optional Enables Auth0 (with AUTH0_AUDIENCE). Tenant domain, e.g. my-tenant.eu.auth0.com
AUTH0_AUDIENCE string — optional Enables Auth0 (with AUTH0_DOMAIN). The API identifier from the Auth0 dashboard
JWT_SECRET string — optional Enables local JWT auth (jose). Signs /auth/login tokens and verifies Bearer tokens
AUTH_DEV_EMAIL string — optional Seeds one account in the in-memory user store (only used when no database is configured)
AUTH_DEV_PASSWORD string — optional Password for AUTH_DEV_EMAIL. Development only — the store is not persistent
CLERK_SECRET_KEY string — optional Enables Clerk auth. Clerk secret key
SENTRY_DSN string — optional Enables Sentry error reporting
X402_PAY_TO string — optional Enables x402 payments for @paidRoute. Wallet address receiving payments
X402_NETWORK string — default base-sepolia x402 network (base-sepolia testnet, base mainnet)
MCP_API_URL string — default http://localhost:8000 API base URL the npm run mcp server calls into

#Feature flags

config.features is derived from which variables are present — this is the entire opt-in mechanism:

ts
features: {
  mongo:    Boolean(env.MONGODB_URI),
  postgres: Boolean(env.DATABASE_URL),
  sqlite:   Boolean(env.SQLITE_PATH),
  auth0:    Boolean(env.AUTH0_DOMAIN && env.AUTH0_AUDIENCE),
  jwt:      Boolean(env.JWT_SECRET),
  clerk:    Boolean(env.CLERK_SECRET_KEY),
  sentry:   Boolean(env.SENTRY_DSN),
  x402:     Boolean(env.X402_PAY_TO)
}

src/integrations/index.ts initializes each integration only when its flag is true; /readyz checks only enabled integrations.

#Adding a variable

  1. Add it to the zod schema in src/config/index.ts:

    ts
    REDIS_URL: z.string().optional();
  2. Expose it (and a feature flag if it gates an integration) on the exported config object.

  3. Document it in .env.example.

Never read process.env elsewhere — the single-schema rule is what makes misconfiguration fail loudly at boot instead of quietly at 3am.

Reference

#Core API reference

Everything below is exported from src/core (import { ... } from '../core').

#Routable

Base class for controllers.

ts
class UserController extends Routable {
  constructor() {
    super('/users'); // base path (default '/')
  }
}
  • registerToRouter(app, basePath?) — builds an express.Router from the class's decorated methods and mounts it. Called for you by the app factory for every class exported from src/controllers/index.ts.

#@route(method, path, middlewares?)

Declares an endpoint on a Routable method.

ts
@route('get', '/:id')
async show(req: Request): Promise<Response> { ... }

@route('post', '/', [validate({ body: schema }), rateLimiter])
async create(req: Request): Promise<Response> { ... }
  • method: 'get' | 'post' | 'put' | 'patch' | 'delete' | 'options' | 'head'
  • path: Express path, relative to the controller's base path
  • middlewares: optional RequestHandler[], run before the handler
  • Handlers may throw — Express 5 forwards rejected promises to the central error handler.

#@protectedRoute(method, path, middlewares?)

Like @route, but the configured auth provider runs first; your middlewares run after it. Answers 501 when no provider is configured. See Authentication.

#req.resHandler (ResponseHandler)

Attached to every request. Every method logs structured metadata (status, method, endpoint, callId) and returns the Express Response.

Success:

Method Status
ok(data?) 200
created(data?) 201
noContent() 204

Failure — all respond with a consistent envelope { statusCode, statusReason, errorId, callId, ...extra }:

Method Status
badRequest(message?) 400
validation(issues) 400
wrongToken(message?) 401
forbidden(message?) 403
notFound(message?) 404
conflict(message?) 409
error(err) 500 — sanitized in production, stack included otherwise
notImplemented(message?) 501
serviceUnavailable(data?) 503
manualError(code, data?) any ErrorCode

#AppError and ERROR_CODES

Throw from anywhere (controller, service, model hook); the central error handler maps it:

ts
throw new AppError(ERROR_CODES.CONFLICT, 'Email already registered');
throw new AppError(ERROR_CODES.FORBIDDEN, 'Owners only', { userId });
  • new AppError(code, message?, details?)details is included in the response body when provided.
  • ERROR_CODES entries carry { id, statusCode, statusReason }; id is a stable application-level code clients can switch on. Add your own domain codes to src/core/errors.ts.

#validate(schemas)

Zod validation middleware for body, query, and params:

ts
@route('post', '/', [validate({ body: createUserSchema, query: pageSchema })])
  • On failure: 400 with issues: [{ part, path, message }].
  • On success: the parsed/transformed result replaces req.body (query/params are read-only getters in Express 5 and are validated in place).

#setAuthProvider(middlewares) / requireAuth()

The pluggable-auth seam. Integrations call setAuthProvider([...]) at boot; @protectedRoute runs the registered chain per request via requireAuth(). See src/core/auth.ts.

#Request extensions

Declared in src/types/express.d.ts, available on every Request:

Property Type Source
req.callId string x-call-id header or a generated UUID; echoed in the response header and all logs
req.resHandler ResponseHandler attached by middleware before any route runs

#createApp(options?)

The pure app factory (src/app.ts). No I/O — integrations boot separately in server.ts.

ts
const app = createApp({ extraRoutables: [new FakeController()] });
  • extraRoutables — extra Routable instances mounted after the auto-registered controllers and before the error handlers. Built for tests; see src/__tests__/app.test.ts.
Project

#Agent guide

Instructions for AI coding agents (Claude Code, Cursor, Copilot, Codex, …) working in a Chassis project. Follow these conventions and the code you produce will match what a human maintainer would write — that's the whole point of this file.

Golden rule: finish every change by running npm run verify and only stop when it passes. It runs typecheck + lint + tests — the same gate CI uses. Green means done; anything else means keep going.

#The 60-second mental model

  • Endpoints are controller methods. A controller is a class extending Routable; each method is decorated with @route(method, path).
  • Exported controllers auto-mount. Everything exported from src/controllers/index.ts is registered at boot. There are no router files to edit.
  • Responses go through req.resHandlerok, created, notFound, validation, … Never call res.status().json() directly.
  • Errors are thrown, not handled. throw new AppError(ERROR_CODES.X, msg) from anywhere; a central handler maps it. Do not write try/catch in controllers — Express 5 forwards rejected promises for you.
  • Config is one validated file. src/config is the only place that reads process.env.

#Do this

  • Add an endpoint: run npm run gen <Name> to scaffold a controller + test, then edit the generated methods. It's DB-aware — it wires the controller to the installed ORM (Drizzle or Mongoose) and, for Drizzle, appends a table to src/db/<engine>/schema.ts. (Or copy an existing *.controller.ts and export it from src/controllers/index.ts.)
  • Protect or charge for a route with @protectedRoute(...) (auth) or @paidRoute(method, path, price) (x402 payments). Both answer 501 until their module is configured — never silently open or free.
  • Validate input with validate({ body|query|params: zodSchema }) in the route's middleware array. Define the schema next to the controller or in src/schemas.
  • Return via req.resHandler and match the semantic helper to the outcome (created for 201, noContent for 204, notFound, conflict, …). See docs/reference/core-api.md for the full list.
  • Signal errors by throwing AppError with the right ERROR_CODES entry. Add new domain codes to src/core/errors.ts rather than inventing ad-hoc status numbers.
  • Add a config value by extending the zod schema in src/config, exposing it on the config object, and documenting it in .env.example.
  • Keep controllers thin. Push real logic into src/services/* and data access into src/models/*. Controllers translate HTTP ↔ domain.

#Don't do this

  • ❌ Don't call res.status(...).json(...) — use req.resHandler.
  • ❌ Don't wrap controller bodies in try/catch — throw AppError instead.
  • ❌ Don't read process.env outside src/config.
  • ❌ Don't hand-write route registration or express.Router() — the decorators and the app factory do it.
  • ❌ Don't edit src/core/** to build a feature. That's the framework; features live in controllers, services, models, schemas, integrations.
  • ❌ Don't add a dependency when a listed one already covers it (zod for validation, winston for logging, your installed ORM — Drizzle or Mongoose — for data access).
  • ❌ Don't disable lint rules or loosen tsconfig to make verify pass — fix the actual issue.

#Where things live

Need to… Go to
Add/change an endpoint src/controllers/*.controller.ts
Validate request input validate({...}) + a zod schema
Business logic src/services/* (create if absent)
Database models src/models/* (create if absent)
Add an env var / config src/config/index.ts + .env.example
Add an error type src/core/errors.ts (ERROR_CODES)
Add an optional integration src/integrations/* — see docs/modules.md
Framework internals (rarely) src/core/*
Front-end page or API call web/app/*, web/lib/api.ts (if present)
Front-end auth web/auth/providers/* — see docs/guides/web.md

#Reference

Deeper docs live in docs/ (docs/README.md is the index). The most useful for agents:

  • docs/reference/core-api.md — every decorator, resHandler method, AppError, validate()
  • docs/guides/building-an-api.md — a full worked CRUD resource
  • docs/architecture.md — request lifecycle and why the pieces fit

#Definition of done

  1. Feature works (add or update a test proving it).
  2. npm run verify passes — typecheck, lint, and tests all green. When a web app is present, verify covers it too; adding an auth provider there means adding it to web/auth/providers/conformance.ts.
  3. npm run build passes. In a monorepo that also runs next build, which is the only thing that exercises front-end middleware and bundling.
  4. No new process.env reads, no res.status().json(), no try/catch in controllers, no edits to src/core for feature work.
Project

#Maintainers guide

The step-by-step path from this repo to a public, reusable project — and the routine for keeping it healthy afterwards.

#Going live

#1. Rename the repository (once)

Rename to chassis (or chassis-api) in GitHub → Settings. GitHub redirects old clone URLs automatically. Then update the references:

  • REPO constant in cli/index.mjs (used for the template tarball)
  • repository.url in package.json and cli/package.json
  • Clone URLs in README.md and docs/getting-started.md

#2. Flip the template switch (once)

GitHub → Settings → General → check Template repository. Visitors get a "Use this template" button that creates a clean copy without your git history.

#3. Publish the CLI (once, then per release)

bash
cd cli

# sanity check first — scaffold from the local template and verify it
CHASSIS_TEMPLATE=.. node index.mjs /tmp/chassis-smoke --bare
cd /tmp/chassis-smoke && npm install && npm run verify && cd -

# and once with the front end, which exercises the monorepo restructure
CHASSIS_TEMPLATE=.. node index.mjs /tmp/chassis-full --preset fullstack
cd /tmp/chassis-full && npm install && npm run verify && cd -

npm login
npm publish        # unscoped packages are public by default

Verify the flow end-to-end from a clean directory:

bash
npm create chassis@latest smoke-test -- --yes

#4. Enable Renovate (once)

Install the Renovate GitHub app on the repo — renovate.json is already configured (non-major updates are grouped into a single PR). This is the single most important step for a template: templates rot silently, and automated dependency PRs + green CI are what keep it trustworthy for strangers.

Alternative: GitHub's own Dependabot (Settings → Code security).

#The web app

web/ is a self-contained package with its own package.json, lockfile, tsconfig and eslint config — it is not an npm workspace of the template, because a project scaffolded without --web must stay a plain single package. Install it separately:

bash
npm install && npm ci --prefix web

Root npm run verify then delegates to it, and CI installs both. That delegation is what keeps web/ from rotting: it is typechecked, linted and tested on every PR even though no generated project ever ships it in this shape.

Its tests are deliberately narrow — apiFetch, the /api/session route handler and the local-JWT middleware. Those hold the parts that would fail silently and expensively: whether the bearer token is attached, and whether the session token stays in an httpOnly cookie instead of reaching client JavaScript. Rendering is left to next build in the scaffold suite.

Two files there exist only for this repo and never reach a generated project — web/auth/types.ts and web/auth/providers/conformance.ts. The latter typechecks all four auth providers against the same contract, which is the only place that check can happen: a generated project keeps exactly one provider, so a provider that drifts out of shape would otherwise break only for whoever picked it. Adding an auth provider means adding it there too.

#The documentation site

site/ builds the published docs from the markdown in this repo — one self-contained dist/index.html with sidebar nav, client-side search and highlighted code, no server required.

bash
npm ci --prefix site
npm run build --prefix site     # then open site/dist/index.html
npm run check --prefix site     # build without writing (what CI runs)

It holds no prose of its own: every page is rendered from docs/**, README.md and AGENTS.md, so the site cannot drift from the repo. Adding a doc means adding it to site/pages.mjs — the build fails on any markdown file under docs/ that no section lists, so a page can never be published without a way to reach it.

Like cli/, site/ is template-only: it is in the CLI's ignore list and never reaches a generated project, and a scaffold test asserts that. Its marked dependency therefore never lands in anyone's package.json.

Pushing to master publishes it via .github/workflows/docs.yml. That needs Settings → Pages → Source: GitHub Actions enabled once.

The stylesheet in site/theme.mjs duplicates the tokens from web/app/globals.css on purpose — the web app is a template that gets copied into user projects, so it must not import from this build.

#chassis-mcp

mcp-server/ is published separately as chassis-mcp: an MCP server that scaffolds Chassis projects, so an agent can create one without knowing the CLI's flags.

bash
npm ci --prefix mcp-server
npm test --prefix mcp-server    # drives it over stdio, like a real client

It imports the option catalog from create-chassis rather than restating it, so it cannot advertise a stack the CLI does not support — and its tests run against the CLI in this checkout via CHASSIS_CLI, so a change to cli/modules.mjs fails here rather than in someone's agent.

Like cli/ and site/, it is template-only and never reaches a generated project. Note the directory is mcp-server, not mcp: the CLI's ignore list matches by basename, so a top-level mcp/ would also have excluded src/mcp and silently broken the --mcp module.

Releasing it follows the same shape as the CLI — bump mcp-server/package.json, npm publish from that directory, tag mcp-v<version>.

#Routine maintenance

#Weekly-ish

  • Merge Renovate PRs once CI is green. CI runs npm run verify on Node 20 and 22, which covers typecheck, lint, and the test suite.

#When changing the template

  • Keep the module contract intact: anything specific to an optional integration stays on single lines tagged // chassis:<name>, or the CLI's pruning breaks.

  • Two marker rules the test suite enforces, both learned the hard way:

    • The marker must be last on the line. A marker after an opening brace (sqliteTable('users', { // chassis:jwt) gets moved onto its own line by Prettier, and pruning then deletes the body but keeps the declaration. Put the construct in its own file and mark the import.
    • A module name must not appear as chassis:<name> anywhere else in the template. Pruning drops any line containing the string, so naming a module routes would delete Symbol('chassis:routes') from src/core/routable.ts.
  • After touching integrations or markers, run the scaffold suite:

    bash
    node --test cli/scaffold.test.mjs                  # catalog + structural
    SCAFFOLD_BUILD=1 node --test cli/scaffold.test.mjs # + install/verify/build

    The fast layers take seconds and cover the catalog, the CLI's failure modes, and every prune permutation. The build layer is the only one that runs next build, so it is the only thing that catches a front-end bundling break in a generated project — run it before releasing the CLI.

    A pruned project must pass verify and build with zero warnings — that's the contract the CLI advertises.

    Coverage is the thing to watch, not depth. --auth jwt --db none shipped broken (an import left unused once every database line was pruned) purely because no build case ever compiled that combination; any layer would have caught it. When you add a module, add the combination that empties out something else.

#Releasing CLI changes

  1. Bump version in cli/package.json (semver: new prompts/flags = minor, fixes = patch).
  2. cd cli && npm publish.
  3. Tag the repo (git tag cli-vX.Y.Z && git push --tags) so CLI versions map to template states.

#Upgrading major dependencies

Express, Mongoose, ESLint, and the Sentry SDK occasionally ship breaking majors. For each: read the migration guide, upgrade in a branch, run npm run verify plus both CLI smoke tests (--yes and --bare), and check the boot log still shows a clean standalone start.

#Support surface

Keep these in sync when the code changes — they're the public promise:

Artifact Source of truth for
README.md First impression, quick start
docs/ Everything else (index in docs/README.md)
.env.example Every supported variable
cli/README.md npm package page for create-chassis