Build a production‑ready REST API in Node.js with TypeScript. Learn project setup, routing, validation, testing, authentication, and deployment with clear code examples.

Designing and shipping a clean REST API is a great way to showcase engineering fundamentals: domain modeling, testing, security, and deployment. In this guide, we will build a small but production‑grade API using Node.js, TypeScript, Express, and a few battle‑tested libraries. You will see how I structure projects, enforce type safety, validate inputs, handle errors consistently, and prepare the service for CI/CD.
We will create a simple "Notes" API with CRUD endpoints:
Tech stack
mkdir ts-notes-api && cd ts-notes-api
npm init -y
npm i express zod jsonwebtoken bcryptjs cors morgan dotenv
npm i -D typescript ts-node-dev @types/express @types/jsonwebtoken @types/bcryptjs @types/jest jest ts-jest supertest @types/supertest prisma
npx tsc --init
npx prisma init --datasource-provider sqlitetsconfig.json essentials:
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}Prisma schema (prisma/schema.prisma):
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model User {
id String @id @default(cuid())
email String @unique
password String
notes Note[]
}
model Note {
id String @id @default(cuid())
title String
content String
ownerId String
owner User @relation(fields: [ownerId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}Generate the client:
npx prisma migrate dev --name initsrc/
app.ts # Express app wiring
server.ts # HTTP server boot
lib/
prisma.ts # Prisma singleton
auth.ts # JWT helpers
modules/
notes/
notes.routes.ts
notes.controller.ts
notes.schemas.ts
notes.service.ts
users/
users.routes.ts
users.controller.tsThis keeps routing, validation, controller logic, and domain logic organized per module.
src/app.ts
import express from 'express';
import cors from 'cors';
import morgan from 'morgan';
import notesRouter from './modules/notes/notes.routes';
import usersRouter from './modules/users/users.routes';
const app = express();
app.use(cors());
app.use(express.json());
app.use(morgan('tiny'));
app.use('/api/notes', notesRouter);
app.use('/api/users', usersRouter);
app.get('/health', (_req, res) => res.json({ ok: true }));
export default app;src/server.ts
import 'dotenv/config';
import app from './app';
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`API listening on :${port}`));src/modules/notes/notes.schemas.ts
import { z } from 'zod';
export const createNoteSchema = z.object({
title: z.string().min(1).max(120),
content: z.string().min(1).max(5000)
});
export const updateNoteSchema = createNoteSchema.partial();
export type CreateNoteInput = z.infer<typeof createNoteSchema>;
export type UpdateNoteInput = z.infer<typeof updateNoteSchema>;A small validation middleware ensures we never trust input:
import { ZodSchema } from 'zod';
import { Request, Response, NextFunction } from 'express';
export const validate = (schema: ZodSchema) => (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ message: 'Validation failed', issues: result.error.issues });
}
req.body = result.data;
next();
};src/lib/prisma.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;src/modules/notes/notes.service.ts
import { prisma } from '../../lib/prisma';
import { CreateNoteInput, UpdateNoteInput } from './notes.schemas';
export const createNote = (ownerId: string, data: CreateNoteInput) =>
prisma.note.create({ data: { ...data, ownerId } });
export const listNotes = (ownerId: string) =>
prisma.note.findMany({ where: { ownerId }, orderBy: { createdAt: 'desc' } });
export const getNote = (id: string, ownerId: string) =>
prisma.note.findFirst({ where: { id, ownerId } });
export const updateNote = (id: string, ownerId: string, data: UpdateNoteInput) =>
prisma.note.update({ where: { id }, data });
export const deleteNote = (id: string, ownerId: string) =>
prisma.note.delete({ where: { id } });src/lib/auth.ts
import jwt from 'jsonwebtoken';
const SECRET = process.env.JWT_SECRET || 'dev-secret';
export const sign = (userId: string) =>
jwt.sign({ sub: userId }, SECRET, { expiresIn: '7d' });
export const verify = (token: string): string | null => {
try {
const payload = jwt.verify(token, SECRET) as { sub: string };
return payload.sub;
} catch {
return null;
}
};A simple auth guard:
import { Request, Response, NextFunction } from 'express';
import { verify } from './auth';
export const requireAuth = (req: Request, res: Response, next: NextFunction) => {
const header = req.headers.authorization || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
const userId = token ? verify(token) : null;
if (!userId) return res.status(401).json({ message: 'Unauthorized' });
(req as any).userId = userId;
next();
};src/modules/notes/notes.controller.ts
import { Request, Response } from 'express';
import * as svc from './notes.service';
export const create = async (req: Request, res: Response) => {
const userId = (req as any).userId as string;
const note = await svc.createNote(userId, req.body);
res.status(201).json(note);
};
export const list = async (req: Request, res: Response) => {
const userId = (req as any).userId as string;
const notes = await svc.listNotes(userId);
res.json(notes);
};
export const get = async (req: Request, res: Response) => {
const userId = (req as any).userId as string;
const note = await svc.getNote(req.params.id, userId);
if (!note) return res.status(404).json({ message: 'Not found' });
res.json(note);
};
export const update = async (req: Request, res: Response) => {
const userId = (req as any).userId as string;
const note = await svc.updateNote(req.params.id, userId, req.body);
res.json(note);
};
export const remove = async (req: Request, res: Response) => {
const userId = (req as any).userId as string;
await svc.deleteNote(req.params.id, userId);
res.status(204).send();
};src/modules/notes/notes.routes.ts
import { Router } from 'express';
import { validate } from '../../lib/validate';
import { createNoteSchema, updateNoteSchema } from './notes.schemas';
import * as ctrl from './notes.controller';
import { requireAuth } from '../../lib/requireAuth';
const router = Router();
router.use(requireAuth);
router.post('/', validate(createNoteSchema), ctrl.create);
router.get('/', ctrl.list);
router.get('/:id', ctrl.get);
router.patch('/:id', validate(updateNoteSchema), ctrl.update);
router.delete('/:id', ctrl.remove);
export default router;User registration and login (minimal):
// users.routes.ts
import { Router } from 'express';
import { prisma } from '../../lib/prisma';
import bcrypt from 'bcryptjs';
import { sign } from '../../lib/auth';
const router = Router();
router.post('/register', async (req, res) => {
const { email, password } = req.body;
const hash = await bcrypt.hash(password, 10);
const user = await prisma.user.create({ data: { email, password: hash } });
res.status(201).json({ id: user.id, email: user.email });
});
router.post('/login', async (req, res) => {
const { email, password } = req.body;
const user = await prisma.user.findUnique({ where: { email } });
if (!user || !(await bcrypt.compare(password, user.password))) {
return res.status(401).json({ message: 'Invalid credentials' });
}
res.json({ token: sign(user.id) });
});
export default router;A small error handler avoids leaking stack traces in production and standardizes responses.
// in app.ts
app.use((req, res, next) => {
res.status(404).json({ message: 'Route not found' });
});
app.use((err: any, _req: any, res: any, _next: any) => {
console.error(err);
res.status(500).json({ message: 'Internal Server Error' });
});// src/modules/notes/notes.e2e.test.ts
import request from 'supertest';
import app from '../../app';
describe('Notes API', () => {
it('health', async () => {
const res = await request(app).get('/health');
expect(res.status).toBe(200);
});
});Run tests:
npx jest --watchnpm run dev with ts-node-devThis small service demonstrates how I approach building pragmatic, maintainable backends: clear module boundaries, strict input validation, automated tests, and a production‑minded setup. From here, you can extend the domain, add background jobs, or expose a GraphQL gateway while keeping the same foundations. If you would like to see this structure applied to a different domain—real‑time chat with WebSockets, AI‑assisted endpoints, or a mobile‑ready backend—the same patterns carry over cleanly.