Most Node.js API tutorials get you to "Hello World" and stop there. Production APIs need authentication, input validation, error handling, rate limiting, structured logging, database migrations, and a deployment strategy that doesn't involve SSH-ing into a server at 2am.
This is the complete version.
Project Setup
mkdir my-api && cd my-api
npm init -y
npm install express zod bcryptjs jsonwebtoken pg redis helmet cors morgan
npm install -D typescript @types/express @types/node @types/bcryptjs \
@types/jsonwebtoken @types/pg @types/cors @types/morgan \
ts-node-dev tsx
tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
package.json scripts:
{
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"migrate": "node-pg-migrate up"
}
}
Project Structure
src/
├── index.ts # App entry point
├── app.ts # Express setup
├── config/
│ ├── env.ts # Validated environment variables
│ └── db.ts # Database pool
├── middleware/
│ ├── auth.ts # JWT authentication
│ ├── validate.ts # Request validation (Zod)
│ ├── error-handler.ts # Global error handler
│ └── rate-limit.ts # Rate limiting
├── routes/
│ ├── auth.routes.ts
│ └── users.routes.ts
├── controllers/
│ ├── auth.controller.ts
│ └── users.controller.ts
├── services/
│ ├── auth.service.ts
│ └── users.service.ts
├── repositories/
│ └── users.repository.ts
└── types/
└── index.ts
Environment Variables (Type-Safe)
// src/config/env.ts
import { z } from 'zod';
const EnvSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters'),
JWT_EXPIRES_IN: z.string().default('7d'),
BCRYPT_ROUNDS: z.coerce.number().default(12),
RATE_LIMIT_MAX: z.coerce.number().default(100),
RATE_LIMIT_WINDOW_MS: z.coerce.number().default(15 * 60 * 1000),
});
const result = EnvSchema.safeParse(process.env);
if (!result.success) {
console.error('❌ Invalid environment variables:');
console.error(result.error.flatten().fieldErrors);
process.exit(1);
}
export const env = result.data;
Database Setup with Connection Pool
// src/config/db.ts
import { Pool, PoolClient } from 'pg';
import { env } from './env';
export const pool = new Pool({
connectionString: env.DATABASE_URL,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
});
pool.on('error', (err) => {
console.error('PostgreSQL pool error:', err);
});
// Helper for transactions
export async function withTransaction<T>(
callback: (client: PoolClient) => Promise<T>
): Promise<T> {
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await callback(client);
await client.query('COMMIT');
return result;
} catch (err) {
await client.query('ROLLBACK');
throw err;
} finally {
client.release();
}
}
Custom Error Classes
// src/types/errors.ts
export class AppError extends Error {
constructor(
public message: string,
public statusCode: number,
public code?: string
) {
super(message);
this.name = 'AppError';
Error.captureStackTrace(this, this.constructor);
}
}
export class ValidationError extends AppError {
constructor(message: string, public details?: unknown) {
super(message, 400, 'VALIDATION_ERROR');
}
}
export class UnauthorizedError extends AppError {
constructor(message = 'Unauthorized') {
super(message, 401, 'UNAUTHORIZED');
}
}
export class ForbiddenError extends AppError {
constructor(message = 'Forbidden') {
super(message, 403, 'FORBIDDEN');
}
}
export class NotFoundError extends AppError {
constructor(resource: string) {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
export class ConflictError extends AppError {
constructor(message: string) {
super(message, 409, 'CONFLICT');
}
}
Validation Middleware
// src/middleware/validate.ts
import { Request, Response, NextFunction } from 'express';
import { z, ZodSchema } from 'zod';
import { ValidationError } from '../types/errors';
export function validate<T>(schema: ZodSchema<T>, source: 'body' | 'query' | 'params' = 'body') {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req[source]);
if (!result.success) {
return next(new ValidationError('Validation failed', result.error.flatten()));
}
req[source] = result.data;
next();
};
}
// Schemas
export const RegisterSchema = z.object({
email: z.string().email(),
password: z.string().min(8).max(100),
name: z.string().min(1).max(100).trim(),
});
export const LoginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
export const PaginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
});
export type RegisterDTO = z.infer<typeof RegisterSchema>;
export type LoginDTO = z.infer<typeof LoginSchema>;
JWT Authentication Middleware
// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { env } from '../config/env';
import { UnauthorizedError } from '../types/errors';
interface JwtPayload {
userId: string;
email: string;
role: string;
}
// Extend Express Request type
declare global {
namespace Express {
interface Request {
user?: JwtPayload;
}
}
}
export function authenticate(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return next(new UnauthorizedError('Missing or invalid authorization header'));
}
const token = authHeader.slice(7);
try {
const payload = jwt.verify(token, env.JWT_SECRET) as JwtPayload;
req.user = payload;
next();
} catch {
next(new UnauthorizedError('Invalid or expired token'));
}
}
export function requireRole(...roles: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user || !roles.includes(req.user.role)) {
return next(new ForbiddenError());
}
next();
};
}
Global Error Handler
// src/middleware/error-handler.ts
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../types/errors';
import { logger } from '../config/logger';
export function errorHandler(
err: Error,
req: Request,
res: Response,
next: NextFunction
) {
if (err instanceof AppError) {
// Operational error - expected, log at warn level
logger.warn({
code: err.code,
message: err.message,
path: req.path,
method: req.method,
});
return res.status(err.statusCode).json({
error: {
code: err.code,
message: err.message,
...(err instanceof ValidationError && { details: err.details }),
},
});
}
// Unexpected error - log at error level with full stack
logger.error({
message: err.message,
stack: err.stack,
path: req.path,
method: req.method,
});
res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: 'An unexpected error occurred',
},
});
}
// Catch async errors without try/catch in every controller
export function asyncHandler(
fn: (req: Request, res: Response, next: NextFunction) => Promise<unknown>
) {
return (req: Request, res: Response, next: NextFunction) => {
fn(req, res, next).catch(next);
};
}
Auth Service
// src/services/auth.service.ts
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { env } from '../config/env';
import { UsersRepository } from '../repositories/users.repository';
import { ConflictError, UnauthorizedError } from '../types/errors';
import type { RegisterDTO, LoginDTO } from '../middleware/validate';
export class AuthService {
constructor(private usersRepo: UsersRepository) {}
async register(dto: RegisterDTO) {
const existing = await this.usersRepo.findByEmail(dto.email);
if (existing) {
throw new ConflictError('Email already registered');
}
const passwordHash = await bcrypt.hash(dto.password, env.BCRYPT_ROUNDS);
const user = await this.usersRepo.create({
email: dto.email,
name: dto.name,
passwordHash,
});
const token = this.generateToken(user);
return { user: this.sanitizeUser(user), token };
}
async login(dto: LoginDTO) {
const user = await this.usersRepo.findByEmail(dto.email);
// Constant time comparison to prevent timing attacks
const isValid = user
? await bcrypt.compare(dto.password, user.password_hash)
: await bcrypt.compare(dto.password, '$2b$12$invalid.hash.for.timing');
if (!user || !isValid) {
throw new UnauthorizedError('Invalid email or password');
}
const token = this.generateToken(user);
return { user: this.sanitizeUser(user), token };
}
private generateToken(user: { id: string; email: string; role: string }) {
return jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
env.JWT_SECRET,
{ expiresIn: env.JWT_EXPIRES_IN }
);
}
private sanitizeUser(user: any) {
const { password_hash, ...safe } = user;
return safe;
}
}
Repository Pattern
// src/repositories/users.repository.ts
import { PoolClient } from 'pg';
import { pool } from '../config/db';
import { NotFoundError } from '../types/errors';
interface User {
id: string;
email: string;
name: string;
role: string;
password_hash: string;
created_at: Date;
}
interface CreateUserInput {
email: string;
name: string;
passwordHash: string;
role?: string;
}
export class UsersRepository {
constructor(private db: typeof pool | PoolClient = pool) {}
async findById(id: string): Promise<User | null> {
const { rows } = await this.db.query(
'SELECT * FROM users WHERE id = $1',
[id]
);
return rows[0] ?? null;
}
async findByEmail(email: string): Promise<User | null> {
const { rows } = await this.db.query(
'SELECT * FROM users WHERE email = $1',
[email]
);
return rows[0] ?? null;
}
async create(input: CreateUserInput): Promise<User> {
const { rows } = await this.db.query(
`INSERT INTO users (email, name, password_hash, role)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[input.email, input.name, input.passwordHash, input.role ?? 'user']
);
return rows[0];
}
async findAll(page: number, limit: number): Promise<{ users: User[]; total: number }> {
const offset = (page - 1) * limit;
const { rows } = await this.db.query(
`SELECT *, COUNT(*) OVER () AS total_count
FROM users
ORDER BY created_at DESC
LIMIT $1 OFFSET $2`,
[limit, offset]
);
return {
users: rows.map(({ total_count, ...user }) => user),
total: rows[0]?.total_count ?? 0,
};
}
}
Routes
// src/routes/auth.routes.ts
import { Router } from 'express';
import { AuthController } from '../controllers/auth.controller';
import { validate, RegisterSchema, LoginSchema } from '../middleware/validate';
import { rateLimit } from '../middleware/rate-limit';
const router = Router();
const controller = new AuthController();
router.post(
'/register',
rateLimit({ max: 5, windowMs: 60 * 60 * 1000 }), // 5 per hour
validate(RegisterSchema),
controller.register
);
router.post(
'/login',
rateLimit({ max: 10, windowMs: 15 * 60 * 1000 }), // 10 per 15 min
validate(LoginSchema),
controller.login
);
export default router;
Rate Limiting
// src/middleware/rate-limit.ts
import { Request, Response, NextFunction } from 'express';
import { createClient } from 'redis';
import { env } from '../config/env';
const redis = env.REDIS_URL
? createClient({ url: env.REDIS_URL })
: null;
redis?.connect();
interface RateLimitOptions {
max: number;
windowMs: number;
keyFn?: (req: Request) => string;
}
export function rateLimit(options?: RateLimitOptions) {
const max = options?.max ?? env.RATE_LIMIT_MAX;
const windowMs = options?.windowMs ?? env.RATE_LIMIT_WINDOW_MS;
return async (req: Request, res: Response, next: NextFunction) => {
const key = options?.keyFn?.(req) ?? `rate:${req.ip}:${req.path}`;
if (!redis) return next(); // Skip if no Redis
const current = await redis.incr(key);
if (current === 1) {
await redis.pExpire(key, windowMs);
}
res.setHeader('X-RateLimit-Limit', max);
res.setHeader('X-RateLimit-Remaining', Math.max(0, max - current));
if (current > max) {
return res.status(429).json({
error: {
code: 'RATE_LIMITED',
message: 'Too many requests. Please try again later.',
},
});
}
next();
};
}
Express App Assembly
// src/app.ts
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import morgan from 'morgan';
import { errorHandler } from './middleware/error-handler';
import authRoutes from './routes/auth.routes';
import usersRoutes from './routes/users.routes';
export function createApp() {
const app = express();
// Security
app.use(helmet());
app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(',') ?? '*' }));
// Parsing
app.use(express.json({ limit: '10kb' })); // Prevent large payload attacks
// Logging
app.use(morgan('combined'));
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Routes
app.use('/api/v1/auth', authRoutes);
app.use('/api/v1/users', usersRoutes);
// 404
app.use((req, res) => {
res.status(404).json({ error: { code: 'NOT_FOUND', message: 'Route not found' } });
});
// Error handler (must be last)
app.use(errorHandler);
return app;
}
// src/index.ts
import { createApp } from './app';
import { env } from './config/env';
import { pool } from './config/db';
import { logger } from './config/logger';
async function start() {
// Test DB connection
await pool.query('SELECT 1');
logger.info('Database connected');
const app = createApp();
const server = app.listen(env.PORT, () => {
logger.info(`Server running on port ${env.PORT} [${env.NODE_ENV}]`);
});
// Graceful shutdown
process.on('SIGTERM', async () => {
logger.info('SIGTERM received, shutting down gracefully');
server.close(async () => {
await pool.end();
process.exit(0);
});
});
}
start().catch((err) => {
console.error('Failed to start server:', err);
process.exit(1);
});
Production Deployment (Docker)
# Dockerfile
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:22-alpine AS production
WORKDIR /app
RUN addgroup -g 1001 -S nodejs && adduser -S api -u 1001
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package.json ./
USER api
EXPOSE 3000
CMD ["node", "dist/index.js"]
# docker-compose.yml
services:
api:
build: .
ports:
- "3000:3000"
environment:
- DATABASE_URL=postgres://user:pass@db:5432/myapp
- REDIS_URL=redis://redis:6379
- JWT_SECRET=${JWT_SECRET}
- NODE_ENV=production
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
restart: unless-stopped
db:
image: postgres:16-alpine
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: myapp
POSTGRES_USER: user
POSTGRES_PASSWORD: pass
healthcheck:
test: ["CMD-SHELL", "pg_isready -U user -d myapp"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
restart: unless-stopped
volumes:
postgres_data:
redis_data:
Aunimeda builds production-grade Node.js backends, REST APIs, and microservices - with TypeScript, proper architecture, and everything you need to ship and scale.
Contact us for backend engineering services. See also: Custom Software Development, Web Development, DevOps