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:
| Feature | Prisma | Traditional ORM |
|---|---|---|
| Type Safety | Full TypeScript integration | Manual type definitions |
| Schema | Declarative, version-controlled | Code-first or DB-first |
| Migrations | Automatic with history | Manual SQL or code |
| Query Builder | Auto-generated, type-safe | Generic, often stringly-typed |
| Performance | Optimized queries | Varies 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:
| Endpoint | Method | Description |
|---|---|---|
/posts | GET | List all posts |
/posts/:id | GET | Get single post |
/posts | POST | Create new post |
/posts/:id | PUT | Update post |
/posts/:id | DELETE | Delete post |
/posts/:id/publish | POST | Publish 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 Code | Use Case |
|---|---|
| 200 | Successful GET, PUT |
| 201 | Successful POST (created) |
| 204 | Successful DELETE |
| 400 | Validation error |
| 401 | Unauthorized |
| 404 | Resource not found |
| 500 | Server 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.