Introduction
When I first started using APIs on the frontend, things felt simple. I fetch some data, display it nicely, and done. That worked perfectly until I needed to use an API key. I tried everything to hide it like environment variables, build tools, and even hosting tricks. But no matter what I did, the key still showed up somewhere in the browser. That’s when I learned a hard truth, if the browser can access it, the user can too.
The only way to really protect your API keys is to handle them on a server. Instead of fetching data directly from the client side, your app can send a request to a small server you control. That server then talks to the external API using your keys safely behind the scenes.
In this post, I’ll show you how to build that server yourself using Node.js. We’ll start with the core Node.js setup (no extra packages), then move on to Express to make it cleaner, add routes, serve HTML, and handle common issues like CORS and body parsing. By the end, you’ll see that creating your own backend isn’t hard, it’s just JavaScript with a new purpose.
Node.js basics
Before jumping straight into Express, it’s important to understand what’s happening under the hood. Express is just a library that makes Node.js easier to work with, so let’s start with plain Node, no packages, no shortcuts.
Every Node server starts by using the built-in http module to create and listen for requests. Think of this as Node opening a door (a port) and waiting for someone to knock. Each “knock” is an incoming request, and Node decides how to respond.
Here’s a simple example of a server that handles both GET and POST requests:
// server.js
const http = require("http");
// Basic server setup
const server = http.createServer((req, res) => {
// Set response headers
res.setHeader("Content-Type", "application/json");
// Handle routes
if (req.method === "GET" && req.url === "/") {
res.writeHead(200);
res.end(JSON.stringify({ message: "Hello from Node server!" }));
} else if (req.method === "POST" && req.url === "/data") {
let body = "";
// Collect data chunks from request
req.on("data", (chunk) => {
body += chunk.toString();
});
// Once all data is received
req.on("end", () => {
let parsedData;
try {
parsedData = JSON.parse(body);
} catch {
parsedData = {};
}
res.writeHead(200);
res.end(
JSON.stringify({ message: "Data received", data: parsedData })
);
});
} else {
res.writeHead(404);
res.end(JSON.stringify({ error: "Route not found" }));
}
});
// Start server
server.listen(3000, () => {
console.log("Server is running on http://localhost:3000");
});
Let's break down what is going on in the above code block. First, we use the built-in HTTP module. It handles all communication over HTTP, you don’t need to install anything. Then we call http.createServer() to create the server. This method listens for incoming requests and runs a callback for each one. The callback receives two arguments:
req (request): contains all the details about the incoming request such as the URL, method, headers, and body.
res (response): used to send data back to whoever made the request.
Because we want the browser to understand our response, we set a response header with res.setHeader("Content-Type", "application/json"). Headers are pieces of metadata sent with each request or response. They tell the receiver how to interpret the data being transmitted, for example, what format it’s in or how it should be cached.
Inside the if block, we handle both the GET and POST requests. For the GET request, we check if the method is "GET" and the endpoint is /. Endpoints are the paths added to your domain, like /feed/subscriptions in https://www.youtube.com/feed/subscriptions. Here, our root ( / ) is the home endpoint. We respond with status 200 and send a JSON object back to the client.
For the POST request, we handle incoming data. The req.on("data") event receives small pieces of the request body called "chunks". These are combined into one string and then parsed into an object when the req.on("end") event fires. After parsing the body, we return a JSON response confirming the data was received.
Finally, we start the server with server.listen(3000), telling Node to listen for connections on port 3000. Visiting http://localhost:3000 will show your first response from Node. We can test this by running it in the terminal with "node server.js".
At this stage, you now have a functioning backend that accepts requests, responds to clients, and can handle incoming data. But managing routes and parsing bodies manually like this can quickly get messy as your app grows.
That’s where Express comes in, it simplifies everything we just built into shorter, cleaner, and easier-to-read code.
Express
After writing your first Node server, you might have noticed how repetitive it can get. You have to check the request method, compare URLs, manually parse body data, and write custom responses every single time. Once your app has more than a few routes, this gets complicated quickly.
That’s where Express comes in. Express is a small library built on top of Node’s http module, designed to make creating APIs and serving web content simpler. It doesn’t replace Node, it just gives us the tools to write cleaner, more manageable code.
To get started, open your project in a terminal and run:
npm init -y
npm install express
This installs Express and creates a simple package.json file. Then create a file named server.js and add the following:
// server.js
const express = require("express");
const app = express();
const PORT = 3000;
From here, we’ll break down how Express simplifies our Node server with middleware and endpoints.
Middleware
Middleware is what makes Express flexible and clean.
It acts like a series of checkpoints or layers that your request passes through before reaching its destination (your endpoint). Each layer can modify the request, check for conditions, or even stop it completely. It’s your chance to prepare data before sending a response.
// Middleware to log every request
app.use((req, res, next) => {
console.log(`${req.method} - ${req.url}`);
next(); // move to the next middleware or route
});
// Middleware to parse incoming JSON bodies
app.use(express.json());
Here’s what’s happening, the first middleware logs the method and URL of every request. This is useful for debugging or just seeing what the frontend is sending. The second middleware, express.json(), automatically parses JSON data that comes from POST requests and attaches it to req.body. In the plain Node example earlier, we had to manually listen for "data" and "end" events. Express takes care of all that for you.
Endpoints
Endpoints are the different paths or “addresses” of your backend where specific actions happen. In Express, defining them is simple, you just call a method for the HTTP verb (get, post, put, delete) and assign it a path. Let’s recreate our Node example using Express routes:
// GET endpoint
app.get("/", (req, res) => {
res.json({ message: "Hello from Express server!" });
});
// POST endpoint
app.post("/data", (req, res) => {
const data = req.body;
console.log(data);
res.json({ message: "Data received", data });
});
Let’s break down what’s happening. The GET "/" is when someone goes to the root of your site (http://localhost:3000/) with a GET request, which sends a simple JSON message back. The POST "/data" is when someone sends a POST request to /data, Express automatically parses the body and stores it in req.body. We can immediately access and use that data.
And finally, don’t forget to tell your app to listen for connections:
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
All together now:
const express = require("express");
const app = express();
const PORT = 3000;
app.use((req, res, next) => {
console.log(`${req.method} - ${req.url}`);
next();
});
app.use(express.json());
app.get("/", (req, res) => {
res.json({ message: "Hello from Express server!" });
});
app.post("/data", (req, res) => {
const data = req.body;
console.log(data);
res.json({ message: "Data received", data });
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
Now your Express version of the server is fully working. You can send a GET request to see the welcome message, or send a POST request to see the data returned.
Here is an example form to test the post:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title></title>
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="">
</head>
<body>
<form>
<label for="userName"> Name
<input type="text" name="userName" id="userName">
</label>
<label for="userAge"> Age
<input type="number" name="userAge" id="userAge">
</label>
<input type="submit" id="userSubmit" value="Send">
</form>
<script>
const submit = document.getElementById("userSubmit");
const userName = document.getElementById("userName");
const userAge = document.getElementById("userAge");
submit.addEventListener("click", (e) => {
e.preventDefault();
fetch("http://localhost:3000/data",{method: "POST", body: JSON.stringify({userName: userName.value, userAge:userAge.value}), headers: { "Content-Type":"application/json"}})
})
</script>
</body>
</html>
Express has now replaced all the manual work we did earlier with just a few lines of code, and it’s much easier to expand.
When you try sending the form data, you might see this error in your browser console:
Access to fetch at 'http://localhost:3000/data' from origin 'http://localhost' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource.
This is a common next step every frontend developer hits a CORS (Cross-Origin Resource Sharing) issue.
Let’s talk about what it means and how to solve it.
CORS
What is CORS?
CORS stands for Cross-Origin Resource Sharing. It’s a security feature that browsers use to control how web pages from one website (or origin) can request data from another.
Here’s a quick way to think about it:
Imagine you’re visiting a restaurant (your browser) and ordering food (data) from the kitchen (a server). Normally, you can only order from the kitchen that belongs to that restaurant (the same origin). If you suddenly start ordering from a kitchen next door (another origin), the restaurant wants to make sure it’s allowed, so it asks the other kitchen for permission before delivering that food to you.
That permission step is what CORS handles. It makes sure your browser doesn’t just start pulling sensitive data from any random site on the internet.
To fix our problem, we need to install CORS package:
npm install cors
Now update the server file with this:
const express = require("express");
const cors = require("cors");
const app = express();
const PORT = 3000;
app.use(cors()); // Enable CORS for all routes
app.use(express.json());
That one line app.use(cors()); tells Express to automatically add the correct CORS headers, which basically say: "It’s okay for any origin to access this server." Now, if you reload your form page and server, then send data again, the error disappears. Everything works.
While allowing all origins is great for testing, in production you’ll probably want to limit who can access your server. Let’s change the configuration to only allow requests from the frontend running on your own localhost:
app.use(
cors({
origin: "http://localhost:5500", // replace with your actual frontend port
})
);
Now, if someone tries to send a request from any other site or port, the browser will block it again. This is how you keep your API open for the right users while still staying secure. If you’re unsure what origin to use, open your frontend in the browser and check the URL bar, everything before the first / after the domain is your origin.
Other Common Trip-Ups
While you’re working with Node and Express for the first time, you might bump into a few other common issues.
- Forgetting to use express.json()
If your server logs undefined instead of the request body, make sure you’ve added app.use(express.json()) before your routes. Without it, Express doesn’t parse incoming JSON.
- Using the wrong fetch Content-Type
When sending JSON from the frontend, always include the header. Otherwise, the body won’t be parsed correctly:
headers: { "Content-Type": "application/json" }
- Not restarting the server
Remember, changes to your backend code don’t auto-refresh. Use a tool like nodemon for development so it restarts automatically whenever you save.
Each of these is completely normal to run into, especially when you’re connecting your frontend and backend for the first time. Now that CORS is sorted out and your server can talk safely with your frontend, you can start building more interesting connections, like serving HTML pages, fetching data from external APIs, or even adding authentication.
Serving HTML and Handling API keys
Now that our server can handle requests safely, let’s bring everything full circle, serving content and making API calls securely.
Serving HTML with Express
You can serve an HTML page directly from your server using sendFile(). First, create a folder called public and drop your HTML form inside it, e.g. public/index.html.
Then, update your server.js:
const path = require("path");
app.use(express.static("public"));
app.get("/", (req, res) => {
res.sendFile(path.join(__dirname, "public", "index.html"));
});
Now, when you visit http://localhost:3000, your form loads directly from the server, no more “file://” setup issues, and it’s easier to pair frontend and backend together.
Connecting to an External API
Let’s go back to the reason we started all this, keeping API keys safe. Instead of calling a third‑party API directly from the browser, you send the request to your server, and it calls the external API for you.
app.get("/weather", async (req, res) => {
try {
const response = await fetch(
`https://api.open-meteo.com/v1/forecast?api_key=12345`
);
const data = await response.json();
res.json({ weather: data.current_weather });
} catch (error) {
res.status(500).json({ error: "Failed to fetch weather data" });
}
});
The browser never sees your API key or the third‑party URL directly, it just communicates with your server, and the server handles the rest behind the scenes.
Conclusion
You’ve now created a simple but complete setup. A backend built with Express handling both GET and POST requests, CORS configured safely and a route that can call external APIs securely.
It’s the same JavaScript you already know, just running in a different space. From here, you can connect your server to a database, deploy it to a platform like Render or Vercel, or build your own small API for other apps to use.
That one small step, moving your API calls to Node, you can start your backend journey.
Top comments (0)