Skip to main content

Hotwire Turbo integration for FastAPI — turbo-stream responses, frame detection, and test helpers.

Project description

fastapi-turbo

PyPI Python CI License: MIT

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 implementing render(name, context) -> str.
  • SessionLike — any MutableMapping[str, Any] that hangs off request.session (Starlette SessionMiddleware, starsessions, an in-memory dict).

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-id dedup 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

fastapi_turbo-0.1.0.tar.gz (61.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

fastapi_turbo-0.1.0-py3-none-any.whl (19.2 kB view details)

Uploaded Python 3

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

Hashes for fastapi_turbo-0.1.0.tar.gz
Algorithm Hash digest
SHA256 0d776a56998ea51e67b2654b96c125ec60308ba95aa0484c20932655226ce628
MD5 8f62568444045eb916487ac0c8965094
BLAKE2b-256 433a32cfd995cd2a7486a9986e9265696ee51d3481e36a839a450f27746b7bcd

See more details on using hashes here.

Provenance

The following attestation bundles were made for fastapi_turbo-0.1.0.tar.gz:

Publisher: release.yml on bhtabor/fastapi-turbo

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for fastapi_turbo-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 d9a917c13e825260c50e3a4126ebd881833ecb30ceaa2e5e72c516df51842203
MD5 12fe80bc5a8f741ac4d24bd3885ca377
BLAKE2b-256 629bfc31ad97158c6c763b18b6620101db64fed845ec0a4a8156f1dccd0e81d0

See more details on using hashes here.

Provenance

The following attestation bundles were made for fastapi_turbo-0.1.0-py3-none-any.whl:

Publisher: release.yml on bhtabor/fastapi-turbo

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page