What is REST API Design?
REST (Representational State Transfer) is an architectural style for designing networked applications. Coined by Roy Fielding in his doctoral dissertation, REST relies on a stateless, client-server communication protocolβtypically HTTP. A REST API is an interface that conforms to these constraints, allowing systems to exchange data in a standardized, predictable way.
At its core, REST is built on six guiding constraints:
- Client-Server Architecture β Separation of concerns between the user interface and data storage
- Statelessness β Each request from client to server must contain all information needed to understand and process the request
- Cacheability β Responses must explicitly define whether they are cacheable or not
- Uniform Interface β A consistent way of interacting with resources via well-defined endpoints
- Layered System β The architecture can be composed of hierarchical layers (proxies, gateways, load balancers)
- Code on Demand (optional) β Servers can extend client functionality by transferring executable code
In practice, REST APIs treat everything as a resourceβa user, a product, an orderβand expose these resources through well-structured URLs. Clients interact with resources using standard HTTP methods: GET, POST, PUT, PATCH, DELETE. Each resource is identified by a unique URI, and representations of those resources (usually JSON or XML) are exchanged between client and server.
Why REST API Design Matters
π Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Poorly designed APIs slow development, confuse consumers, and create brittle integrations. A well-designed REST API brings concrete benefits:
- Developer Experience β Consistent naming, predictable behavior, and clear error messages reduce onboarding time for new developers
- Scalability β Statelessness allows horizontal scaling; cacheable responses reduce server load
- Interoperability β Standard HTTP verbs and status codes enable any HTTP client to interact with your API without custom tooling
- Evolvability β A properly versioned and hypermedia-driven API can evolve without breaking existing clients
- Security β A clean separation of layers makes it easier to insert security controls like authentication gateways and rate limiters
- Testability β Resource-oriented design maps naturally to unit and integration tests
When teams neglect REST design principles, they end up with what is often called a "REST-ish" APIβendpoints that use POST for everything, URLs that embed verbs instead of nouns, inconsistent error payloads, and tight coupling between client and server. The cost of fixing these problems grows exponentially as the API gains consumers.
From Theory to Practice: Building a REST API
Choosing Your Technology Stack
For this tutorial, we'll use Node.js with Expressβa minimal, unopinionated framework that lets us focus on REST concepts without framework magic obscuring the fundamentals. The patterns translate directly to Python/Flask, Go/Gin, Java/Spring Boot, or any other HTTP framework.
Initialize a new project and install dependencies:
mkdir rest-api-tutorial
cd rest-api-tutorial
npm init -y
npm install express cors helmet morgan
npm install --save-dev nodemon
Resource Naming and URL Structure
Resources are the nouns of your API. Follow these conventions:
- Use plural nouns for collections:
/users,/products,/orders - Use nested resources for relationships:
/users/123/orders - No verbs in URLs β the HTTP method already expresses the action
- Use kebab-case or snake_case for multi-word resources (pick one and stay consistent)
- Keep URLs predictable and hierarchical
Bad examples to avoid:
// β Verbs in URL, inconsistent casing
POST /getUserById
GET /fetch_all_products
// β Mixing singular and plural arbitrarily
GET /user/123
POST /users
// β
Good RESTful URLs
GET /users β List all users
GET /users/123 β Retrieve user 123
POST /users β Create a new user
PUT /users/123 β Replace user 123 entirely
PATCH /users/123 β Partially update user 123
DELETE /users/123 β Delete user 123
// β
Nested relationships
GET /users/123/orders β List orders belonging to user 123
GET /users/123/orders/456 β Retrieve order 456 for user 123
HTTP Methods and Status Codes
Each HTTP method has a clear semantic meaning. Use them consistently:
| Method | Semantics | Idempotent? | Safe? |
|---|---|---|---|
| GET | Retrieve a resource or collection | Yes | Yes |
| POST | Create a new resource (or trigger an action) | No | No |
| PUT | Full replacement of a resource | Yes | No |
| PATCH | Partial update of a resource | No | No |
| DELETE | Remove a resource | Yes | No |
Status codes communicate the outcome clearly. Here are the most commonly used ones:
- 2xx Success:
200 OK,201 Created,204 No Content(for successful DELETE) - 3xx Redirection:
301 Moved Permanently,304 Not Modified - 4xx Client Error:
400 Bad Request,401 Unauthorized,403 Forbidden,404 Not Found,409 Conflict,422 Unprocessable Entity - 5xx Server Error:
500 Internal Server Error,503 Service Unavailable
Setting Up the Express Server
Create a file called app.js with the basic server scaffolding:
// app.js
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const morgan = require('morgan');
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(helmet()); // Security headers
app.use(cors()); // Cross-origin support
app.use(morgan('dev')); // Request logging
app.use(express.json()); // Parse JSON bodies
app.use(express.urlencoded({ extended: true }));
// Routes will be registered here
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
module.exports = app;
Implementing CRUD Operations for a "Users" Resource
We'll create a full CRUD controller using an in-memory data store. In production, you'd replace this with a database layer, but the REST patterns remain identical.
Create routes/users.js:
// routes/users.js
const express = require('express');
const router = express.Router();
// In-memory data store (replace with database in production)
let users = [
{ id: 1, name: 'Alice Johnson', email: 'alice@example.com', role: 'admin', createdAt: '2024-01-15' },
{ id: 2, name: 'Bob Smith', email: 'bob@example.com', role: 'user', createdAt: '2024-02-20' },
];
let nextId = 3;
// Utility to find a user by ID
const findUserById = (id) => users.find(u => u.id === id);
// GET /users β List all users with optional filtering
router.get('/', (req, res) => {
const { role, sort, limit, offset } = req.query;
let result = [...users];
// Filtering
if (role) {
result = result.filter(u => u.role === role);
}
// Sorting (simple implementation)
if (sort === 'name') {
result.sort((a, b) => a.name.localeCompare(b.name));
} else if (sort === '-name') {
result.sort((a, b) => b.name.localeCompare(a.name));
}
// Pagination
const startIndex = parseInt(offset, 10) || 0;
const endIndex = parseInt(limit, 10) ? startIndex + parseInt(limit, 10) : result.length;
const paginatedResult = result.slice(startIndex, endIndex);
res.status(200).json({
data: paginatedResult,
meta: {
total: result.length,
offset: startIndex,
limit: endIndex - startIndex,
},
});
});
// GET /users/:id β Retrieve a single user
router.get('/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
const user = findUserById(id);
if (!user) {
return res.status(404).json({
error: 'User not found',
detail: `No user exists with id ${id}`,
});
}
res.status(200).json({ data: user });
});
// POST /users β Create a new user
router.post('/', (req, res) => {
const { name, email, role } = req.body;
// Validation
if (!name || !email) {
return res.status(400).json({
error: 'Validation failed',
detail: 'name and email are required fields',
fields: { name: !name ? 'missing' : 'ok', email: !email ? 'missing' : 'ok' },
});
}
// Check for duplicate email
if (users.find(u => u.email === email)) {
return res.status(409).json({
error: 'Conflict',
detail: `A user with email '${email}' already exists`,
});
}
const newUser = {
id: nextId++,
name,
email,
role: role || 'user',
createdAt: new Date().toISOString().split('T')[0],
};
users.push(newUser);
// Return 201 with a Location header pointing to the new resource
res.setHeader('Location', `/users/${newUser.id}`);
res.status(201).json({ data: newUser });
});
// PUT /users/:id β Full replacement of a user
router.put('/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
const user = findUserById(id);
if (!user) {
return res.status(404).json({
error: 'User not found',
detail: `No user exists with id ${id}`,
});
}
const { name, email, role } = req.body;
// PUT requires all fields to be present (full replacement)
if (!name || !email) {
return res.status(400).json({
error: 'Validation failed',
detail: 'PUT requires name and email for full replacement',
});
}
user.name = name;
user.email = email;
user.role = role || 'user';
res.status(200).json({ data: user });
});
// PATCH /users/:id β Partial update of a user
router.patch('/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
const user = findUserById(id);
if (!user) {
return res.status(404).json({
error: 'User not found',
detail: `No user exists with id ${id}`,
});
}
const { name, email, role } = req.body;
// PATCH: update only the fields that are provided
if (name !== undefined) user.name = name;
if (email !== undefined) user.email = email;
if (role !== undefined) user.role = role;
res.status(200).json({ data: user });
});
// DELETE /users/:id β Remove a user
router.delete('/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
const index = users.findIndex(u => u.id === id);
if (index === -1) {
return res.status(404).json({
error: 'User not found',
detail: `No user exists with id ${id}`,
});
}
users.splice(index, 1);
// 204 No Content β body is empty by design
res.status(204).send();
});
module.exports = router;
Register the routes in app.js:
// In app.js, add:
const usersRouter = require('./routes/users');
// Mount routes at /users
app.use('/users', usersRouter);
// Root endpoint for API discovery
app.get('/', (req, res) => {
res.status(200).json({
api: 'REST API Tutorial v1',
resources: {
users: '/users',
},
documentation: '/docs', // hypothetical
});
});
Structuring Consistent Response Payloads
Every response should follow a predictable envelope. Here's a pattern that works well:
// Successful responses
{
"data": { ... }, // The actual resource or collection
"meta": { ... } // Optional: pagination, totals, etc.
}
// Error responses
{
"error": "Brief error title", // Machine-readable identifier
"detail": "Human-readable explanation",
"fields": { ... } // Optional: per-field validation errors
}
Create a shared response helper to enforce this consistency across all routes:
// utils/response.js
class ApiResponse {
static success(res, data, meta = {}, statusCode = 200) {
const body = { data };
if (Object.keys(meta).length > 0) {
body.meta = meta;
}
return res.status(statusCode).json(body);
}
static error(res, errorTitle, detail, statusCode = 400, fields = null) {
const body = { error: errorTitle, detail };
if (fields) {
body.fields = fields;
}
return res.status(statusCode).json(body);
}
static notFound(res, resourceName, id) {
return this.error(
res,
`${resourceName} not found`,
`No ${resourceName.toLowerCase()} exists with id ${id}`,
404
);
}
}
module.exports = ApiResponse;
Now refactor the users route to use the helper for cleaner, consistent responses:
const ApiResponse = require('../utils/response');
// GET /users/:id
router.get('/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
const user = findUserById(id);
if (!user) {
return ApiResponse.notFound(res, 'User', id);
}
return ApiResponse.success(res, user);
});
Error Handling Middleware
Centralized error handling prevents scattered try-catch blocks and ensures every error gets a proper response:
// middleware/errorHandler.js
const errorHandler = (err, req, res, next) => {
console.error('Unhandled error:', err.stack);
// Handle known operational errors
if (err.type === 'validation') {
return res.status(422).json({
error: 'Unprocessable Entity',
detail: err.message,
fields: err.fields || null,
});
}
// Handle Mongoose/MongoDB errors (if using a database)
if (err.name === 'CastError' && err.kind === 'ObjectId') {
return res.status(400).json({
error: 'Bad Request',
detail: 'Invalid resource ID format',
});
}
// Fallback for unexpected errors
res.status(500).json({
error: 'Internal Server Error',
detail: process.env.NODE_ENV === 'production'
? 'An unexpected error occurred'
: err.message,
});
};
module.exports = errorHandler;
Register it after all routes in app.js:
const errorHandler = require('./middleware/errorHandler');
// ... routes go here ...
// Error handler must be the last middleware
app.use(errorHandler);
Pagination, Filtering, and Sorting
For collection endpoints, pagination is essential to prevent overwhelming responses. Use query parameters consistently:
// Standard query parameters for collection endpoints:
// ?offset=0&limit=20 β Offset-based pagination
// ?page=1&per_page=20 β Page-based pagination (alternative)
// ?sort=name β Sort ascending by field
// ?sort=-createdAt β Sort descending by field (prefix with -)
// ?role=admin β Filter by exact match
// ?q=searchterm β Full-text search (if applicable)
Here's a reusable middleware for pagination:
// middleware/pagination.js
const paginationMiddleware = (req, res, next) => {
const offset = Math.max(0, parseInt(req.query.offset, 10) || 0);
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 20));
const sort = req.query.sort || '-createdAt';
// Parse sort parameter: prefix with '-' means descending
const sortField = sort.replace(/^-/, '');
const sortOrder = sort.startsWith('-') ? -1 : 1;
req.pagination = { offset, limit, sortField, sortOrder };
next();
};
module.exports = paginationMiddleware;
Authentication and Authorization
REST APIs typically use token-based authentication (JWT). The stateless nature of REST means the token must be sent with every request, usually in the Authorization header:
// middleware/auth.js
const jwt = require('jsonwebtoken');
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
// Authentication middleware β verifies the JWT token
const authenticate = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
error: 'Unauthorized',
detail: 'Missing or malformed Authorization header. Expected: Bearer ',
});
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, JWT_SECRET);
req.user = decoded; // Attach user info to request
next();
} catch (err) {
return res.status(401).json({
error: 'Unauthorized',
detail: 'Token is invalid or expired',
});
}
};
// Authorization middleware β checks role/permissions
const authorize = (...allowedRoles) => {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({
error: 'Unauthorized',
detail: 'Authentication is required before authorization',
});
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({
error: 'Forbidden',
detail: `Role '${req.user.role}' is not permitted for this operation`,
});
}
next();
};
};
module.exports = { authenticate, authorize };
Apply these middlewares to protect sensitive routes:
const { authenticate, authorize } = require('./middleware/auth');
// Public routes β no authentication required
router.get('/', (req, res) => { /* ... */ });
router.get('/:id', (req, res) => { /* ... */ });
// Protected routes β require authentication
router.post('/', authenticate, (req, res) => { /* ... */ });
router.put('/:id', authenticate, (req, res) => { /* ... */ });
router.patch('/:id', authenticate, (req, res) => { /* ... */ });
// Admin-only routes β require authentication AND admin role
router.delete('/:id', authenticate, authorize('admin'), (req, res) => { /* ... */ });
API Versioning
Versioning allows you to evolve your API without breaking existing clients. There are several strategies:
- URL path versioning β
/v1/users,/v2/users(most explicit, easiest to route) - Header versioning β
Accept: application/vnd.api+json;version=1(keeps URLs clean) - Query parameter versioning β
/users?version=1(simple but pollutes query params)
URL path versioning is the most common and easiest to implement:
// app.js β Mount versioned routes
const usersRouterV1 = require('./routes/v1/users');
const usersRouterV2 = require('./routes/v2/users');
app.use('/v1/users', usersRouterV1);
app.use('/v2/users', usersRouterV2);
// Latest version can also be mounted at the root path
app.use('/users', usersRouterV2);
Keep versioned code separate:
// routes/v1/users.js β Original version
// routes/v2/users.js β New version with breaking changes (e.g., renamed fields)
// V1 user shape: { id, name, email, role }
// V2 user shape: { id, fullName, emailAddress, role, avatar, metadata }
Input Validation
Never trust client input. Validate every request body, query parameter, and path parameter:
// middleware/validate.js
const validateCreateUser = (req, res, next) => {
const { name, email } = req.body;
const errors = {};
if (!name || typeof name !== 'string' || name.trim().length === 0) {
errors.name = 'Name is required and must be a non-empty string';
}
if (!email || typeof email !== 'string') {
errors.email = 'Email is required and must be a string';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.email = 'Email must be a valid email address';
}
if (Object.keys(errors).length > 0) {
return res.status(422).json({
error: 'Unprocessable Entity',
detail: 'Validation failed for the submitted data',
fields: errors,
});
}
// Sanitize: trim strings
req.body.name = name.trim();
req.body.email = email.trim().toLowerCase();
next();
};
module.exports = { validateCreateUser };
Testing Your REST API
A complete REST API includes tests. Here's how to test the users endpoints using Jest and Supertest:
// tests/users.test.js
const request = require('supertest');
const app = require('../app');
describe('Users API', () => {
describe('GET /users', () => {
it('should return a list of users with 200 status', async () => {
const res = await request(app).get('/users');
expect(res.status).toBe(200);
expect(res.body).toHaveProperty('data');
expect(Array.isArray(res.body.data)).toBe(true);
expect(res.body).toHaveProperty('meta');
expect(res.body.meta).toHaveProperty('total');
});
it('should support role filtering', async () => {
const res = await request(app).get('/users?role=admin');
expect(res.status).toBe(200);
res.body.data.forEach(user => {
expect(user.role).toBe('admin');
});
});
});
describe('GET /users/:id', () => {
it('should return a user for a valid ID', async () => {
const res = await request(app).get('/users/1');
expect(res.status).toBe(200);
expect(res.body.data).toHaveProperty('id', 1);
expect(res.body.data).toHaveProperty('name');
expect(res.body.data).toHaveProperty('email');
});
it('should return 404 for a non-existent ID', async () => {
const res = await request(app).get('/users/99999');
expect(res.status).toBe(404);
expect(res.body).toHaveProperty('error', 'User not found');
});
});
describe('POST /users', () => {
it('should create a new user and return 201', async () => {
const newUser = { name: 'Charlie Day', email: 'charlie@example.com' };
const res = await request(app)
.post('/users')
.send(newUser)
.set('Content-Type', 'application/json');
expect(res.status).toBe(201);
expect(res.body.data).toHaveProperty('id');
expect(res.body.data.name).toBe('Charlie Day');
expect(res.headers).toHaveProperty('location');
});
it('should return 400 when required fields are missing', async () => {
const res = await request(app)
.post('/users')
.send({})
.set('Content-Type', 'application/json');
expect(res.status).toBe(400);
expect(res.body).toHaveProperty('error');
expect(res.body).toHaveProperty('fields');
});
});
describe('PATCH /users/:id', () => {
it('should partially update a user', async () => {
const res = await request(app)
.patch('/users/1')
.send({ name: 'Alice Updated' })
.set('Content-Type', 'application/json');
expect(res.status).toBe(200);
expect(res.body.data.name).toBe('Alice Updated');
// Email should remain unchanged
expect(res.body.data.email).toBe('alice@example.com');
});
});
describe('DELETE /users/:id', () => {
it('should delete a user and return 204', async () => {
const res = await request(app).delete('/users/2');
expect(res.status).toBe(204);
expect(res.body).toEqual({});
// Verify it's actually gone
const getRes = await request(app).get('/users/2');
expect(getRes.status).toBe(404);
});
});
});
Run the tests with:
npx jest tests/users.test.js --verbose
Best Practices Summary
After building and testing a REST API, here are the key principles that separate professional APIs from amateur ones:
- Use nouns, not verbs, in URLs β
/usersnot/getUsers. The HTTP method already conveys the action. - Return proper HTTP status codes β Don't return
200 OKwith an error message in the body. Use400,404,409,422appropriately. - Make responses consistently structured β Every success response should have a
datakey; every error should haveerroranddetail. - Support content negotiation β Accept
Accept: application/jsonheaders and return appropriateContent-Type. - Use HATEOAS links where practical β Include
_linksin responses so clients can discover related resources without hardcoding URLs. - Implement rate limiting β Protect your API from abuse with
429 Too Many Requestsresponses. - Version from day one β It's easier to version early than to introduce it after clients depend on your API.
- Log and monitor β Use structured logging (like Morgan combined with a log aggregator) to track every request.
- Write tests before shipping β Integration tests for every endpoint catch regressions immediately.
- Document with OpenAPI/Swagger β Machine-readable API descriptions enable auto-generated client SDKs and interactive docs.
Adding HATEOAS Links (Hypermedia)
While often overlooked, hypermedia links make your API self-discoverable:
// Enhance the GET /users/:id response with links
router.get('/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
const user = findUserById(id);
if (!user) {
return ApiResponse.notFound(res, 'User', id);
}
// Add hypermedia links so clients can navigate the API
const response = {
data: user,
_links: {
self: { href: `/users/${user.id}`, method: 'GET' },
update: { href: `/users/${user.id}`, method: 'PUT' },
patch: { href: `/users/${user.id}`, method: 'PATCH' },
delete: { href: `/users/${user.id}`, method: 'DELETE' },
orders: { href: `/users/${user.id}/orders`, method: 'GET' },
collection: { href: '/users', method: 'GET' },
},
};
res.status(200).json(response);
});
Complete Project Structure
Here's the final project layout for your REST API:
rest-api-tutorial/
βββ app.js # Main application entry point
βββ package.json
βββ routes/
β βββ users.js # User resource routes
β βββ v1/
β βββ users.js # Version 1 (legacy)
β βββ v2/
β βββ users.js # Version 2 (current)
βββ middleware/
β βββ errorHandler.js # Centralized error handling
β βββ auth.js # Authentication & authorization
β βββ pagination.js # Pagination helper
β βββ validate.js # Input validation
βββ utils/
β βββ response.js # Consistent response builder
βββ tests/
βββ users.test.js # Integration tests
Conclusion
REST API design is not merely about choosing URLs and HTTP methodsβit's about building a contract between your server and its consumers. A well-designed REST API feels predictable: you know that GET /users returns a paginated list, that POST /users creates a resource and returns 201 with a Location header, and that errors come with structured, actionable information. This predictability reduces onboarding time, minimizes support tickets, and allows your API to scale across teams and organizations.
The journey from theory to practice involves concrete decisions: choosing plural nouns for collections, picking between PUT and PATCH for updates, deciding on a versioning strategy, and enforcing consistent response envelopes. By applying the patterns in this tutorialβstructured routing, centralized error handling, token-based authentication, input validation, and hypermedia linksβyou transform abstract REST principles into a living, testable codebase. The investment pays dividends every time a new developer integrates with your API and says, "I already know how this works."