Back to articles

Build a REST API in Node.js with TypeScript: A Step‑by‑Step Tutorial

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

Nov 12, 20255 min read
TutorialWeb Development
📝
Meta description: Build a production‑ready REST API in Node.js with TypeScript. Learn project setup, routing, validation, testing, authentication, and deployment with clear code examples.

Build a REST API in Node.js with TypeScript: A Step‑by‑Step Tutorial

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.

What we will build

We will create a simple "Notes" API with CRUD endpoints:

  • Create a note
  • List notes
  • Get a note by id
  • Update a note
  • Delete a note

Tech stack

  • Node.js + TypeScript
  • Express for HTTP
  • Zod for runtime validation
  • Prisma (SQLite for local dev) for data access
  • Jest + Supertest for tests
  • JWT for stateless auth

1) Project setup

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 sqlite

tsconfig.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 init

2) Application structure

src/
  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.ts

This keeps routing, validation, controller logic, and domain logic organized per module.


3) Wiring Express

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}`));

4) Validation with Zod

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();
};

5) Data access with Prisma

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 } });

6) Authentication (JWT)

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();
};

7) Routes and controllers

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;

8) Error handling and 404s

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' });
});

9) Testing with Jest + Supertest

// 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 --watch

10) Running locally and next steps

  • Create .env with JWT_SECRET and DATABASE_URL if needed
  • Start dev server: npm run dev with ts-node-dev
  • Seed a test user and authenticate to exercise the notes routes
  • Containerize with a multi‑stage Dockerfile and run behind a reverse proxy (Caddy or Nginx)
  • Add rate limiting and request logging correlation ids
  • Promote SQLite to Postgres in production by changing Prisma datasource

Practical takeaways

  • Type safety end‑to‑end: Combine TypeScript types with Zod runtime validation to keep inputs safe and controllers clean.
  • Separation of concerns: Routes validate, controllers coordinate, services encapsulate domain logic, and Prisma isolates data access.
  • Observability and resilience: Centralized error handling and structured logs make debugging and on‑call easier.
  • Security from day one: Use JWT with short lifetimes, hash passwords, avoid leaking stack traces, and add rate limits.
  • Production readiness: Write tests early, add CI checks, containerize, and plan a straightforward path from SQLite to Postgres.

Conclusion

This 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.

Built with ❤️ by Abdulkarim Edres. All rights reserved.• © 2025