FastAPI is best known as a framework for building JSON APIs, but it also supports server-side rendering through its integration with Jinja2—the same templating engine used by Flask and Django. By returning TemplateResponse objects instead of JSON, FastAPI routes can render full HTML pages, partial fragments, or dynamic dashboard widgets directly on the server. This makes it straightforward to build interactive web applications without a separate frontend build pipeline or a JavaScript framework.
An internal dashboard project demonstrates this pattern in practice: FastAPI handles async route handlers that query databases and call external APIs, Jinja2 templates render the resulting data into structured HTML, and HTMX intercepts user interactions to swap in partial template fragments without full-page reloads. The result is a responsive dashboard that feels interactive but is built almost entirely in Python and HTML—with no custom JavaScript required for most UI interactions.
Server-side rendering with FastAPI and Jinja2 is an increasingly relevant approach for internal tools, admin dashboards, and data-heavy applications where the developer team is Python-centric. It avoids the complexity of maintaining a separate JavaScript frontend while still delivering a polished, dynamic user experience when combined with HTMX for partial updates.
Jinja2 is a Python templating engine that renders HTML by interpolating variables, iterating over collections, and applying filters within {{ }} and {% %} tags. Templates inherit from base layouts using {% extends %} and {% block %}, enabling consistent page structure with minimal repetition. Jinja2’s auto-escaping protects against XSS by default when rendering HTML content.
FastAPI integrates with Jinja2 through the Jinja2Templates class from starlette.templating. A route returns a TemplateResponse by passing the template filename, the HTTP Request object (required for URL generation and middleware context), and a dictionary of variables. FastAPI handles content negotiation, header setting, and streaming the rendered output to the client.
FastAPI routes defined with async def run on the event loop without blocking, making it efficient to await database queries, external API calls, or cache lookups before rendering a template. Unlike synchronous frameworks, a single FastAPI process can handle many concurrent dashboard requests—each waiting on I/O—without spawning additional threads or processes.
FastAPI handles HTML form submissions through Form parameters declared in the route signature. When a browser submits a <form> with method="post", FastAPI reads the application/x-www-form-urlencoded or multipart/form-data body and injects the values as typed Python arguments. Validation errors can be caught and re-rendered into the form template with user-friendly messages.
HTMX is a lightweight JavaScript library that adds HTTP request behavior to standard HTML attributes. By adding hx-get, hx-post, and hx-target attributes to HTML elements, the browser sends AJAX requests to FastAPI endpoints and swaps the response HTML into the page. FastAPI routes detect HTMX requests via the HX-Request header and return partial template fragments instead of full pages, enabling seamless partial updates.
The setup requires installing jinja2 and python-multipart alongside FastAPI. The Jinja2Templates instance is configured with the directory where templates live, and static files (CSS, JS) are mounted as a StaticFiles app on a dedicated URL path.
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
app = FastAPI()
# Mount static assets
app.mount("/static", StaticFiles(directory="static"), name="static")
# Configure Jinja2 template directory
templates = Jinja2Templates(directory="templates")
@app.get("/", response_class=HTMLResponse)
async def dashboard_home(request: Request):
# Fetch dashboard data asynchronously
metrics = await fetch_summary_metrics()
recent_events = await fetch_recent_events(limit=20)
return templates.TemplateResponse("dashboard/index.html", {
"request": request,
"metrics": metrics,
"events": recent_events,
"page_title": "Dashboard",
})
Jinja2 template inheritance lets you define a single base layout that all pages extend. The base template defines {% block %} slots that child templates fill in. This ensures consistent navigation, CSS imports, and page structure across every route without repeating HTML.
{# templates/base.html #}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{% block title %}Dashboard{% endblock %} — My App</title>
<link rel="stylesheet" href="{{ url_for('static', path='css/app.css') }}">
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
<nav>{% include "partials/nav.html" %}</nav>
<main>{% block content %}{% endblock %}</main>
</body>
</html>
{# templates/dashboard/index.html #}
{% extends "base.html" %}
{% block title %}Overview{% endblock %}
{% block content %}
<div class="metrics-grid">
{% for metric in metrics %}
<div class="metric-card">
<span class="metric-label">{{ metric.label }}</span>
<span class="metric-value">{{ metric.value | format_number }}</span>
</div>
{% endfor %}
</div>
<div id="events-table"
hx-get="/partials/events"
hx-trigger="every 30s"
hx-swap="innerHTML">
{% include "partials/events_table.html" %}
</div>
{% endblock %}
FastAPI processes HTML form submissions by declaring Form parameters. The python-multipart package must be installed for FastAPI to parse form bodies. After processing, routes typically redirect (Post/Redirect/Get pattern) to prevent duplicate submissions on browser refresh, or return a partial template for HTMX-driven forms.
from fastapi import Form, status
from fastapi.responses import RedirectResponse
@app.post("/alerts/create")
async def create_alert(
request: Request,
name: str = Form(...),
threshold: float = Form(...),
metric: str = Form(...),
notify_email: str = Form(...),
):
try:
await alert_service.create({
"name": name,
"threshold": threshold,
"metric": metric,
"notify_email": notify_email,
})
# Post/Redirect/Get: redirect after successful POST
return RedirectResponse(url="/alerts", status_code=status.HTTP_303_SEE_OTHER)
except ValidationError as e:
# Re-render the form with error context
return templates.TemplateResponse("alerts/create.html", {
"request": request,
"errors": e.errors(),
"form_data": {"name": name, "threshold": threshold,
"metric": metric, "notify_email": notify_email},
}, status_code=422)
Routes that serve HTMX requests return only the HTML fragment that HTMX will swap into the page—not a full page with <html> and <head> tags. A helper function inspects the HX-Request header to choose between full-page and partial rendering from the same route, keeping URLs clean and bookmarkable.
def is_htmx(request: Request) -> bool:
return request.headers.get("HX-Request") == "true"
@app.get("/partials/events", response_class=HTMLResponse)
async def events_partial(request: Request, page: int = 1, filter: str = ""):
events = await event_service.list(page=page, filter=filter)
template = "partials/events_table.html"
return templates.TemplateResponse(template, {
"request": request,
"events": events,
"current_page": page,
"filter": filter,
})
@app.get("/events", response_class=HTMLResponse)
async def events_page(request: Request, page: int = 1, filter: str = ""):
if is_htmx(request):
# HTMX navigation: return only the page body fragment
return await events_partial(request, page=page, filter=filter)
events = await event_service.list(page=page, filter=filter)
return templates.TemplateResponse("events/index.html", {
"request": request,
"events": events,
"current_page": page,
"filter": filter,
})
An internal dashboard project uses this pattern to build an operational dashboard for monitoring pipeline health and job status. Each dashboard panel is a Jinja2 template partial that renders independently—HTMX polls the corresponding FastAPI endpoint every 30 seconds and swaps in fresh data without reloading the page. The async route handlers concurrently await multiple data sources (PostgreSQL for job records, Redis for counters, an external metrics API) and merge the results before rendering, keeping latency low even when pulling from several backends.
Form-driven workflows in the dashboard—creating alerts, configuring data sources, triggering manual pipeline runs—use the Post/Redirect/Get pattern for full-page forms and HTMX inline forms for lower-friction interactions like toggling a setting or acknowledging an alert. The same FastAPI endpoint handles both cases: a standard POST returns a redirect, while an HTMX POST returns a rendered confirmation fragment that replaces just the form element.
Register custom filters on the templates.env object to format data directly in templates without cluttering route handlers. Common examples include number formatting, relative timestamps, and status badge rendering. Globals inject values available in every template—the current user, feature flags, or environment name—without passing them explicitly in every TemplateResponse call.
from datetime import datetime, timezone
def format_number(value: float) -> str:
if value >= 1_000_000:
return f"{value / 1_000_000:.1f}M"
if value >= 1_000:
return f"{value / 1_000:.1f}K"
return str(int(value))
def relative_time(dt: datetime) -> str:
delta = datetime.now(timezone.utc) - dt
if delta.seconds < 60:
return "just now"
if delta.seconds < 3600:
return f"{delta.seconds // 60}m ago"
return f"{delta.seconds // 3600}h ago"
# Register on the Jinja2 environment
templates.env.filters["format_number"] = format_number
templates.env.filters["relative_time"] = relative_time
templates.env.globals["app_env"] = settings.ENVIRONMENT
FastAPI’s dependency injection system works with template routes just as well as JSON routes. Define a dependency that resolves the current authenticated user from the session cookie or JWT header, then declare it in the route signature. The user object becomes available to inject into the template context for personalized rendering and access control.
Cache-bust static assets by appending a content hash or build timestamp to filenames. Create a Jinja2 global or filter that maps logical asset names to their versioned paths. In development, serve assets with StaticFiles directly; in production, point the same URL path at a CDN-backed storage bucket containing the pre-built, versioned assets.
request parameter from TemplateResponse: Starlette’s TemplateResponse requires the Request object in the context dictionary under the key "request". Omitting it raises a runtime error—even if the template doesn’t reference request directly—because Jinja2 URL generation helpers depend on it.async def handlers blocks the event loop and negates FastAPI’s concurrency benefits. Use asyncio.to_thread() or switch to an async driver (e.g., asyncpg, motor) for I/O-bound work.<html>, <head>, <body> tags) in response to an HTMX swap request, the browser will insert that entire document into the target element—creating nested <html> tags and breaking layout. Always return bare fragments to HTMX requests.SameSite=Strict cookies at minimum. HTMX requests are not immune—they still originate from the browser and carry cookies.url_for: Hardcoded paths break when the app is mounted at a sub-path (e.g., behind a reverse proxy at /dashboard/). Always generate URLs with {{ url_for('route_name', **params) }} so they resolve correctly regardless of the deployment prefix.