Building a Production-Ready REST API with Node.js and TypeScript

Learn to build scalable REST APIs with Node.js, Express, TypeScript, and PostgreSQL. Includes authentication, validation, and error handling.

Alex Chen
Alex Chen
December 8, 2024 20 min read
Building a Production-Ready REST API with Node.js and TypeScript

Building a REST API that’s ready for production requires more than just handling requests. You need proper structure, validation, error handling, authentication, and database integration. Let’s build a complete API from scratch.

Project Setup

# Create project
mkdir api-project && cd api-project
npm init -y

# Install dependencies
npm install express cors helmet compression morgan
npm install pg drizzle-orm dotenv zod jsonwebtoken bcrypt

# Install dev dependencies
npm install -D typescript @types/node @types/express @types/cors
npm install -D @types/morgan @types/jsonwebtoken @types/bcrypt
npm install -D tsx drizzle-kit vitest @types/pg

TypeScript Configuration

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "dist",
    "rootDir": "src",
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules"]
}

Project Structure

src/
β”œβ”€β”€ config/
β”‚   β”œβ”€β”€ env.ts
β”‚   └── database.ts
β”œβ”€β”€ modules/
β”‚   β”œβ”€β”€ auth/
β”‚   β”‚   β”œβ”€β”€ auth.controller.ts
β”‚   β”‚   β”œβ”€β”€ auth.service.ts
β”‚   β”‚   β”œβ”€β”€ auth.routes.ts
β”‚   β”‚   └── auth.schema.ts
β”‚   └── users/
β”‚       β”œβ”€β”€ users.controller.ts
β”‚       β”œβ”€β”€ users.service.ts
β”‚       β”œβ”€β”€ users.routes.ts
β”‚       └── users.schema.ts
β”œβ”€β”€ middleware/
β”‚   β”œβ”€β”€ auth.middleware.ts
β”‚   β”œβ”€β”€ error.middleware.ts
β”‚   └── validate.middleware.ts
β”œβ”€β”€ lib/
β”‚   β”œβ”€β”€ errors.ts
β”‚   └── response.ts
β”œβ”€β”€ db/
β”‚   β”œβ”€β”€ schema.ts
β”‚   └── index.ts
β”œβ”€β”€ app.ts
└── server.ts

Environment Configuration

// src/config/env.ts
import { z } from 'zod';
import dotenv from 'dotenv';

dotenv.config();

const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  PORT: z.string().transform(Number).default('3000'),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  JWT_EXPIRES_IN: z.string().default('7d'),
});

const parsed = envSchema.safeParse(process.env);

if (!parsed.success) {
  console.error('❌ Invalid environment variables:', parsed.error.flatten().fieldErrors);
  process.exit(1);
}

export const env = parsed.data;

Database Schema with Drizzle

// src/db/schema.ts
import { pgTable, serial, varchar, timestamp, boolean, integer } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  email: varchar('email', { length: 255 }).notNull().unique(),
  password: varchar('password', { length: 255 }).notNull(),
  name: varchar('name', { length: 255 }).notNull(),
  role: varchar('role', { length: 50 }).notNull().default('user'),
  emailVerified: boolean('email_verified').notNull().default(false),
  createdAt: timestamp('created_at').notNull().defaultNow(),
  updatedAt: timestamp('updated_at').notNull().defaultNow(),
});

export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  title: varchar('title', { length: 255 }).notNull(),
  content: varchar('content', { length: 10000 }).notNull(),
  published: boolean('published').notNull().default(false),
  authorId: integer('author_id').notNull().references(() => users.id),
  createdAt: timestamp('created_at').notNull().defaultNow(),
  updatedAt: timestamp('updated_at').notNull().defaultNow(),
});

// Type inference
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type Post = typeof posts.$inferSelect;
export type NewPost = typeof posts.$inferInsert;
// src/db/index.ts
import { drizzle } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import { env } from '../config/env';
import * as schema from './schema';

const pool = new Pool({
  connectionString: env.DATABASE_URL,
});

export const db = drizzle(pool, { schema });

Custom Error Classes

// src/lib/errors.ts
export class AppError extends Error {
  constructor(
    public message: string,
    public statusCode: number = 500,
    public code: string = 'INTERNAL_ERROR'
  ) {
    super(message);
    this.name = 'AppError';
    Error.captureStackTrace(this, this.constructor);
  }
}

export class NotFoundError extends AppError {
  constructor(resource: string = 'Resource') {
    super(`${resource} not found`, 404, 'NOT_FOUND');
  }
}

export class UnauthorizedError extends AppError {
  constructor(message: string = 'Unauthorized') {
    super(message, 401, 'UNAUTHORIZED');
  }
}

export class ForbiddenError extends AppError {
  constructor(message: string = 'Forbidden') {
    super(message, 403, 'FORBIDDEN');
  }
}

export class ValidationError extends AppError {
  constructor(
    message: string = 'Validation failed',
    public errors: Record<string, string[]> = {}
  ) {
    super(message, 400, 'VALIDATION_ERROR');
  }
}

export class ConflictError extends AppError {
  constructor(message: string = 'Resource already exists') {
    super(message, 409, 'CONFLICT');
  }
}

API Response Helper

// src/lib/response.ts
import { Response } from 'express';

interface ApiResponse<T> {
  success: boolean;
  data?: T;
  message?: string;
  error?: {
    code: string;
    message: string;
    details?: Record<string, string[]>;
  };
  pagination?: {
    page: number;
    perPage: number;
    total: number;
    totalPages: number;
  };
}

export function success<T>(
  res: Response,
  data: T,
  statusCode: number = 200
): Response {
  const response: ApiResponse<T> = {
    success: true,
    data,
  };
  return res.status(statusCode).json(response);
}

export function paginated<T>(
  res: Response,
  data: T[],
  pagination: { page: number; perPage: number; total: number }
): Response {
  const response: ApiResponse<T[]> = {
    success: true,
    data,
    pagination: {
      ...pagination,
      totalPages: Math.ceil(pagination.total / pagination.perPage),
    },
  };
  return res.status(200).json(response);
}

export function error(
  res: Response,
  code: string,
  message: string,
  statusCode: number = 500,
  details?: Record<string, string[]>
): Response {
  const response: ApiResponse<null> = {
    success: false,
    error: { code, message, details },
  };
  return res.status(statusCode).json(response);
}

Validation Middleware with Zod

// src/middleware/validate.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { AnyZodObject, ZodError } from 'zod';
import { ValidationError } from '../lib/errors';

export function validate(schema: AnyZodObject) {
  return async (req: Request, _res: Response, next: NextFunction) => {
    try {
      await schema.parseAsync({
        body: req.body,
        query: req.query,
        params: req.params,
      });
      next();
    } catch (err) {
      if (err instanceof ZodError) {
        const errors: Record<string, string[]> = {};
        err.errors.forEach((e) => {
          const path = e.path.join('.');
          if (!errors[path]) errors[path] = [];
          errors[path].push(e.message);
        });
        next(new ValidationError('Validation failed', errors));
      } else {
        next(err);
      }
    }
  };
}

Authentication Middleware

// src/middleware/auth.middleware.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { env } from '../config/env';
import { UnauthorizedError, ForbiddenError } from '../lib/errors';

interface TokenPayload {
  userId: number;
  email: string;
  role: string;
}

declare global {
  namespace Express {
    interface Request {
      user?: TokenPayload;
    }
  }
}

export function authenticate(req: Request, _res: Response, next: NextFunction) {
  const authHeader = req.headers.authorization;

  if (!authHeader?.startsWith('Bearer ')) {
    throw new UnauthorizedError('No token provided');
  }

  const token = authHeader.split(' ')[1];

  try {
    const payload = jwt.verify(token, env.JWT_SECRET) as TokenPayload;
    req.user = payload;
    next();
  } catch {
    throw new UnauthorizedError('Invalid token');
  }
}

export function requireRole(...roles: string[]) {
  return (req: Request, _res: Response, next: NextFunction) => {
    if (!req.user) {
      throw new UnauthorizedError('Not authenticated');
    }

    if (!roles.includes(req.user.role)) {
      throw new ForbiddenError('Insufficient permissions');
    }

    next();
  };
}

Error Handling Middleware

// src/middleware/error.middleware.ts
import { Request, Response, NextFunction } from 'express';
import { AppError, ValidationError } from '../lib/errors';
import { error } from '../lib/response';
import { env } from '../config/env';

export function errorHandler(
  err: Error,
  _req: Request,
  res: Response,
  _next: NextFunction
) {
  console.error('Error:', err);

  if (err instanceof ValidationError) {
    return error(res, err.code, err.message, err.statusCode, err.errors);
  }

  if (err instanceof AppError) {
    return error(res, err.code, err.message, err.statusCode);
  }

  // Unknown error
  const message = env.NODE_ENV === 'production'
    ? 'Internal server error'
    : err.message;

  return error(res, 'INTERNAL_ERROR', message, 500);
}

export function notFoundHandler(req: Request, res: Response) {
  return error(res, 'NOT_FOUND', `Route ${req.method} ${req.path} not found`, 404);
}

Auth Module

// src/modules/auth/auth.schema.ts
import { z } from 'zod';

export const registerSchema = z.object({
  body: z.object({
    email: z.string().email('Invalid email address'),
    password: z.string().min(8, 'Password must be at least 8 characters'),
    name: z.string().min(2, 'Name must be at least 2 characters'),
  }),
});

export const loginSchema = z.object({
  body: z.object({
    email: z.string().email('Invalid email address'),
    password: z.string().min(1, 'Password is required'),
  }),
});

export type RegisterInput = z.infer<typeof registerSchema>['body'];
export type LoginInput = z.infer<typeof loginSchema>['body'];
// src/modules/auth/auth.service.ts
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { eq } from 'drizzle-orm';
import { db } from '../../db';
import { users, User, NewUser } from '../../db/schema';
import { env } from '../../config/env';
import { ConflictError, UnauthorizedError } from '../../lib/errors';
import { RegisterInput, LoginInput } from './auth.schema';

export async function register(input: RegisterInput): Promise<Omit<User, 'password'>> {
  // Check if user exists
  const existing = await db.query.users.findFirst({
    where: eq(users.email, input.email),
  });

  if (existing) {
    throw new ConflictError('Email already registered');
  }

  // Hash password
  const hashedPassword = await bcrypt.hash(input.password, 12);

  // Create user
  const [user] = await db.insert(users).values({
    email: input.email,
    password: hashedPassword,
    name: input.name,
  }).returning();

  const { password: _, ...userWithoutPassword } = user;
  return userWithoutPassword;
}

export async function login(input: LoginInput): Promise<{ user: Omit<User, 'password'>; token: string }> {
  const user = await db.query.users.findFirst({
    where: eq(users.email, input.email),
  });

  if (!user) {
    throw new UnauthorizedError('Invalid credentials');
  }

  const isValidPassword = await bcrypt.compare(input.password, user.password);

  if (!isValidPassword) {
    throw new UnauthorizedError('Invalid credentials');
  }

  const token = jwt.sign(
    { userId: user.id, email: user.email, role: user.role },
    env.JWT_SECRET,
    { expiresIn: env.JWT_EXPIRES_IN }
  );

  const { password: _, ...userWithoutPassword } = user;
  return { user: userWithoutPassword, token };
}
// src/modules/auth/auth.controller.ts
import { Request, Response } from 'express';
import * as authService from './auth.service';
import { success } from '../../lib/response';
import { RegisterInput, LoginInput } from './auth.schema';

export async function register(req: Request, res: Response) {
  const user = await authService.register(req.body as RegisterInput);
  return success(res, user, 201);
}

export async function login(req: Request, res: Response) {
  const result = await authService.login(req.body as LoginInput);
  return success(res, result);
}

export async function me(req: Request, res: Response) {
  return success(res, req.user);
}
// src/modules/auth/auth.routes.ts
import { Router } from 'express';
import { validate } from '../../middleware/validate.middleware';
import { authenticate } from '../../middleware/auth.middleware';
import * as authController from './auth.controller';
import { registerSchema, loginSchema } from './auth.schema';

const router = Router();

router.post('/register', validate(registerSchema), authController.register);
router.post('/login', validate(loginSchema), authController.login);
router.get('/me', authenticate, authController.me);

export default router;

Express App Setup

// src/app.ts
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import compression from 'compression';
import morgan from 'morgan';
import { errorHandler, notFoundHandler } from './middleware/error.middleware';
import authRoutes from './modules/auth/auth.routes';
import usersRoutes from './modules/users/users.routes';

const app = express();

// Security middleware
app.use(helmet());
app.use(cors());
app.use(compression());

// Body parsing
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true }));

// Logging
app.use(morgan('combined'));

// Health check
app.get('/health', (_req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

// API routes
app.use('/api/v1/auth', authRoutes);
app.use('/api/v1/users', usersRoutes);

// Error handling
app.use(notFoundHandler);
app.use(errorHandler);

export default app;
// src/server.ts
import app from './app';
import { env } from './config/env';

const server = app.listen(env.PORT, () => {
  console.log(`πŸš€ Server running on port ${env.PORT}`);
  console.log(`πŸ“ Environment: ${env.NODE_ENV}`);
});

// Graceful shutdown
process.on('SIGTERM', () => {
  console.log('SIGTERM received, shutting down gracefully');
  server.close(() => {
    console.log('Server closed');
    process.exit(0);
  });
});

Running the API

// package.json scripts
{
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js",
    "db:generate": "drizzle-kit generate:pg",
    "db:migrate": "drizzle-kit push:pg",
    "test": "vitest"
  }
}

Conclusion

This API structure provides:

  • Type safety: Full TypeScript coverage
  • Validation: Zod schemas for input validation
  • Error handling: Consistent error responses
  • Authentication: JWT-based auth with role support
  • Database: Type-safe queries with Drizzle ORM
  • Security: Helmet, CORS, rate limiting ready

Use this as a foundation and extend with additional modules as needed.


Questions about building APIs? Drop them in the comments!

Advertisement

In-Article Ad

Dev Mode

Share this article

Alex Chen

Alex Chen

Senior Full-Stack Developer

I'm a passionate full-stack developer with 10+ years of experience building scalable web applications. I write about Vue.js, Node.js, PostgreSQL, and modern DevOps practices.

Enjoyed this article?

Subscribe to get more tech content delivered to your inbox.

Related Articles