A production-ready Express + TypeScript starter focused on developer experience, type safety, and modern tooling. Drop in your routes and ship.
- TypeScript 5.9 β strict mode,
noUncheckedIndexedAccess, ES2022 target - Zod validation β for environment variables (fail-fast at startup) and request payloads
- Winston logging β daily-rotated file logs in production, readable console output in dev
- @hapi/boom error formatting β standardized HTTP error responses
- Graceful shutdown β SIGTERM/SIGINT handlers,
uncaughtException/unhandledRejectiontraps - Health check β
GET /healthendpoint for load balancers and orchestrators - Request IDs β
X-Request-Idheader on every request for tracing - Rate limiting β
express-rate-limitconfigured on/api/*out of the box - Security β Helmet, CORS, body size limits
- Vitest β fast tests with
supertestfor API integration tests - tsx β sub-100ms hot reload in development
- ESLint 9 + Prettier β flat config with
typescript-eslint, SonarJS, Unicorn, security, simple-import-sort - Husky + lint-staged β pre-commit checks (typecheck β lint β test)
- Docker β production Dockerfile with non-root user, plus dev compose setup
Requires Node.js >= 24 (Active LTS).
git clone https://github.com/binoy638/express-typescript-boilerplate.git my-app
cd my-app
cp .env.example .env
npm install
npm run devServer boots on http://localhost:8080. Try:
GET /healthβ health checkGET /api/test/worldβ example route returning{ "message": "Hello world" }
This repo is designed to be cloned and customized. Pick whichever flow you prefer.
Option 1 β GitHub "Use this template" (recommended if you fork it on GitHub): Click Use this template at the top of the repo page. GitHub creates a fresh repo with no commit history.
Option 2 β degit (clone without git history):
npx degit binoy638/express-typescript-boilerplate my-app
cd my-app
git init && git add . && git commit -m "initial commit"Option 3 β Manual clone + reset history:
git clone https://github.com/binoy638/express-typescript-boilerplate.git my-app
cd my-app
rm -rf .git
git init && git add . && git commit -m "initial commit"First-time setup checklist:
- Update
package.json:name,description,author, repo URLs. - Replace the example
src/modules/test/with your first real module (see Adding a new module). - Edit
.env.examplefor the env vars you need, then update the Zod schema insrc/config/env.tsto match. - Update this
README.mdwith your project's specifics. - If you change the port, update the
PORTdefault insrc/config/env.ts, the docker-composeportsmapping, and any references in this README.
| Script | Description |
|---|---|
npm run dev |
Start dev server with hot reload (debug port 8181) |
npm run build |
Compile TypeScript to dist/ |
npm start |
Build then run the compiled server |
npm run typecheck |
Type-check without emitting |
npm run lint |
Run ESLint with auto-fix |
npm run format |
Format all files with Prettier |
npm run format:check |
Verify formatting (CI) |
npm test |
Run Vitest test suite |
npm run test:watch |
Vitest in watch mode |
npm run test:coverage |
Run tests with coverage report |
The codebase uses a feature-module layout β each module owns its router, controller, service, and schemas in one folder. Cross-cutting concerns (middlewares, config, utils) live at the top level of src/.
src/
βββ app.ts # Express app config (importable in tests)
βββ index.ts # Server entry point + graceful shutdown
βββ config/
β βββ env.ts # Zod-validated env var manager
β βββ logger.ts # Winston logger (singleton)
βββ middlewares/
β βββ errorHandler.middleware.ts # Global error handler (Zod + Boom)
β βββ notFoundHandler.middleware.ts # 404 handler
β βββ rateLimiter.middleware.ts # Rate limiter for /api/*
β βββ requestId.middleware.ts # X-Request-Id header
β βββ validation.middleware.ts # Zod request validation factory
βββ modules/ # Feature modules (vertical slices)
β βββ test/ # Example module β replace with your own
β βββ test.router.ts # Routes + validateRequest wiring
β βββ test.controller.ts # HTTP layer: req β service β res
β βββ test.service.ts # Business logic (no Express imports)
β βββ test.schema.ts # Zod schemas (single source of truth)
β βββ __tests__/
β βββ test.router.test.ts # Module integration tests
βββ routers/
β βββ health.router.ts # GET /health (app-level, not a feature)
βββ utils/
β βββ asyncHandler.ts # Async controller wrapper
βββ __tests__/
βββ test.router.test.ts # App-level tests (health, 404 fallback)
src/app.ts exports the configured Express app (all middleware + routers + error handlers registered). This is what tests import via supertest.
src/index.ts imports app, calls listen(), and wires up graceful shutdown for SIGTERM/SIGINT plus crash handlers for uncaughtException and unhandledRejection.
Middleware order (in src/app.ts):
requestIdβhelmetβmorganβcorsβjsonβurlencoded/health(no rate limit, no auth β designed for orchestrator probes)/api/*β rate limiter applied globally before all API routersnotFoundHandlerβerrorHandler(registered last)
Error flow:
- Controllers throw
boom.badRequest('msg')(or anyboom.*helper) anderrorHandlerserializes them to a standardized response - Validation middleware passes
ZodErrorstraight tonext(error);errorHandlerreturns the issues array in dev and a generic message in prod - 5xx errors are logged via Winston before responding
- Use
asyncHandler(fn)to wrap async controllers β rejected promises auto-forward toerrorHandler
All env vars are validated by Zod at startup (see src/config/env.ts). Missing required vars cause an immediate, descriptive error.
| Variable | Type | Default | Description |
|---|---|---|---|
NODE_ENV |
'development' | 'production' | 'test' |
development |
Runtime environment |
PORT |
number | 8080 |
HTTP port to bind |
CORS_ORIGIN |
string | * |
CORS allowed origin |
LOG_DIR |
string | logs |
Log file directory (prod) |
LOG_LEVEL |
string | debug |
Winston log level |
MAX_FILE_SIZE |
string | 10m |
Daily log rotation file size |
MAX_FILES |
string | 14d |
Daily log retention |
Adding a new variable:
// src/config/env.ts
const envSchema = z.object({
// ...existing
DATABASE_URL: z.string().url(),
});Then access it anywhere with envManager.getEnv('DATABASE_URL') β fully typed.
Each feature gets its own folder under src/modules/. The layering rule is router β controller β service: the router handles wiring + validation, the controller is the thin HTTP glue, and the service holds business logic with no Express imports (so it stays unit-testable).
Define the Zod schema once in the module folder; both the router (runtime validation) and the controller (compile-time types) import it β keeping the schema as the single source of truth.
// src/modules/users/users.schema.ts
import { z } from 'zod';
export const getUserSchema = {
params: z.object({ id: z.string().uuid() }),
};
export const createUserSchema = {
body: z.object({ name: z.string(), email: z.string().email() }),
};// src/modules/users/users.service.ts
// Pure business logic β no Express, easy to unit test.
export const findUserById = (id: string) => {
return { id, name: 'Jane' };
};
export const createUser = (data: { name: string; email: string }) => {
return { id: crypto.randomUUID(), ...data };
};// src/modules/users/users.controller.ts
import type { TypedRequestHandler } from '../../middlewares/validation.middleware';
import { createUserSchema, getUserSchema } from './users.schema';
import * as usersService from './users.service';
export const getUser: TypedRequestHandler<typeof getUserSchema> = (req, res) => {
const { id } = req.params; // string, not string | undefined
res.json(usersService.findUserById(id));
};
export const createUser: TypedRequestHandler<typeof createUserSchema> = (req, res) => {
res.status(201).json(usersService.createUser(req.body));
};// src/modules/users/users.router.ts
import { Router } from 'express';
import validateRequest from '../../middlewares/validation.middleware';
import * as usersController from './users.controller';
import { createUserSchema, getUserSchema } from './users.schema';
const usersRouter = Router();
usersRouter.get('/:id', validateRequest(getUserSchema), usersController.getUser);
usersRouter.post('/', validateRequest(createUserSchema), usersController.createUser);
export default usersRouter;// src/app.ts β mount it
import usersRouter from './modules/users/users.router';
app.use('/api/users', usersRouter);TypedRequestHandler<typeof schema> infers req.params, req.body, and req.query from the Zod schema. The middleware also writes the parsed values back to req, so any Zod transforms (z.coerce.number(), defaults, etc.) are visible in the controller.
Tests use Vitest + supertest. Import app directly β no real TCP server needed.
- Module tests live next to the code they test, in
src/modules/<name>/__tests__/. - App-level tests (health, 404 fallback, app-wide middleware) live in
src/__tests__/.
// src/modules/users/__tests__/users.router.test.ts
import request from 'supertest';
import { describe, expect, it } from 'vitest';
import app from '../../../app';
describe('GET /api/users/:id', () => {
it('returns the user', async () => {
const res = await request(app).get('/api/users/abc');
expect(res.status).toBe(200);
});
});Run a single test file:
npm test -- src/modules/users/__tests__/users.router.test.tsDevelopment (with bind mount + hot reload):
docker compose -f docker-compose.dev.yml upProduction build:
docker compose up --buildThe production image runs as the non-root node user and uses npm ci --omit=dev for reproducible installs.
MIT