How to build a REST API with Node.js and Express
Learn to create a production-ready RESTful API using Node.js and Express: setup, routing, middleware, environment variables, error handling, and deployment best practices — with a practical CRUD example for managing resources.
On this page
- Why Node.js and Express?
- Project Setup
- Basic Server Implementation
- Defining Routes and Controllers
- Essential Middleware
- CORS (Cross-Origin Resource Sharing)
- Security Headers
- Request Logging
- Environment Variables
- Error Handling
- Running in Production
- Testing the API
- Common Best Practices
- FAQ
- What is the difference between Express 4.x and 5.x?
- Do I need a database to build a REST API?
- How do I handle authentication in an Express API?
- Can I use TypeScript with Express?
- How should I structure a larger Express application?
- Summary
- Sources
Express is the most popular web framework for Node.js, providing a minimal, unopinionated layer for building web servers and APIs. Combined with Node.js's efficient I/O model, Express enables developers to create fast, scalable RESTful services with straightforward routing and middleware. This guide shows how to build a production-ready REST API using Node.js and Express, covering project setup, core concepts, environment configuration, error handling, and deployment practices — illustrated with a practical CRUD API for managing a collection of books.
Why Node.js and Express?
Node.js is a server-side JavaScript runtime built on Chrome's V8 engine, designed for building scalable network applications. Its event-driven, non-blocking I/O model makes it efficient for data-intensive real-time applications. Express, first released in 2010, has become the de facto standard web framework for Node.js due to its simplicity, flexibility, and massive ecosystem. As of September 2024, Express 5.0.0 introduced modern async/await support and improved routing while maintaining backward compatibility for most middleware. The framework remains actively maintained, with the 4.x line receiving long-term support alongside the current 5.x releases.
Understanding REST principles is essential before coding. REST (Representational State Transfer) is an architectural style for networked applications that uses standard HTTP methods (GET, POST, PUT, DELETE) and status codes to manipulate resources identified by URLs. A RESTful API adheres to constraints like statelessness, cacheability, and a uniform interface, making it predictable and easy to consume.
Project Setup
Begin by initializing a new Node.js project and installing Express:
mkdir book-api && cd book-api
npm init -y
npm install express
For development convenience, also install nodemon for automatic restarts and dotenv for environment variable management:
npm install --save-dev nodemon dotenv
Create the project structure:
book-api/
├── src/
│ ├── controllers/
│ ├── middleware/
│ ├── routes/
│ ├── models/
│ └── app.js
├── .env
├── .gitignore
├── package.json
└── server.js
Basic Server Implementation
Create src/app.js to configure the Express application:
const express = require('express');
const app = express();
// Middleware to parse JSON bodies
app.use(express.json());
// Middleware to parse URL-encoded bodies (for form data)
app.use(express.urlencoded({ extended: true }));
// Basic route
app.get('/', (req, res) => {
res.json({ message: 'Welcome to the Book API' });
});
module.exports = app;
Create src/server.js to start the server:
const app = require('./app');
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Update package.json to include a dev script:
"scripts": {
"dev": "nodemon src/server.js",
"start": "node src/server.js"
}
Run npm dev to start the server in development mode. Visiting http://localhost:3000 should return the welcome message.
Defining Routes and Controllers
REST APIs organize functionality around resources. For a book collection, typical endpoints include:
GET /books— Retrieve all booksGET /books/:id— Retrieve a specific bookPOST /books— Create a new bookPUT /books/:id— Update a bookDELETE /books/:id— Delete a book
Create src/controllers/bookController.js:
// In-memory store for demonstration (use a database in production)
let books = [];
let nextId = 1;
exports.getAllBooks = (req, res) => {
res.json(books);
};
exports.getBookById = (req, res) => {
const book = books.find(b => b.id === parseInt(req.params.id));
if (!book) return res.status(404).json({ error: 'Book not found' });
res.json(book);
};
exports.createBook = (req, res) => {
const { title, author, publishedYear } = req.body;
if (!title || !author) {
return res.status(400).json({ error: 'Title and author are required' });
}
const newBook = {
id: nextId++,
title,
author,
publishedYear: publishedYear || null
};
books.push(newBook);
res.status(201).json(newBook);
};
exports.updateBook = (req, res) => {
const book = books.find(b => b.id === parseInt(req.params.id));
if (!book) return res.status(404).json({ error: 'Book not found' });
const { title, author, publishedYear } = req.body;
if (title !== undefined) book.title = title;
if (author !== undefined) book.author = author;
if (publishedYear !== undefined) book.publishedYear = publishedYear;
res.json(book);
};
exports.deleteBook = (req, res) => {
const index = books.findIndex(b => b.id === parseInt(req.params.id));
if (index === -1) return res.status(404).json({ error: 'Book not found' });
books.splice(index, 1);
res.status(204).send();
};
Create src/routes/bookRoutes.js:
const express = require('express');
const router = express.Router();
const bookController = require('../controllers/bookController');
router.get('/', bookController.getAllBooks);
router.get('/:id', bookController.getBookById);
router.post('/', bookController.createBook);
router.put('/:id', bookController.updateBook);
router.delete('/:id', bookController.deleteBook);
module.exports = router;
Register the routes in src/app.js:
const express = require('express');
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
const bookRoutes = require('./routes/bookRoutes');
app.use('/books', bookRoutes);
app.get('/', (req, res) => {
res.json({ message: 'Welcome to the Book API' });
});
module.exports = app;
Essential Middleware
Production APIs benefit from middleware that enhances security, logging, and functionality.
CORS (Cross-Origin Resource Sharing)
If your API will be accessed from different domains (e.g., a frontend hosted separately), enable CORS:
npm install cors
const cors = require('cors');
app.use(cors()); // Allows all origins — configure restrictively in production
Security Headers
Use Helmet to set various HTTP headers for security:
npm install helmet
const helmet = require('helmet');
app.use(helmet());
Request Logging
Log incoming requests for debugging and monitoring:
app.use((req, res, next) => {
console.log(`${req.method} ${req.path} - ${new Date().toISOString()}`);
next();
});
Environment Variables
Store configuration like port, database URLs, and API keys in environment variables using dotenv. Create .env:
PORT=3000
NODE_ENV=development
Load them in src/server.js:
require('dotenv').config();
const app = require('./app');
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running in ${process.env.NODE_ENV} mode on port ${PORT}`);
});
Add .env to .gitignore to prevent committing sensitive data:
node_modules/
.env
.env.*
Error Handling
Implement centralized error handling to catch and format errors consistently. Add this middleware after all routes:
// 404 handler
app.use((req, res) => {
res.status(404).json({ error: 'Route not found' });
});
// Global error handler
app.use((err, req, res, next) => {
console.error(err.stack);
const status = err.status || 500;
res.status(status).json({
error: {
message: err.message || 'Internal Server Error',
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
}
});
});
In controllers, pass errors to next(err) for asynchronous operations:
exports.createBook = async (req, res, next) => {
try {
// ... logic that might throw
} catch (error) {
next(error);
}
};
Running in Production
For production, use a process manager like PM2 to keep the application running and handle clustering:
npm install -g pm2
pm2 start src/server.js --name book-api
Ensure NODE_ENV=production in your environment to disable development-specific logging and enable optimizations. Use a reverse proxy like Nginx for SSL termination, load balancing, and serving static files.
Testing the API
Test endpoints using tools like curl, HTTPie, or Postman:
# Get all books
curl http://localhost:3000/books
# Create a book
curl -X POST http://localhost:3000/books \
-H "Content-Type: application/json" \
-d '{"title":"The Great Gatsby","author":"F. Scott Fitzgerald","publishedYear":1925}'
# Get a specific book
curl http://localhost:3000/books/1
# Update a book
curl -X PUT http://localhost:3000/books/1 \
-H "Content-Type: application/json" \
-d '{"title":"Great Gatsby"}'
# Delete a book
curl -X DELETE http://localhost:3000/books/1
Common Best Practices
- Use HTTP status codes correctly: 200 (OK), 201 (Created), 204 (No Content), 400 (Bad Request), 401 (Unauthorized), 403 (Forbidden), 404 (Not Found), 409 (Conflict), 500 (Internal Server Error).
- Validate input: Always validate request data (body, params, query) before processing.
- Keep controllers thin: Move business logic to service layers or models.
- Use asynchronous handling: Prefer async/await with try/catch or promises; avoid blocking the event loop.
- Secure sensitive data: Never hardcode secrets; use environment variables and secrets managers.
- Enable compression: Use compression middleware (e.g., compression) to reduce response sizes.
- Implement rate limiting: Protect against abuse with rate-limiter middleware.
- Log effectively: Use structured logging (e.g., winston, pino) for production monitoring.
- Document your API: Use OpenAPI/Swagger to generate interactive documentation.
- Write tests: Implement unit and integration tests with frameworks like Jest and Supertest.
FAQ
What is the difference between Express 4.x and 5.x?
Express 5.x introduces native support for async/await in route handlers and middleware, eliminates certain deprecated APIs (like app.param(fn)), and improves error handling for asynchronous operations. The 4.x line remains under long-term support for stability-critical applications.
Do I need a database to build a REST API?
No — you can start with in-memory storage or flat files for learning or prototyping. For production, integrate a database (e.g., PostgreSQL, MongoDB) using an ORM (like Sequelize or Prisma) or a query builder.
How do I handle authentication in an Express API?
Common strategies include JSON Web Tokens (JWT), session-based authentication, or OAuth 2.0. Libraries like jsonwebtoken, passport, or express-jwt simplify implementation. Always use HTTPS in production to protect tokens.
Can I use TypeScript with Express?
Yes. Express officially supports TypeScript via @types/express. Initialize a TypeScript project with tsc --init, install type definitions, and rename .js files to .ts. Ensure esModuleInterop is enabled in tsconfig.json.
How should I structure a larger Express application?
As your API grows, adopt a layered structure: routes (handle HTTP), controllers (logic), services (business rules), models (data access), and middleware (cross-cutting concerns). Keep each file focused on a single responsibility.
Summary
Building a REST API with Node.js and Express involves setting up the server, defining RESTful routes, applying essential middleware, managing configuration via environment variables, handling errors gracefully, and following production best practices. By leveraging Express's minimalist core and Node.js's efficient runtime, developers can create scalable, maintainable APIs suitable for everything from small projects to enterprise systems. Remember to validate inputs, secure your endpoints, and monitor performance as your API evolves.
Sources
- MDN Web Docs: Express/Node introduction (CC-BY-SA)
- Express.js Documentation: Guide (MIT License)
- Node.js Documentation: About Node.js (MIT License)
- Wikipedia: Representational state transfer (CC-BY-SA)
- GitHub: Express repository (MIT License)
Sources
- MDN Web Docs — Express/Node introduction (developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Server-side/Express_Nodejs/Introduction)
- Express.js Documentation — Guide (expressjs.com)
- Node.js Documentation — About Node.js (nodejs.org/en/about)
- Wikipedia — Representational state transfer (en.wikipedia.org/wiki/Representational_state_transfer)
- GitHub — Express repository (github.com/expressjs/express)
Share this guide
Related guides
- What is GraphQL? How it works and when you should use it
GraphQL is an open-source query language for APIs that lets clients request exactly the data they need. This guide explains how GraphQL works, its type system, queries and mutations, and how it compares to REST.
- REST vs GraphQL: which API style should you choose?
REST and GraphQL are two ways to build web APIs. REST uses fixed endpoints and HTTP methods; GraphQL uses a single endpoint where clients query exactly the fields they need. Compare the trade-offs and learn how to choose.
- How to handle errors in Node.js: try/catch, error-first callbacks, promises, and event emitters
Node.js errors come in several asynchronous styles, and each has its own correct way to be caught. This guide explains try/catch for synchronous code, error-first callbacks, promise rejection handling, and the 'error' event for streams and event emitters.