Back/Module F-8 Your First Real Application
Module F-8·22 min read

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

bash
mkdir 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:

bash
npx 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" }
bash
npm 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/config loads 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.example is committed; .env is 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.

Discussion