Every website and API runs on HTTP, the protocol browsers and apps use to ask servers for things. A client sends a request, made of a method (such as GET or POST), a URL, headers and sometimes a body. The server sends back a response: a status code (such as 200 OK or 404 Not Found), headers, and a body containing HTML, JSON, an image or anything else.
Node.js was designed for exactly this job. Its built-in http module can create a working web server in a few lines, with no packages to install. Each incoming request triggers your handler function with two objects: req, describing the request, and res, which you use to build the response. Because Node is non-blocking, one server process can handle many requests at the same time.
Most real projects use a framework such as Express, covered in the next lesson, but frameworks are built on the http module. Understanding it first means you'll know exactly what the framework is doing for you. In this lesson you'll create a server, route requests by method and path, read query strings and JSON bodies, return proper status codes and headers, and make requests to other servers with fetch.
Creating a server
import { createServer } from 'node:http';
const server = createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Hello from Node!');
});
server.listen(3000, () => {
console.log('Listening on http://localhost:3000');
});- The handler runs once for every request.
res.writeHead(status, headers)sets the status code and headers;res.end(body)sends the body and finishes the response.- Every request must end with
res.end(), or the browser waits until it times out. server.listen(port)starts accepting connections. Stop the server with Ctrl+C.
The request: method, URL and headers
req.methodis the HTTP method:GETto read,POSTto create,PUTorPATCHto update,DELETEto remove.req.urlis the path and query string, such as/products?search=lamp. Parse it withnew URL(req.url, 'http://localhost')to getpathnameandsearchParams.req.headersis an object of request headers, with lower-case names:req.headers['content-type'].- The request body arrives in chunks, because
reqis a stream. Collect the chunks, join them and parse the JSON:
let body = '';
for await (const chunk of req) body += chunk;
const data = JSON.parse(body);Responses and status codes
| Code | Meaning | Typical use |
|---|---|---|
| 200 | OK | Successful GET |
| 201 | Created | Successful POST that created something |
| 204 | No Content | Successful DELETE |
| 400 | Bad Request | Invalid input |
| 404 | Not Found | Unknown path or item |
| 405 | Method Not Allowed | Wrong method for a path |
| 500 | Internal Server Error | Something broke on the server |
For JSON, set Content-Type: application/json and send JSON.stringify(data). Node 18 and later also include fetch, so your server can call other APIs exactly as a browser would.
Building a response step by step
writeHead sets everything in one call, but you can also build a response gradually, which is handy when different parts of your code decide different things:
res.statusCode = 302;
res.setHeader('Location', '/login');
res.end();res.statusCodesets the status; it's200if you never change it.res.setHeader(name, value)adds one header, andres.getHeader(name)reads it back. Headers set this way are merged with any passed towriteHead.res.headersSentisfalseuntil the first part of the response goes out, thentrue. After that point you can't change the status or headers, so error handlers check it before trying to send an error page.- The example above is a redirect: a
3xxstatus plus aLocationheader tells the browser to request a different URL. Use301for a permanent move and302or307for a temporary one.
Node.js Web Syntax
http module
import { createServer } from 'node:http';
const server = createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`);
if (req.method === 'GET' && url.pathname === '/') {
res.writeHead(200, { 'Content-Type': 'text/html' });
return res.end('<h1>Home</h1>');
}
res.writeHead(404).end('Not found');
});
server.listen(process.env.PORT ?? 3000);
const r = await fetch('https://api.example.com/data');
const data = await r.json();Node.js Web Summary
- createServer((req, res) => ...) handles every request; listen(port) starts the server.
- req.method, req.url and req.headers describe the request; the body arrives as a stream.
- res.writeHead sets the status and headers; res.end sends the body and must always be called.
- Use correct status codes: 200, 201, 204, 400, 404, 405, 500.
- fetch is built into Node for calling other servers.
Node.js Web Examples
Each example shows the code, the output it produces and what each part does.
A server with routes and JSON
import { createServer } from 'node:http';
const products = [
{ id: 1, name: 'Mug', price: 8.5 },
{ id: 2, name: 'Lamp', price: 24 },
];
function sendJson(res, status, data) {
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(data));
}
const server = createServer((req, res) => {
const url = new URL(req.url, 'http://localhost');
if (req.method === 'GET' && url.pathname === '/products') {
const q = (url.searchParams.get('search') ?? '').toLowerCase();
return sendJson(res, 200, products.filter((p) => p.name.toLowerCase().includes(q)));
}
const match = url.pathname.match(/^\/products\/(\d+)$/);
if (req.method === 'GET' && match) {
const product = products.find((p) => p.id === Number(match[1]));
return product ? sendJson(res, 200, product) : sendJson(res, 404, { error: 'Not found' });
}
sendJson(res, 404, { error: 'Unknown route' });
});
server.listen(0, async () => {
const base = `http://localhost:${server.address().port}`;
for (const path of ['/products', '/products?search=la', '/products/2', '/products/9', '/nope']) {
const r = await fetch(base + path);
console.log(r.status, path, JSON.stringify(await r.json()));
}
server.close();
});200 /products [{"id":1,"name":"Mug","price":8.5},{"id":2,"name":"Lamp","price":24}]
200 /products?search=la [{"id":2,"name":"Lamp","price":24}]
200 /products/2 {"id":2,"name":"Lamp","price":24}
404 /products/9 {"error":"Not found"}
404 /nope {"error":"Unknown route"}Line-by-line explanation
- Lines 8 to 11A helper sets the JSON header and sends the data.
- Line 14URL splits req.url into a pathname and search parameters.
- Line 16A missing search parameter becomes an empty string, which matches everything.
- Line 19A regular expression extracts the id from paths like /products/2.
- Line 27Port 0 picks any unused port; the demo then calls its own server with fetch.
Reading a JSON request body
import { createServer } from 'node:http';
const orders = [];
async function readJson(req) {
let body = '';
for await (const chunk of req) body += chunk;
return JSON.parse(body);
}
const server = createServer(async (req, res) => {
if (req.url !== '/orders') return res.writeHead(404).end();
if (req.method !== 'POST') {
return res.writeHead(405, { Allow: 'POST' }).end();
}
try {
const data = await readJson(req);
if (!data.item || !(data.qty > 0)) {
res.writeHead(400, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ error: 'item and a positive qty are required' }));
}
const order = { id: orders.length + 1, ...data };
orders.push(order);
res.writeHead(201, { 'Content-Type': 'application/json', Location: `/orders/${order.id}` });
res.end(JSON.stringify(order));
} catch {
res.writeHead(400).end('Invalid JSON');
}
});
server.listen(0, async () => {
const url = `http://localhost:${server.address().port}/orders`;
const post = (body) => fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body });
let r = await post(JSON.stringify({ item: 'Mug', qty: 2 }));
console.log(r.status, r.headers.get('location'), await r.text());
r = await post(JSON.stringify({ item: 'Lamp', qty: 0 }));
console.log(r.status, await r.text());
r = await post('{not json');
console.log(r.status, await r.text());
r = await fetch(url);
console.log(r.status, r.headers.get('allow'));
server.close();
});201 /orders/1 {"id":1,"item":"Mug","qty":2}
400 {"error":"item and a positive qty are required"}
400 Invalid JSON
405 POSTLine-by-line explanation
- Lines 5 to 9The body arrives in chunks; for await collects them before parsing.
- Lines 13 to 15Wrong method gets 405 with an Allow header listing what is accepted.
- Lines 18 to 21Input is validated before anything is saved.
- Line 24201 Created with a Location header pointing to the new order.
- Lines 26 to 27Invalid JSON throws in JSON.parse and becomes a 400 response.
Calling another API with fetch
import { createServer } from 'node:http';
const ratesApi = createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ base: 'GBP', rates: { EUR: 1.17, USD: 1.27 } }));
});
ratesApi.listen(0, async () => {
const url = `http://localhost:${ratesApi.address().port}/latest`;
try {
const response = await fetch(url, { signal: AbortSignal.timeout(3000) });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const { rates } = await response.json();
const pounds = 50;
console.log(`£${pounds} = €${(pounds * rates.EUR).toFixed(2)} = $${(pounds * rates.USD).toFixed(2)}`);
} catch (err) {
console.log(`Rates unavailable: ${err.message}`);
} finally {
ratesApi.close();
}
});£50 = €58.50 = $63.50Line-by-line explanation
- Lines 3 to 6A tiny local server stands in for a real exchange-rate API.
- Line 11fetch is built into Node; the signal adds a three-second timeout.
- Line 12fetch doesn't throw for 404 or 500, so check response.ok yourself.
- Line 13response.json() reads and parses the body.
- Line 19finally closes the demo server whether the request worked or not.
Common Node.js Web Mistakes
Forgetting res.end
createServer((req, res) => {
res.writeHead(200);
res.write('Hello'); // the response never finishes; the browser keeps waiting
});createServer((req, res) => {
res.writeHead(200);
res.end('Hello');
});Sending an object directly
res.end({ ok: true }); // TypeError: the chunk must be a string or Bufferres.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));Assuming fetch throws on HTTP errors
const data = await (await fetch(url)).json(); // a 500 error page is parsed as if it were dataconst response = await fetch(url);
if (!response.ok) throw new Error(`Request failed: ${response.status}`);
const data = await response.json();Node.js Web in Real Projects
The http module in practice
Express, Fastify, Koa and Next.js all sit on top of Node's http module. Developers use it directly for tiny services such as health-check endpoints, webhooks receivers and test servers that fake an external API, exactly like the demos in this lesson. fetch is used throughout Node back ends to call payment providers, email services and other internal APIs.
Node.js Web FAQ
How do I fix "EADDRINUSE: address already in use" in Node.js?
Another program, often an older copy of your own server, is already listening on that port. Stop it (check other terminal windows, or find the process with lsof -i :3000 on macOS and Linux), or start your server on a different port. In code, listen for the server's 'error' event and check err.code === 'EADDRINUSE' to print a clear message.
How do I stop a Node.js HTTP server gracefully?
Call server.close(). It stops accepting new connections and calls its callback once the existing ones have finished, so requests in progress aren't cut off. Servers usually do this when they receive SIGTERM, then close database connections and let the process exit.
How do I serve an HTML file with the http module?
Read the file and send it with the right content type: res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) followed by res.end(await readFile(file)). For several files, map extensions to content types and never build the path directly from req.url without checking it. Frameworks such as Express do this for you with express.static.
How do I allow CORS requests to a plain Node.js server?
Add an Access-Control-Allow-Origin header naming the site that may call you, such as https://app.example, to each response. For requests with JSON bodies or custom headers, the browser first sends an OPTIONS request, so answer it with 204 plus Access-Control-Allow-Methods and Access-Control-Allow-Headers. Avoid * for APIs that use cookies or other credentials.
Do I need the https module to run a production server?
Usually not. Most hosting platforms, load balancers and reverse proxies such as Nginx handle HTTPS in front of your app and forward plain HTTP to it. You only need node:https with your own certificate when Node itself must accept encrypted connections directly, for example on a server with no proxy in front.
Node.js Web Exercises
Answer each one and check it instantly. If you get stuck, open the hint before the answer.
Complete the line to finish the response.
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.('OK');
Which status code means "Not Found"?
What does new URL(req.url, 'http://localhost').searchParams.get('page') return for /list?page=3?
A POST successfully creates a new item. Which status code should the server send?
Why does reading a request body need for await (const chunk of req)?
Node.js Web Quiz
5 questions to check what you learned.
Which built-in module creates a web server?
- node:net only
- node:web
- node:http
- node:server
Which HTTP method is usually used to create a resource?
- GET
- DELETE
- HEAD
- POST
Which header tells the client that the body is JSON?
- Accept: application/json
- Body-Type: json
- Content-Type: application/json
- Format: json
Does fetch throw an error when the server responds with 500?
- No, check response.ok or response.status
- Yes
- Only for POST requests
- Only in Node
What does status 405 mean?
- The resource moved
- The method isn't allowed for this URL
- The server is overloaded
- Authentication is required