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
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.
- Confirm the server is running.
- Test the endpoint with
curl. - Check the HTTP status code.
- Inspect the response body.
- Inspect browser Network tools.
- Check server logs.
- 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.
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