Building a Web App 🚀
You have been calling other people's servers. Now you write one, and the whole thing stops being mysterious.
A real web server, no packages
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == "/":
self.respond(200, "text/html", "<h1>Ahoy from Python</h1>")
elif self.path == "/api/crew":
body = json.dumps([{"name": "Guybrush"}, {"name": "Elaine"}])
self.respond(200, "application/json", body)
else:
self.respond(404, "text/plain", "Not found")
def respond(self, status, content_type, body):
encoded = body.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", content_type + "; charset=utf-8")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
def log_message(self, *args):
pass # quiet
if __name__ == "__main__":
HTTPServer(("localhost", 8000), Handler).serve_forever()
That is a genuinely working web server in thirty lines, with nothing installed. Run it,
visit http://localhost:8000, and you have served a page. The Rusty School
next door is served by a Rust program of exactly this shape, which is
the capstone project over there.
It is also not something you would deploy: no routing to speak of, one request at a time, no templates, no security. Which is what frameworks are for.
Flask: the small one
from flask import Flask, request, jsonify, render_template_string # pip install flask
app = Flask(__name__)
CREW = [
{"id": 1, "name": "Guybrush", "role": "captain"},
{"id": 2, "name": "Elaine", "role": "governor"},
]
PAGE = """
<!doctype html>
<title>Crew</title>
<h1>The crew of the {{ ship }}</h1>
<ul>{% for member in crew %}<li>{{ member.name }} ({{ member.role }})</li>{% endfor %}</ul>
"""
@app.route("/")
def index():
return render_template_string(PAGE, ship="Sea Monkey", crew=CREW)
@app.route("/api/crew")
def list_crew():
return jsonify(CREW)
@app.route("/api/crew/<int:member_id>")
def get_member(member_id):
for member in CREW:
if member["id"] == member_id:
return jsonify(member)
return jsonify({"error": "no such crew member"}), 404
@app.route("/api/crew", methods=["POST"])
def add_member():
data = request.get_json()
if not data or "name" not in data:
return jsonify({"error": "name is required"}), 400
member = {"id": max(m["id"] for m in CREW) + 1, "name": data["name"],
"role": data.get("role", "deckhand")}
CREW.append(member)
return jsonify(member), 201
if __name__ == "__main__":
app.run(debug=True)
The @app.route decorator should look familiar now: Lesson 35 explained
exactly what it is doing, which is registering your function in a lookup table of paths.
Nothing here is magic any more.
FastAPI: the modern one
from fastapi import FastAPI, HTTPException # pip install "fastapi[standard]"
from pydantic import BaseModel
app = FastAPI(title="Crew API")
class Member(BaseModel):
"""The type hints are the validation, the docs and the parsing."""
name: str
role: str = "deckhand"
insults: int = 0
CREW: dict[int, Member] = {1: Member(name="Guybrush", role="captain", insults=8)}
@app.get("/api/crew")
def list_crew() -> list[Member]:
return list(CREW.values())
@app.get("/api/crew/{member_id}")
def get_member(member_id: int) -> Member:
if member_id not in CREW:
raise HTTPException(status_code=404, detail="no such crew member")
return CREW[member_id]
@app.post("/api/crew", status_code=201)
def add_member(member: Member) -> Member:
new_id = max(CREW) + 1
CREW[new_id] = member
return member
$ fastapi dev main.py
INFO Uvicorn running on http://127.0.0.1:8000
INFO Application startup complete.
# and for free, at http://127.0.0.1:8000/docs :
# a complete interactive API documentation page, generated from your type hints
Look at what those type hints just bought. FastAPI reads them and generates request parsing, validation with proper error messages, JSON serialisation, and a full interactive documentation site.
Send it {\"name\": 42} and it replies with a precise 422 explaining that name must be a string, without you writing a line of validation. This is the strongest practical argument for Lesson 38 that exists.
Choosing
| Framework | Best for | Trade |
|---|---|---|
http.server | Learning, tiny internal tools | You write everything |
| Flask | Small apps, HTML pages, huge ecosystem | Synchronous by default; validation is manual |
| FastAPI | JSON APIs, async, automatic docs | Newer; more concepts to learn |
| Django | Full products: admin, auth, ORM, migrations | Large and opinionated; overkill for an API |
| Litestar, Starlette | Alternatives worth knowing about |
A reasonable default in 2026: FastAPI if you are serving JSON, Django if you are building a product with users and an admin panel, Flask if you want something small that renders HTML.
Templates: HTML with holes in it
from jinja2 import Template # ships with Flask; pip install jinja2 otherwise
template = Template("""
<h1>{{ ship }}</h1>
<ul>
{% for member in crew %}
<li>{{ member.name }}{% if member.captain %} (captain){% endif %}</li>
{% endfor %}
</ul>
<p>{{ crew | length }} aboard.</p>
""")
print(template.render(
ship="Sea Monkey",
crew=[{"name": "Guybrush", "captain": True}, {"name": "Otis", "captain": False}],
))
If a user's name is <script>steal()</script> and you insert it into a page unescaped, you have a cross-site scripting hole. Jinja escapes by default in Flask; the |safe filter turns that off and should be treated as a loaded weapon. Never build HTML with f-strings and user input.
Where the danger actually is
- SQL injection. Never build queries with f-strings. Lesson 45 shows the parameterised form.
- Cross-site scripting. Use a template engine and leave escaping on.
- Secrets in code. Environment variables, as in Lesson 42.
debug=Truein production. Flask's debugger lets anyone who triggers an error run arbitrary Python on your server. It is off by default for a reason.- Trusting any input. Validate at the boundary. Pydantic does this for you, which is most of why FastAPI exists.
Getting it online
# development
fastapi dev main.py
# production: a real server process, several workers
pip install "uvicorn[standard]"
uvicorn main:app --host 0.0.0.0 --port 8000 --workers 4
# in front of it, nginx or Caddy for TLS and static files
| Where | Good for | Cost |
|---|---|---|
| A small VPS | Full control, learning how it all fits | A few pounds a month, and you patch it |
| Fly.io, Railway, Render | Push and it deploys | Free tiers exist; they sleep when idle |
| A serverless platform | Bursty traffic, no servers to run | Cold starts, and a different mental model |
| Cloudflare Pages / Workers | Static sites and edge functions | Free tier, and how this school is hosted |
The site you are reading is static HTML on Cloudflare Pages, with a handful of small serverless functions for progress sync and the anonymous completion counter. Free tier, no server to patch, and the whole thing is in a public repository you can read. Static-first with a small dynamic edge is a genuinely good default for a personal project.
A JSON API from the standard library
Extend the plain http.server example with a /api/time endpoint returning JSON, and a 404 that is also JSON.
Reveal solution
import json
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, HTTPServer
ROUTES = {}
def route(path):
"""A tiny decorator, exactly like Flask's, so you can see there is no magic."""
def register(func):
ROUTES[path] = func
return func
return register
@route("/api/time")
def current_time():
return {"utc": datetime.now(timezone.utc).isoformat(), "ok": True}
@route("/api/crew")
def crew():
return {"crew": ["Guybrush", "Elaine"]}
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
handler = ROUTES.get(self.path)
status = 200 if handler else 404
body = handler() if handler else {"error": "not found", "path": self.path}
encoded = json.dumps(body).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
def log_message(self, *args):
pass
# Proving the routing works without starting a server:
print(json.dumps(ROUTES["/api/crew"]()))
print(sorted(ROUTES))
{"crew": ["Guybrush", "Elaine"]}
['/api/crew', '/api/time']Writing your own @route decorator is the moment Flask stops being magic. It is a dictionary from paths to functions, and that is genuinely all a router is.
Find the security holes
Three serious problems. Name them.
from flask import Flask, request
import sqlite3
app = Flask(__name__)
@app.route("/search")
def search():
term = request.args.get("q")
conn = sqlite3.connect("shop.db")
rows = conn.execute(f"SELECT * FROM products WHERE name LIKE '%{term}%'").fetchall()
return f"<h1>Results for {term}</h1>" + "".join(f"<p>{r}</p>" for r in rows)
app.run(debug=True, host="0.0.0.0")Reveal solution
- SQL injection. The query is built with an f-string, so
?q=' OR 1=1 --returns the whole table and worse is possible. Useconn.execute("... LIKE ?", (f"%{{term}}%",)). - Cross-site scripting.
termgoes straight into the HTML, so?q=<script>...</script>executes in every visitor's browser. Render a template and let it escape. debug=Trueon0.0.0.0. That exposes an interactive Python console to the entire network. This is a remote code execution hole, deliberately, for development only.
Bonus: the connection is never closed, and there is no error handling if the database file is missing.
Design the endpoints
Design a REST API for a to-do list, before writing code. What paths, what methods, what status codes?
Reveal solution
GET /api/tasks 200 list all, supports ?done=true&limit=20
POST /api/tasks 201 create one, returns it with its new id
400 if the body is invalid
GET /api/tasks/{id} 200 one task
404 if there is no such id
PUT /api/tasks/{id} 200 replace it entirely
PATCH /api/tasks/{id} 200 change some fields, eg {"done": true}
DELETE /api/tasks/{id} 204 deleted, no body to return
404 if it was not thereThe conventions worth absorbing: plural nouns for collections, the id in the path rather than a query parameter, the method carries the verb so the URL never contains /deleteTask, 201 for creation with the new object in the body, and 204 for a successful delete with nothing to say. Following them means other developers can guess your API correctly.