Server-side rendering in pure Python, part 1: from data to HTML
Build a real server-side-rendered page in ~45 lines of Python — standard library only, no framework. You'll serve HTML built from data, filter it with a query parameter, and prove it's SSR with view-source. Part 1 of 2: here the data is a fixed list; part 2 swaps it for PostgreSQL.
mindmap — quick refresh
Prerequisites: Python 3 installed, plus the ideas from How HTTP servers work and SSR vs CSR. Time: ~30 minutes.
Server-side rendering sounds like a framework feature. It isn't — it's just building a string of HTML from data, on the server, per request. Today you'll do it with nothing but Python's standard library, so there's nowhere for the magic to hide.
What "rendering" literally is
Before the server, understand the core move: a template with holes, data, and a merge:
That's the entire trick. Real template engines (Django's, Jinja2) add loops, inheritance, and — critically — escaping (more on that at the end), but the mental model stays: template + data → HTML string.
The server, complete
Save this as server.py — it's the whole lab, ~45 lines, standard library only:
# server.py — server-side rendering with only the standard library
from http.server import HTTPServer, BaseHTTPRequestHandler
from urllib.parse import urlparse, parse_qs
STUDENTS = [
{"id": 1, "name": "Asha", "course": "AI"},
{"id": 2, "name": "Ravi", "course": "AI"},
{"id": 3, "name": "Meena", "course": "Data Science"},
]
PAGE = """<!doctype html>
<html>
<head><title>Students</title></head>
<body>
<h1>Students — {course}</h1>
<ul>
{rows}
</ul>
</body>
</html>"""
def render(course):
wanted = [s for s in STUDENTS if course in ("all", s["course"])]
rows = "\n".join(
f' <li>{s["id"]} — {s["name"]} ({s["course"]})</li>' for s in wanted
)
return PAGE.format(course=course, rows=rows)
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
url = urlparse(self.path)
if url.path != "/students":
self.send_error(404, "try /students")
return
course = parse_qs(url.query).get("course", ["all"])[0]
body = render(course).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
if __name__ == "__main__":
print("serving on http://127.0.0.1:8000/students")
HTTPServer(("127.0.0.1", 8000), Handler).serve_forever()
Run it and visit the page:
python3 server.py
# → http://127.0.0.1:8000/studentsRead it as the server loop
Map the code onto the loop from How HTTP servers work — every stage is here:
- accept + parse —
http.serverdoes both and callsdo_GETwithself.pathfilled in. - route — our humble
if url.path != "/students"is the routing table (withsend_error(404)as the fall-through). - handler —
render(course): filter the data, build rows, fill the template. This is the dynamic lane from the static-vs-dynamic simulator, and the SSR lane from the race. - respond — status,
Content-Type,Content-Length, body. The exact anatomy from the HTTP guide, typed by hand.
Notice ?course=AI — visit http://127.0.0.1:8000/students?course=AI and the server renders a different page from the same template. That per-request variation is what makes it dynamic.
Prove it's server-side
Three experiments, in order of persuasiveness:
- View-source (
Ctrl+U/Cmd+Option+U): Asha and Ravi are right there in the HTML. The browser received finished content. - Disable JavaScript in DevTools and reload: the page is identical. There is no JS to depend on.
curlit — a program with no rendering engine at all sees the full content:
curl "http://127.0.0.1:8000/students?course=AI"
Now do experiment 1 on a CSR app (any big dashboard-style site): the source shows a nearly-empty <div>. You can see the strategy difference you learned in SSR vs CSR.
One warning before you go: escaping
Our render() pastes data into HTML raw. If a student's name were <script>alert(1)</script>, we'd be injecting code into every visitor's browser — the classic XSS vulnerability. The stdlib fix is one function: pass every value through html.escape() from the html module. Real template engines escape by default, and that — not convenience — is the serious reason to use one in production.
Exercises
- Add a column: give each student a
year, show it in the row. - Add a route:
/coursesshould render the list of distinct courses (hint: another template string + a secondifindo_GET). - Escape it: import
htmland wrap every interpolated value inhtml.escape(). Then add a student named<b>Bold</b>and confirm it displays as text instead of becoming markup. - Break the loop (from the servers guide): add
time.sleep(5)insiderender()and open the page in two tabs at once. Which tab suffers, and why? (HTTPServeris single-threaded; tryThreadingHTTPServerand watch the difference.)
What's next
One honest limitation remains: STUDENTS is a list frozen into the source code — changing the data means editing the program and restarting it. Real applications render from a database. That's exactly part 2: same server, same template, but the rows come live from PostgreSQL.
References
- MDN Web Docs. (n.d.). Introduction to the server side. Mozilla. Retrieved August 16, 2026, from https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Server-side/First_steps/Introduction
- OWASP Foundation. (n.d.). Cross site scripting prevention cheat sheet. Retrieved August 16, 2026, from https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html
- Python Software Foundation. (n.d.). http.server — HTTP servers (Python documentation). Retrieved August 16, 2026, from https://docs.python.org/3/library/http.server.html
- Python Software Foundation. (n.d.). html — HyperText Markup Language support (Python documentation). Retrieved August 16, 2026, from https://docs.python.org/3/library/html.html