AboutBlogContact
Backend EngineeringMay 1, 2026 10 min read 7

Node.js + TypeScript: Building a Production REST API from Scratch in 2026

AunimedaAunimeda
📋 Table of Contents

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

Read Also

PostgreSQL Performance Optimization: The Practical Guide for 2026aunimeda
Backend Engineering

PostgreSQL Performance Optimization: The Practical Guide for 2026

Slow queries, missing indexes, N+1 problems, and connection pool exhaustion account for 90% of PostgreSQL performance issues. Here's how to diagnose and fix each one with real queries.

Building a Multi-Tenant SaaS with Next.js and PostgreSQL in 2026aunimeda
Backend Engineering

Building a Multi-Tenant SaaS with Next.js and PostgreSQL in 2026

Multi-tenancy is the architecture decision that determines how your SaaS scales. Database-per-tenant, schema-per-tenant, or row-level isolation — here's when to use each and how to implement it.

Clean Architecture in Node.js: A Practical Guide Without the Academic Fluffaunimeda
Backend Engineering

Clean Architecture in Node.js: A Practical Guide Without the Academic Fluff

Clean Architecture sounds great in theory. In practice, most implementations add complexity without benefit. This guide shows the pattern that actually works in Node.js - dependency inversion, use cases, and repository pattern with real, runnable code.

Need IT development for your business?

We build websites, mobile apps and AI solutions. Free consultation.

Get Consultation All articles