REST API Design: Build Predictable RESTful APIs with Express

Updated

REST API design gives clients predictable resource URLs, HTTP methods and error responses. This guide explains the core conventions, correct retry semantics and a runnable Express example so you can build and test a small API before adding production concerns such as authorization and persistence.

I remember my first attempt at building an API. I treated it like a function call, ignored HTTP status codes and returned 200 OK for errors with a JSON body saying {"error": "Something went wrong"}. It made frontend integration harder than necessary.

This guide explains practical REST patterns for resource naming, status codes, validation, authentication, pagination, documentation, testing and reliability.

Understanding RESTful APIs

What Is a RESTful API?

REST is an architectural style that defines a set of constraints for creating web services. RESTful APIs are:

  • Stateless: each request contains the information needed to process it.
  • Client-server: the client and server have clear responsibilities.
  • Cacheable: responses can use caching rules where appropriate.
  • Uniform interface: resources are accessed through consistent URLs and methods.
  • Layered system: middleware, gateways and proxies can sit between client and server.

Why Choose REST?

  1. Simplicity: Uses standard HTTP methods
  2. Scalability: Stateless nature enables better scaling
  3. Flexibility: Supports multiple data formats
  4. Wide Adoption: Extensive tooling and community support

REST is a strong choice when your API is resource-oriented, needs broad client support and benefits from standard HTTP behavior such as caching, status codes and retries.

Core Components of RESTful APIs

1. Resources and URIs

Resources are the key entities your API exposes. Use nouns for resources and let HTTP methods describe the action.

# Good URI Design
GET /api/v1/users      # Get all users
GET /api/v1/users/123   # Get specific user
POST /api/v1/users     # Create new user
PUT /api/v1/users/123   # Update user
DELETE /api/v1/users/123  # Delete user

# Poor URI Design (Avoid)
GET /api/v1/getUsers
POST /api/v1/createUser
PUT /api/v1/updateUser/123

2. HTTP Methods

Understanding when to use each HTTP method is crucial:

MethodPurposeIdempotentSafe
GETRetrieve resourceYesYes
POSTCreate resourceNoNo
PUTReplace the state of a resourceYesNo
PATCHApply a partial modificationNot guaranteedNo
DELETERemove resourceYesNo

Understanding Idempotency

An idempotent method has the same intended effect on the server when the same request is repeated as when it is sent once. It does not promise identical response bodies or status codes. See HTTP semantics in RFC 9110.

  • GET is idempotent: Retrieving a user does not request a state change; the representation can still change between requests.
  • DELETE is idempotent: Deleting a user once deletes them. Deleting them again (if they are already gone) results in the same state (user is gone), usually returning a 404 or 204.
  • POST is not inherently idempotent: repeating a create request may create duplicates unless the API implements a deduplication contract.
  • PATCH can be designed idempotently: setting completed to true is repeatable, while incrementing a counter is not. The PATCH reference explains why partial updates need explicit semantics.

Understanding this distinction is critical for building reliable APIs, especially when dealing with network retries.

3. Status Codes

Use appropriate status codes to communicate API responses. Avoid returning 200 OK for failed requests because clients then have to inspect every response body to know whether the request worked.

200 OK                 # Successful request
201 Created            # Resource created
204 No Content         # Successful request with no response body
400 Bad Request        # Invalid client input
401 Unauthorized       # Authentication required or invalid
403 Forbidden          # Authenticated but not allowed
404 Not Found          # Resource not found
409 Conflict           # Request conflicts with current state
422 Unprocessable Entity # Valid JSON, but business validation failed
429 Too Many Requests  # Rate limit exceeded
500 Server Error       # Unexpected server error

Best Practices for RESTful API Design

1. Versioning Your API

Version APIs when clients outside your control depend on them. Mobile apps, partner integrations and public APIs need extra care because clients may not update immediately.

// URL-based versioning
https://api.example.com/v1/users
https://api.example.com/v2/users

// Header-based versioning
Accept: application/vnd.company.api+json;version=1

2. Authentication and Security

Implement robust security measures:

  • Use HTTPS everywhere: Encrypt data in transit.
  • Implement rate limiting: Reduce abuse and accidental overload.
  • Validate inputs: Validate request bodies, params and query strings on the server.
  • Use parameterized queries: Avoid SQL injection by never building SQL with raw user input.
  • Set security headers: Use tools like Helmet.js for headers such as Content-Security-Policy.
  • Handle CORS carefully: Allow only the origins that need access.
  • Check authorization: Authentication confirms identity; authorization confirms permission.
