Building a REST API is easy to start and surprisingly easy to structure badly. A few routes inside one large file may work for a demo, but production code needs clear boundaries, validated input, predictable errors, database migrations, and a design that remains readable as features grow.

In this tutorial, we will build a TypeScript REST API with Express, Prisma, and PostgreSQL. The result includes CRUD endpoints, pagination, centralized error handling, and a practical folder structure. The goal is not just to make requests succeed; it is to create a foundation that another developer can understand and extend safely.

What we are building

Our API manages users and their posts. It exposes conventional resource endpoints:

  • POST /api/users creates a user.

  • GET /api/users returns a paginated collection.

  • GET /api/users/:id returns one user.

  • PATCH /api/users/:id updates selected fields.

  • DELETE /api/users/:id removes a user.

Express handles HTTP routing and middleware, Prisma provides type-safe database access, and PostgreSQL stores relational data.

1. Create the project

Initialize a new Node.js project and install the runtime packages:

mkdir express-prisma-api && cd express-prisma-api

npm init -y

npm install express @prisma/client

Install the development tools:

npm install -D typescript tsx prisma @types/express @types/node

Create a TypeScript configuration with strict type checking. Strict mode catches nullable values and incorrect assumptions before they reach production.

2. Initialize Prisma and connect PostgreSQL

Initialize Prisma:

npx prisma init

Set DATABASE_URL in your local environment file. A typical PostgreSQL connection uses this shape:

postgresql://app_user:strong_password@localhost:5432/tutorial_api

Never commit real database credentials. Keep an .env.example file with placeholder values so required configuration remains documented.

3. Define the database schema

Open the Prisma schema and define the relational models. A user has many posts, while every post belongs to one user:

model User { id Int @id @default(autoincrement()) email String @unique name String? posts Post[] createdAt DateTime @default(now()) }

model Post { id Int @id @default(autoincrement()) title String content String? published Boolean @default(false) authorId Int author User @relation(fields: [authorId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) }

Use database constraints for rules the database must always enforce. The unique email constraint protects integrity even when two requests arrive at nearly the same time.

Create and apply the first migration:

npx prisma migrate dev --name init

Generate the client when required:

npx prisma generate

4. Create one reusable Prisma client

Create src/lib/prisma.ts and export a single Prisma client instance:

import { PrismaClient } from "@prisma/client";

export const prisma = new PrismaClient();

Do not create a new client inside every route handler. Repeated instances create unnecessary connection pools and can exhaust database connections under load.

5. Build the Express application

Create src/app.ts with JSON parsing and a health endpoint:

import express from "express";

export const app = express();

app.use(express.json({ limit: "1mb" }));

app.get("/health", (_req, res) => res.json({ status: "ok" }));

Keep app.listen in a separate src/server.ts. This separation lets integration tests import the application without opening a real network port.

6. Organize routes, controllers, and services

A maintainable structure separates HTTP decisions from database operations:

  • Routes map a URL and HTTP method to a controller.

  • Controllers read request data and produce an HTTP response.

  • Services contain business rules and Prisma queries.

  • Middleware handles shared concerns such as authentication and errors.

A route file should remain small:

router.get("/", listUsers);

router.get("/:id", getUser);

router.post("/", createUser);

router.patch("/:id", updateUser);

router.delete("/:id", deleteUser);

Mount the router with app.use("/api/users", userRouter).

7. Implement create and read operations

A create service can call Prisma with an explicit data object:

return prisma.user.create({ data: { email, name } });

Never pass the entire request body directly into Prisma. Explicit field mapping prevents clients from setting protected fields that may be added later.

For a single record, convert and validate the route parameter before querying:

const id = Number(req.params.id);

const user = await prisma.user.findUnique({ where: { id } });

Return 400 for an invalid identifier and 404 when a valid identifier does not exist.

8. Add pagination and filtering

Never return an unlimited table. Parse a page and limit, apply safe maximums, and calculate skip:

const page = Math.max(1, Number(req.query.page) || 1);

const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 20));

const skip = (page - 1) * limit;

Query items and total count together, preferably in a transaction:

prisma.$transaction([prisma.user.findMany({ skip, take: limit }), prisma.user.count()])

Return metadata such as page, limit, total, and totalPages. Clients should not have to guess whether another page exists.

9. Implement update and delete safely

For partial updates, accept only supported fields and distinguish an omitted value from an empty value. Build the update object explicitly and reject a request that contains no valid changes.

Prisma throws known errors for cases such as duplicate unique values and missing records. Translate those database-specific errors into stable API responses rather than leaking internal error messages.

Choose deletion behavior deliberately. The example relation uses cascading deletion for posts, but many business systems should prefer soft deletion or reject deletion while dependent records exist.

10. Validate every external value

TypeScript types disappear at runtime. Data received through JSON, query strings, headers, and route parameters is untrusted until validated.

  • Require a valid email format.

  • Limit string length.

  • Reject unknown fields for sensitive endpoints.

  • Apply defaults only after validation.

  • Return field-level error details without exposing stack traces.

A schema validation library is useful, but the important principle is consistent validation before the service layer runs.

11. Centralize asynchronous error handling

Express 5 forwards rejected promises from async handlers to error middleware. Create an error middleware after all routes:

app.use((error, _req, res, _next) => { ... });

Log the internal error with a request identifier, then send a stable public shape:

{ "error": { "code": "INTERNAL_ERROR", "message": "Unexpected server error" } }

Operational errors such as validation failures belong in controlled responses. Programming errors should still be logged prominently and fixed.

12. Shut down cleanly

Handle SIGTERM and SIGINT. Stop accepting traffic, disconnect Prisma, and exit after active requests finish. Clean shutdown matters when PM2, Docker, or an orchestrator replaces the process.

await prisma.$disconnect();

Production checklist

  • Use environment variables for secrets and connection strings.

  • Run migrations as a controlled deployment step.

  • Validate body, query, parameter, and header values.

  • Limit request-body size and collection size.

  • Use centralized errors and structured logs.

  • Add authentication and authorization before exposing private resources.

  • Test success, validation, conflict, not-found, and database-failure paths.

  • Expose a lightweight health endpoint.

  • Monitor database connections and slow queries.

Final thoughts

A reliable REST API is built from predictable boundaries. Express should manage the HTTP layer, Prisma should manage data access, PostgreSQL should enforce persistent integrity, and business rules should live outside route files.

Start with this structure while the codebase is small. It is far easier to add features to a clean API than to separate routing, validation, and database logic after every endpoint has become tightly coupled.

Official references