Reference · /learn/
ISSUE 02Four words that get used interchangeably and shouldn't. The clearest way to learn what a backend is turns out to be looking closely at a site that doesn't have one — this one.
Where this is going: Part A pins down the vocabulary against a concrete contrast case. Part B builds the smallest thing that needs all of it — a guestbook: a form, an API, a database table, and a list that comes back from the server.
PART A — THE WORDS
01Part A
Every page you have ever loaded from this domain already existed as a file before you asked for it.
The whole serving side of this site is one command:
$ python3 -m http.server 8000 --bind 0.0.0.0
That process does exactly one thing. A request arrives for /tokyo-field-card.html; it looks for a file at that path under ~/projects/hello-web/site/; if the file is there it sends the bytes, and if it isn't it sends a 404. It has no idea what a trip is, what a day is, or who you are. It is a very literal file cupboard.
Which means every interesting thing about this site happened earlier, when build.py ran on this machine and wrote those files to disk. By the time anyone visits, all the work is finished. Nothing is worked out per visitor.
Static is not a lesser thing to be upgraded away from. It is astonishingly fast, it cannot be hacked through a login form it doesn't have, it cannot lose data it doesn't store, and it survives being copied to a USB stick. The trip cards should stay static forever. The question is never “should this be dynamic” in general — it is “does this specific feature need something a file can't do”.
~/.config/systemd/user/hello-web.service02Part A
Code that runs at request time, on a machine you control, and can therefore give a different answer each time it is asked.
That is the entire definition, and everything else follows from it. A static file gives the same bytes to every visitor forever. A backend is a running program that receives the request and decides what to send back.
Having code run at that moment unlocks things a file categorically cannot do:
The last two are not stylistic preferences. Anything the browser can see, a visitor can see and change. A length limit in JavaScript stops typos, not malice. Validation in the browser is a nicety; validation on the server is the real thing. The guestbook below does both, and only the second one counts.
Two things a backend is not. It is not “the hard part” — the guestbook backend is about sixty lines. And it is not a place in the world: “the backend” is just a process listening on a port, which on this site will be a process on this same container, on the same port 8000 the static files use today.
the process holding port 800003Part A
The published list of things you are allowed to ask the backend, and the exact shape of the answer.
If the backend is a program that answers questions, the API is the menu. It is a contract with three parts: which URLs exist, what you must send, and what comes back. The guestbook's API is two lines long:
| Method & path | You send | You get back |
|---|---|---|
GET /api/guestbook |
nothing | a JSON list of entries, newest first |
POST /api/guestbook |
JSON: {name, message} |
the created entry, with its id and posted_at |
Two details in that table are the whole idea. First, the method matters as much as the path: same URL, different verb, completely different operation. GET means “give me” and changes nothing; POST means “here is something new”. Second, both sides are JSON — not HTML. The API returns data, not a page.
[
{
"id": 3,
"name": "Alice",
"message": "First one that actually saved.",
"posted_at": "2026-08-19T18:04:11.912+00:00"
},
{ /* ... */ }
]
That distinction is what makes an API useful. Because the answer is data rather than a finished page, the same endpoint serves the form on this page, a curl command in the terminal, a Python script, or an app that doesn't exist yet. None of them need to know how the others display it.
The /api/ prefix is a convention, not a requirement. It earns its keep here for a very practical reason: it gives a clean rule for splitting traffic. Anything starting /api/ is answered by code; everything else is a file on disk. That rule is what makes the infrastructure change in Part B a small one.
GET + POST /api/guestbook04Part A
This site already stores structured data in JSON files and it works fine. So what is Postgres for?
The honest answer is that data/*.json is the right tool for the trip cards and the wrong tool for a guestbook, and the reasons are specific:
Two people submit at the same instant. The read-modify-write dance on a JSON file — load the list, append, write the whole file back — means one of them silently overwrites the other. A database handles simultaneous writers as its core job; that is what transactions are.
With a file, “the twenty newest entries” means loading all of them into memory and sorting. With SQL it is ORDER BY posted_at DESC LIMIT 20, and the database does the work — without reading everything, once there is an index.
A JSON file is rewritten whole every time. That is fine at 30 KB and painful at 30 MB. A database appends a row.
The table declares that name is text and cannot be null, and id is unique. Those rules are enforced by the database itself, so a bug in the application cannot write a malformed row. JSON will cheerfully store anything.
A process killed halfway through rewriting a JSON file leaves a truncated, unparseable file — all the data, gone. A database write either completed or it didn't.
Line them up and the pattern is clear. Every one of those is about data that arrives while the program is running. The trip JSON has none of those problems because it is edited by one person, in an editor, between builds — there is no concurrency, no growth, no crash mid-write.
Data you author before the build belongs in a file, in version control, where you can diff it. Data the world submits while the program runs belongs in a database. The guestbook is the second kind, which is exactly why it is the right first project.
The table is four columns:
CREATE TABLE IF NOT EXISTS guestbook ( id serial PRIMARY KEY, name text NOT NULL, message text NOT NULL, posted_at timestamptz NOT NULL DEFAULT now() );
Three of those four are things the database gives you rather than things the application sends. serial makes Postgres allocate the next unused id itself — no counter to keep, and no chance of two rows colliding. DEFAULT now() means the timestamp is the server's, not the browser's, so it cannot be wrong or faked. timestamptz stores the instant with its timezone rather than a naive wall-clock reading, which is the difference between a time that survives a trip to Japan and one that doesn't. The application only ever supplies name and message.
table guestbook (database: alice)05Part A
Not two job titles. Two machines, two languages, and a network between them that can fail.
The words sound like jargon until you notice they describe a genuine physical boundary:
| Frontend | Backend | |
|---|---|---|
| Runs on | the visitor's laptop | this container |
| Written in | HTML, CSS, JavaScript | anything — here, Python |
| Who can change it | the visitor, freely | only you |
| Can see | only what you sent it | the database, the filesystem, secrets |
| Fails by | losing the network, old browser | crashing, DB down |
| You trust it | never | yes |
The bottom row is the one worth internalising. The frontend runs on a computer belonging to someone else, who can edit it, disable it, or skip it entirely and talk to your API directly with curl. This is not paranoia about attackers — it is just true, and it decides where rules have to live.
The second thing the split forces is that the network in between can fail. In a single Python script, calling a function either returns or raises. Across a network there is a third outcome: no answer at all, indefinitely. Every frontend that talks to an API needs a visible state for “asked, still waiting” and another for “that didn't work”. The demo below has both, which is why it has a status pill in its corner.
Right now this site is all frontend. The trip cards' clever bits — the live weather, the “now” marker, offline mode — run entirely in the browser against public APIs. There has never been a backend of yours in the picture. The guestbook is the first thing that puts one there.
the “you trust it” rowPART B — THE BUILD
06Part B
Deliberately the smallest thing that still needs every piece: a form, an API, a table, and a list that came from the server.
Text only, no accounts, no editing, no deleting. Every one of those omissions is on purpose — the goal is to see the full loop close once, cleanly, without a single distraction. Once submitting a name and watching it come back from Postgres feels ordinary, adding to it is easy. Building it all at once is how you end up debugging four things simultaneously and learning none of them.
serves one · about sixty lines · text only
~/projects/guestbook/app.pyGET and POST /api/guestbookhello-web.service, kept — that is the way backserial, DEFAULT now() and timestamptz mean the app only ever supplies a name and a message.GET selects newest-first; POST inserts and returns the finished row, id and timestamp included.StaticFiles at / last, so /api/* matches first and every other path falls through to the files already on disk.127.0.0.1:8011 with curl first — post an entry, get the list, check a 600-character message is rejected, confirm a trip card still serves.hello-web.service, enable guestbook.service. The rollback is the same two commands the other way round.keep this card — it is the whole build on one page
JavaScript on this page intercepts the form, so the browser doesn't do its default full-page reload.
fetch('/api/guestbook', {method:'POST', body: JSON.stringify({name, message})}). That is the frontend's entire half of the conversation.
A Pydantic model declares the rules — name 1–60 characters, message 1–500. If the JSON doesn't fit, FastAPI rejects it with a 422 before a line of your code runs.
INSERT INTO guestbook (name, message) VALUES (%s, %s) RETURNING ... — the values passed separately from the SQL text, never glued into the string. Postgres fills in the id and posted_at and hands the finished row back.
Which is why the page can show it immediately, with the real server timestamp rather than a guess.
The frontend puts it at the top of the list. No page reload happened at any point.
Step 4 passes the values as parameters, not by building a SQL string with + or an f-string. Doing it the other way is SQL injection, and it is the single most common way small projects get wrecked. Postgres receives the query shape and the data down separate channels, so a message containing '); DROP TABLE guestbook;-- is stored as those exact harmless characters. Get this habit right once and it costs nothing forever.
Sixty-odd lines, and about a third of it is the connection helper:
from fastapi import FastAPI from pydantic import BaseModel, Field import psycopg2, psycopg2.extras app = FastAPI() class NewEntry(BaseModel): # the contract. FastAPI enforces this before the handler runs. name: str = Field(min_length=1, max_length=60) message: str = Field(min_length=1, max_length=500) @app.get("/api/guestbook") def list_entries(): with db() as cur: cur.execute("SELECT id, name, message, posted_at" " FROM guestbook ORDER BY id DESC LIMIT 200") return cur.fetchall() @app.post("/api/guestbook", status_code=201) def add_entry(entry: NewEntry): with db() as cur: cur.execute( "INSERT INTO guestbook (name, message) VALUES (%s, %s)" " RETURNING id, name, message, posted_at", (entry.name.strip(), entry.message.strip()), # <-- separate ) return cur.fetchone() # mounted LAST so /api/* is matched first, and everything else # falls through to the files build.py already wrote: app.mount("/", StaticFiles(directory=SITE, html=True), name="site")
Two things are worth pointing at. The NewEntry class is the input half of the API contract — it is not documentation of the rules, it is the rules, checked on every request. And the last line is what lets one process serve both the API and every static page this site already has, which is the subject of the next section.
~/projects/guestbook/app.py07Part B
This is the part that touches the live site rather than adding to it, so it is worth understanding before it happens.
Port 8000 is currently held by hello-web.service, running python3 -m http.server. That program cannot be extended. It has no concept of running code for a request — a POST to it can only ever return an error. There is no version of “add an API to http.server”; something else has to hold the port.
Since the port is also what serves every trip card, whatever takes it over has to keep serving those too. Two ways:
| Option | How | Cost |
|---|---|---|
| FastAPI serves both chosen |
One process on 8000. API routes declared first; StaticFiles mounted at / last, catching everything else. |
One process, one unit file, nothing new installed. |
| Reverse proxy | nginx or Caddy on 8000, routing /api/* to FastAPI on a private port and the rest to the files directly. |
Neither is installed. Adds a config file and a second service to keep running. |
Option one, for now. The proxy is the right answer at the point where there is real traffic or more than one backend service, and swapping to it later changes nothing about the application code — it is a deployment decision, not an architectural one.
http.server generates a clickable directory listing for any folder with no index.html. StaticFiles returns a 404 instead. Every page anyone actually visits has an index.html, so nothing linked breaks — but a bare directory URL that used to show a file list will stop doing so. That is a loss of an accidental feature, not of any content.
$ systemctl --user disable --now hello-web.service $ systemctl --user enable --now guestbook.service
$ systemctl --user disable --now guestbook.service $ systemctl --user enable --now hello-web.service
The old unit file is not deleted, so the way back is always exactly one command. That is worth doing on purpose with anything that takes over a working service: the point at which you discover you need the rollback is never the point at which you want to be reconstructing it.
The new app can be run on 127.0.0.1:8011 first and exercised with curl — POST an entry, GET the list, check a 600-character message is rejected, confirm a trip card still serves. The live site is untouched the entire time. Only once all of that passes is there any reason to take port 8000 away from the thing currently doing its job perfectly well.
~/.config/systemd/user/guestbook.service08Part B
Not a mockup. This form talks to the real API, and the entries below come out of Postgres.
Loading…
If the status pill reads API not running, the backend hasn't taken over port 8000 yet — the page is being served by http.server, which has no /api/guestbook to answer with. Everything above still reads correctly; only this box needs the service.
Open the browser's dev tools, go to the Network tab, and submit an entry. You will see exactly one request go out, with your JSON in the body, and one JSON response come back — no page reload. That request is the frontend/backend boundary, and watching it happen makes the split concrete in a way reading about it doesn't.
09Part B
Noted here so it isn't forgotten. Not built.
Once the text guestbook works end to end, the natural next move is swapping the message for a photo. It is the same skeleton — same table, same two endpoints, same form — with a file field instead of a text one. What changes:
multipart/form-data instead of JSON, because a JPEG is not text.UploadFile rather than a Pydantic string field.That last point is the reason for doing text first. File uploads are where a small project acquires its first real security surface, and it is much easier to think about when the rest of the loop is already working and understood.