// JWT Authentication Example
const express = require('express');
const jwt = require('jsonwebtoken');
const helmet = require('helmet');
const cors = require('cors');

const app = express();

// Security middleware
app.use(helmet());
app.use(cors({ origin: 'https://app.example.com' }));
app.use(express.json());

app.post('/api/login', async (req, res) => {
  const { email, password } = req.body || {};
  if (typeof email !== 'string' || typeof password !== 'string') {
    return res.status(400).json({ message: 'Email and password are required' });
  }

  // Replace this with a real password check against your user store.
  const user = await findUserByEmail(email);
  if (!user || !(await isValidPassword(user, password))) {
    return res.status(401).json({ message: 'Invalid credentials' });
  }

  if (!process.env.JWT_SECRET) {
    return res.status(500).json({ message: 'Authentication is not configured' });
  }

  const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET, {
    expiresIn: '24h'
  });
  res.json({ token });
});

// Protected Route
app.get('/api/protected', authenticateToken, (req, res) => {
  // Handle protected resource
});

Never hard-code a JWT secret in source control. Keep it in an environment variable, rotate it when team access changes and use a different secret for local, staging and production environments.

This authentication fragment assumes Express 5 and configured user-store, password-verification and authenticateToken adapters. It is not a complete login service. Token verification must check the expected algorithm, issuer, audience and expiration; a protected endpoint also needs resource-level authorization. Replace the example CORS origin with your actual client origin. CORS controls browser access and does not authenticate API callers.

Also avoid storing sensitive data in tokens unless it is required. Tokens can be decoded by clients, so keep payloads small and non-sensitive.

3. Request/Response Formatting

Maintain consistent data formatting so clients do not need special handling for every endpoint.

// Good Response Format
{
  "status": "success",
  "data": {
    "id": 123,
    "name": "John Doe",
    "email": "john@example.com"
  },
  "meta": {
    "timestamp": "2024-02-28T08:00:00Z"
  }
}

// Error Response Format
{
  "status": "error",
  "error": {
    "code": "INVALID_INPUT",
    "message": "Email is required",
    "details": {...}
  }
}

4. Pagination and Filtering

Implement efficient data handling for list endpoints. Without pagination, a successful endpoint can become slow or expensive as the database grows.

// Pagination Example
GET /api/users?page=2&limit=10

// Response
{
  "data": [...],
  "pagination": {
    "current_page": 2,
    "total_pages": 5,
    "total_items": 48,
    "items_per_page": 10
  }
}

// Filtering Example
GET /api/users?role=admin&status=active

Building a Basic RESTful API

Prerequisites and Setup

Use Node.js 22 or later and npm in a new practice folder. The following commands install Express 5 for the standalone demo; they are not commands to run inside an existing application without checking its dependencies.

mkdir rest-api-practice
cd rest-api-practice
npm init -y
npm install express@5

Save the following complete program as server.cjs. It exposes in-memory learning data on your own computer:

const express = require('express');
const { randomUUID } = require('node:crypto');
const app = express();

// Middleware
app.use(express.json({ limit: '10kb' }));

// Sample data
const users = [];

// GET all users
app.get('/api/users', (req, res) => {
  res.json({
    status: 'success',
    data: users
  });
});

// POST new user
app.post('/api/users', (req, res) => {
  const { name, email } = req.body || {};

  // Validation
  if (typeof name !== 'string' || !name.trim() ||
      typeof email !== 'string' || !email.trim()) {
    return res.status(400).json({
      status: 'error',
      error: {
        message: 'Name and email are required'
      }
    });
  }

  const user = {
    id: randomUUID(),
    name: name.trim(),
    email: email.trim()
  };

  users.push(user);
  res.status(201).json({
    status: 'success',
    data: user
  });
});

Add the following at the end of server.cjs to start the server:

app.listen(3000, '127.0.0.1', () => {
  console.log('Practice API: http://127.0.0.1:3000');
});

Run node server.cjs, then test from another terminal:

curl http://127.0.0.1:3000/api/users
curl -i -X POST http://127.0.0.1:3000/api/users \
  -H 'Content-Type: application/json' \
  -d '{"name":"Asha","email":"asha@example.com"}'

