Quick Summary :-
In this in-depth tutorial, we explain how to build RESTful APIs using Node.js and Express efficiently. From creating endpoints to managing middleware and optimizing performance, this guide covers everything developers need to design robust APIs that support scalable and high-performing web applications.Do you feel stuck while building RESTful APIs using Node.js? To your rescue, we bring a quick and easy-to-understand, step-by-step guide on how to build and use the Node.js API effectively.
To your rescue, we bring a clear and easy-to-follow step-by-step guide on building RESTful APIs using Node.js and Express.
It starts by revisiting the core strengths of Node.js. Simply put, Node.js is a powerful JavaScript runtime that enables developers to build fast, scalable servers and backend APIs. Its widespread adoption is evident, with 52.2% of developers admiring Node.js and 45.5% favoring Express for API development.
Compared to traditional backend technologies, Node.js delivers superior performance through its non-blocking, event-driven architecture. With frameworks like Express, along with databases and frontend libraries, developers can build efficient, secure, and production-ready REST APIs.
This guide walks you through every step from setting up your Node.js environment to designing, building, and consuming RESTful APIs, making it ideal for both beginners and experienced developers.
What Is a RESTful API?
A RESTful API (Representational State Transfer API) is an architectural style that enables communication between client and server using standard HTTP methods like GET, POST, PUT, and DELETE. It follows stateless principles, uses structured URLs, and exchanges data, most commonly in JSON format, making it lightweight, scalable, and easy to integrate across platforms.
RESTful APIs are widely used to power web applications, mobile apps, and third-party integrations due to their flexibility, performance, and simplicity.
Why Choose Node.js and Express for Building RESTful APIs?
Node.js and Express form one of the most popular stacks for building RESTful APIs because of their speed, scalability, and developer-friendly ecosystem.
- High Performance: Node.js uses a non-blocking, event-driven architecture, allowing APIs to handle multiple requests efficiently.
- Lightweight Framework: Express simplifies routing, middleware handling, and request-response management with minimal overhead.
- Fast Development: With JavaScript used on both frontend and backend, teams can build APIs faster and maintain consistency.
- Scalability: Ideal for building APIs that need to scale in real time for high-traffic applications.
- Rich Ecosystem: A vast NPM ecosystem offers ready-made libraries for authentication, validation, security, and API documentation.
Together, Node.js and Express enable developers to build secure, high-performing, and maintainable RESTful APIs suitable for modern application development.
Also Read: Express vs Koa: Which Node.js Framework Is Best For You?
Step-by-Step Guide to Building RESTful APIs
Follow this practical, easy-to-understand guide to set up your environment, create routes, connect databases, and build fully functional RESTful APIs using Node.js and Express.
Step 1: Set up the project
First, ensure Node.js (LTS 20 or 22+) and npm are installed. You can download them from the official Node.js website and confirm installation:
| bash |
| node -v
npm -v |
Now create a new folder and initialize a Node.js project:
| bash |
| mkdir nodejs-rest-api
cd nodejs-rest-api # quick init with defaults npm init -y |
This creates a package.json file which will track your dependencies and scripts.
Step 2: Install Express and basic tools
Express is the de‑facto standard web framework for building REST APIs in Node.js. Install Express and a dev tool to auto‑restart the server:
| bash |
| npm install express
npm install –save-dev nodemon |
Update your package.json scripts so you can start the API quickly in dev mode:
| json |
| {
“name”: “nodejs-rest-api”, “version”: “1.0.0”, “description”: “Building a RESTful API with Node.js and Express”, “main”: “server.js”, “type”: “module”, “scripts”: { “start”: “node server.js”, “dev”: “nodemon server.js”, “test”: “echo \”Error: no test specified\” && exit 1″ }, “keywords”: [“nodejs”, “express”, “rest”, “api”], “author”: “Your Name”, “license”: “ISC”, “dependencies”: { “express”: “^4.21.0” }, “devDependencies”: { “nodemon”: “^3.1.0” } } |
Using “type”: “module” lets you use modern ES module syntax (import/export) instead of older require calls, which aligns with current Node.js best practices.
Step 3: Create a basic Express server
Create a server.js file in the project root and add the following code:
| javascript |
| // server.js
import express from ‘express’; const app = express(); const PORT = process.env.PORT || 3000; // Built‑in middleware to parse JSON request bodies app.use(express.json()); // Simple health check route app.get(‘/’, (req, res) => { res.json({ message: ‘Node.js REST API is running 🚀’ }); }); app.listen(PORT, () => { console.log(`Server is running on http://localhost:${PORT}`); }); |
This minimal setup does three important things:
- Creates an Express app instance listening on port 3000
- Enables JSON body parsing via express.json() so your API can accept JSON payloads
- Exposes a root GET / endpoint that returns a simple JSON response instead of HTML
Start the development server:
| bash |
| npm run dev |
Open http://localhost:3000 in your browser or use Postman/Insomnia, and you should see a JSON response similar to:
| json |
| {
“message”: “Node.js REST API is running 🚀” } |
At this point, you have a clean, modern foundation ready for building out RESTful endpoints such as /api/users or /api/notes using proper HTTP methods and status codes.
Step 4: Create a notes router
Instead of putting every route in server.js, use an Express Router for better structure and scalability (this is standard practice in modern Node.js APIs).
Create a new folder and file:
| bash |
| mkdir routes
touch routes/notes.routes.js |
Add the following code in routes/notes.routes.js:
| javascript |
| // routes/notes.routes.jsimport { Router } from ‘express’;
const router = Router(); // Temporary in‑memory store (replace with DB later) let notes = []; let nextId = 1; // CREATE – POST /api/notes router.post(‘/’, (req, res) => { const { title, content } = req.body; if (!title || !content) { return res.status(400).json({ error: ‘Title and content are required.’ }); } const note = { id: nextId++, title, content, createdAt: new Date().toISOString(), }; notes.push(note); return res.status(201).json(note); // 201 = Created }); // READ ALL – GET /api/notes router.get(‘/’, (req, res) => { return res.status(200).json(notes); }); // READ ONE – GET /api/notes/:id router.get(‘/:id’, (req, res) => { const id = Number(req.params.id); const note = notes.find((n) => n.id === id); if (!note) { return res.status(404).json({ error: ‘Note not found.’ }); } return res.status(200).json(note); }); // UPDATE – PUT /api/notes/:id router.put(‘/:id’, (req, res) => { const id = Number(req.params.id); const { title, content } = req.body; const index = notes.findIndex((n) => n.id === id); if (index === –1) { return res.status(404).json({ error: ‘Note not found.’ }); } if (!title || !content) { return res.status(400).json({ error: ‘Title and content are required.’ }); } notes[index] = { …notes[index], title, content, updatedAt: new Date().toISOString(), }; return res.status(200).json(notes[index]); }); // PARTIAL UPDATE – PATCH /api/notes/:id router.patch(‘/:id’, (req, res) => { const id = Number(req.params.id); const { title, content } = req.body; const index = notes.findIndex((n) => n.id === id); if (index === –1) { return res.status(404).json({ error: ‘Note not found.’ }); } if (title !== undefined) { notes[index].title = title; } if (content !== undefined) { notes[index].content = content; } notes[index].updatedAt = new Date().toISOString(); return res.status(200).json(notes[index]); }); // DELETE – DELETE /api/notes/:id router.delete(‘/:id’, (req, res) => { const id = Number(req.params.id); const index = notes.findIndex((n) => n.id === id); if (index === –1) { return res.status(404).json({ error: ‘Note not found.’ }); } notes.splice(index, 1); return res.status(204).send(); // 204 = No Content }); export default router; |
This router demonstrates how to structure CRUD endpoints, validates input, and uses appropriate HTTP status codes (200, 201, 400, 404, 204), which aligns with REST best practices.
Step 5: Register the router in the main server
Now, wire this router into server.js so all /api/notes requests are handled there.
Update server.js:
| javascript |
| // server.jsimport express from ‘express’;
import notesRouter from ‘./routes/notes.routes.js’; const app = express(); const PORT = process.env.PORT || 3000; app.use(express.json()); // Health check app.get(‘/’, (req, res) => { res.json({ message: ‘Node.js REST API is running 🚀’ }); }); // Notes API app.use(‘/api/notes’, notesRouter); app.listen(PORT, () => { console.log(`Server is running on http://localhost:${PORT}`); }); |
With this, your API structure now looks like:
- GET / – simple health check
- POST /api/notes – create a new note
- GET /api/notes – list all notes
- GET /api/notes/:id – get a single note
- PUT /api/notes/:id – replace a note
- PATCH /api/notes/:id – partially update a note
- DELETE /api/notes/:id – delete a note
Step 6: Test the CRUD endpoints
Use Postman, Insomnia, or any REST client to test the new routes.
Create a note – POST http://localhost:3000/api/notes
- Body (JSON):
| json |
| {
“title”: “First note”, “content”: “This is my first REST API note.” } |
- Expected: 201 Created with the created note object.
Get all notes – GET http://localhost:3000/api/notes
- Expected: 200 OK with an array of notes.
Get a single note – GET http://localhost:3000/api/notes/1
- Expected: 200 OK if it exists, otherwise 404 Not Found.Update a note – PUT http://localhost:3000/api/notes/1
- Body:
| json |
| {
“title”: “Updated note”, “content”: “This note has been updated.” } |
Delete a note – DELETE http://localhost:3000/api/notes/1
- Expected: 204 No Content if deletion is successful.
This gives your readers a clean, modern CRUD API with Express that they can run immediately, and sets you up to introduce MongoDB/Mongoose or SQL in the next section without carrying any legacy or insecure patterns.
Step 7: Install and configure Mongoose
From the project root, install Mongoose:
| bash |
| npm install mongoose |
Create a simple config file for the database connection string:
| bash |
| mkdir config
touch config/db.js |
Add the following in config/db.js:
| javascript |
| // config/db.js
export const MONGODB_URI = process.env.MONGODB_URI || ‘mongodb://127.0.0.1:27017/nodejs_rest_api’; |
In a real deployment, you will typically use MongoDB Atlas and store MONGODB_URI in an environment variable instead of hard‑coding it.
Now update server.js to connect to MongoDB at startup:
| javascript |
| // server.js
import express from ‘express’; import mongoose from ‘mongoose’; import notesRouter from ‘./routes/notes.routes.js’; import { MONGODB_URI } from ‘./config/db.js’; const app = express(); const PORT = process.env.PORT || 3000; app.use(express.json()); // Health check app.get(‘/’, (req, res) => { res.json({ message: ‘Node.js REST API is running 🚀’ }); }); // Notes API (in‑memory) app.use(‘/api/notes’, notesRouter); // MongoDB connection mongoose .connect(MONGODB_URI) .then(() => { console.log(‘Connected to MongoDB’); app.listen(PORT, () => { console.log(`Server is running on http://localhost:${PORT}`); }); }) .catch((err) => { console.error(‘MongoDB connection error:’, err); process.exit(1); }); |
This pattern ensures the server only starts listening after a successful database connection, which avoids runtime errors when handling requests.
Step 8: Define a User model with Mongoose
Create a models folder and a User model file:
| bash |
| mkdir models
touch models/user.model.js |
Add the following schema and model:
| javascript |
| // models/user.model.js
import mongoose from ‘mongoose’; const userSchema = new mongoose.Schema( { name: { type: String, required: true, trim: true, minlength: 2, maxlength: 100, }, email: { type: String, required: true, unique: true, lowercase: true, trim: true, }, }, { timestamps: true, // adds createdAt and updatedAt } ); const User = mongoose.model(‘User’, userSchema); export default User; |
Mongoose schemas allow you to define required fields, uniqueness, basic validation, and automatic timestamps, which simplifies backend logic and helps keep your data consistent.
Step 9: Build RESTful User endpoints
Create a separate router for user‑related endpoints:
| bash |
| mkdir routes # if not already created
touch routes/users.routes.js |
Add the CRUD logic in routes/users.routes.js using async/await:
| javascript |
| // routes/users.routes.js
import { Router } from ‘express’; import User from ‘../models/user.model.js’; const router = Router(); // CREATE – POST /api/users router.post(‘/’, async (req, res) => { try { const { name, email } = req.body; if (!name || !email) { return res .status(400) .json({ error: ‘Name and email are required.’ }); } const existing = await User.findOne({ email }); if (existing) { return res.status(409).json({ error: ‘Email already in use.’ }); } const user = await User.create({ name, email }); return res.status(201).json(user); } catch (err) { console.error(‘Error creating user:’, err); return res.status(500).json({ error: ‘Internal server error.’ }); } }); // READ ALL – GET /api/users router.get(‘/’, async (req, res) => { try { const users = await User.find().sort({ createdAt: –1 }); return res.status(200).json(users); } catch (err) { console.error(‘Error fetching users:’, err); return res.status(500).json({ error: ‘Internal server error.’ }); } }); // READ ONE – GET /api/users/:id router.get(‘/:id’, async (req, res) => { try { const user = await User.findById(req.params.id); if (!user) { return res.status(404).json({ error: ‘User not found.’ }); } return res.status(200).json(user); } catch (err) { console.error(‘Error fetching user:’, err); return res.status(400).json({ error: ‘Invalid user id.’ }); } }); // UPDATE – PATCH /api/users/:id router.patch(‘/:id’, async (req, res) => { try { const updates = req.body; const user = await User.findByIdAndUpdate( req.params.id, updates, { new: true, runValidators: true } ); if (!user) { return res.status(404).json({ error: ‘User not found.’ }); } return res.status(200).json(user); } catch (err) { console.error(‘Error updating user:’, err); return res.status(400).json({ error: ‘Invalid data or user id.’ }); } }); // DELETE – DELETE /api/users/:id router.delete(‘/:id’, async (req, res) => { try { const user = await User.findByIdAndDelete(req.params.id); if (!user) { return res.status(404).json({ error: ‘User not found.’ }); } return res.status(204).send(); } catch (err) { console.error(‘Error deleting user:’, err); return res.status(400).json({ error: ‘Invalid user id.’ }); } }); export default router; |
This implementation follows typical REST and HTTP status code conventions: 201 for creation, 200 for successful fetch/update, 204 for successful deletion without a body, and 4xx/5xx for validation and server errors.
Follow this practical Node.js and Express guide to build APIs faster.
Start Building APIsStep 10: Register the User router
Finally, plug the new router into server.js:
| javascript |
| // server.js
import express from ‘express’; import mongoose from ‘mongoose’; import notesRouter from ‘./routes/notes.routes.js’; import usersRouter from ‘./routes/users.routes.js’; import { MONGODB_URI } from ‘./config/db.js’; const app = express(); const PORT = process.env.PORT || 3000; app.use(express.json()); app.get(‘/’, (req, res) => { res.json({ message: ‘Node.js REST API is running 🚀’ }); }); app.use(‘/api/notes’, notesRouter); app.use(‘/api/users’, usersRouter); mongoose .connect(MONGODB_URI) .then(() => { console.log(‘Connected to MongoDB’); app.listen(PORT, () => { console.log(`Server is running on http://localhost:${PORT}`); }); }) .catch((err) => { console.error(‘MongoDB connection error:’, err); process.exit(1); }); |
You now have:
- An in‑memory notes API to show fast, framework‑only CRUD
- A database‑backed users API that demonstrates how to wire Express routes to MongoDB via Mongoose, using clean async/await patterns and robust status codes.
Step 11: Install auth dependencies
Install JWT and bcrypt for password hashing:
| bash |
| npm install jsonwebtoken bcrypt |
- jsonwebtoken signs and verifies tokens sent in the Authorization: Bearer <token> header
- bcrypt hashes passwords before storing them and verifies plaintext passwords on login.
Step 12: Extend the User model for authentication
Update models/user.model.js to include a password field. Do not return this field in responses in a real app; here it’s simplified for learning.
| javascript |
| // models/user.model.js
import mongoose from ‘mongoose’; const userSchema = new mongoose.Schema( { name: { type: String, required: true, trim: true, minlength: 2, maxlength: 100, }, email: { type: String, required: true, unique: true, lowercase: true, trim: true, }, password: { type: String, required: true, minlength: 6, }, }, { timestamps: true, } ); const User = mongoose.model(‘User’, userSchema); export default User; |
This schema now supports storing a hashed password for each user record.
Step 13: Create auth routes (register & login)
Create a dedicated auth router:
| bash |
| touch routes/auth.routes.js |
Add registration and login logic with bcrypt and JWT:
| javascript |
| // routes/auth.routes.js
import { Router } from ‘express’; import bcrypt from ‘bcrypt’; import jwt from ‘jsonwebtoken’; import User from ‘../models/user.model.js’; const router = Router(); const JWT_SECRET = process.env.JWT_SECRET || ‘dev_secret_change_me’; const JWT_EXPIRES_IN = ‘1h’; // POST /api/auth/register router.post(‘/register’, async (req, res) => { try { const { name, email, password } = req.body; if (!name || !email || !password) { return res .status(400) .json({ error: ‘Name, email, and password are required.’ }); } const existing = await User.findOne({ email }); if (existing) { return res.status(409).json({ error: ‘Email already in use.’ }); } const hashedPassword = await bcrypt.hash(password, 10); const user = await User.create({ name, email, password: hashedPassword, }); // In production, avoid returning password even if hashed const safeUser = { id: user._id, name: user.name, email: user.email, createdAt: user.createdAt, }; return res.status(201).json(safeUser); } catch (err) { console.error(‘Error registering user:’, err); return res.status(500).json({ error: ‘Internal server error.’ }); } }); // POST /api/auth/login router.post(‘/login’, async (req, res) => { try { const { email, password } = req.body; if (!email || !password) { return res .status(400) .json({ error: ‘Email and password are required.’ }); } const user = await User.findOne({ email }); if (!user) { return res.status(401).json({ error: ‘Invalid credentials.’ }); } const isMatch = await bcrypt.compare(password, user.password); if (!isMatch) { return res.status(401).json({ error: ‘Invalid credentials.’ }); } const payload = { sub: user._id.toString(), email: user.email, }; const accessToken = jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN, }); return res.status(200).json({ accessToken, user: { id: user._id, name: user.name, email: user.email, }, }); } catch (err) { console.error(‘Error logging in:’, err); return res.status(500).json({ error: ‘Internal server error.’ }); } }); export default router; |
This flow hashes passwords on registration and returns a signed JWT on successful login, following modern guidance for secure authentication workflows.
Step 14: Add JWT verification middleware
Create a middleware that validates the JWT from the Authorization header:
| bash |
| mkdir middlewares
touch middlewares/auth.middleware.js |
| javascript |
| // middlewares/auth.middleware.js
import jwt from ‘jsonwebtoken’; const JWT_SECRET = process.env.JWT_SECRET || ‘dev_secret_change_me’; export const requireAuth = (req, res, next) => { const header = req.headers[‘authorization’]; if (!header) { return res.status(401).json({ error: ‘Authorization header missing.’ }); } const [scheme, token] = header.split(‘ ‘); if (scheme !== ‘Bearer’ || !token) { return res.status(401).json({ error: ‘Invalid authorization format.’ }); } try { const decoded = jwt.verify(token, JWT_SECRET); req.user = decoded; // attach user payload to request return next(); } catch (err) { console.error(‘JWT verification failed:’, err); return res.status(403).json({ error: ‘Invalid or expired token.’ }); } }; |
This middleware applies the “Bearer token” pattern recommended for securing REST APIs with JWTs.
Step 15: Protect sensitive routes
Finally, wire the new auth routes and middleware into server.js and protect user endpoints:
| javascript |
| // server.js
import express from ‘express’; import mongoose from ‘mongoose’; import notesRouter from ‘./routes/notes.routes.js’; import usersRouter from ‘./routes/users.routes.js’; import authRouter from ‘./routes/auth.routes.js’; import { requireAuth } from ‘./middlewares/auth.middleware.js’; import { MONGODB_URI } from ‘./config/db.js’; const app = express(); const PORT = process.env.PORT || 3000; app.use(express.json()); app.get(‘/’, (req, res) => { res.json({ message: ‘Node.js REST API is running 🚀’ }); }); // Public auth routes app.use(‘/api/auth’, authRouter); // Public notes routes (for demo) app.use(‘/api/notes’, notesRouter); // Protected user routes app.use(‘/api/users’, requireAuth, usersRouter); mongoose .connect(MONGODB_URI) .then(() => { console.log(‘Connected to MongoDB’); app.listen(PORT, () => { console.log(`Server is running on http://localhost:${PORT}`); }); }) .catch((err) => { console.error(‘MongoDB connection error:’, err); process.exit(1); }); |
Now the flow looks like this:
- Register – POST /api/auth/register with { name, email, password }
- Login – POST /api/auth/login to receive an accessToken
- Access protected routes – send Authorization: Bearer <token> to endpoints under /api/users
Frequently Asked Questions
A Node.js API provides an interface for building server side applications. It enables handling HTTP requests, interacting with databases and exposing endpoints for client applications, supporting fast and efficient backend development.
Use the built-in http or https modules or libraries like Axios or node fetch. Send GET or POST requests, handle responses asynchronously and manage errors to ensure reliable communication with external services.
Use database clients or ORMs such as Mongoose for MongoDB or Sequelize for SQL databases. Establish connections, execute queries or operations asynchronously and handle errors to maintain consistent application behavior.
Implement structured error objects, try-catch blocks for synchronous code and promise rejection handlers for asynchronous operations. Centralized logging and standardized error responses improve reliability and maintainability.
Validate inputs, implement authentication and authorization, encrypt sensitive data and regularly audit dependencies. Protect endpoints from common attacks like injection, cross site scripting and improper access control.
