Building a complete CRUD REST API end-to-end — project structure, error handling, dotenv, nodemon, and testing with curl and Postman.
Module F-8 — Your First Real Application
What this module covers: Modules F-1 through F-7 gave you all the individual pieces. This module assembles them into a real, structured project. We build a Blog API — full CRUD for posts and users, proper project structure, environment configuration, error handling, and a working Prisma database layer. By the end you will have a template that reflects how real Node.js applications are organised, and you will understand why each layer exists.
What We Are Building
A Blog API with two resources:
- Users — register, read profile
- Posts — create, read, update, delete blog posts
Endpoints:
POST /users — register a user
GET /users/:id — get user profile
GET /posts — list all published posts (with author)
GET /posts/:id — get a single post
POST /posts — create a post (requires userId in body)
PATCH /posts/:id — update a post
DELETE /posts/:id — delete a post
This is deliberately simple — no authentication yet (that's P-2). The goal is clean project structure and solid fundamentals.
Project Structure
blog-api/
├── prisma/
│ └── schema.prisma # database schema
├── src/
│ ├── db/
│ │ └── prisma.js # Prisma client singleton
│ ├── routes/
│ │ ├── users.js # user route handlers
│ │ └── posts.js # post route handlers
│ ├── middleware/
│ │ └── errorHandler.js # global error handler
│ └── index.js # app entry point
├── .env # environment variables (not in git)
├── .env.example # example env file (committed to git)
├── .gitignore
├── nodemon.json
└── package.json
This is the layered architecture we will formalise in P-1. For now the pattern is: routes handle HTTP, the db/ layer handles data access, and middleware/ holds cross-cutting concerns.
Step 1: Project Initialisation
bashmkdir blog-api && cd blog-api npm init -y npm install express prisma @prisma/client dotenv npm install -D nodemon npx prisma init
Update package.json:
json{ "name": "blog-api", "version": "1.0.0", "type": "module", "scripts": { "start": "node src/index.js", "dev": "nodemon src/index.js", "db:migrate": "prisma migrate dev", "db:studio": "prisma studio" } }
Create .gitignore:
node_modules/
.env
dist/
*.log
Create .env:
DATABASE_URL=postgres://postgres:password@localhost:5432/blog_dev
PORT=3000
NODE_ENV=development
Create .env.example (committed — shows what vars are needed without exposing values):
DATABASE_URL=postgres://user:password@host:5432/dbname
PORT=3000
NODE_ENV=development
Step 2: Database Schema
prisma// prisma/schema.prisma generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id Int @id @default(autoincrement()) name String email String @unique createdAt DateTime @default(now()) 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 createdAt DateTime @default(now()) updatedAt DateTime @updatedAt }
Run the migration:
bashnpx prisma migrate dev --name init
This creates the tables in your PostgreSQL database and generates the Prisma Client.
Step 3: Prisma Client Singleton
javascript// src/db/prisma.js import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient({ log: process.env.NODE_ENV === 'development' ? ['query', 'warn', 'error'] : ['error'], }); export default prisma;
One PrismaClient instance for the entire application. Creating one per request wastes connections and causes pool exhaustion.
Step 4: Route Handlers
javascript// src/routes/users.js import { Router } from 'express'; import prisma from '../db/prisma.js'; const router = Router(); // POST /users — register a user router.post('/', async (req, res, next) => { try { const { name, email } = req.body; if (!name || !email) { return res.status(400).json({ error: 'name and email are required' }); } const user = await prisma.user.create({ data: { name, email }, }); res.status(201).json(user); } catch (err) { if (err.code === 'P2002') { return res.status(409).json({ error: 'Email already registered' }); } next(err); } }); // GET /users/:id — get user profile with their posts router.get('/:id', async (req, res, next) => { try { const id = parseInt(req.params.id, 10); if (isNaN(id)) return res.status(400).json({ error: 'id must be a number' }); const user = await prisma.user.findUnique({ where: { id }, include: { posts: { where: { published: true }, orderBy: { createdAt: 'desc' }, select: { id: true, title: true, createdAt: true }, }, }, }); if (!user) return res.status(404).json({ error: 'User not found' }); res.json(user); } catch (err) { next(err); } }); export default router;
javascript// src/routes/posts.js import { Router } from 'express'; import prisma from '../db/prisma.js'; const router = Router(); // GET /posts — list published posts with author name router.get('/', async (req, res, next) => { try { const posts = await prisma.post.findMany({ where: { published: true }, orderBy: { createdAt: 'desc' }, include: { author: { select: { id: true, name: true } }, }, }); res.json(posts); } catch (err) { next(err); } }); // GET /posts/:id — get a single post router.get('/:id', async (req, res, next) => { try { const id = parseInt(req.params.id, 10); if (isNaN(id)) return res.status(400).json({ error: 'id must be a number' }); const post = await prisma.post.findUnique({ where: { id }, include: { author: { select: { id: true, name: true } } }, }); if (!post) return res.status(404).json({ error: 'Post not found' }); res.json(post); } catch (err) { next(err); } }); // POST /posts — create a post router.post('/', async (req, res, next) => { try { const { title, content, authorId, published = false } = req.body; if (!title || !content || !authorId) { return res.status(400).json({ error: 'title, content, and authorId are required' }); } const post = await prisma.post.create({ data: { title, content, published, authorId: parseInt(authorId, 10), }, include: { author: { select: { id: true, name: true } } }, }); res.status(201).json(post); } catch (err) { if (err.code === 'P2003') { return res.status(400).json({ error: 'Author not found' }); } next(err); } }); // PATCH /posts/:id — update a post router.patch('/:id', async (req, res, next) => { try { const id = parseInt(req.params.id, 10); if (isNaN(id)) return res.status(400).json({ error: 'id must be a number' }); const { title, content, published } = req.body; const post = await prisma.post.update({ where: { id }, data: { ...(title !== undefined && { title }), ...(content !== undefined && { content }), ...(published !== undefined && { published }), }, include: { author: { select: { id: true, name: true } } }, }); res.json(post); } catch (err) { if (err.code === 'P2025') { return res.status(404).json({ error: 'Post not found' }); } next(err); } }); // DELETE /posts/:id — delete a post router.delete('/:id', async (req, res, next) => { try { const id = parseInt(req.params.id, 10); if (isNaN(id)) return res.status(400).json({ error: 'id must be a number' }); await prisma.post.delete({ where: { id } }); res.status(204).send(); } catch (err) { if (err.code === 'P2025') { return res.status(404).json({ error: 'Post not found' }); } next(err); } }); export default router;
Step 5: Error Handler Middleware
javascript// src/middleware/errorHandler.js export function errorHandler(err, req, res, next) { console.error(`[${new Date().toISOString()}] ${req.method} ${req.path}`, err); // Prisma errors that slipped through route handlers if (err.code === 'P2025') { return res.status(404).json({ error: 'Record not found' }); } if (err.code === 'P2002') { return res.status(409).json({ error: 'A record with that value already exists' }); } // Express JSON parse error if (err.type === 'entity.parse.failed') { return res.status(400).json({ error: 'Invalid JSON in request body' }); } const status = err.statusCode || err.status || 500; const message = process.env.NODE_ENV === 'production' ? 'Internal server error' : err.message; res.status(status).json({ error: message }); }
Step 6: Application Entry Point
javascript// src/index.js import 'dotenv/config'; import express from 'express'; import usersRouter from './routes/users.js'; import postsRouter from './routes/posts.js'; import { errorHandler } from './middleware/errorHandler.js'; const app = express(); const PORT = process.env.PORT || 3000; // ── Middleware ───────────────────────────────────────────── app.use(express.json()); // Health check — useful for Docker and load balancers app.get('/health', (req, res) => { res.json({ status: 'ok', uptime: process.uptime() }); }); // ── Routes ──────────────────────────────────────────────── app.use('/users', usersRouter); app.use('/posts', postsRouter); // ── 404 handler ─────────────────────────────────────────── app.use((req, res) => { res.status(404).json({ error: `Route ${req.method} ${req.path} not found` }); }); // ── Error handler (must be last) ────────────────────────── app.use(errorHandler); // ── Start ───────────────────────────────────────────────── app.listen(PORT, () => { console.log(`Blog API running on http://localhost:${PORT}`); console.log(`Environment: ${process.env.NODE_ENV}`); });
Step 7: nodemon for Development
json// nodemon.json { "watch": ["src"], "ext": "js,json", "ignore": ["src/**/*.test.js"], "exec": "node src/index.js" }
bashnpm run dev
Nodemon watches src/ and restarts the server whenever you save a file.
Testing the API
bash# Create a user curl -X POST http://localhost:3000/users \ -H "Content-Type: application/json" \ -d '{"name": "Jatin", "email": "jatin@example.com"}' # → {"id":1,"name":"Jatin","email":"jatin@example.com","createdAt":"..."} # Create a post curl -X POST http://localhost:3000/posts \ -H "Content-Type: application/json" \ -d '{"title":"My First Post","content":"Hello world!","authorId":1,"published":true}' # → {"id":1,"title":"My First Post",...,"author":{"id":1,"name":"Jatin"}} # List posts curl http://localhost:3000/posts # Get user profile with their posts curl http://localhost:3000/users/1 # Update a post curl -X PATCH http://localhost:3000/posts/1 \ -H "Content-Type: application/json" \ -d '{"title":"My Updated Post"}' # Delete a post curl -X DELETE http://localhost:3000/posts/1 # Test 404 curl http://localhost:3000/posts/9999 # → {"error":"Post not found"} # Test validation curl -X POST http://localhost:3000/posts \ -H "Content-Type: application/json" \ -d '{"title":"Missing content"}' # → {"error":"title, content, and authorId are required"}
What This Structure Gives You
Looking at the project layout again:
src/
├── db/ → Data access layer (only place that talks to DB)
├── routes/ → HTTP layer (only place that reads req, writes res)
├── middleware/ → Cross-cutting concerns (logging, errors, auth later)
└── index.js → Wires everything together
Why separate db/ from routes/?
Your route handlers are now thin — they validate input, call a function, and send a response. All database logic is in db/. If you switch from Prisma to raw SQL tomorrow, you change db/ only. Routes stay the same.
Why a dedicated error handler? All errors flow to one place. You change error formatting once. You add logging once. Every route benefits automatically.
Why dotenv/config at the top of index.js?
Environment variables must be loaded before anything else reads them. Loading it first in the entry file ensures process.env.DATABASE_URL is available when prisma.js is imported.
What Comes Next
This application is the baseline. As you move into the Practitioner phase, you will add:
- P-1: Proper layered architecture (service layer between routes and DB)
- P-2: JWT authentication — protect routes, know who is making requests
- P-3: TypeScript — full type safety from route parameters to database results
- P-4: Zod validation — replace manual
if (!title)checks with schema declarations - P-5: Tests — Jest and Supertest to verify every endpoint automatically
- P-8: Docker — containerise the entire app and database
Each addition builds on the structure you have here. The fundamentals do not change — they get reinforced.
Summary
- A real application has layers: routes (HTTP), services (logic), and data (database). Even this small app separates HTTP handling from database access.
dotenv/configloads environment variables first, before any imports that need them.- One Prisma client instance per application. Never create one per request.
- Pass errors to
next(err)in every route handler. The global error handler catches them all. - A health check endpoint (
GET /health) is a small addition that makes your app ready for Docker and Kubernetes load balancer checks. .env.exampleis committed;.envis not. Everyone on the team knows what configuration the app needs.
Next: Event Emitters — the mechanism underlying everything in Node.js from HTTP servers to file streams.