The first request returns {"status":"success","data":[]} before any user is added. A valid POST returns HTTP 201 with a generated UUID and the submitted values; an empty object returns HTTP 400. On Windows, use curl.exe in a compatible shell or send the equivalent JSON in an API client.

Data disappears when the process restarts. This demo has no authentication, uniqueness constraint, full email validation or persistence and must not be exposed with real user data. Refer to the Express starter example for the application/listen lifecycle. If port 3000 is busy, stop the conflicting local process or change the port and request URLs together.

Testing Your API

Using Postman

  1. Create a new collection.
  2. Add request examples.
  3. Write test scripts.
  4. Set up environments for local, staging and production.
// Postman Test Script Example
pm.test("Response status is 200", () => {
  pm.response.to.have.status(200);
});

pm.test("Response has correct structure", () => {
  const response = pm.response.json();
  pm.expect(response).to.have.property('status');
  pm.expect(response).to.have.property('data');
});

API Documentation

Use Swagger/OpenAPI for documentation:

openapi: 3.0.0
info:
 title: User API
 version: 1.0.0
paths:
 /users:
  get:
   summary: Get all users
   responses:
    '200':
     description: Successful response
     content:
      application/json:
       schema:
        type: object
        properties:
         status:
          type: string
         data:
          type: array
          items:
           type: object
           properties:
            id:
             type: string
            name:
             type: string

Advanced Topics

1. Rate Limiting

Protect your API from abuse and DoS attacks by limiting request frequency.

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."
});

// Apply to all API routes
app.use('/api/', limiter);

2. Caching Strategies

Improve performance by caching responses. You can use in-memory caching (like Redis) or HTTP caching headers.

// Route fragment: publicCatalog must contain only non-personal public data.
app.get('/api/public-catalog', (req, res) => {
  res.set('Cache-Control', 'public, max-age=60');
  res.json(publicCatalog);
});

Do not cache authenticated responses under a URL-only shared key. User-specific data needs an explicit private caching policy or no-store; a cache must preserve relevant status, headers, authorization boundaries and invalidation behavior.

3. HATEOAS (Hypermedia as the Engine of Application State)

Some REST APIs include links in the response to help clients navigate available actions dynamically.

{
  "id": 123,
  "name": "John Doe",
  "links": [
    { "rel": "self", "method": "GET", "href": "https://api.example.com/v1/users/123" },
    { "rel": "delete", "method": "DELETE", "href": "https://api.example.com/v1/users/123" },
    { "rel": "update", "method": "PUT", "href": "https://api.example.com/v1/users/123" }
  ]
}

4. REST vs GraphQL: When to Choose What

REST and GraphQL solve different problems.

  • REST: Best for simple, resource-oriented APIs, caching and clear separation of concerns.
  • GraphQL: Best for complex data requirements, reducing over-fetching/under-fetching and mobile apps where bandwidth is limited.

Choose REST for simplicity and standard tooling. Choose GraphQL for flexibility and efficiency in data retrieval.

Interactive Example: Building a Todo API

Try implementing this simple Todo API:

The router below is a separate exercise fragment. Mount it on an Express app after JSON parsing, for example with app.use('/api', router), before calling listen. It intentionally omits persistence and authorization.

Click to see the implementation
const express = require('express');
const router = express.Router();

let todos = [];

// Get all todos
router.get('/todos', (req, res) => {
  res.json(todos);
});

// Add new todo
router.post('/todos', (req, res) => {
  if (typeof req.body?.title !== 'string' || !req.body.title.trim()) {
    return res.status(400).json({ error: 'Title is required' });
  }

  const todo = {
    id: Date.now(),
    title: req.body.title,
    completed: false
  };
  todos.push(todo);
  res.status(201).json(todo);
});

// Mark todo as completed
router.patch('/todos/:id', (req, res) => {
  if (typeof req.body?.completed !== 'boolean') {
    return res.status(400).json({ error: 'completed must be a boolean' });
  }
  const todo = todos.find(t => t.id === parseInt(req.params.id));
  if (todo) {
    todo.completed = req.body.completed;
    res.json(todo);
  } else {
    res.status(404).json({ error: 'Todo not found' });
  }
});

Common Challenges and Solutions

