Prisma has revolutionized how we interact with databases in Node.js. With its type-safe client, intuitive schema language, and powerful migrations, building robust REST APIs has never been easier. This guide walks you through building a production-ready API from scratch.

Why Prisma?

Traditional ORMs often sacrifice either type safety or developer experience. Prisma delivers both:

FeaturePrismaTraditional ORM
Type SafetyFull TypeScript integrationManual type definitions
SchemaDeclarative, version-controlledCode-first or DB-first
MigrationsAutomatic with historyManual SQL or code
Query BuilderAuto-generated, type-safeGeneric, often stringly-typed
PerformanceOptimized queriesVaries widely

Setting up your project

Start with a fresh Node.js project and install the dependencies:

npm init -y
npm install express prisma @prisma/client
npm install -D typescript @types/express @types/node ts-node

Initialize Prisma:

npx prisma init

This creates a prisma folder with your schema file and a .env file for your database URL.

Designing your schema

The Prisma schema is the single source of truth for your data model. Here’s a practical example for a blog API:

// prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}
 
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}
 
model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String?
  posts     Post[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}
 
model Post {
  id        String   @id @default(cuid())
  title     String
  content   String?
  published Boolean  @default(false)
  author    User     @relation(fields: [authorId], references: [id])
  authorId  String
  tags      Tag[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}
 
model Tag {
  id    String @id @default(cuid())
  name  String @unique
  posts Post[]
}

Run the migration:

npx prisma migrate dev --name init

Request flow architecture

Understanding how requests flow through your API helps you design clean, maintainable code:

Project structure

Organize your code for scalability:

src/
├── routes/
│   ├── users.ts
│   └── posts.ts
├── services/
│   ├── user.service.ts
│   └── post.service.ts
├── middleware/
│   ├── auth.ts
│   └── validate.ts
├── lib/
│   └── prisma.ts
├── types/
│   └── api.ts
└── index.ts

Creating the Prisma client

Create a singleton Prisma client to reuse across your application:

// src/lib/prisma.ts
import { PrismaClient } from '@prisma/client';
 
const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};
 
export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === 'development' 
      ? ['query', 'error', 'warn'] 
      : ['error'],
  });
 
if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma;
}

The singleton pattern prevents creating multiple Prisma clients during hot reloading in development.

Building the service layer

Services encapsulate business logic and database operations:

// src/services/post.service.ts
import { prisma } from '../lib/prisma';
import { Prisma } from '@prisma/client';
 
export async function getAllPosts(options?: {
  published?: boolean;
  authorId?: string;
  limit?: number;
  offset?: number;
}) {
  const where: Prisma.PostWhereInput = {};
  
  if (options?.published !== undefined) {
    where.published = options.published;
  }
  
  if (options?.authorId) {
    where.authorId = options.authorId;
  }
 
  return prisma.post.findMany({
    where,
    take: options?.limit ?? 10,
    skip: options?.offset ?? 0,
    include: {
      author: {
        select: { id: true, name: true, email: true },
      },
      tags: true,
    },
    orderBy: { createdAt: 'desc' },
  });
}
 
export async function getPostById(id: string) {
  return prisma.post.findUnique({
    where: { id },
    include: {
      author: {
        select: { id: true, name: true, email: true },
      },
      tags: true,
    },
  });
}
 
export async function createPost(data: {
  title: string;
  content?: string;
  authorId: string;
  tagNames?: string[];
}) {
  return prisma.post.create({
    data: {
      title: data.title,
      content: data.content,
      author: { connect: { id: data.authorId } },
      tags: data.tagNames
        ? {
            connectOrCreate: data.tagNames.map((name) => ({
              where: { name },
              create: { name },
            })),
          }
        : undefined,
    },
    include: {
      author: { select: { id: true, name: true } },
      tags: true,
    },
  });
}

RESTful endpoint design

Follow REST conventions for predictable APIs:

EndpointMethodDescription
/postsGETList all posts
/posts/:idGETGet single post
/postsPOSTCreate new post
/posts/:idPUTUpdate post
/posts/:idDELETEDelete post
/posts/:id/publishPOSTPublish post

Building the routes

// src/routes/posts.ts
import { Router } from 'express';
import * as postService from '../services/post.service';
 
const router = Router();
 
router.get('/', async (req, res) => {
  try {
    const { published, authorId, limit, offset } = req.query;
    
    const posts = await postService.getAllPosts({
      published: published === 'true' ? true : published === 'false' ? false : undefined,
      authorId: authorId as string,
      limit: limit ? parseInt(limit as string) : undefined,
      offset: offset ? parseInt(offset as string) : undefined,
    });
    
    res.json({ data: posts });
  } catch (error) {
    res.status(500).json({ error: 'Failed to fetch posts' });
  }
});
 
router.get('/:id', async (req, res) => {
  try {
    const post = await postService.getPostById(req.params.id);
    
    if (!post) {
      return res.status(404).json({ error: 'Post not found' });
    }
    
    res.json({ data: post });
  } catch (error) {
    res.status(500).json({ error: 'Failed to fetch post' });
  }
});
 
router.post('/', async (req, res) => {
  try {
    const { title, content, authorId, tags } = req.body;
    
    const post = await postService.createPost({
      title,
      content,
      authorId,
      tagNames: tags,
    });
    
    res.status(201).json({ data: post });
  } catch (error) {
    res.status(500).json({ error: 'Failed to create post' });
  }
});
 
export default router;

Error handling

Create a consistent error response format:

// src/types/api.ts
export interface ApiError {
  code: string;
  message: string;
  details?: Record<string, string[]>;
}
 
export interface ApiResponse<T> {
  data?: T;
  error?: ApiError;
  meta?: {
    total?: number;
    page?: number;
    limit?: number;
  };
}
Status CodeUse Case
200Successful GET, PUT
201Successful POST (created)
204Successful DELETE
400Validation error
401Unauthorized
404Resource not found
500Server error

Never expose internal error messages to clients in production. Log them server-side and return generic messages.

Input validation

Use Zod for runtime validation that generates types:

import { z } from 'zod';
 
const createPostSchema = z.object({
  title: z.string().min(1).max(200),
  content: z.string().optional(),
  authorId: z.string().cuid(),
  tags: z.array(z.string()).optional(),
});
 
type CreatePostInput = z.infer<typeof createPostSchema>;
 
function validateCreatePost(data: unknown): CreatePostInput {
  return createPostSchema.parse(data);
}

Pagination best practices

Implement cursor-based pagination for large datasets:

async function getPaginatedPosts(cursor?: string, limit = 10) {
  const posts = await prisma.post.findMany({
    take: limit + 1, // Fetch one extra to check for next page
    cursor: cursor ? { id: cursor } : undefined,
    orderBy: { createdAt: 'desc' },
  });
 
  const hasNextPage = posts.length > limit;
  const items = hasNextPage ? posts.slice(0, -1) : posts;
  const nextCursor = hasNextPage ? items[items.length - 1].id : null;
 
  return {
    items,
    nextCursor,
    hasNextPage,
  };
}

Conclusion

Prisma transforms Node.js API development with its type-safe queries, intuitive schema language, and powerful migrations. Combined with Express and TypeScript, you get a development experience that catches errors early and scales well.

Start with a clean project structure, embrace the service layer pattern, and let Prisma’s generated types guide your implementation. Your future self will thank you.