A production-grade REST API boilerplate built the way senior engineers actually build things.
TypeScript · Express · PostgreSQL · Prisma · JWT · Zod · Vitest · Docker
Most Node.js starters are either too minimal (a bare Express setup) or too opinionated (a full framework). This one sits in the sweet spot — it's what you'd build on day one at a serious company.
Every decision here has a reason:
- TypeScript strict mode — catch bugs at compile time, not in production
- Zod for everything — env validation, request validation, same tool, same mental model
- Feature-module architecture — files live next to what they belong to, scales without restructuring
- Repository pattern — DB queries are isolated, controllers never touch Prisma directly
- JWT rotation — refresh tokens are stored, rotated on use, and revocable
- Zero-debt error handling — one error class, one error catalog, one handler
- Agent-ready —
AGENTS.mddocuments the module pattern and conventions so Claude Code, Cursor, Copilot, etc. generate code that fits the codebase instead of fighting it
| Concern | Choice | Why |
|---|---|---|
| Language | TypeScript 5 (strict) | Type safety end-to-end |
| Framework | Express 4 | Stable, well-understood, minimal magic |
| Database | PostgreSQL + Prisma | Type-safe queries, great migration story |
| Auth | JWT (access + refresh) | Stateless + revocable, industry standard |
| Validation | Zod | Runtime + compile-time types from one schema |
| Security | Helmet + CORS + rate limiting | Non-negotiable defaults |
| Logging | Winston + Morgan | Structured JSON in prod, colorized in dev |
| Testing | Vitest + Supertest | Fast, ESM-native, real DB integration tests |
| API Docs | Swagger / OpenAPI | Auto-generated from JSDoc annotations |
| Container | Docker (multi-stage) | Prod-ready image, separate dev compose |
| CI | GitHub Actions | Lint → typecheck → test on every push |
nodejs-starter-template/
│
├── .github/
│ ├── ISSUE_TEMPLATE/ # Bug report + feature request forms
│ ├── PULL_REQUEST_TEMPLATE.md
│ └── workflows/
│ └── ci.yml # CI: lint → typecheck → test
│
├── scripts/
│ └── setup.mjs # `npm run setup` — one-time template rename, self-deletes
│
├── prisma/
│ ├── schema.prisma # User + RefreshToken models
│ └── seed.ts # Seed admin + test users
│
├── src/
│ ├── app.ts # Express app factory (side-effect-free, testable)
│ ├── server.ts # Entry point: start + graceful shutdown
│ │
│ ├── config/
│ │ ├── env.ts # Zod-validated env — fails at boot if invalid
│ │ ├── database.ts # Prisma client singleton (test/dev/prod aware)
│ │ └── swagger.ts # Swagger UI + OpenAPI JSON endpoint
│ │
│ ├── modules/ # Feature-first: each module owns its full slice
│ │ ├── auth/
│ │ │ ├── auth.schema.ts # Zod: RegisterSchema, LoginSchema, RefreshSchema
│ │ │ ├── auth.service.ts # register / login / refreshTokens / logout / logoutAll
│ │ │ ├── auth.controller.ts # Thin HTTP handlers + Swagger JSDoc
│ │ │ └── auth.routes.ts # Routes with rate limiting + validation middleware
│ │ │
│ │ └── user/
│ │ ├── user.schema.ts # Zod: UpdateProfile, UserListQuery, UserIdParam
│ │ ├── user.repository.ts # All Prisma queries — controllers never touch DB
│ │ ├── user.service.ts # getMe / listUsers / updateProfile
│ │ ├── user.controller.ts # Thin HTTP handlers + Swagger JSDoc
│ │ └── user.routes.ts # Routes with auth + RBAC guards
│ │
│ ├── middlewares/
│ │ ├── authenticate.middleware.ts # JWT guard → populates req.user
│ │ ├── validate.middleware.ts # Zod middleware factory (body/query/params)
│ │ ├── error.middleware.ts # Global error handler + 404 handler
│ │ ├── cors.middleware.ts # Origin whitelist from env
│ │ ├── rateLimiter.middleware.ts # Global limiter + strict auth limiter
│ │ └── requestId.middleware.ts # X-Request-ID correlation ID
│ │
│ ├── lib/
│ │ ├── errors/
│ │ │ ├── AppError.ts # Base error: code, statusCode, isOperational
│ │ │ └── errors.ts # BadRequest, Unauthorized, NotFound, Conflict …
│ │ ├── response/
│ │ │ ├── httpStatus.ts # HttpStatusCode enum
│ │ │ └── response.ts # sendSuccess / sendCreated / sendError + types
│ │ └── logger/
│ │ ├── logger.ts # Winston: JSON prod / colorized dev / silent test
│ │ └── requestLogger.ts # Morgan → Winston pipe (skip health polls)
│ │
│ ├── routes/
│ │ ├── index.ts # Root API router
│ │ └── health.routes.ts # GET /health → uptime + DB ping
│ │
│ ├── types/
│ │ └── express.d.ts # Augments req.user + req.requestId
│ │
│ └── utils/
│ ├── asyncHandler.ts # Wraps async route handlers, auto-forwards errors
│ ├── pagination.ts # parsePagination + buildPaginationMeta
│ └── token.util.ts # signAccessToken / verifyAccessToken / extractBearer
│
├── tests/
│ ├── setup.ts # Migrate + clean DB before/after each test
│ ├── integration/
│ │ ├── auth.test.ts # register / login / refresh / protected routes
│ │ └── health.test.ts # Health endpoint + request ID propagation
│ └── unit/
│ └── token.util.test.ts # JWT sign/verify/tamper + Bearer extraction
│
├── .env.example # All env vars documented with comments
├── .editorconfig # Consistent indentation/line endings across editors
├── .eslintrc.json # TypeScript-aware ESLint (type-checked rules)
├── .prettierrc # Formatting config
├── AGENTS.md # Instructions for AI coding agents (architecture, conventions, commands)
├── CLAUDE.md # Points Claude Code at AGENTS.md
├── CONTRIBUTING.md # Setup + workflow for contributors
├── commitlint.config.js # Conventional commits enforcement
├── docker-compose.yml # App + Postgres with health checks
├── Dockerfile # Multi-stage build (builder → production)
├── nodemon.json # Dev: tsx watch on .ts files
├── tsconfig.json # Strict TS (ES2022, CommonJS output)
├── tsconfig.eslint.json # Extended tsconfig for lint coverage of tests/
└── vitest.config.mts # Vitest: globals, coverage, path aliases
Click Use this template on GitHub (or gh repo create my-api --template isSubham/nodejs-starter-template), then:
npm install
npm run setup # interactive: renames package.json, README, LICENSE to your project — then deletes itselfSkip npm run setup if you'd rather rename things by hand.
- Node.js ≥ 20
- PostgreSQL 14+ (or use Docker Compose)
- npm
git clone https://github.com/isSubham/nodejs-starter-template.git
cd nodejs-starter-template
npm installcp .env.example .envOpen .env and fill in the required values. The server will not start if any required variable is missing — this is intentional (fail-fast).
# Minimum required to run locally
DATABASE_URL=postgresql://postgres:password@localhost:5432/starter_db
JWT_ACCESS_SECRET=your-access-secret-min-16-chars
JWT_REFRESH_SECRET=your-refresh-secret-min-16-chars
PORT=8000npm run db:migrate # Create tables via Prisma migrations
npm run db:seed # Insert seed users (admin + regular)npm run dev| URL | What |
|---|---|
http://localhost:8000/api/v1/health |
Health check |
http://localhost:8000/docs |
Swagger UI |
http://localhost:8000/docs.json |
Raw OpenAPI spec |
The fastest way to get everything running with no local Postgres setup:
# Start both the API and Postgres
docker-compose up
# First time only — run migrations and seed
docker-compose exec app npm run db:migrate
docker-compose exec app npm run db:seedThe Dockerfile uses a multi-stage build:
builderstage: compiles TypeScript, generates Prisma clientproductionstage: copies only the compiled output + prod deps, runs as a non-root user
This template implements the dual-token JWT pattern used in production applications:
POST /api/v1/auth/register → 201 { user }
POST /api/v1/auth/login → 200 { accessToken, refreshToken, user }
accessToken expires in 15 minutes
refreshToken expires in 7 days, stored in DB
POST /api/v1/auth/refresh → 200 { accessToken, refreshToken }
Old refresh token is deleted (rotation), new pair is issued.
Stolen refresh tokens cannot be reused.
POST /api/v1/auth/logout → 200 (invalidates one refresh token)
POST /api/v1/auth/logout-all → 200 (revokes all sessions for the user)
Using the access token:
curl -H "Authorization: Bearer <accessToken>" http://localhost:8000/api/v1/users/me| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST |
/api/v1/auth/register |
Public | Create account |
POST |
/api/v1/auth/login |
Public | Login, get token pair |
POST |
/api/v1/auth/refresh |
Public | Rotate refresh token |
POST |
/api/v1/auth/logout |
Public | Invalidate a refresh token |
POST |
/api/v1/auth/logout-all |
🔒 User | Revoke all sessions |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET |
/api/v1/users/me |
🔒 User | Get own profile |
PATCH |
/api/v1/users/:id |
🔒 Owner / Admin | Update profile |
GET |
/api/v1/users |
🔒 Admin | Paginated user list |
GET |
/api/v1/users/:id |
🔒 Admin | Get any user by ID |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET |
/api/v1/health |
Public | Uptime + DB connectivity |
GET |
/docs |
Public | Swagger UI (dev only) |
npm test # Run all tests once
npm run test:watch # Watch mode (re-runs on file change)
npm run test:coverage # Generate HTML coverage reportWhat's tested:
tests/integration/auth.test.ts— Full auth lifecycle: register → login → refresh → protected route → logouttests/integration/health.test.ts— Health endpoint, 404s, request ID propagationtests/unit/token.util.test.ts— JWT sign/verify, tamper detection, Bearer extraction edge cases
Integration tests run against a real test database (TEST_DATABASE_URL). The setup file runs migrations and cleans all tables between tests for full isolation.
npm run dev # Start dev server with hot reload (nodemon + tsx)
npm run build # Compile TypeScript to dist/
npm start # Run compiled build (production)
npm run typecheck # Type-check without emitting files
npm run lint # ESLint on all .ts files
npm run lint:fix # ESLint + auto-fix
npm run format # Prettier write
npm run format:check # Prettier check (used in CI)
npm test # Vitest run
npm run test:watch # Vitest watch
npm run test:coverage # Vitest with coverage (v8)
npm run db:generate # Re-generate Prisma client after schema changes
npm run db:migrate # Create and apply a new migration (dev)
npm run db:migrate:prod # Apply existing migrations (production/CI)
npm run db:seed # Run prisma/seed.ts
npm run db:studio # Open Prisma Studio in browser
npm run db:reset # Drop + recreate DB and re-migrate (dev only)
npm run commit # Interactive conventional commit via CommitizenThe pattern is consistent across every module. To add a post feature:
1. Create the module directory:
src/modules/post/
├── post.schema.ts ← Zod input schemas + inferred types
├── post.repository.ts ← All Prisma queries (no DB calls outside this file)
├── post.service.ts ← Business logic (calls repository, throws AppErrors)
├── post.controller.ts ← HTTP layer (parse req → call service → send response)
└── post.routes.ts ← Route defs with middleware (validate, authenticate, authorize)
2. Register in src/routes/index.ts:
import postRoutes from '../modules/post/post.routes';
router.use('/posts', postRoutes);That's it. No global registries, no decorators, no magic.
All variables are documented in .env.example. The server parses them with Zod on startup and exits with a clear error message if anything is missing or invalid.
| Variable | Required | Default | Description |
|---|---|---|---|
NODE_ENV |
No | development |
development / production / test |
PORT |
No | 8000 |
Server port |
API_PREFIX |
No | /api/v1 |
Mount path for all API routes |
DATABASE_URL |
Yes | — | PostgreSQL connection string |
TEST_DATABASE_URL |
No | — | Separate DB for integration tests |
JWT_ACCESS_SECRET |
Yes | — | Min 16 chars |
JWT_ACCESS_EXPIRES_IN |
No | 15m |
Access token TTL |
JWT_REFRESH_SECRET |
Yes | — | Min 16 chars |
JWT_REFRESH_EXPIRES_IN |
No | 7d |
Refresh token TTL |
CORS_ALLOWED_ORIGINS |
No | http://localhost:3000 |
Comma-separated origins |
RATE_LIMIT_WINDOW_MS |
No | 900000 |
Rate limit window (15 min) |
RATE_LIMIT_MAX |
No | 100 |
Max requests per window |
LOG_LEVEL |
No | debug |
error / warn / info / debug |
LOG_DIR |
No | logs |
Directory for log files |
After running npm run db:seed:
| Password | Role | |
|---|---|---|
admin@example.com |
Admin@123 |
ADMIN |
user@example.com |
User@1234 |
USER |
This template enforces a clean, consistent Git workflow out of the box:
- Commitizen (
npm run commit) — interactive prompt for conventional commits - commitlint — validates commit messages on
commit-msghook - lint-staged — runs ESLint + Prettier only on staged files (fast)
- Husky pre-push — runs
tsc --noEmitbefore every push - Branch naming — enforced via
validate-branch-name:feat/<name> fix/<name> hotfix/<name> chore/<name> refactor/<name>
GitHub Actions runs on every push to main/develop and on every pull request:
1. Lint & Type Check
├── npm ci
├── prisma generate
├── tsc --noEmit
├── eslint
└── prettier --check
2. Tests (with real Postgres service)
├── prisma migrate deploy
└── vitest run --coverage
└── uploads coverage artifact
Logs are written to logs/ with daily rotation (20MB max, 14-day retention, gzipped archives).
logs/
├── error/ # error level only
├── combined/ # info and above
└── http/ # HTTP request logs (Morgan)
In development: colorized, human-readable output in the terminal.
In production: structured JSON, no console transport.
In test: logging is silenced entirely.
Every request gets a correlation ID (X-Request-ID) that flows through the logger, making it easy to trace a single request across all log lines.
All errors extend AppError:
throw new NotFoundError('User'); // 404 RESOURCE_NOT_FOUND
throw new ConflictError('Email taken'); // 409 CONFLICT
throw new UnauthorizedError(); // 401 UNAUTHORIZED
throw new ValidationError('Bad input', details); // 422 VALIDATION_ERRORThe global error handler (src/middlewares/error.middleware.ts) catches everything, logs operational errors at warn and programming errors at error, and returns a consistent JSON shape:
{
"success": false,
"error": {
"code": "NOT_FOUND",
"message": "User not found",
"details": { ... },
"stack": "..." ← development only
}
}Subham Haldar