Node.js + TypeScript: Building a Production REST API from Scratch in 2026
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