Prisma is a popular ORM for Node.js that turns a schema file into a fully typed database client. In this guide you will build a small Express API with users and posts, backed by PostgreSQL and Prisma ORM 7. You need Node.js 22 or newer and a PostgreSQL database, either local or hosted.

How the pieces fit together

Before typing commands, it helps to know what each part does. The schema file describes your tables in Prisma’s own short syntax. Migrations turn changes to that file into SQL and apply them to the database, keeping a history in a migrations folder you commit to Git. The generator reads the schema and writes a client library with a TypeScript type for every model, so prisma.user.create knows exactly which fields a user has. Finally, the driver adapter is the piece that actually talks to PostgreSQL. In Prisma 7 it is a separate package, which is why you will install both @prisma/adapter-pg and the pg driver.

The payoff is that a typo in a field name, or a missing required value, shows up as a red line in your editor instead of a crash in production.

1. Create the project

mkdir prisma-express-api && cd prisma-express-api
npm init -y
npm pkg set type=module
npm install express @prisma/client@7 @prisma/adapter-pg pg dotenv
npm install -D prisma@7 typescript tsx @types/node @types/express @types/pg
npx tsc --init

Prisma 7 is ESM-first, so set these options in tsconfig.json:

{
  "compilerOptions": {
    "module": "ESNext",
    "moduleResolution": "bundler",
    "target": "ES2023",
    "strict": true,
    "esModuleInterop": true
  }
}

2. Initialize Prisma

npx prisma init --datasource-provider postgresql --output ../generated/prisma

This creates prisma/schema.prisma, a .env file and a Prisma config file (named prisma7.config.ts in recent 7.x releases). Put your connection string in .env:

DATABASE_URL="postgresql://username:password@localhost:5432/mydb?schema=public"

3. Define your models

Open prisma/schema.prisma and add a User and a Post model below the generated blocks:

generator client {
  provider = "prisma-client"
  output   = "../generated/prisma"
}

datasource db {
  provider = "postgresql"
}

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

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

Create the tables and generate the client:

npx prisma migrate dev --name init
npx prisma generate

4. Create a shared Prisma client

In Prisma 7 the client connects through a driver adapter. Create lib/prisma.ts:

import "dotenv/config";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "../generated/prisma/client";

const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });

export const prisma = new PrismaClient({ adapter });

5. Write the Express server

Create server.ts. Express 5 forwards errors from async handlers to the error middleware automatically, so no extra wrapper is needed:

import express, { type NextFunction, type Request, type Response } from "express";
import { prisma } from "./lib/prisma";

const app = express();
app.use(express.json());

app.get("/users", async (_req, res) => {
  const users = await prisma.user.findMany({ include: { posts: true } });
  res.json(users);
});

app.post("/users", async (req, res) => {
  const { email, name } = req.body;
  const user = await prisma.user.create({ data: { email, name } });
  res.status(201).json(user);
});

app.post("/posts", async (req, res) => {
  const { title, content, authorEmail } = req.body;
  const post = await prisma.post.create({
    data: { title, content, author: { connect: { email: authorEmail } } },
  });
  res.status(201).json(post);
});

app.patch("/posts/:id/publish", async (req, res) => {
  const post = await prisma.post.update({
    where: { id: Number(req.params.id) },
    data: { published: true },
  });
  res.json(post);
});

app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
  console.error(err);
  res.status(500).json({ error: "Something went wrong" });
});

const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => console.log(`API running on http://localhost:${port}`));

6. Run and test it

npx tsx watch server.ts

curl -X POST http://localhost:3000/users -H "Content-Type: application/json" -d '{"email":"alice@example.com","name":"Alice"}'
curl -X POST http://localhost:3000/posts -H "Content-Type: application/json" -d '{"title":"Hello Prisma","authorEmail":"alice@example.com"}'
curl http://localhost:3000/users

Run npx prisma studio to browse your data in the browser.

7. Return useful errors

Right now, creating a user with an email that already exists returns a generic 500. Prisma throws errors with stable codes you can check. Two you will meet constantly are P2002, a unique constraint failure, and P2025, a record that doesn’t exist. Replace the error middleware with one that maps them to proper HTTP responses:

import { Prisma } from "./generated/prisma/client";

app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
  if (err instanceof Prisma.PrismaClientKnownRequestError) {
    if (err.code === "P2002") return res.status(409).json({ error: "Already exists" });
    if (err.code === "P2025") return res.status(404).json({ error: "Not found" });
  }
  console.error(err);
  res.status(500).json({ error: "Something went wrong" });
});

Now publishing a post that doesn’t exist returns a clean 404, and a duplicate email returns 409 Conflict, which a front end can turn into a friendly message.

8. Going to production

Development and production use different migration commands, and mixing them up is the most common Prisma mistake:

  • prisma migrate dev is for your laptop. It creates new migration files, may reset the database when it detects drift and regenerates the client.
  • prisma migrate deploy is for staging and production. It only applies migration files that are already committed, in order, and never resets anything.

Run migrate deploy as a step in your deployment, before the new version of the app starts. Keep the database URL in your host’s secret settings rather than in a committed .env file, and use a database user with only the permissions the app needs.

Common problems

  • “Cannot find module generated/prisma”: the client hasn’t been generated yet. Run npx prisma generate.
  • Type errors after editing the schema: your editor is still using the old client. Regenerate it and restart the TypeScript server.
  • Too many connections on a hosted database: create the Prisma client once and import it everywhere, as lib/prisma.ts does, instead of creating a new client per request.

Next steps

  • Validate request bodies with a library such as Zod before passing them to Prisma.
  • Add pagination to GET /users with Prisma’s take and skip options before the table grows.
  • Every time you change schema.prisma, run prisma migrate dev again to update the database and the generated client.

What we think

Prisma is a strong default for TypeScript back ends that use a relational database. The generated types catch a whole class of bugs before they reach production, and migrations give you a clear history of every schema change.

It isn’t magic, though. You still need to understand the SQL it generates for complex queries, add indexes yourself and keep development and production migration commands separate. Learn those few rules early and Prisma stays a time-saver rather than a source of mysteries.