Building a REST API from Scratch
APIs are the backbone of modern software. Every mobile app, single-page application, and third-party integration depends on a well-designed API. REST (Representational State Transfer) remains the dominant architectural style for HTTP APIs — powering services at Twitter, GitHub, Stripe, and millions of other products. In this guide you'll build a complete, production-quality REST API from scratch, learning the principles behind the design decisions as you go.
REST is the right default for most APIs: it's stateless, cacheable, and universally understood by HTTP clients. GraphQL shines when clients need flexible queries over complex graphs of data. gRPC is ideal for high-performance internal microservice communication. Start with REST and migrate if you hit its limits.
REST Principles
Roy Fielding defined REST in his 2000 dissertation. Understanding the constraints helps you make consistent design decisions rather than guessing:
- Stateless: Each request must contain all information needed to process it. No session state is stored on the server between requests.
- Client-Server: The UI and data storage are separated. The client doesn't know how data is stored; the server doesn't care how it's displayed.
- Cacheable: Responses must declare whether they can be cached. Proper caching eliminates unnecessary requests and improves performance.
- Uniform Interface: Resources are identified by URIs. Interactions happen through standard HTTP methods with consistent response formats.
- Layered System: The client can't tell whether it's talking to the final server or an intermediary (load balancer, cache, CDN).
In practice this translates to using HTTP methods correctly:
| Method | Purpose | Request Body | Idempotent | Safe |
|---|---|---|---|---|
GET | Retrieve a resource or list | None | Yes | Yes |
POST | Create a new resource | Resource data | No | No |
PUT | Replace a resource completely | Full resource | Yes | No |
PATCH | Update part of a resource | Partial data | No | No |
DELETE | Remove a resource | None | Yes | No |
Designing Your API Structure
Good URL design makes an API self-documenting. The rules are simple: use nouns (not verbs), use plural resource names, and nest sub-resources only one level deep.
Prefix every route with /api/v1/. When you need breaking changes, release /api/v2/ alongside v1 instead of breaking existing clients. This is the single most important API design decision you'll make.
Here is a complete set of endpoints for a blog API:
| Method | Endpoint | Description | Status |
|---|---|---|---|
GET | /api/v1/posts | List all posts (paginated) | 200 |
POST | /api/v1/posts | Create a new post | 201 |
GET | /api/v1/posts/{id} | Get a single post | 200 |
PUT | /api/v1/posts/{id} | Replace a post | 200 |
PATCH | /api/v1/posts/{id} | Partially update a post | 200 |
DELETE | /api/v1/posts/{id} | Delete a post | 204 |
GET | /api/v1/posts/{id}/comments | List comments on a post | 200 |
POST | /api/v1/posts/{id}/comments | Add a comment to a post | 201 |
Building with Node.js and Express
Initialise the project
mkdir blog-api && cd blog-api && npm init -y
Install dependencies
npm install express morgan helmet cors dotenv
Create the server entry point
Set up Express with middleware and mount your route files.
Define resource routes
Create a router for each resource and register it on the app.
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import morgan from 'morgan';
const app = express();
// ── Middleware ────────────────────────────────────────────────
app.use(helmet()); // Security headers
app.use(cors()); // Allow cross-origin requests
app.use(morgan('dev')); // HTTP request logger
app.use(express.json()); // Parse JSON bodies
// ── In-memory store (replace with a database) ─────────────────
let posts = [];
let nextId = 1;
// ── Helper ────────────────────────────────────────────────────
const notFound = (res, id) =>
res.status(404).json({ status: 'error', message: `Post ${id} not found` });
// ── Routes ───────────────────────────────────────────────────
// GET /api/v1/posts — list with pagination
app.get('/api/v1/posts', (req, res) => {
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = Math.min(50, parseInt(req.query.limit) || 10);
const start = (page - 1) * limit;
const items = posts.slice(start, start + limit);
res.json({
status: 'success',
data: items,
meta: { page, limit, total: posts.length, pages: Math.ceil(posts.length / limit) },
});
});
// POST /api/v1/posts — create
app.post('/api/v1/posts', (req, res) => {
const { title, body, author } = req.body;
if (!title || !body) {
return res.status(422).json({ status: 'error', message: 'title and body are required' });
}
const post = { id: nextId++, title, body, author: author || 'Anonymous', createdAt: new Date() };
posts.push(post);
res.status(201).json({ status: 'success', data: post });
});
// GET /api/v1/posts/:id — single
app.get('/api/v1/posts/:id', (req, res) => {
const post = posts.find(p => p.id === +req.params.id);
if (!post) return notFound(res, req.params.id);
res.json({ status: 'success', data: post });
});
// PUT /api/v1/posts/:id — full replace
app.put('/api/v1/posts/:id', (req, res) => {
const idx = posts.findIndex(p => p.id === +req.params.id);
if (idx === -1) return notFound(res, req.params.id);
const { title, body, author } = req.body;
if (!title || !body) {
return res.status(422).json({ status: 'error', message: 'title and body are required' });
}
posts[idx] = { ...posts[idx], title, body, author: author || posts[idx].author, updatedAt: new Date() };
res.json({ status: 'success', data: posts[idx] });
});
// PATCH /api/v1/posts/:id — partial update
app.patch('/api/v1/posts/:id', (req, res) => {
const idx = posts.findIndex(p => p.id === +req.params.id);
if (idx === -1) return notFound(res, req.params.id);
posts[idx] = { ...posts[idx], ...req.body, updatedAt: new Date() };
res.json({ status: 'success', data: posts[idx] });
});
// DELETE /api/v1/posts/:id
app.delete('/api/v1/posts/:id', (req, res) => {
const idx = posts.findIndex(p => p.id === +req.params.id);
if (idx === -1) return notFound(res, req.params.id);
posts.splice(idx, 1);
res.status(204).send();
});
// ── Global error handler ──────────────────────────────────────
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ status: 'error', message: 'Internal server error' });
});
app.listen(3000, () => console.log('API running on http://localhost:3000'));
Building with Laravel
Laravel's API resource controllers reduce boilerplate to almost nothing. One artisan command scaffolds all five controller methods; one route registration handles all eight endpoint patterns.
php artisan make:model Post -mcr # model + migration + resource controller
<?php
namespace App\Http\Controllers;
use App\Models\Post;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
class PostController extends Controller
{
public function index(): JsonResponse
{
$posts = Post::latest()->paginate(10);
return response()->json(['status' => 'success', 'data' => $posts]);
}
public function store(Request $request): JsonResponse
{
$data = $request->validate([
'title' => 'required|string|max:255',
'body' => 'required|string',
'author' => 'nullable|string|max:100',
]);
$post = Post::create($data);
return response()->json(['status' => 'success', 'data' => $post], 201);
}
public function show(Post $post): JsonResponse
{
return response()->json(['status' => 'success', 'data' => $post]);
}
public function update(Request $request, Post $post): JsonResponse
{
$data = $request->validate([
'title' => 'sometimes|string|max:255',
'body' => 'sometimes|string',
'author' => 'nullable|string|max:100',
]);
$post->update($data);
return response()->json(['status' => 'success', 'data' => $post]);
}
public function destroy(Post $post): JsonResponse
{
$post->delete();
return response()->json(null, 204);
}
}
use App\Http\Controllers\PostController;
Route::apiResource('posts', PostController::class);
That single apiResource call registers all six routes (index, store, show, update, destroy — and a combined update for PUT/PATCH). Run php artisan route:list to see them all.
Authentication with JWT
Stateless APIs can't use session cookies. The standard approach is to issue a signed JWT on login and require it in the Authorization: Bearer <token> header on protected routes.
Never store JWTs in localStorage — they're vulnerable to XSS. Use httpOnly cookies for browser clients. Set short expiry times (15 minutes for access tokens) and implement refresh tokens for longer sessions.
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET; // keep this out of source control!
// ── Issue a token on login ────────────────────────────────────
app.post('/api/v1/auth/login', (req, res) => {
const { email, password } = req.body;
// TODO: look up user in database and verify password hash
const user = { id: 1, email, role: 'editor' };
const token = jwt.sign(
{ sub: user.id, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: '15m' }
);
res.json({ status: 'success', token });
});
// ── Auth middleware ───────────────────────────────────────────
function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ status: 'error', message: 'Missing token' });
}
try {
const token = authHeader.split(' ')[1];
req.user = jwt.verify(token, JWT_SECRET);
next();
} catch (err) {
res.status(401).json({ status: 'error', message: 'Invalid or expired token' });
}
}
// ── Protect routes ───────────────────────────────────────────
app.post('/api/v1/posts', authenticate, (req, res) => { /* ... */ });
app.put('/api/v1/posts/:id', authenticate, (req, res) => { /* ... */ });
app.delete('/api/v1/posts/:id', authenticate, (req, res) => { /* ... */ });
Error Handling and HTTP Status Codes
Consistent error responses are as important as consistent success responses. Clients should always receive a predictable JSON shape — never an HTML error page or a bare string.
| Status | Meaning | When to use |
|---|---|---|
200 OK | Success | GET, PUT, PATCH succeeded |
201 Created | Resource created | POST succeeded and created a resource |
204 No Content | Success, no body | DELETE succeeded |
400 Bad Request | Malformed request | Missing required fields, bad JSON |
401 Unauthorized | Not authenticated | Missing or invalid token |
403 Forbidden | Not authorised | Authenticated but lacks permission |
404 Not Found | Resource missing | ID doesn't exist in database |
422 Unprocessable | Validation failed | Field values fail business rules |
429 Too Many Requests | Rate limited | Client exceeds request quota |
500 Internal Error | Server fault | Unhandled exception (never expose details) |
A consistent error response format across all endpoints makes client-side error handling trivial:
{
"status": "error",
"message": "Validation failed",
"errors": {
"title": ["The title field is required."],
"body": ["The body must be at least 20 characters."]
}
}
Testing Your API
Test every endpoint during development using curl before wiring up a frontend:
# List posts
curl http://localhost:3000/api/v1/posts
# Create a post
curl -X POST http://localhost:3000/api/v1/posts \
-H "Content-Type: application/json" \
-d '{"title":"Hello World","body":"My first post","author":"Mayur"}'
# Get a single post
curl http://localhost:3000/api/v1/posts/1
# Partially update
curl -X PATCH http://localhost:3000/api/v1/posts/1 \
-H "Content-Type: application/json" \
-d '{"title":"Updated Title"}'
# Delete
curl -X DELETE http://localhost:3000/api/v1/posts/1 -v
REST API request lifecycle: Client → Auth → Router → Controller → Database → JSON response
REST API Best Practices Checklist
- Always version your API:
/api/v1/ - Use nouns, not verbs, in URLs (
/postsnot/getPosts) - Return consistent JSON shapes for both success and error responses
- Use correct HTTP status codes — don't return 200 for errors
- Paginate all list endpoints; never return unbounded result sets
- Validate all input server-side, even if you validate client-side too
- Never expose internal error details (stack traces, SQL errors) in production
- Rate-limit public endpoints to prevent abuse
- Use HTTPS everywhere — never ship an HTTP API to production
- Document your API with OpenAPI/Swagger so consumers can self-serve
