Backend Development

Building a REST API from Scratch

Mayur Dabhi
Mayur Dabhi
April 7, 2026
16 min read

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 vs GraphQL vs gRPC

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:

In practice this translates to using HTTP methods correctly:

MethodPurposeRequest BodyIdempotentSafe
GETRetrieve a resource or listNoneYesYes
POSTCreate a new resourceResource dataNoNo
PUTReplace a resource completelyFull resourceYesNo
PATCHUpdate part of a resourcePartial dataNoNo
DELETERemove a resourceNoneYesNo

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.

Always version your API

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:

MethodEndpointDescriptionStatus
GET/api/v1/postsList all posts (paginated)200
POST/api/v1/postsCreate a new post201
GET/api/v1/posts/{id}Get a single post200
PUT/api/v1/posts/{id}Replace a post200
PATCH/api/v1/posts/{id}Partially update a post200
DELETE/api/v1/posts/{id}Delete a post204
GET/api/v1/posts/{id}/commentsList comments on a post200
POST/api/v1/posts/{id}/commentsAdd a comment to a post201

Building with Node.js and Express

1

Initialise the project

mkdir blog-api && cd blog-api && npm init -y

2

Install dependencies

npm install express morgan helmet cors dotenv

3

Create the server entry point

Set up Express with middleware and mount your route files.

4

Define resource routes

Create a router for each resource and register it on the app.

server.js — Express REST API with full CRUD
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.

Terminal
php artisan make:model Post -mcr  # model + migration + resource controller
app/Http/Controllers/PostController.php
<?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);
    }
}
routes/api.php
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.

JWT Security

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.

Node.js — JWT authentication middleware
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.

StatusMeaningWhen to use
200 OKSuccessGET, PUT, PATCH succeeded
201 CreatedResource createdPOST succeeded and created a resource
204 No ContentSuccess, no bodyDELETE succeeded
400 Bad RequestMalformed requestMissing required fields, bad JSON
401 UnauthorizedNot authenticatedMissing or invalid token
403 ForbiddenNot authorisedAuthenticated but lacks permission
404 Not FoundResource missingID doesn't exist in database
422 UnprocessableValidation failedField values fail business rules
429 Too Many RequestsRate limitedClient exceeds request quota
500 Internal ErrorServer faultUnhandled exception (never expose details)

A consistent error response format across all endpoints makes client-side error handling trivial:

JSON — Standard error response shape
{
  "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:

Terminal — curl test commands
# 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
Client Browser / App Auth Middleware Verify JWT Router Match route Controller Business logic Database Query / write JSON response flows back to client

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 (/posts not /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
REST API Backend Node.js Laravel Express
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.