This blog post is part of the series “Learning web development” – which teaches people who have never programmed how to create web apps with JavaScript.
To download the projects, go to the GitHub repository learning-web-dev-code
and follow the instructions there.
I’m interested in feedback! If there is something you don’t understand, please write a comment at the end of this page.
In this chapter, we’ll write our own web server: It will serve files and manage the data for a browser app.
The following pairs of opposites are all related:
browser | server |
---|---|
local | remote |
frontend | backend |
client | server |
The term “client” is interesting because it is more general than the term “browser” – it refers to any app (web app, mobile app, etc.) that connects to a server. In web development, it usually means “browser” or “web app”.
Before we can write our first web server, we’ll need to learn more about how resources (roughly: files) are served to the web:
HTTP requests and responses are called HTTP messages.
In version 1.1 of the HTTP protocol serves HTML pages as text, in the following format:
HTTP/1.1 200 0K
content-type: text/html
last-modified: Mon, 13 Jan 2025 20:11:20 GMT
date: Thu, 11 Sep 2025 09:55:04 GMT
content-length: 1256
<!doctype html>
<html>
...
content-type
: It specifies the media type of the served resource. text/html
is the media type for HTML.HTTP/3 is the current version of HTTP. HTTP versions after 1.1 don’t use a text format anymore but the core parts are the same: protocol version, status code, headers, content.
Tips:
You can use the shell command curl -i <url>
to see response headers. curl
may already be installed on your operating system. Otherwise, you have to install it yourself.
More information: “HTTP messages” on MDN
Each status code has a number and a description. The numbers have the following ranges:
These are a few examples:
For more information, see the official HTTP Status Code Registry.
In most operating systems, the filename extension reveals what kind of data is stored in a file. The web uses a different kind of mechanism for its resources: a media type (also known as a MIME type) that is attached to a resource via an HTTP header. A public standard lists all of media types; these are a few examples:
text/plain
text/html
text/css
text/javascript
application/json
image/jpeg
simple-server-html.js
Let’s write a simple web server that only serves a single web page – whose content comes from a string. You can run that server like this:
node --watch simple-server-html.js
Option --watch
means that Node.js restarts the JavaScript file every time it is changed – e.g. because we edited and saved it. Note that that only reloads the server, it does not reload what’s shown in the browser.
This is what simple-server-html.js
looks like:
import { createServer } from 'node:http';
const hostname = 'localhost';
const port = 3000;
const server = createServer(
(request, response) => {
// ...
}
);
server.listen(port, hostname, () => { // (A)
console.log(`Server running at http://${hostname}:${port}/`);
});
We first create the server. Its argument is a listener for requests. It is similar to an event listener. In this case, the events are HTTP requests.
Then we start the server (line A) and specify which port it listens to.
Requests are handled as follows:
const server = createServer(
(request, response) => {
response.statusCode = 200;
response.setHeader('Content-Type', 'text/html');
const content = [
'<!DOCTYPE html>',
'<meta charset="UTF-8">',
'<title>Simple Web Server</title>',
`Path: ${request.url}`
];
response.end(content.join('\n'));
}
);
We first make two meta-data settings: the status code and the content type. We provide the content as a single chunk of text and then end (close) the response. We could also have provided the data in multiple chunks – which can make sense for large amounts of data, but it’s not something we do in this series.
Note that we are serving a minimal web page and omit as much HTML as possible. That’s normally not recommended but browsers can handle it just fine.
request.url
The served web pages give us an opportunity to examine the paths that web servers receive when requests are made. request.url
is a slight misnomer – it’s only the part of the URL that comes after the host. The following table shows a few examples:
URL of web page | request.url |
---|---|
http://localhost:3000 |
/ |
http://localhost:3000/dir/ |
/dir/ |
http://localhost:3000/file.html |
/file.html |
We can see that request.url
always starts with a slash.
Search parameters (or query strings) are something that we can add after the path of a URL: They consist of a question mark (?
) followed by one or more key=value pairs (the value is optional) that are separated by ampersands (&
) – e.g.:
https://example.com/home.html?key1=value1&key2=value2
In URL objects, we can access this suffix via the property .search
:
> const url = new URL('https://example.com/home.html?k1=v1&k2=v2');
> url.search
'?k1=v1&k2=v2'
> url.pathname
'/home.html'
Search parameters can be used to send instructions to a server. We’ll do that later in this chapter.
URLSearchParams
Class URLSearchParams
helps with parsing search parameters:
> const usp = new URLSearchParams('?k1=v1&k2=v2');
> Array.from(usp.entries())
[ [ 'k1', 'v1' ], [ 'k2', 'v2' ] ]
> usp.get('k1')
'v1'
> usp.has('k1')
true
> usp.has('KEY')
false
As we can see, instances of URLSearchParams
works similarly to Maps. When we invoke the class via new
then the question mark at the beginning of the string is optional and ignored.
url.searchParams
Conveniently, each URL
object has a property .searchParams
with an instance of URLSearchParams
:
> const url = new URL('https://example.com/home.html?key=value');
> Array.from(url.searchParams.entries())
[ [ 'key', 'value' ] ]
What happens if we omit the value part of a search parameter and only provide a key? In that case, the corresponding URLSearchParams
entry has the empty string as a value:
> Array.from(new URLSearchParams('?k1&k2').entries())
[ [ 'k1', '' ], [ 'k2', '' ] ]
What happens if we use the same key multiple times? In Maps, the last time the key is used determines its value. In contrast, URLSearchParams
records all values and returns them via .getAll()
:
> Array.from(new URLSearchParams('?k=v1&k=v2').entries())
[ [ 'k', 'v1' ], [ 'k', 'v2' ] ]
> new URLSearchParams('?k=v1&k=v2').getAll('k')
[ 'v1', 'v2' ]
Method .get()
returns the first of those values:
> new URLSearchParams('?k=v1&k=v2').get('k')
'v1'
Some characters, such as space, are not allowed in URLs – which is why they are encoded. Spaces are encoded via plus (+
):
> Array.from(new URLSearchParams('?key=with+space').entries())
[ [ 'key', 'with space' ] ]
For other characters, percent-encoding is used: a percent sign (%
) followed by the two-digit hexadecimal number of the code point of the character.
> Array.from(new URLSearchParams('?key=one%2Bone').entries())
[ [ 'key', 'one+one' ] ]
> Array.from(new URLSearchParams('?key=1%2F2').entries())
[ [ 'key', '1/2' ] ]
+
is hexadecimal 2B (decimal 43)./
is hexadecimal 2F (decimal 47).We can also use URLSearchParams
to create search parameters:
const usp = new URLSearchParams();
usp.append('key1', 'value1');
usp.append('key2', 'value with spaces');
usp.append('key3', 'one+one');
assert.equal(
usp.toString(),
'key1=value1&key2=value+with+spaces&key3=one%2Bone'
);
In programming we distinguish:
An interface is the surface of a collection of functions, classes, etc.: It describes their structure (their names, how many parameters they have, etc.) and sometimes even the rules for how to use them.
We can implement an interface and write code that conforms to the interface. That results in one implementation of that interface. There can be more.
An interface is often implicit – e.g., with a JavaScript module, the interface is not separate from the implementation, but we can write another module that has the same interface. As a result, we can easily swap out one module for the other.
An API (Application Programming Interface) is an interface for one specific purpose – e.g.:
JSON
(JSON.parse()
etc.) can be called JavaScript’s JSON API.simple-server-api.js
In this project, we write an API server. It lets us invoke functionality that runs on that server:
const server = createServer(
(request, response) => {
response.statusCode = 200;
response.setHeader('Content-Type', 'text/plain'); // (A)
const url = new URL('file:' + request.url); // (B)
const params = url.searchParams;
const content = [
'Number of search parameters: ' + Array.from(params.entries()).length
];
for (const [key, value] of params.entries()) {
content.push(
key + '=' + value
);
}
response.end(content.join('\n'));
}
);
request.url
, we use a trick: We convert request.url
from an URL suffix to a real URL by prefixing the protocol file:
and use the URL
class to parse the result (line B).What is happening is like a (remote) function call:
application/json
).As an example, the server could perform addition and clients (including web apps in browsers) could invoke it like this:
http://example.com/add?num=3&num=4
Run simple-server-api.js
and explore what it serves! You can also access it from the Node.js REPL like this:
> await (await fetch('http://localhost:3000/?key=value')).text()
'Number of search parameters: 1\nkey=value'
Object.entries()
converts an object to an array with [key, value] pairs:
> Object.entries({a: 1})
[ [ 'a', 1 ] ]
Object.fromEntries()
does the opposite:
> Object.fromEntries([['a', 1]])
{ a: 1 }
We’ll need both in the next project. Let’s look at two examples.
The parameter of new Map()
is an iterable with [key, value] pairs – which is what Object.entries()
returns:
const map = new Map(Object.entries({
one: 1,
two: 2,
}));
assert.deepEqual(
Array.from(map.entries()),
[
['one', 1],
['two', 2],
]
);
Note that the keys are always strings if we use an object like this.
The following code prefixes the property keys of an object with underscores:
function changePropKeys(obj) {
return Object.fromEntries(
Object.entries(obj).map(
([key, value]) => ['_' + key, value]
)
);
}
const obj = {
one: 1,
two: 2,
};
assert.deepEqual(
changePropKeys(obj),
{
_one: 1,
_two: 2,
}
);
todo-list-server
In the previous chapter, we implemented a web app for editing a todo list in the browser. In this project, we additionally implement a web server and store the todo list there.
In this project, we stick to a single user editing a single todo list. We want the server to store the todo list so that it doesn’t get lost when we close the web page. Two ways (among more) of doing that are:
Let’s use approach #2 because that lets us explore server-side APIs and asynchronous updates of the user interface (after each time we receive a new model from the server).
Note that browsers also support long-term storage – which means data survives the closing of a web page. However, doing that is beyond the scope of this series. You can read about the Web Storage API and the IndexedDB API on MDN if you are interested.
We now have two JavaScript apps: A client app and a server app. The project has the following file system structure:
package.json
contains package scripts for building the client app, starting the server app, etc. It also lists the dependencies of the client app.site/
: This directory contains the files that are served by the server. The client app is built into this directory.node_modules/
contains the packages used by the client app.html/
: When building the client app, the files in this directory are copied to site/
(there is only a single HTML file).client/
: When building the client app, the JavaScript modules in this directory are bundled into site/bundle.js
.server/
: This directory contains the code of the server app.data/
: This is where the server app stores the model after each change so that we can restart the server without losing the model.package.json
Among the package scripts, build
and watch
are basically the same as in todo-list-browser
. start
is different:
"scripts": {
// ...
"start": "node --watch ./server/server.js"
},
This time, we are using our own web server! Note that this server doesn’t automatically reload the browser whenever a file changes. Therefore, to run the app we do:
npm start
npm run watch
What happens if we change something?
node
automatically restarts it.site/
. As an additional step, we need to reload the web page after that.server/server.js
import { createServer } from 'node:http';
import { API_PATH_PREFIX, handleApiRequest } from './handle-api-request.js';
import { handleFileRequest } from './handle-file-request.js';
// ...
const server = createServer(
async (request, response) => {
const webPath = request.url;
console.log('Request: '+ webPath);
if (webPath.startsWith(API_PATH_PREFIX)) {
await handleApiRequest(request, response, webPath);
return;
}
await handleFileRequest(request, response, webPath);
}
);
The path of the request determines what the server does:
/api/
? Then the request is an API invocation.server/handle-file-request.js
This is how we serve a file:
// Remove search parameters etc.
let absPath = new URL('file:' + request.url).pathname;
if (absPath === '/') {
absPath = '/index.html';
}
// Remove leading slash
const relPath = absPath.slice(1);
const fileUrl = new URL(relPath, SITE_DIR);
if (existsSync(fileUrl)) {
response.statusCode = 200; // OK
// Allow both uppercase and lowercase filename extensions
const ext = path.extname(absPath).toLowerCase();
const contentType = extensionToContentType.get(ext) ?? 'text/plain';
response.setHeader('Content-Type', contentType);
const content = await fs.readFile(fileUrl); // binary
response.end(content);
return;
}
If the web path refers to the top-level directory of the server then we serve the index.html
in that directory.
Then we convert the absolute path to a relative path by removing the leading slash. We resolve the result against the URL of the site/
directory. If there is a file at that URL then we serve it:
The status code is 200 because everything went well.
We look up the content type in a Map whose keys are filename extensions. Module path
is the POSIX (which basically means Unix) version which we can use because URL paths are basically Unix paths. It was imported like this:
import * as path from 'node:path/posix';
Finally, we read the file’s content and add it to the response. Without a second parameter, readFile()
returns binary data (and not a string). That is important because we want to serve text files (HTML, JavaScript, etc.) as well as non-text files (images etc.).
If we can’t find the file, the response is an error message:
response.statusCode = 404; // Not Found
response.setHeader('Content-Type', 'text/plain');
const content = [
'File not found: ' + absoluteWebPath
];
response.end(content.join('\n'));
This is a very simple web server. For a real web server, we’d want more features:
Currently only accessing the top-level directory serves the index.html
in that directory. That’s functionality we want for all directories, at any level.
We’d want better error handling: We should catch exceptions and report them. handle-api-request.js
shows how to do that.
server/handle-api-request.js
When we start the server, we read the model from storage (last line):
// Sibling of parent directory of this module
const DATA_DIR = new URL('../data/', import.meta.url);
if (!existsSync(DATA_DIR)) {
throw new Error('Could not find data directory: ' + DATA_DIR);
}
const CORE_MODEL_FILE = new URL('todos.json', DATA_DIR);
// ...
const coreModel = await readCoreModelFile();
This is how API requests are handled:
export const handleApiRequest = async (request, response) => {
try {
const url = new URL('file:' + request.url);
// Remove the prefix '/api/' so that only the function name remains
const functionName = url.pathname.slice(API_PATH_PREFIX.length);
const searchParams = url.searchParams;
const entries = Array.from(searchParams.entries());
const params = Object.fromEntries(
entries.map(
([key, value]) => [key, JSON.parse(value)]
)
);
// ...
if (functionName === 'addTodo') {
coreModel.todos.push(
{ text: params.text, checked: false }
);
await writeCoreModelFile(coreModel);
serveCoreModel(response, coreModel);
return;
}
// ...
throw new Error('Could not parse API request: ' + request.url);
} catch (err) {
// ...
}
};
We first extract the functionName
from the path. Then we convert the search parameters to and object. Since parameter values can only be strings, we use JSON parsing to convert them to more kinds of values (we use booleans, numbers and strings).
Each function follows the same pattern:
coreModel
.coreModel
to storage.If we catch an error then we handle it like this:
response.statusCode = 400; // Bad Request
response.setHeader('Content-Type', 'text/plain');
let content = 'An error happened: ' + String(err);
if (err.stack !== undefined) {
content += '\n';
content += err.stack;
}
response.end(content);
We can now run (just) the server via npm run
and interact with the API from the Node.js REPL:
> const api = 'http://localhost:3000/api/';
> await (await fetch(api + 'loadCoreModel')).json()
{ todos: [] }
> await (await fetch(api + 'addTodo?text=%22Groceries%22')).json()
{ todos: [ { text: 'Groceries', checked: false } ] }
> await (await fetch(api + 'deleteTodo?index=0')).json()
{ todos: [] }
Note how the API always sends back the complete model.
client/main.js
With the client, we face a conundrum: On one hand, we want to display a user interface as quickly as possible. On the other hand, we first have to load the model from the server – which may take a while. Therefore, we use the following approach:
const appModel = signal(undefined);
// ...
function App() {
if (appModel.value === undefined) {
return html`
<div>Loading...</div>
`;
}
// ...
}
render( // (A)
html`<${App} />`,
document.body
);
const coreModel = await loadCoreModel(); // (B)
appModel.value = coreModel;
appModel.value
is initially undefined
. Therefore, when it is initially rendered (line A), the App
component displays a “loading” message. After that initial rendering, we load the model (line B) and assign it to appModel.value
. That triggers a re-rendering of App
, which then displays a todo list.
client/app-model.js
Where the app-model.js
of todo-list-browser
performed model changes itself, the todo-list-server
version of that module delegates that work to the API server.
This is one model operation:
export const addTodo = async (appModel, text) => {
const coreModel = await sendApiRequest(
'addTodo', { text }
);
appModel.value = coreModel;
};
We send an API request, wait until the server returns a new model and then assign it to appModel.value
– which triggers a re-rendering of the user interface.
The following function sends API requests:
const sendApiRequest = async (functionName, params) => {
const usp = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
usp.append(key, JSON.stringify(value));
}
const response = await fetch(
`/api/` + functionName + '?' + usp.toString()
);
const coreModel = await response.json();
return coreModel;
};
params
is an object. We convert that object to search parameters by iterating over its entries. We use JSON stringification to convert JavaScript values (booleans, numbers and strings) to strings. sendApiRequest
always asynchronously returns a new model.