Performance Issues

  • Use pagination for large lists.
  • Cache safe and frequently requested responses.
  • Optimize database queries before adding infrastructure.
  • Add indexes for fields used in filters and joins.

Security Concerns

  • Use HTTPS.
  • Implement rate limiting.
  • Validate input data.
  • Use proper authentication and authorization.
  • Log security-relevant events without logging secrets.

Scalability

  • Keep endpoints stateless when possible.
  • Use load balancers when traffic grows.
  • Add caching strategies where measurement shows value.
  • Consider service boundaries only when the codebase or team needs them.

Building RESTful APIs is both design and engineering. Start simple, focus on consistency and improve based on real usage patterns and client feedback.

API Review Checklist Before Launch

Use this checklist before you publish an API endpoint:

  • Does the endpoint name describe a resource, not an internal function?
  • Are success and failure responses documented with examples?
  • Are authentication, authorization and rate limits tested?
  • Does pagination protect large list endpoints?
  • Can clients safely retry requests without creating duplicates?

A small checklist like this prevents many support tickets after the API starts serving real users.

A Complete Mini Endpoint Walkthrough

To make these ideas concrete, imagine an endpoint that creates a project for the authenticated user.

app.post('/api/v1/projects', authenticateUser, async (req, res) => {
  const { name } = req.body || {};

  if (typeof name !== 'string' || name.trim().length < 3) {
    return res.status(400).json({
      status: 'error',
      error: {
        code: 'INVALID_PROJECT_NAME',
        message: 'Project name must be at least 3 characters long'
      }
    });
  }

  const project = await projectRepository.create({
    ownerId: req.user.id,
    name: name.trim()
  });

  return res.status(201).json({
    status: 'success',
    data: project
  });
});

This endpoint demonstrates several REST habits at once: the route describes a resource, authentication happens before business logic, invalid input returns 400, successful creation returns 201 and the response shape stays predictable.

It is a route fragment requiring authenticateUser, projectRepository, an authenticated req.user and an application error handler. Add a Location header only when the corresponding new-resource GET route exists. For duplicate create retries, use a persisted, scoped idempotency key; the data-structure guide's request-ID example explains why an in-memory set alone is insufficient across workers.

Before shipping this endpoint, I would test four cases: unauthenticated request, empty name, valid project creation and duplicate-name behavior if the product requires unique project names.

Frequently Asked Questions

What makes an API RESTful?

A RESTful API exposes resources through consistent URLs, uses standard HTTP methods, keeps requests stateless and communicates results with meaningful status codes and response bodies.

Should REST endpoints use nouns or verbs?

Use nouns for resources, such as /users, /projects or /orders. The HTTP method describes the action, such as GET /users, POST /users or DELETE /users/123.

When should I create a new API version?

Create a new version when a change would break existing clients. Adding optional fields usually does not require a new version, but removing fields, renaming fields or changing response shape often does.

Is JWT always the best authentication choice?

No. JWT works well for some stateless APIs, but session cookies, OAuth or managed identity providers may be better depending on the client, security requirements and product architecture.

Why can a repeated DELETE return a different status and still be idempotent?

Idempotency concerns the intended server-side effect. After one successful deletion, the resource remains absent even if a later request returns 404 instead of 204. Clients should follow the endpoint's documented retry and error contract.

Additional Resources

API Design Tools

  • Swagger/OpenAPI
  • Postman
  • Insomnia
  • API Blueprint

Testing Frameworks

  • Jest
  • Mocha
  • Supertest
  • Newman

Documentation

Final Takeaway

A good REST API is predictable before it is fancy. Clear resource names, consistent status codes, safe authentication and useful error messages will help your API more than adding extra endpoints too early.

Related Posts

How to Start Coding: A Beginner's Guide to Learning Programming

Learning how to start coding begins with one language, a working first program and regular practice. This beginner's guide helps you choose Python or JavaScript, set up your tools, understand the cor

Read More

Clean Code Best Practices for Readable, Maintainable Software

Clean code best practices make a program's intent, rules and side effects easier to understand. This guide uses JavaScript examples and a short code audit to help you improve naming, function boundar

Read More

Building Your Own Software: A Roadmap from Problem to Product

Building your own software means choosing which problem to solve and accepting responsibility for the product after launch. This roadmap helps you validate demand, compare building with buying, docum

Read More