Backend

Building REST & GraphQL APIs with Node.js, Express & Prisma

A
Admin
March 9, 2026 • 7 min read • 1,382 words
8
Overall Scoreout of 10

Rating

8/10

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(); export function rateLimit(maxRequests: number, windowMs: number) { return (req: Request, res: Response, next: NextFunction) => { const ip = (req.headers['x-forwarded-for'] as string)?.split(',')[0] || req.ip || 'unknown'; const now = Date.now(); const entry = ipMap.get(ip); if (!entry || now > entry.resetAt) { ipMap.set(ip, { count: 1, resetAt: now + windowMs }); return next(); } if (entry.count >= maxRequests) { return res.status(429).json({ error: 'Too many requests', retryAfter: Math.ceil((entry.resetAt - now) / 1000), }); } entry.count++; next(); }; } // Apply per route app.use('/api/auth', rateLimit(10, 15 * 60 * 1000)); // 10 req / 15 min app.use('/api/', rateLimit(100, 60 * 1000)); // 100 req / min

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.

#Node.js#Express#Prisma#GraphQL#REST API#Backend