Building REST & GraphQL APIs with Node.js, Express & Prisma
Rating
Verdict
Node.js with Express + Prisma is an excellent, proven stack for building REST APIs. Add GraphQL with Apollo Server when clients need flexible querying. Strong choice for most web and mobile backends.
Pros
- Prisma provides type-safe database access
- Express ecosystem is massive
- JWT authentication is stateless and scalable
- Zod integration for runtime validation
- Apollo Server GraphQL is battle-tested
Cons
- JavaScript/Node.js single-threaded nature limits CPU-bound work
- Express requires manual setup of many patterns
- Prisma migrations can be tricky in team environments
Building REST & GraphQL APIs with Node.js, Express & Prisma
Node.js remains one of the most popular backend runtimes, and for good reason — its event-driven, non-blocking I/O model handles concurrent connections extremely well. Paired with Express for routing, Prisma for type-safe database access, and Apollo Server for GraphQL, you have a powerful, developer-friendly API stack. This guide builds a production-ready API from scratch.
Project Setup
mkdir my-api && cd my-api
npm init -y
npm install express prisma @prisma/client zod jsonwebtoken bcryptjs cors helmet morgan
npm install -D typescript ts-node nodemon @types/express @types/node @types/jsonwebtoken @types/bcryptjs
npx prisma init
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
}
}
Prisma Schema & Database
// 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
password String
name String
role Role @default(USER)
posts Post[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([email])
}
model Post {
id String @id @default(cuid())
title String
content String
excerpt String?
slug String @unique
published Boolean @default(false)
authorId String
author User @relation(fields: [authorId], references: [id])
tags String[]
viewCount Int @default(0)
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([authorId])
@@index([published, publishedAt])
}
enum Role {
USER
ADMIN
MODERATOR
}
npx prisma migrate dev --name init
npx prisma generate
Express Application Structure
// src/app.ts
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import { errorHandler } from './middleware/errorHandler';
import { authRouter } from './routes/auth';
import { postsRouter } from './routes/posts';
import { usersRouter } from './routes/users';
const app = express();
// Security & logging middleware
app.use(helmet());
app.use(cors({ origin: process.env.ALLOWED_ORIGINS?.split(','), credentials: true }));
app.use(morgan('combined'));
app.use(express.json({ limit: '10mb' }));
// Routes
app.use('/api/auth', authRouter);
app.use('/api/posts', postsRouter);
app.use('/api/users', usersRouter);
// Health check
app.get('/api/health', (_, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Global error handler (must be last)
app.use(errorHandler);
export { app };
JWT Authentication Middleware
// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { prisma } from '../lib/prisma';
export interface AuthRequest extends Request {
user?: { id: string; email: string; role: string };
}
export async function authenticate(req: AuthRequest, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.slice(7);
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!) as {
userId: string;
email: string;
role: string;
};
// Optionally verify user still exists
const user = await prisma.user.findUnique({
where: { id: payload.userId },
select: { id: true, email: true, role: true },
});
if (!user) return res.status(401).json({ error: 'User not found' });
req.user = user;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
export function requireRole(...roles: string[]) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user || !roles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
Input Validation with Zod
// src/schemas/post.ts
import { z } from 'zod';
export const CreatePostSchema = z.object({
title: z.string().min(1).max(200).trim(),
content: z.string().min(10),
excerpt: z.string().max(500).optional(),
tags: z.array(z.string().max(50)).max(10).default([]),
published: z.boolean().default(false),
});
export const UpdatePostSchema = CreatePostSchema.partial();
export const PaginationSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sort: z.enum(['createdAt', 'publishedAt', 'viewCount']).default('createdAt'),
order: z.enum(['asc', 'desc']).default('desc'),
search: z.string().max(200).optional(),
});
// Middleware to validate request body
export function validate(schema: z.ZodSchema) {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
issues: result.error.issues.map(i => ({
field: i.path.join('.'),
message: i.message,
})),
});
}
req.body = result.data; // Replace with parsed/coerced data
next();
};
}
Posts Router
// src/routes/posts.ts
import { Router } from 'express';
import { prisma } from '../lib/prisma';
import { authenticate, requireRole } from '../middleware/auth';
import { validate } from '../schemas/post';
import { CreatePostSchema, UpdatePostSchema, PaginationSchema } from '../schemas/post';
import slugify from 'slugify';
export const postsRouter = Router();
// GET /api/posts
postsRouter.get('/', async (req, res, next) => {
try {
const query = PaginationSchema.parse(req.query);
const skip = (query.page - 1) * query.limit;
const where = {
published: true,
...(query.search && {
OR: [
{ title: { contains: query.search, mode: 'insensitive' as const } },
{ content: { contains: query.search, mode: 'insensitive' as const } },
],
}),
};
const [posts, total] = await prisma.$transaction([
prisma.post.findMany({
where,
skip,
take: query.limit,
orderBy: { [query.sort]: query.order },
include: {
author: { select: { id: true, name: true } },
},
}),
prisma.post.count({ where }),
]);
res.json({
posts,
pagination: {
page: query.page,
limit: query.limit,
total,
totalPages: Math.ceil(total / query.limit),
},
});
} catch (err) {
next(err);
}
});
// POST /api/posts — authenticated admin only
postsRouter.post(
'/',
authenticate,
requireRole('ADMIN'),
validate(CreatePostSchema),
async (req, res, next) => {
try {
const slug = slugify(req.body.title, { lower: true, strict: true });
const post = await prisma.post.create({
data: {
...req.body,
slug,
authorId: req.user!.id,
publishedAt: req.body.published ? new Date() : null,
},
include: { author: { select: { id: true, name: true } } },
});
res.status(201).json({ post });
} catch (err) {
next(err);
}
}
);
Rate Limiting
// src/middleware/rateLimit.ts
import { Request, Response, NextFunction } from 'express';
const ipMap = new Map
Centralized Error Handling
// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { Prisma } from '@prisma/client';
import { ZodError } from 'zod';
export function errorHandler(err: unknown, req: Request, res: Response, next: NextFunction) {
console.error('[API Error]', err);
if (err instanceof ZodError) {
return res.status(400).json({ error: 'Validation error', issues: err.issues });
}
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2002') {
return res.status(409).json({ error: 'A record with this value already exists' });
}
if (err.code === 'P2025') {
return res.status(404).json({ error: 'Record not found' });
}
}
if (err instanceof Prisma.PrismaClientValidationError) {
return res.status(400).json({ error: 'Invalid data provided' });
}
const status = (err as any).status || 500;
const message = status < 500 ? (err as any).message : 'Internal server error';
res.status(status).json({ error: message });
}
GraphQL with Apollo Server
// src/graphql/schema.ts
import { gql } from 'graphql-tag';
import { ApolloServer } from '@apollo/server';
import { expressMiddleware } from '@apollo/server/express4';
const typeDefs = gql`
type Post {
id: ID!
title: String!
content: String!
excerpt: String
slug: String!
published: Boolean!
author: User!
tags: [String!]!
viewCount: Int!
publishedAt: String
createdAt: String!
}
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
}
type Query {
posts(page: Int, limit: Int, search: String): PostsResult!
post(slug: String!): Post
me: User
}
type PostsResult {
posts: [Post!]!
total: Int!
totalPages: Int!
}
`;
const resolvers = {
Query: {
posts: async (_: any, { page = 1, limit = 20, search }: any, { prisma }: any) => {
const skip = (page - 1) * limit;
const where = search ? {
published: true,
OR: [
{ title: { contains: search, mode: 'insensitive' } },
{ content: { contains: search, mode: 'insensitive' } },
],
} : { published: true };
const [posts, total] = await prisma.$transaction([
prisma.post.findMany({ where, skip, take: limit, include: { author: true } }),
prisma.post.count({ where }),
]);
return { posts, total, totalPages: Math.ceil(total / limit) };
},
post: (_: any, { slug }: any, { prisma }: any) =>
prisma.post.findUnique({ where: { slug }, include: { author: true } }),
me: (_: any, __: any, { user, prisma }: any) =>
user ? prisma.user.findUnique({ where: { id: user.id }, include: { posts: true } }) : null,
},
};
Conclusion
A well-structured Node.js API with Express, Prisma, and proper middleware handles production workloads reliably. The key patterns — centralized error handling, Zod validation, JWT auth middleware, and rate limiting — form the foundation every API needs. Adding GraphQL with Apollo Server gives your clients powerful querying flexibility without abandoning your REST API for clients that prefer it.