What This Guide Actually Covers
There are a hundred "build a REST API with Node.js" tutorials online. Most of them get you to a working Hello World endpoint and then leave you to figure out the rest. This guide is going to focus on the pieces that actually matter for an API you'd put in front of real users: proper project structure, error handling that doesn't break in production, validation, and a sensible approach to async code. We'll use Express because it's still the most practical choice for most projects.
Project Setup
Start with a clean directory and initialize your project:
mkdir my-api && cd my-api
npm init -y
npm install express
npm install --save-dev nodemon
Add a start script to package.json:
"scripts": {
"start": "node src/index.js",
"dev": "nodemon src/index.js"
}
Structure your project like this from the beginning — don't put everything in index.js:
src/
index.js ← app entry point
app.js ← express setup
routes/ ← route definitions
controllers/ ← request handlers
middleware/ ← custom middleware
models/ ← data models (if using a DB)
Setting Up Express the Right Way
In app.js, set up Express with the middleware you'll actually need:
const express = require('express');
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Routes
const userRoutes = require('./routes/users');
app.use('/api/users', userRoutes);
// 404 handler
app.use((req, res) => {
res.status(404).json({ error: 'Route not found' });
});
// Error handler (must have 4 params)
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(err.status || 500).json({
error: err.message || 'Internal server error'
});
});
module.exports = app;
Error Handling That Won't Bite You Later
The most common mistake in Node API tutorials is ignoring async error handling. If an async function throws and you haven't caught it, Express won't handle it correctly. The fix is a simple wrapper:
const asyncHandler = fn => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
Use it in your controllers:
router.get('/:id', asyncHandler(async (req, res) => {
const user = await User.findById(req.params.id);
if (!user) {
const err = new Error('User not found');
err.status = 404;
throw err;
}
res.json(user);
}));
This way, any thrown error — whether it's a database failure or a missing record — flows through your central error handler.
Input Validation
Never trust incoming data. Use a library like zod or joi to validate request bodies before they touch your business logic:
npm install zod
const { z } = require('zod');
const createUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(0).optional()
});
router.post('/', asyncHandler(async (req, res) => {
const data = createUserSchema.parse(req.body); // throws if invalid
const user = await User.create(data);
res.status(201).json(user);
}));
When zod throws a ZodError, catch it in your error handler and return a 400 with the validation details.
Before You Deploy
A few things that matter before you call it production-ready: add rate limiting (express-rate-limit), set security headers (helmet), and make sure your error handler never leaks stack traces to clients in production. Environment variables belong in .env files loaded with dotenv, never hardcoded.
The API you build following these patterns won't just work — it'll behave predictably when things go wrong, which is what actually matters in production.
