I built a lightweight but opinionated Express + TypeScript starter that lets me spin up APIs in minutes without re-doing the boring parts. It ships with strict typing, a clean folder layout, a predictable error model, production-ready middleware, and batteries-included testing so you can focus on your domain logic.
GitHub: https://github.com/kalemmentore868/boilerplate-node-ts-api
Why another boilerplate?
Most starters either feel too bare (you still copy/paste half your stack) or too heavy (framework on top of framework). This one aims for the sweet spot:
- TypeScript-first with strict types and path aliases
- Predictable errors from day one (details below)
- Sane security & DX middleware (CORS, Helmet, rate limiting, logging)
- Testing that mirrors production (request-level tests with Supertest)
The error model: explicit, typed, and boring-on-purpose
Errors should be boring. The boilerplate uses a tiny class hierarchy so every failure you throw maps to a clear HTTP response:
class ExpressError extends Error {
status;
constructor(message: string, status: number) {
super();
this.message = message;
this.status = status;
}
}
class NotFoundError extends ExpressError {
constructor(message = "Not Found") { super(message, 404); }
}
class UnauthorizedError extends ExpressError {
constructor(message = "Unauthorized") { super(message, 401); }
}
class BadRequestError extends ExpressError {
constructor(message = "Bad Request") { super(message, 400); }
}
class ForbiddenError extends ExpressError {
constructor(message = "Forbidden Request") { super(message, 403); }
}
class InternalServerError extends ExpressError {
constructor(message = "Internal Server Error") { super(message, 500); }
}
What’s unique here?
- One source of truth for status codes.
You throw domain-friendly classes (new BadRequestError("Email required")
) and never repeat the status in handlers. - Consistent JSON shape.
The global error middleware serializes every error to the same response structure—great for clients and logging. - Type-narrowing friendly.
Since everything extendsExpressError
, the handler can confidently readerr.status
without guessing.
Global error handler:
import type { ErrorRequestHandler } from "express";
import { ExpressError, InternalServerError } from "./errors";
export const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => {
if (err instanceof ExpressError) {
return res.status(err.status).json({
error: { message: err.message, status: err.status },
});
}
const fallback = new InternalServerError();
return res.status(fallback.status).json({
error: { message: fallback.message, status: fallback.status },
});
};
Use in routes:
app.get("/api/users/:id", async (req, res) => {
const user = await repo.find(req.params.id);
if (!user) throw new NotFoundError("User not found");
res.json({ data: user });
});
Result: fewer if/else
branches, zero duplicate status codes, cleaner controllers.
Middleware: opinionated defaults you can keep or swap
- Security:
helmet()
by default, sensible CORS configuration, andexpress-rate-limit
to dampen abuse. - Request tracing: a tiny
requestId
middleware +morgan
log format that includes the ID so logs and client issues correlate. - Validation helper (Zod friendly): a
validate(schema)
middleware you can drop into routes.
Example validator:
import { z } from "zod";
import type { RequestHandler } from "express";
export const validate =
(schema: z.ZodSchema): RequestHandler =>
(req, _res, next) => {
const result = schema.safeParse({
body: req.body,
query: req.query,
params: req.params,
});
if (!result.success)
return next(new BadRequestError(result.error.issues[0]?.message ?? "Invalid request"));
next();
};
Testing setup: API-first, not unit-only
I prefer to test APIs the way they’re used in the wild—through HTTP. The boilerplate includes:
- Jest + Supertest for request-level tests
- TypeScript support via a transformer (e.g.,
ts-jest
/swc-jest
) - Isolated env:
.env.test
loaded for tests so you can point to a test DB or in-memory store - Lightweight factories (optional) for creating test data
Example test:
import request from "supertest";
import { app } from "../app";
describe("Auth", () => {
it("returns 401 on bad credentials", async () => {
const res = await request(app)
.post("/api/auth/login")
.send({ email: "x@y.z", password: "nope" });
expect(res.status).toBe(401);
expect(res.body.error.message).toMatch(/unauthorized/i);
});
});
Because the error shape is consistent, assertions are trivial and stable over time.
Folder structure (brief)
src/
app.ts # Express app
server.ts # boot (separate from app to aid testing)
errors/ # error classes
middleware/ # errorHandler, requestId, validate, rateLimit, etc.
routes/ # modular routers
services/ # business logic
tests/ # request-level tests
Quick start
git clone https://github.com/YOUR-USERNAME/express-ts-boilerplate
cd express-ts-boilerplate
pnpm i # or npm/yarn
cp .env.example .env
pnpm dev # nodemon + ts-node (or tsx) for DX
pnpm test # jest + supertest
pnpm build # compile to dist
pnpm start # run compiled server
What’s “opinionated” here?
- Errors as classes, not strings — fewer branches, better intent.
- Request-level tests first — guard contracts, not just internals.
- Security by default — Helmet/CORS/rate-limits ship enabled.
- Small, composable middlewares — easy to swap, easy to delete.
- Minimal dependencies — no mega-framework; just Express + TS.
Roadmap
- CLI to scaffold modules (route + service + test)
- Example auth flow (JWT) behind the error model
- Pluggable logging transports (pretty vs. JSON)
If you want a TypeScript API starter that’s fast to reason about and boring in all the right places, start here and tweak to taste.
Grab the code: https://github.com/kalemmentore868/boilerplate-node-ts-api