Hotwire Turbo integration for FastAPI — turbo-stream responses, frame detection, and test helpers.
Project description
fastapi-turbo
fastapi-turbo brings Hotwire Turbo to FastAPI. Render targeted DOM updates with <turbo-stream> responses, serve partials for <turbo-frame> requests, and validate forms in place — all from your existing FastAPI handlers, with no JSON layer or client-side framework. Ships with pytest helpers and a Protocol-based design so you can swap template engines or session backends.
Inspired by fastapi-hotwire.
Install
pip install fastapi-turbo
# or
uv add fastapi-turbo
For the Pydantic-backed validation-error stream:
pip install "fastapi-turbo[forms]"
Quickstart
from fastapi import FastAPI, Form, Request
from fastapi_turbo import TurboStreamResponse, TurboTemplates, streams
app = FastAPI()
templates = TurboTemplates(directory="templates")
todos: list[dict] = []
@app.get("/")
def index(request: Request):
return templates.TemplateResponse(request, "index.html", {"todos": todos})
@app.post("/todos")
def create(request: Request, text: str = Form(...)):
todo = {"id": len(todos) + 1, "text": text}
todos.append(todo)
return templates.render_stream(
request,
"_todo_row.html",
action="append",
target="todos",
todo=todo,
)
@app.post("/todos/{todo_id}/delete")
def delete(todo_id: int):
todos[:] = [t for t in todos if t["id"] != todo_id]
return TurboStreamResponse(streams.remove(target=f"todo-{todo_id}"))
_todo_row.html is an ordinary partial template, {% include %}-ed by the page and rendered alone for streams. A complete runnable version lives in examples/minimal/.
What's in the box
| Module | What it does |
|---|---|
TurboStreamResponse |
A Response subclass with Content-Type: text/vnd.turbo-stream.html. |
streams |
Builders for <turbo-stream> actions — the eight built-ins, method="morph", and custom actions via streams.stream(...). |
TurboContext |
A FastAPI dependency that summarizes how the current request relates to Turbo (frame request? stream-capable?), plus accepts_turbo_stream(request) for imperative checks. |
TurboTemplates |
A Jinja2Templates wrapper that adds render_fragment(...) and render_stream(...), plus an automatic turbo_script template global. |
turbo_script |
One line in <head> loads Turbo from the CDN (or your own URL) and exposes window.Turbo. |
forms |
A Pydantic ValidationError → turbo-stream renderer for in-place form validation. |
testing |
pytest assertions and request helpers (assert_turbo_stream, parse_streams, assert_turbo_frame, turbo_frame_request, turbo_stream_request). |
TurboStreamResponse
from fastapi_turbo import TurboStreamResponse, streams
@app.post("/items")
def create():
return TurboStreamResponse(
[
streams.append(item_html, target="items"),
streams.update(counter_html, target="item-count"),
]
)
Pass a single string, a list of strings, or None. The class also works as response_class=TurboStreamResponse so OpenAPI documents the media type.
streams
Each builder returns a markupsafe.Markup so it composes safely with Jinja templates:
from fastapi_turbo import streams
streams.append("<li>...</li>", target="items")
streams.replace(form_html, target="contact-form", method="morph") # Turbo 8 morphing
streams.remove(target="item-42")
streams.refresh(request_id=request.headers.get("x-turbo-request-id"))
The named builders encode each action's requirements in their signatures (remove takes no content, refresh takes neither content nor selector), while the generic streams.stream(action, ...) is permissive and works for custom actions too:
streams.stream("highlight", target="item-7")
# <turbo-stream action="highlight" target="item-7"><template></template></turbo-stream>
# pairs with:
# Turbo.StreamActions.highlight = function () {
# this.targetElements.forEach((el) => el.classList.add("highlight"));
# };
The html argument is interpolated verbatim into the <template> envelope. It must be safe HTML (Jinja autoescaped output is safe). Attribute values (action=, target=, targets=, method=, request-id=) are HTML-escaped automatically.
TurboContext
from typing import Annotated
from fastapi import Depends
from fastapi_turbo import TurboContext, turbo_context
@app.post("/items")
async def create(turbo: Annotated[TurboContext, Depends(turbo_context)]):
if turbo.is_frame_request:
return frame_response(...)
if turbo.accepts_stream:
return stream_response(...)
return full_page_response(...)
Fields: is_frame_request, frame_request_id, accepts_stream.
accepts_stream is capability detection with real Accept-header parsing: it's true only when the client explicitly lists text/vnd.turbo-stream.html with a non-zero q-value. Accept: */* API clients stay on the HTML path, and ;q=0 is honored as an opt-out. The standalone accepts_turbo_stream(request) function exposes the same check for imperative use.
TurboTemplates
from fastapi_turbo import TurboTemplates
templates = TurboTemplates(directory="templates")
@app.get("/items/{id}")
def item_frame(request: Request, id: int):
# Render a partial that carries the matching <turbo-frame> — useful
# for responding to a <turbo-frame src="..."> request.
return templates.render_fragment(request, "partials/item.html", item=load(id))
@app.post("/items")
def create(request: Request, text: str = Form(...)):
item = save(text)
# Render a partial as a turbo-stream that appends to #items.
return templates.render_stream(
request,
"partials/item.html",
action="append",
target="items",
item=item,
)
turbo_script
<head>
{{ turbo_script() }}
</head>
Renders a <script type="module"> that imports Turbo (pinned to the version this library is tested against) and exposes window.Turbo for custom stream-action registrations. TurboTemplates registers it as a Jinja global automatically; pass version= to pin differently or url= to load a self-hosted/vendored build:
turbo_script(version="8.0.23")
turbo_script(url="/static/vendor/turbo.js")
Fragments: files, not blocks
fastapi-turbo renders whole template files; how you carve fragments out of your pages is your business. Partial files are the recommended default — they can be shared between full-page {% include %}s and stream/frame responses. If you prefer keeping fragments inside one page template with {% block %}, render the block to a string with jinja2-fragments and hand it to the stream builders:
from jinja2_fragments import render_block
html = render_block(templates.env, "items.html", "item_row", item=item)
return TurboStreamResponse(streams.append(html, target="items"))
(Heads-up: jinja2-fragments passes context as **kwargs, so context keys that collide with its positional parameter names — e.g. environment — need renaming.)
forms
from fastapi_turbo.forms import validation_error_stream
# Render Pydantic validation errors as a turbo-stream that replaces
# only the form\'s partial — no full-page reload, no scroll loss.
try:
Contact.model_validate(form_data)
except ValidationError as exc:
return validation_error_stream(
exc,
templates=templates,
template="partials/contact_form.html",
target="contact-form",
request=request,
)
testing
from fastapi_turbo.testing import (
assert_turbo_stream,
parse_streams,
assert_turbo_frame,
turbo_frame_request,
turbo_stream_request,
)
def test_create_appends_a_row(client):
resp = client.post("/todos", data={"text": "ship"})
assert_turbo_stream(resp)
actions = parse_streams(resp)
assert actions[0].action == "append"
assert actions[0].target == "todos"
Pluggability
fastapi_turbo.protocols defines the integration seams:
TemplateRenderer— anything implementingrender(name, context) -> str.SessionLike— anyMutableMapping[str, Any]that hangs offrequest.session(StarletteSessionMiddleware,starsessions, an in-memorydict).
You don't need to use the bundled Jinja2 / Starlette code paths to use this library.
Examples
A full runnable example lives under examples/:
examples/minimal/— A todo list with turbo-stream append + remove. The simplest possible integration.
The example's own README has the exact uv run --with ... command — the packages needed beyond fastapi-turbo itself (an ASGI server, python-multipart for form parsing, etc.).
Roadmap
- Turbo Stream broadcasts over SSE — signed stream topics, a pluggable broker, and the Turbo 8
refresh+request-iddedup pattern.
Non-goals
fastapi-turbo deliberately does not:
- Bundle a Stimulus JavaScript distribution — load Stimulus the way you load any other JS.
- Dictate your fragment strategy — partial files, Jinja blocks, or hand-built strings all feed the same stream builders.
- Inject logging / observability / tracing — those are application concerns, not library concerns.
- Replace
request.url_for(...)with anything more magical.
Contributing
See CONTRIBUTING.md. Issues and PRs welcome — please file an issue first for anything bigger than a typo so we can align on scope.
This project follows the Contributor Covenant Code of Conduct.
License
MIT fastapi-turbo began as a rename-and-refocus of fastapi-hotwire.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file fastapi_turbo-0.1.0.tar.gz.
File metadata
- Download URL: fastapi_turbo-0.1.0.tar.gz
- Upload date:
- Size: 61.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
0d776a56998ea51e67b2654b96c125ec60308ba95aa0484c20932655226ce628
|
|
| MD5 |
8f62568444045eb916487ac0c8965094
|
|
| BLAKE2b-256 |
433a32cfd995cd2a7486a9986e9265696ee51d3481e36a839a450f27746b7bcd
|
Provenance
The following attestation bundles were made for fastapi_turbo-0.1.0.tar.gz:
Publisher:
release.yml on bhtabor/fastapi-turbo
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
fastapi_turbo-0.1.0.tar.gz -
Subject digest:
0d776a56998ea51e67b2654b96c125ec60308ba95aa0484c20932655226ce628 - Sigstore transparency entry: 2269253318
- Sigstore integration time:
-
Permalink:
bhtabor/fastapi-turbo@7ef239db5f1e38f884cebab6428da26f185ac338 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/bhtabor
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@7ef239db5f1e38f884cebab6428da26f185ac338 -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file fastapi_turbo-0.1.0-py3-none-any.whl.
File metadata
- Download URL: fastapi_turbo-0.1.0-py3-none-any.whl
- Upload date:
- Size: 19.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
d9a917c13e825260c50e3a4126ebd881833ecb30ceaa2e5e72c516df51842203
|
|
| MD5 |
12fe80bc5a8f741ac4d24bd3885ca377
|
|
| BLAKE2b-256 |
629bfc31ad97158c6c763b18b6620101db64fed845ec0a4a8156f1dccd0e81d0
|
Provenance
The following attestation bundles were made for fastapi_turbo-0.1.0-py3-none-any.whl:
Publisher:
release.yml on bhtabor/fastapi-turbo
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
fastapi_turbo-0.1.0-py3-none-any.whl -
Subject digest:
d9a917c13e825260c50e3a4126ebd881833ecb30ceaa2e5e72c516df51842203 - Sigstore transparency entry: 2269253446
- Sigstore integration time:
-
Permalink:
bhtabor/fastapi-turbo@7ef239db5f1e38f884cebab6428da26f185ac338 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/bhtabor
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@7ef239db5f1e38f884cebab6428da26f185ac338 -
Trigger Event:
workflow_dispatch
-
Statement type: