Table of Contents
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!
In-Article Ad
Dev Mode
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
JWT Authentication in Node.js: Complete Security Guide
Implement secure JWT authentication in Node.js. Learn access tokens, refresh tokens, and security best practices.

Welcome to FullOpenAI Blog
Your go-to resource for in-depth technical articles on Vue.js, TypeScript, PostgreSQL, SaaS architecture, and DevOps - based on real-world experience and academic foundations.
Pinia State Management: The Complete Vue 3 Guide
Master Pinia, the official Vue 3 state management library. Learn stores, actions, getters, plugins, and best practices.