Introduction
If you can build a REST API with Node.js and Express, you can build the backend for almost any web or mobile app on the planet. Express has been the default Node.js framework for over a decade — battle-tested, minimal, and used by Netflix, Uber, and thousands of startups still launching in 2026.
This guide builds a real, working API for a simple "Notes" app, step by step:
- Project setup and your first server
- RESTful routing for CRUD operations
- Middleware — the secret weapon of Express
- Connecting a database (MongoDB)
- JWT authentication
- Input validation and error handling
- Production-ready deployment
By the end, you'll have a complete API you can extend into anything.
Setup: Your First Express Server
Create a new project:
mkdir notes-api && cd notes-api
npm init -y
npm install express
npm install -D nodemon
Update package.json to add a dev script:
{
"scripts": {
"dev": "nodemon server.js",
"start": "node server.js"
},
"type": "module"
}
Setting "type": "module" lets you use modern import syntax instead of require.
Create server.js:
import express from 'express';
const app = express();
const PORT = process.env.PORT || 3000;
app.use(express.json()); // parse JSON bodies
app.get('/', (req, res) => {
res.json({ message: 'API is running' });
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
Run it:
npm run dev
Open http://localhost:3000 — you should see {"message":"API is running"}. That's it. You have a working Node.js server in 10 lines of code.
1. RESTful Routing: The Five Endpoints That Cover Everything
A REST API for any resource follows the same pattern. For our "notes":
| Method | URL | What it does |
|---|---|---|
| GET | /api/notes |
List all notes |
| GET | /api/notes/:id |
Get one note |
| POST | /api/notes |
Create a new note |
| PUT | /api/notes/:id |
Update an existing note |
| DELETE | /api/notes/:id |
Delete a note |
Master these five and you understand REST. Let's implement them — first with an in-memory array, then we'll add a database:
import express from 'express';
const app = express();
app.use(express.json());
let notes = [
{ id: 1, title: 'First note', body: 'Hello world' },
];
let nextId = 2;
// GET all
app.get('/api/notes', (req, res) => {
res.json(notes);
});
// GET one
app.get('/api/notes/:id', (req, res) => {
const note = notes.find(n => n.id === parseInt(req.params.id));
if (!note) return res.status(404).json({ error: 'Not found' });
res.json(note);
});
// CREATE
app.post('/api/notes', (req, res) => {
const { title, body } = req.body;
if (!title) return res.status(400).json({ error: 'Title required' });
const note = { id: nextId++, title, body: body || '' };
notes.push(note);
res.status(201).json(note);
});
// UPDATE
app.put('/api/notes/:id', (req, res) => {
const id = parseInt(req.params.id);
const note = notes.find(n => n.id === id);
if (!note) return res.status(404).json({ error: 'Not found' });
note.title = req.body.title ?? note.title;
note.body = req.body.body ?? note.body;
res.json(note);
});
// DELETE
app.delete('/api/notes/:id', (req, res) => {
const id = parseInt(req.params.id);
notes = notes.filter(n => n.id !== id);
res.status(204).end();
});
app.listen(3000, () => console.log('Running on 3000'));
Test it from your terminal:
# Create a note
curl -X POST http://localhost:3000/api/notes \
-H "Content-Type: application/json" \
-d '{"title":"My note","body":"Hello"}'
# List all
curl http://localhost:3000/api/notes
# Get one
curl http://localhost:3000/api/notes/2
# Delete
curl -X DELETE http://localhost:3000/api/notes/2
That's a fully working CRUD API. Now let's make it production-quality.
2. Middleware: The Secret Weapon of Express
Middleware is just a function that runs between the request and the response. You use middleware for logging, authentication, parsing, error handling — anything that should happen to every request.
import express from 'express';
const app = express();
// Built-in middleware — runs on every request
app.use(express.json());
// Custom middleware — log every request
app.use((req, res, next) => {
console.log(`${new Date().toISOString()} ${req.method} ${req.url}`);
next(); // pass control to the next middleware/route
});
// Middleware only for specific routes
const requireAuth = (req, res, next) => {
const token = req.headers.authorization;
if (!token) return res.status(401).json({ error: 'No token' });
// verify token here...
next();
};
app.get('/api/protected', requireAuth, (req, res) => {
res.json({ data: 'secret stuff' });
});
The next() call is critical — without it, the request hangs forever.
The five middlewares you'll use in every project:
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import rateLimit from 'express-rate-limit';
import compression from 'compression';
app.use(helmet()); // sets secure HTTP headers
app.use(cors()); // allows requests from other origins
app.use(morgan('dev')); // logs requests nicely
app.use(compression()); // gzips responses
app.use(rateLimit({ // prevents abuse
windowMs: 15 * 60 * 1000,
max: 100,
}));
Install them all:
npm install cors helmet morgan express-rate-limit compression
3. Connecting MongoDB
In-memory data dies when the server restarts. Real APIs need a database. MongoDB pairs naturally with Node.js — use Mongoose for clean, schema-based access.
npm install mongoose
Sign up for a free MongoDB Atlas account at mongodb.com/atlas, create a cluster, and copy the connection string.
db.js:
import mongoose from 'mongoose';
export async function connectDB() {
try {
await mongoose.connect(process.env.MONGODB_URI);
console.log('✅ MongoDB connected');
} catch (err) {
console.error('❌ MongoDB connection failed:', err.message);
process.exit(1);
}
}
models/Note.js:
import mongoose from 'mongoose';
const noteSchema = new mongoose.Schema(
{
title: { type: String, required: true, trim: true },
body: { type: String, default: '' },
userId: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
},
{ timestamps: true } // adds createdAt + updatedAt automatically
);
export const Note = mongoose.model('Note', noteSchema);
routes/notes.js:
import { Router } from 'express';
import { Note } from '../models/Note.js';
const router = Router();
router.get('/', async (req, res, next) => {
try {
const notes = await Note.find().sort('-createdAt');
res.json(notes);
} catch (err) {
next(err);
}
});
router.get('/:id', async (req, res, next) => {
try {
const note = await Note.findById(req.params.id);
if (!note) return res.status(404).json({ error: 'Not found' });
res.json(note);
} catch (err) {
next(err);
}
});
router.post('/', async (req, res, next) => {
try {
const note = await Note.create(req.body);
res.status(201).json(note);
} catch (err) {
next(err);
}
});
router.put('/:id', async (req, res, next) => {
try {
const note = await Note.findByIdAndUpdate(
req.params.id,
req.body,
{ new: true, runValidators: true }
);
if (!note) return res.status(404).json({ error: 'Not found' });
res.json(note);
} catch (err) {
next(err);
}
});
router.delete('/:id', async (req, res, next) => {
try {
await Note.findByIdAndDelete(req.params.id);
res.status(204).end();
} catch (err) {
next(err);
}
});
export default router;
server.js (refactored):
import express from 'express';
import 'dotenv/config';
import { connectDB } from './db.js';
import notesRouter from './routes/notes.js';
const app = express();
app.use(express.json());
app.use('/api/notes', notesRouter);
await connectDB();
app.listen(process.env.PORT || 3000);
Create a .env file:
MONGODB_URI=mongodb+srv://user:pass@cluster.mongodb.net/notesdb
PORT=3000
JWT_SECRET=your-super-secret-key-change-me
Install dotenv:
npm install dotenv
Now your data persists. Restart the server, and your notes are still there.
4. JWT Authentication
Every real API needs auth. JWT (JSON Web Tokens) is the standard.
npm install bcryptjs jsonwebtoken
models/User.js:
import mongoose from 'mongoose';
import bcrypt from 'bcryptjs';
const userSchema = new mongoose.Schema({
email: { type: String, unique: true, required: true, lowercase: true },
password: { type: String, required: true, minlength: 8 },
}, { timestamps: true });
userSchema.pre('save', async function (next) {
if (!this.isModified('password')) return next();
this.password = await bcrypt.hash(this.password, 12);
next();
});
userSchema.methods.comparePassword = function (plain) {
return bcrypt.compare(plain, this.password);
};
export const User = mongoose.model('User', userSchema);
routes/auth.js:
import { Router } from 'express';
import jwt from 'jsonwebtoken';
import { User } from '../models/User.js';
const router = Router();
function signToken(userId) {
return jwt.sign({ userId }, process.env.JWT_SECRET, { expiresIn: '7d' });
}
router.post('/register', async (req, res, next) => {
try {
const { email, password } = req.body;
const user = await User.create({ email, password });
res.status(201).json({ token: signToken(user._id) });
} catch (err) {
if (err.code === 11000) {
return res.status(409).json({ error: 'Email already registered' });
}
next(err);
}
});
router.post('/login', async (req, res, next) => {
try {
const { email, password } = req.body;
const user = await User.findOne({ email });
if (!user || !(await user.comparePassword(password))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
res.json({ token: signToken(user._id) });
} catch (err) {
next(err);
}
});
export default router;
middleware/auth.js:
import jwt from 'jsonwebtoken';
export function requireAuth(req, res, next) {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const token = header.split(' ')[1];
const { userId } = jwt.verify(token, process.env.JWT_SECRET);
req.userId = userId;
next();
} catch {
res.status(401).json({ error: 'Invalid or expired token' });
}
}
Wire it up in server.js:
import authRouter from './routes/auth.js';
import { requireAuth } from './middleware/auth.js';
app.use('/api/auth', authRouter);
app.use('/api/notes', requireAuth, notesRouter); // protect notes
Now users register, log in, and get a token. The token must be sent in the Authorization: Bearer <token> header for any protected request.
Test it:
# Register
curl -X POST http://localhost:3000/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"a@b.com","password":"password123"}'
# → {"token":"eyJhbGc..."}
# Use the token
curl http://localhost:3000/api/notes \
-H "Authorization: Bearer eyJhbGc..."
5. Validation With Zod
Never trust user input. Use Zod to validate every request body:
npm install zod
middleware/validate.js:
export const validate = (schema) => (req, res, next) => {
const result = schema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({
error: 'Validation failed',
details: result.error.flatten().fieldErrors,
});
}
req.body = result.data;
next();
};
Use it in a route:
import { z } from 'zod';
import { validate } from '../middleware/validate.js';
const createNoteSchema = z.object({
title: z.string().min(1).max(200),
body: z.string().max(10_000).optional(),
});
router.post('/', validate(createNoteSchema), async (req, res, next) => {
const note = await Note.create({ ...req.body, userId: req.userId });
res.status(201).json(note);
});
Bad input gets rejected with a clear error before your route runs.
6. Centralized Error Handling
A single error handler at the bottom of server.js catches everything:
// 404 — no route matched
app.use((req, res) => {
res.status(404).json({ error: 'Route not found' });
});
// Global error handler — must have 4 arguments
app.use((err, req, res, next) => {
console.error(err);
if (err.name === 'ValidationError') {
return res.status(400).json({ error: err.message });
}
if (err.name === 'CastError') {
return res.status(400).json({ error: 'Invalid ID' });
}
res.status(err.status || 500).json({
error: process.env.NODE_ENV === 'production'
? 'Internal server error'
: err.message,
});
});
The 4-argument signature (err, req, res, next) is how Express knows it's an error handler.
7. Production Checklist
Before you deploy, run through this:
| Concern | Action |
|---|---|
| Secrets | Use .env + dotenv. Never commit. Add .env to .gitignore |
| HTTPS | Use a reverse proxy (Nginx) or platform like Vercel/Render |
| Logging | morgan for dev, structured logs (Pino) for production |
| Error monitoring | Sentry or Logtail |
| Rate limiting | express-rate-limit on auth routes especially |
| CORS | Restrict origins with cors({ origin: 'https://yourdomain.com' }) |
| Headers | helmet() handles 90% of security headers |
| Validation | Zod on every body, query, and param |
| Indexes | Add index: true to fields you query often |
| Tests | Use Vitest or Jest + Supertest |
Deploying in 5 minutes
The fastest way to deploy a Node.js API in 2026:
- Push your code to GitHub
- Sign up at render.com (free tier)
- New → Web Service → connect GitHub repo
- Build command:
npm install - Start command:
node server.js - Add your environment variables (MONGODB_URI, JWT_SECRET)
- Deploy
Your API is live at https://your-app.onrender.com in about 3 minutes.
Alternatives: Railway, Fly.io, DigitalOcean App Platform. All similar.
The Full Project Structure
notes-api/
├── server.js ← entry point
├── db.js ← MongoDB connection
├── .env ← secrets (gitignored)
├── package.json
├── models/
│ ├── Note.js
│ └── User.js
├── routes/
│ ├── auth.js
│ └── notes.js
├── middleware/
│ ├── auth.js
│ └── validate.js
└── README.md
Small, organized, and easy to extend. This structure scales to APIs with 50+ endpoints.
What to Build Next
You now have a real REST API. Extend it into:
1. Add file uploads with multer (images on notes)
2. Add full-text search with MongoDB text indexes
3. Add WebSocket support with Socket.io for real-time updates
4. Add Redis caching for hot endpoints
5. Add OpenAPI/Swagger docs at /docs
6. Add email verification with Nodemailer
7. Build a frontend (Next.js / React) that consumes this API
Final Thought
The architecture in this tutorial — Express + Mongoose + JWT + Zod — is exactly what most production Node.js APIs look like in 2026. You don't need NestJS, Fastify, or any framework-of-the-month to build something real. You need a clean structure, validation on every input, auth on every protected route, and a database that persists.
Build the Notes API end-to-end. Then build the same thing for a different resource (todos, books, customers). After three of these, REST API design becomes muscle memory — and you've leveled up into a backend developer who can actually ship.