Chapter 17 of 18 · Node.js for Beginners — Complete Free Tutorial
Connecting a Frontend: CORS and Fetch
An API nobody can call is a private notebook. The Node.js for Beginners series now puts a page in front of the to-do API: serving static files with Express, understanding the browser's same-origin policy, enabling CORS for split-origin development, and wiring fetch to load, create and delete todos with honest error handling.
Serving the page
Express ships the middleware for this — one line puts public/ on the web.
<!-- public/index.html -->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Todo API demo</title>
</head>
<body>
<h1>Todos</h1>
<ul id="list"></ul>
<script src="/app.js"></script>
</body>
</html>
app.use(express.static('public')); // register before your 404 fallback
Expected output: visiting http://localhost:3000/ serves the HTML, /app.js serves the script, and /api/todos still returns JSON — static files and API routes coexist because Express matches paths against each registered middleware in order.
The same-origin policy in one paragraph
The browser lets a page read responses only from its origin — protocol plus host plus port. http://localhost:5173 calling http://localhost:3000 is cross-origin: the request may be sent, but without the server's permission slip the browser hides the response from your JavaScript. That permission slip is CORS — a set of response headers the server sends.
HTTP/1.1 200 OK
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET, POST, PATCH, DELETE
Content-Type: application/json; charset=utf-8
When the frontend is served by the same Express server (as above), no CORS is involved at all — same origin. CORS becomes necessary the moment your page runs somewhere else: a dev server on another port, or a deployed frontend on app.example.com calling api.example.com.
Enabling CORS
npm install cors
import cors from 'cors';
// Development: allow the Vite dev server explicitly
app.use(cors({ origin: 'http://localhost:5173' }));
// Production: allow only your real frontend origin
const allowed = process.env.CORS_ORIGIN ?? 'https://app.example.com';
app.use(cors({ origin: allowed }));
Expected output: requests from the allowed origin now carry Access-Control-Allow-Origin and succeed in the browser; requests from any other origin are still answered, but the browser refuses to hand the response to your script. Requests with a JSON Content-Type also trigger an automatic OPTIONS preflight probe, which the same middleware answers. Never combine * with credentials in production — an all-origins policy on an API with tokens is an invitation.
Wiring fetch
<script>
const list = document.getElementById('list');
async function load() {
try {
const res = await fetch('/api/todos');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { todos } = await res.json();
list.replaceChildren(
...todos.map((t) => {
const li = document.createElement('li');
li.textContent = `${t.done ? '[x]' : '[ ]'} ${t.title}`;
return li;
}),
);
} catch (err) {
list.textContent = `Could not load todos: ${err.message}`;
}
}
async function add(title) {
const res = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
});
if (!res.ok) throw new Error(await res.text());
await load();
}
load();
</script>
Expected output: the page renders one line per todo; a failed request prints a readable message instead of a silent blank screen. Three habits carry the whole snippet: set Content-Type so the server's express.json() actually parses the body, always check res.ok (fetch treats 404 and 500 as successful promises), and rebuild the list from the server rather than patching local state by guesswork.
Updates and deletes from the page
async function toggle(todo) {
await fetch(`/api/todos/${todo.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ done: !todo.done }),
});
await load();
}
async function remove(id) {
const res = await fetch(`/api/todos/${id}`, { method: 'DELETE' });
if (res.status !== 204) throw new Error(`delete failed: ${res.status}`);
await load();
}
Expected output: toggling flips done and the list refreshes from the API; deleting removes the row and the server's 204 confirms it. Loading after every mutation costs one extra request — the honest trade for a UI that never disagrees with its data.
Common mistakes
- Forgetting
'Content-Type': 'application/json'—express.json()ignores the body and validation fails withundefined. - Trusting
fetchto reject on 4xx/5xx — it resolves; only network failures reject. Checkres.ok. - Hand-rolling preflight headers — let the
corspackage answerOPTIONS; missed headers are the top CORS bug. - Allowing every origin in production —
*on an API with cookies or tokens is a data breach waiting. - Rendering with
innerHTML— todo titles are user text; build nodes withtextContentto stay XSS-safe.
Practice checklist
- Serve
public/index.htmland confirm it loads alongside the API routes. - Open the browser dev tools Network tab and inspect request, response and status for each operation.
- Call the API from a page on a different port and observe the CORS block before adding middleware.
- Add a create form that POSTs JSON, shows server errors, and refreshes the list.
- Break the
Content-Typeheader on purpose and watchreq.bodybecomeundefined.
The full stack works end to end — the final chapter makes it safe, tested and live. Next: Securing, Testing and Deploying Node Apps.
Frequently Asked Questions
Node.js is a runtime that executes JavaScript outside the browser. It combines Google's V8 engine with APIs for files, networks, processes and streams, plus a package ecosystem (npm). You use it to build servers, command-line tools, APIs and anything that needs JavaScript running on a machine rather than in a page.
JavaScript is the language; Node.js is one environment that runs it. In the browser the language comes with a DOM, events and window APIs — in Node those are replaced by the file system, network and process modules. Syntax, functions, arrays, promises and async/await are identical, so your JavaScript knowledge transfers directly.
Browser code is sandboxed: it cannot read your files or open arbitrary ports. Node code has full machine access, which is why it is used for servers and tools but also why you never run untrusted code in it. There is no document or window in Node — the equivalents are global, process and modules.
npm is JavaScript's package manager and public registry. package.json describes your project: name, entry point, dependencies with version ranges, and scripts such as npm run dev. Installing a package copies it into node_modules and records the exact intent in package-lock.json so every machine installs identical versions.
Express is a minimal web framework for Node: a few thousand lines that give you routing, middleware, request parsing and static files on top of the raw HTTP server. It is the most common starting point for REST APIs and the backbone of this tutorial's project chapters.
Both work. CommonJS (require/module.exports) is the classic Node format; ES modules (import/export) are the standard and activated in a package.json with "type": "module". Modern projects favour ESM for browser compatibility, but you will read both in existing code, so this course teaches each.
Middleware are functions that run between the incoming request and the final response, in order: body parsing, logging, authentication, rate limits, error handling. Each receives the request, response and next function and can respond itself or pass control along — Express's pipeline model in one sentence.
A JSON file is perfect for learning CRUD: no setup, readable data, easy to reset. The moment you need concurrent writes, querying or multiple users, move to a real database — MySQL/PostgreSQL for relational data or MongoDB for documents. The storage chapter isolates persistence behind one module so swapping it later is straightforward.
A REST API exposes resources over HTTP using verbs on URLs: GET /api/todos lists, POST creates, PUT or PATCH updates, DELETE removes, and everything answers with JSON plus correct status codes (200, 201, 400, 404, 500). It is stateless — each request carries its own authentication — which makes it easy to cache, scale and consume from any frontend.
Node apps are long-running processes, not static files: deploy them to a Node-friendly host (a VPS, Render, Railway or Fly.io), keep secrets in environment variables, terminate HTTPS at a reverse proxy or platform edge, and send logs somewhere durable. The final chapter covers the full checklist including health checks and graceful shutdown.