The Complete Guide to Building REST APIs
If you've spent any time building modern web applications, you've probably interacted with a REST API.
When your frontend fetches user data, when a mobile app sends a login request, or when two services communicate - there's usually an API behind it.
But when you're starting out, REST APIs can feel confusing:
- What exactly is REST?
- How do endpoints work?
- How do you actually build one?
- What are best practices?
In this guide, we'll walk through everything you need to know about building REST APIs.
The goal is simple:
By the end of this article, you'll understand how REST APIs work and how to build one from scratch.
No unnecessary theory - just practical explanations and examples.
What is a REST API?
REST stands for Representational State Transfer.
That sounds complicated, but the idea is actually simple.
A REST API is a way for applications to communicate using HTTP requests.
Example:
GET /users
This request asks the server:
"Give me the list of users."
The server responds with data, usually in JSON format:
[
{ "id": 1, "name": "Alex" },
{ "id": 2, "name": "Sarah" }
]
Think of a REST API like a menu in a restaurant:
- The menu → list of endpoints
- The order → the request
- The kitchen → the server
- The food → the response
What is an Endpoint?
An endpoint is simply a URL where your API can be accessed.
Example endpoints:
GET /users
GET /users/1
POST /users
PUT /users/1
DELETE /users/1
Each endpoint performs a different action.
HTTP Methods Explained
REST APIs rely heavily on HTTP methods (also called HTTP verbs).
GET
Used to retrieve data.
Example:
GET /users
Returns all users.
Response:
[
{ "id": 1, "name": "Alex" },
{ "id": 2, "name": "Sarah" }
]
POST
Used to create new data.
Example:
POST /users
Content-Type: application/json
Request body:
{
"name": "Alex",
"email": "alex@example.com"
}
Response:
{
"id": 3,
"name": "Alex",
"email": "alex@example.com"
}
PUT
Used to update existing data (full replacement).
Example:
PUT /users/1
Content-Type: application/json
Request body:
{
"name": "Alex Updated",
"email": "alex.new@example.com"
}
PATCH
Used to partially update existing data.
Example:
PATCH /users/1
Content-Type: application/json
Request body:
{
"email": "alex.new@example.com"
}
DELETE
Used to remove data.
Example:
DELETE /users/1
Response:
{
"message": "User deleted successfully"
}
REST API Design Principles
A well-designed REST API follows these principles:
1. Use Nouns, Not Verbs
Bad:
/getUsers
/createUser
/deleteUser
Good:
GET /users
POST /users
DELETE /users/1
2. Use Plural Nouns for Collections
Bad:
GET /user
Good:
GET /users
3. Use Resource Nesting for Relationships
Example:
GET /users/1/posts # Get all posts by user 1
GET /users/1/posts/5 # Get post 5 by user 1
POST /users/1/posts # Create a new post for user 1
4. Use Query Parameters for Filtering and Sorting
Examples:
GET /users?role=admin
GET /users?sort=name&order=asc
GET /users?page=2&limit=10
Setting Up a REST API with Express.js
Let's build a simple API using Express.js, one of the most popular Node.js frameworks.
Step 1: Initialize a Project
mkdir rest-api-demo
cd rest-api-demo
npm init -y
Step 2: Install Express
npm install express
Step 3: Create a Server
Create a file named server.js:
const express = require('express');
const app = express();
// Middleware to parse JSON
app.use(express.json());
// Start server
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Run the server:
node server.js
Now you have a running server on http://localhost:3000.
Creating Your First Endpoint
Let's create a basic GET endpoint.
Add this to server.js:
// In-memory data store
let users = [
{ id: 1, name: 'Alex', email: 'alex@example.com' },
{ id: 2, name: 'Sarah', email: 'sarah@example.com' }
];
// GET all users
app.get('/users', (req, res) => {
res.json(users);
});
Test it:
Visit http://localhost:3000/users in your browser, or use:
curl http://localhost:3000/users
Response:
[
{ "id": 1, "name": "Alex", "email": "alex@example.com" },
{ "id": 2, "name": "Sarah", "email": "sarah@example.com" }
]
Getting a Single Resource
Add a GET endpoint for a specific user:
// GET single user by ID
app.get('/users/:id', (req, res) => {
const id = parseInt(req.params.id);
const user = users.find(u => u.id === id);
if (!user) {
return res.status(404).json({
error: 'User not found'
});
}
res.json(user);
});
Test it:
curl http://localhost:3000/users/1
Creating Data with POST
Add a POST endpoint to create users:
// POST create new user
app.post('/users', (req, res) => {
const { name, email } = req.body;
// Basic validation
if (!name || !email) {
return res.status(400).json({
error: 'Name and email are required'
});
}
const newUser = {
id: users.length + 1,
name,
email
};
users.push(newUser);
res.status(201).json(newUser);
});
Test it:
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name":"John","email":"john@example.com"}'
Response:
{
"id": 3,
"name": "John",
"email": "john@example.com"
}
Updating Data with PUT
Add a PUT endpoint to update users:
// PUT update user
app.put('/users/:id', (req, res) => {
const id = parseInt(req.params.id);
const { name, email } = req.body;
const userIndex = users.findIndex(u => u.id === id);
if (userIndex === -1) {
return res.status(404).json({
error: 'User not found'
});
}
// Validate input
if (!name || !email) {
return res.status(400).json({
error: 'Name and email are required'
});
}
users[userIndex] = { id, name, email };
res.json(users[userIndex]);
});
Test it:
curl -X PUT http://localhost:3000/users/1 \
-H "Content-Type: application/json" \
-d '{"name":"Alex Updated","email":"alex.new@example.com"}'
Partial Updates with PATCH
Add a PATCH endpoint:
// PATCH partially update user
app.patch('/users/:id', (req, res) => {
const id = parseInt(req.params.id);
const user = users.find(u => u.id === id);
if (!user) {
return res.status(404).json({
error: 'User not found'
});
}
// Update only provided fields
if (req.body.name) user.name = req.body.name;
if (req.body.email) user.email = req.body.email;
res.json(user);
});
Deleting Data
Add a DELETE endpoint:
// DELETE user
app.delete('/users/:id', (req, res) => {
const id = parseInt(req.params.id);
const userIndex = users.findIndex(u => u.id === id);
if (userIndex === -1) {
return res.status(404).json({
error: 'User not found'
});
}
users.splice(userIndex, 1);
res.status(204).send(); // No content
});
Test it:
curl -X DELETE http://localhost:3000/users/1
Building the Same API with Fastify
Fastify is a modern alternative to Express, designed for speed and efficiency.
Install Fastify
npm install fastify
Create a Server
Create fastify-server.js:
const fastify = require('fastify')({ logger: true });
// In-memory data
let users = [
{ id: 1, name: 'Alex', email: 'alex@example.com' },
{ id: 2, name: 'Sarah', email: 'sarah@example.com' }
];
// GET all users
fastify.get('/users', async (request, reply) => {
return users;
});
// GET single user
fastify.get('/users/:id', async (request, reply) => {
const id = parseInt(request.params.id);
const user = users.find(u => u.id === id);
if (!user) {
reply.code(404).send({ error: 'User not found' });
return;
}
return user;
});
// POST create user
fastify.post('/users', async (request, reply) => {
const { name, email } = request.body;
if (!name || !email) {
reply.code(400).send({ error: 'Name and email are required' });
return;
}
const newUser = {
id: users.length + 1,
name,
email
};
users.push(newUser);
reply.code(201).send(newUser);
});
// Start server
const start = async () => {
try {
await fastify.listen({ port: 3000 });
} catch (err) {
fastify.log.error(err);
process.exit(1);
}
};
start();
Fastify APIs are often 2-3x faster than Express while maintaining simplicity.
HTTP Status Codes Explained
Every API response should include an appropriate status code.
Common Status Codes
| Code | Meaning | Use Case |
|---|---|---|
200 |
OK | Successful GET, PUT, PATCH |
201 |
Created | Successful POST |
204 |
No Content | Successful DELETE |
400 |
Bad Request | Invalid input data |
401 |
Unauthorized | Missing/invalid authentication |
403 |
Forbidden | Authenticated but not authorized |
404 |
Not Found | Resource doesn't exist |
500 |
Server Error | Unexpected server error |
Example in Express:
res.status(201).json({
message: 'User created successfully',
user: newUser
});
API Versioning
As your API evolves, you'll need versioning to avoid breaking existing clients.
URL Versioning (Recommended)
/api/v1/users
/api/v2/users
Implementation:
const v1Router = express.Router();
const v2Router = express.Router();
v1Router.get('/users', (req, res) => {
// Version 1 logic
});
v2Router.get('/users', (req, res) => {
// Version 2 logic
});
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
Header Versioning
GET /users
Accept: application/vnd.api.v1+json
Input Validation
Never trust user input. Always validate and sanitize data.
Manual Validation
app.post('/users', (req, res) => {
const { name, email } = req.body;
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return res.status(400).json({ error: 'Valid name is required' });
}
if (!email || !email.includes('@')) {
return res.status(400).json({ error: 'Valid email is required' });
}
// Process valid data
});
Using Validation Libraries
With Joi:
npm install joi
const Joi = require('joi');
const userSchema = Joi.object({
name: Joi.string().min(2).max(50).required(),
email: Joi.string().email().required()
});
app.post('/users', (req, res) => {
const { error, value } = userSchema.validate(req.body);
if (error) {
return res.status(400).json({
error: error.details[0].message
});
}
// Use validated data
const newUser = { id: users.length + 1, ...value };
users.push(newUser);
res.status(201).json(newUser);
});
With Zod:
npm install zod
const { z } = require('zod');
const userSchema = z.object({
name: z.string().min(2).max(50),
email: z.string().email()
});
app.post('/users', (req, res) => {
try {
const validatedData = userSchema.parse(req.body);
const newUser = { id: users.length + 1, ...validatedData };
users.push(newUser);
res.status(201).json(newUser);
} catch (error) {
res.status(400).json({ error: error.errors });
}
});
Error Handling
Proper error handling improves debugging and user experience.
Centralized Error Handler
// Error handling middleware (put this at the end)
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(err.status || 500).json({
error: {
message: err.message || 'Internal server error',
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
}
});
});
Async Error Handling
// Wrapper for async route handlers
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
app.get('/users/:id', asyncHandler(async (req, res) => {
const user = await getUserById(req.params.id);
if (!user) {
const error = new Error('User not found');
error.status = 404;
throw error;
}
res.json(user);
}));
REST API Best Practices
1. Use Proper Status Codes
Don't return 200 OK for everything. Use appropriate codes:
res.status(201).json(newUser); // Created
res.status(204).send(); // No content
res.status(404).json({ error }); // Not found
2. Maintain Consistent Response Structure
Example format:
{
"success": true,
"data": {
"id": 1,
"name": "Alex"
},
"message": "User retrieved successfully"
}
For errors:
{
"success": false,
"error": {
"code": "USER_NOT_FOUND",
"message": "User with ID 123 not found"
}
}
3. Implement Pagination
For large datasets, always paginate:
app.get('/users', (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const startIndex = (page - 1) * limit;
const endIndex = page * limit;
const results = {
total: users.length,
page,
limit,
data: users.slice(startIndex, endIndex)
};
if (endIndex < users.length) {
results.next = {
page: page + 1,
limit
};
}
if (startIndex > 0) {
results.previous = {
page: page - 1,
limit
};
}
res.json(results);
});
Usage:
GET /users?page=2&limit=10
4. Implement Filtering and Sorting
app.get('/users', (req, res) => {
let result = [...users];
// Filter by role
if (req.query.role) {
result = result.filter(u => u.role === req.query.role);
}
// Sort
if (req.query.sort) {
const sortField = req.query.sort;
const order = req.query.order === 'desc' ? -1 : 1;
result.sort((a, b) => {
if (a[sortField] < b[sortField]) return -1 * order;
if (a[sortField] > b[sortField]) return 1 * order;
return 0;
});
}
res.json(result);
});
Usage:
GET /users?role=admin&sort=name&order=asc
5. Secure Your API
API Keys
const apiKeyMiddleware = (req, res, next) => {
const apiKey = req.header('X-API-Key');
if (!apiKey || apiKey !== process.env.API_KEY) {
return res.status(401).json({ error: 'Invalid API key' });
}
next();
};
app.use('/api', apiKeyMiddleware);
JWT Authentication
npm install jsonwebtoken
const jwt = require('jsonwebtoken');
// Generate token
app.post('/login', (req, res) => {
// Validate credentials...
const token = jwt.sign(
{ userId: user.id },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
res.json({ token });
});
// Verify token
const authMiddleware = (req, res, next) => {
const token = req.header('Authorization')?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.userId = decoded.userId;
next();
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
};
app.get('/users', authMiddleware, (req, res) => {
// Protected route
});
6. Enable CORS
npm install cors
const cors = require('cors');
app.use(cors({
origin: 'https://your-frontend.com',
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization']
}));
7. Rate Limiting
Prevent abuse by limiting requests:
npm install express-rate-limit
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // Limit each IP to 100 requests per windowMs
message: 'Too many requests, please try again later'
});
app.use('/api', limiter);
8. Add Request Logging
npm install morgan
const morgan = require('morgan');
app.use(morgan('combined'));
Testing Your API
Manual Testing Tools
- Postman - GUI for testing APIs
- Insomnia - Alternative to Postman
- cURL - Command-line tool
cURL example:
# GET request
curl http://localhost:3000/users
# POST request
curl -X POST http://localhost:3000/users \
-H "Content-Type: application/json" \
-d '{"name":"John","email":"john@example.com"}'
# With authentication
curl http://localhost:3000/users \
-H "Authorization: Bearer YOUR_TOKEN"
Automated Testing
Install testing libraries:
npm install --save-dev jest supertest
Create users.test.js:
const request = require('supertest');
const app = require('./server'); // Export your app
describe('User API', () => {
test('GET /users should return all users', async () => {
const response = await request(app).get('/users');
expect(response.status).toBe(200);
expect(Array.isArray(response.body)).toBe(true);
});
test('POST /users should create a new user', async () => {
const newUser = {
name: 'Test User',
email: 'test@example.com'
};
const response = await request(app)
.post('/users')
.send(newUser);
expect(response.status).toBe(201);
expect(response.body).toHaveProperty('id');
expect(response.body.name).toBe(newUser.name);
});
test('GET /users/:id should return 404 for non-existent user', async () => {
const response = await request(app).get('/users/999');
expect(response.status).toBe(404);
});
});
Run tests:
npx jest
Complete Example: Full REST API
Here's a complete, production-ready example with all best practices:
const express = require('express');
const cors = require('cors');
const morgan = require('morgan');
const rateLimit = require('express-rate-limit');
const { z } = require('zod');
const app = express();
// Middleware
app.use(express.json());
app.use(cors());
app.use(morgan('combined'));
// Rate limiting
const limiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 100
});
app.use('/api', limiter);
// In-memory database
let users = [
{ id: 1, name: 'Alex', email: 'alex@example.com', role: 'admin' },
{ id: 2, name: 'Sarah', email: 'sarah@example.com', role: 'user' }
];
// Validation schema
const userSchema = z.object({
name: z.string().min(2).max(50),
email: z.string().email(),
role: z.enum(['user', 'admin']).optional()
});
// Async handler wrapper
const asyncHandler = (fn) => (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
// Routes
// GET all users with filtering, sorting, pagination
app.get('/api/v1/users', asyncHandler(async (req, res) => {
let result = [...users];
// Filter
if (req.query.role) {
result = result.filter(u => u.role === req.query.role);
}
// Sort
if (req.query.sort) {
const sortField = req.query.sort;
const order = req.query.order === 'desc' ? -1 : 1;
result.sort((a, b) => {
if (a[sortField] < b[sortField]) return -1 * order;
if (a[sortField] > b[sortField]) return 1 * order;
return 0;
});
}
// Paginate
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const startIndex = (page - 1) * limit;
const endIndex = page * limit;
const paginatedResults = {
success: true,
data: result.slice(startIndex, endIndex),
pagination: {
total: result.length,
page,
limit,
totalPages: Math.ceil(result.length / limit)
}
};
res.json(paginatedResults);
}));
// GET single user
app.get('/api/v1/users/:id', asyncHandler(async (req, res) => {
const id = parseInt(req.params.id);
const user = users.find(u => u.id === id);
if (!user) {
return res.status(404).json({
success: false,
error: { message: 'User not found' }
});
}
res.json({
success: true,
data: user
});
}));
// POST create user
app.post('/api/v1/users', asyncHandler(async (req, res) => {
const validatedData = userSchema.parse(req.body);
const newUser = {
id: users.length + 1,
...validatedData,
role: validatedData.role || 'user'
};
users.push(newUser);
res.status(201).json({
success: true,
data: newUser,
message: 'User created successfully'
});
}));
// PUT update user
app.put('/api/v1/users/:id', asyncHandler(async (req, res) => {
const id = parseInt(req.params.id);
const userIndex = users.findIndex(u => u.id === id);
if (userIndex === -1) {
return res.status(404).json({
success: false,
error: { message: 'User not found' }
});
}
const validatedData = userSchema.parse(req.body);
users[userIndex] = { id, ...validatedData };
res.json({
success: true,
data: users[userIndex],
message: 'User updated successfully'
});
}));
// PATCH partial update
app.patch('/api/v1/users/:id', asyncHandler(async (req, res) => {
const id = parseInt(req.params.id);
const user = users.find(u => u.id === id);
if (!user) {
return res.status(404).json({
success: false,
error: { message: 'User not found' }
});
}
const partialSchema = userSchema.partial();
const validatedData = partialSchema.parse(req.body);
Object.assign(user, validatedData);
res.json({
success: true,
data: user,
message: 'User updated successfully'
});
}));
// DELETE user
app.delete('/api/v1/users/:id', asyncHandler(async (req, res) => {
const id = parseInt(req.params.id);
const userIndex = users.findIndex(u => u.id === id);
if (userIndex === -1) {
return res.status(404).json({
success: false,
error: { message: 'User not found' }
});
}
users.splice(userIndex, 1);
res.status(204).send();
}));
// Error handling middleware
app.use((err, req, res, next) => {
if (err instanceof z.ZodError) {
return res.status(400).json({
success: false,
error: {
message: 'Validation error',
details: err.errors
}
});
}
console.error(err.stack);
res.status(500).json({
success: false,
error: { message: 'Internal server error' }
});
});
// 404 handler
app.use((req, res) => {
res.status(404).json({
success: false,
error: { message: 'Route not found' }
});
});
// Start server
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
module.exports = app; // For testing
Documentation
Always document your API. Popular tools include:
Swagger/OpenAPI
npm install swagger-jsdoc swagger-ui-express
const swaggerJsdoc = require('swagger-jsdoc');
const swaggerUi = require('swagger-ui-express');
const swaggerOptions = {
definition: {
openapi: '3.0.0',
info: {
title: 'Users API',
version: '1.0.0',
description: 'A simple users API'
},
servers: [
{
url: 'http://localhost:3000/api/v1'
}
]
},
apis: ['./server.js']
};
const swaggerSpec = swaggerJsdoc(swaggerOptions);
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec));
Visit http://localhost:3000/api-docs to see interactive documentation.
Deployment Checklist
Before deploying to production:
- [ ] Use environment variables for sensitive data
- [ ] Enable HTTPS
- [ ] Set up proper logging
- [ ] Implement rate limiting
- [ ] Add authentication/authorization
- [ ] Validate all inputs
- [ ] Handle errors gracefully
- [ ] Add request/response compression
- [ ] Set security headers (use
helmet) - [ ] Monitor API performance
- [ ] Set up automated backups (if using a database)
- [ ] Document your API
- [ ] Write tests
My Thoughts On This
Building REST APIs is one of the most essential skills for modern developers.
Once you understand the fundamentals - HTTP methods, endpoints, status codes, and JSON responses - everything becomes much clearer.
Start small:
- Build a simple CRUD API (users, posts, or todos)
- Add a database (PostgreSQL, MongoDB, etc.)
- Implement authentication (JWT or OAuth)
- Add input validation and error handling
- Deploy to production (Heroku, Railway, AWS, etc.)
Before long, you'll be building production-ready APIs that power real applications.
What's Next?
Now that you understand REST APIs, consider exploring:
- GraphQL - An alternative to REST
- WebSockets - For real-time communication
- gRPC - For high-performance APIs
- API Gateways - For managing multiple services
- Microservices Architecture - Breaking apps into smaller services
Additional Resources
- Express.js Documentation
- Fastify Documentation
- HTTP Status Codes
- REST API Design Best Practices
- Postman Learning Center
Top comments (0)