Skip to content

Build a Tiny API From Scratch

A Practical Introduction to Backend Development

Create a small API, accept requests, return JSON, handle errors, and connect a simple frontend to your backend.

12 min read · Web Development · JavaScript · APIs

Developer working with code on a computer
A backend does not need to be complicated to teach you how modern web applications work.

One of the best ways to understand web development is to stop thinking about the frontend and backend as mysterious separate systems and build a very small version of both.

In this tutorial we will create a tiny API using Node.js. It will expose a list of products, allow a client to request them, and return structured JSON data. We will also add basic validation and error handling so the example resembles a real application rather than a single demonstration endpoint.

What are we building?

Our finished application will look roughly like this:

Browser
   │
   │ GET /api/products
   ↓
Node.js server
   │
   ├── Validate request
   ├── Find products
   └── Build response
   │
   ↓
JSON response
   │
   ↓
Browser

There is no database yet. Our goal is to understand the communication layer first.

Before we start

You will need Node.js installed on your machine. Check that it is available from the terminal:

node --version
npm --version

Create a new project:

mkdir tiny-api
cd tiny-api

npm init -y

We can now create a simple server without adding a framework.

Create the server

Create a file called server.js:

const http = require("http");

const server = http.createServer((req, res) => {
  res.writeHead(200, {
    "Content-Type": "application/json"
  });

  res.end(JSON.stringify({
    message: "Hello from the API!"
  }));
});

server.listen(3000, () => {
  console.log("API running at http://localhost:3000");
});

Start the server:

node server.js

Open http://localhost:3000 in your browser. You should receive:

{
  "message": "Hello from the API!"
}

Congratulations — you have just created an HTTP API.

What is actually happening?

The http.createServer() function creates an HTTP server and gives us access to incoming requests.

The callback receives two important objects:

  • req — information about the incoming request.
  • res — the response we send back to the client.

The server then listens on port 3000.

Browser
   ↓
localhost:3000
   ↓
Node.js
   ↓
createServer()
   ↓
Response
   ↓
Browser

Add some data

Let’s make the API useful. Define a small product collection:

const products = [
  {
    id: 1,
    name: "Notebook",
    price: 12.5
  },
  {
    id: 2,
    name: "Desk Lamp",
    price: 39.9
  },
  {
    id: 3,
    name: "Keyboard",
    price: 79
  }
];

We can now return this collection from an API endpoint.

Creating an API route

HTTP requests contain a method and a URL. We can use those values to decide what our server should do.

const server = http.createServer((req, res) => {
  if (req.method === "GET" && req.url === "/api/products") {
    res.writeHead(200, {
      "Content-Type": "application/json"
    });

    res.end(JSON.stringify(products));
    return;
  }

  res.writeHead(404, {
    "Content-Type": "application/json"
  });

  res.end(JSON.stringify({
    error: "Not found"
  }));
});

Now requesting:

GET /api/products

returns our product collection.

[
  {
    "id": 1,
    "name": "Notebook",
    "price": 12.5
  },
  {
    "id": 2,
    "name": "Desk Lamp",
    "price": 39.9
  },
  {
    "id": 3,
    "name": "Keyboard",
    "price": 79
  }
]

Testing the API from the terminal

You do not need a browser to test an API. The curl command is extremely useful for this.

curl http://localhost:3000/api/products

You can also make the response easier to read with jq:

curl http://localhost:3000/api/products | jq

Being comfortable with tools such as curl is useful when debugging APIs because it lets you test the backend independently from the frontend.

Adding a single product endpoint

A useful API usually needs more than a collection endpoint. Let’s support:

GET /api/products/2

We can extract the ID from the URL:

if (req.method === "GET" && req.url.startsWith("/api/products/")) {
  const id = Number(req.url.split("/").pop());

  const product = products.find(item => item.id === id);

  if (!product) {
    res.writeHead(404, {
      "Content-Type": "application/json"
    });

    res.end(JSON.stringify({
      error: "Product not found"
    }));

    return;
  }

  res.writeHead(200, {
    "Content-Type": "application/json"
  });

  res.end(JSON.stringify(product));
  return;
}

Requesting product 2 now produces:

{
  "id": 2,
  "name": "Desk Lamp",
  "price": 39.9
}

HTTP status codes matter

An API should not return 200 OK for every situation. Status codes communicate what happened.

200 → Request succeeded
201 → Resource created
400 → Invalid request
401 → Authentication required
403 → Access denied
404 → Resource not found
500 → Server error

For example, if somebody asks for a product that does not exist, returning 404 is much more useful than returning an empty object with 200.

Centralise JSON responses

Our example is starting to repeat itself. We can make it cleaner by creating a helper function:

function sendJson(res, status, data) {
  res.writeHead(status, {
    "Content-Type": "application/json"
  });

  res.end(JSON.stringify(data));
}

The route becomes much easier to read:

if (req.method === "GET" && req.url === "/api/products") {
  sendJson(res, 200, products);
  return;
}

sendJson(res, 404, {
  error: "Not found"
});

Adding a frontend

Now let’s consume the API from JavaScript running in a browser.

<button id="load-products">
  Load products
</button>

<ul id="products"></ul>

The JavaScript can call the API using fetch():

const button = document.querySelector("#load-products");
const list = document.querySelector("#products");

button.addEventListener("click", async () => {
  const response = await fetch("http://localhost:3000/api/products");

  const products = await response.json();

  list.innerHTML = products
    .map(product => `
      <li>
        ${product.name} — €${product.price}
      </li>
    `)
    .join("");
});

We now have the fundamental architecture of many modern web applications:

Frontend
   │
   │ fetch()
   ↓
HTTP API
   │
   ↓
Application logic
   │
   ↓
Data

What about a database?

Our array is obviously not a production database. Every time the server restarts, the data returns to its original state.

In a real application, the API might communicate with PostgreSQL, MySQL, SQLite, MongoDB or another storage system.

Frontend
   ↓
API
   ↓
Application
   ↓
Database

Importantly, the frontend does not need to know how the database works. The API becomes the boundary between the client and the data layer.

Why APIs are so useful

Once data is exposed through a well-designed API, many different clients can consume it.

                ┌── Web application
                │
API ────────────┼── Mobile application
                │
                ├── Desktop application
                │
                └── Internal tools

This separation is one of the foundations of modern application architecture. A backend can provide data while different interfaces consume it.

Handling errors on the frontend

Network requests can fail. A production frontend should never assume every request succeeds.

async function loadProducts() {
  try {
    const response = await fetch("/api/products");

    if (!response.ok) {
      throw new Error(`Request failed: ${response.status}`);
    }

    return await response.json();

  } catch (error) {
    console.error("Unable to load products:", error);

    return [];
  }
}

The important principle is simple: treat network communication as something that can fail.

Security comes next

Our example intentionally has no authentication because it is designed to demonstrate the basic request lifecycle.

Real APIs may need authentication, authorization, rate limiting, validation, input sanitisation, logging and protection against abuse.

Never assume that because an endpoint is called by your frontend it can only receive valid requests. Anyone can inspect and reproduce HTTP requests.

Browser
   ↓
Public API
   ↓
Validate input
   ↓
Authenticate
   ↓
Authorize
   ↓
Application logic
   ↓
Database

A practical debugging workflow

When an API does not work, avoid changing random pieces of code. Follow the request systematically.

  1. Confirm the server is running.
  2. Test the endpoint with curl.
  3. Check the HTTP status code.
  4. Inspect the response body.
  5. Inspect browser Network tools.
  6. Check server logs.
  7. Only then modify the application code.

For example:

curl -i http://localhost:3000/api/products

The -i option includes the response headers, which makes it easier to see the status code and content type.

From tiny API to production system

Our example is intentionally small, but the concepts scale surprisingly well.

Tiny API
   ↓
Routing
   ↓
Validation
   ↓
Authentication
   ↓
Business logic
   ↓
Database
   ↓
Caching
   ↓
Logging
   ↓
Monitoring
   ↓
Deployment

Production systems add complexity because real systems have real requirements. They need to handle many users, failures, security threats, large datasets and changing business rules.

But underneath all those layers, the fundamental interaction remains the same: a client sends a request and a server produces a response.

Server infrastructure in a data center
Production infrastructure adds layers around the same basic request-and-response model.

Final project structure

A slightly more organised version of our project could eventually look like:

tiny-api/
├── server.js
├── package.json
├── routes/
│   └── products.js
├── services/
│   └── products.js
├── data/
│   └── products.js
└── README.md

You would not necessarily start with all these directories. In fact, starting small is usually better. Introduce structure when the project actually needs it.

The bigger lesson

Building a tiny API teaches something much more valuable than the specific Node.js syntax used in this example.

It teaches you to think in terms of boundaries:

  • The browser is a client.
  • The API is a communication boundary.
  • The application contains business logic.
  • The database stores persistent information.
  • HTTP connects the pieces.

Once those concepts become familiar, frameworks become easier to understand. Express, Fastify, Laravel, Django, Rails, WordPress REST APIs and GraphQL all provide different abstractions around many of the same underlying ideas.

Learn the request before learning the framework.

Frameworks will change. Libraries will change. Deployment platforms will change. The fundamentals of clients, servers, HTTP, data and application logic are much more durable.

Start with a tiny API, break it, inspect the request, fix it, and then keep building. That is how a simple endpoint becomes an understanding of backend development.


Leave a Reply

Your email address will not be published. Required fields are marked *