Skip to main content

pyjoist

A server-side component architecture for Jinja2 + HTMX applications.

pip install pyjoist — import as joist (the package name on PyPI differs from the import name).

joist introduces a component layer between your routes and your templates. Components are typed dataclasses that map to Jinja2 templates. Builder functions assemble them into trees. Routes just load data and render.

Routes (load data)
    ↓
Builders (assemble component trees)    ── joist lives here
    ↓
Templates (render markup)

joist ships as a single architectural primitive — Component = dataclass + template — plus HTMX utilities and optional FastAPI integration. There is no framework, no lifecycle, and no state management. Render and forget.


Why this exists

Jinja works well for server-rendered pages, and HTMX works well for incremental HTML updates. But once an application mixes full-page rendering, partial swaps, modal content, and out-of-band updates, the template layer becomes hard to manage.

Common problems:

  • Unstructured context dictionaries. Route handlers assemble large, untyped dicts of template variables. Required fields are implicit, relationships are hard to see, and refactoring is risky.
  • Full-page and partial rendering drift apart. The same UI element may be rendered by two different templates — one for the initial page load, another for an HTMX swap. Markup diverges over time.
  • Reusable fragments are awkward. Avatars, badges, cards, and modals must be duplicated across templates or assembled with Jinja macros that mix logic with markup.
  • Route handlers become view assemblers. Instead of focusing on HTTP and data loading, routes shape nested context structures, duplicate view logic, and decide between fragment vs. page rendering.

joist solves these problems with a lightweight component layer:

  • Each UI fragment becomes a typed Python object with an explicit data contract.
  • Builders (plain functions) assemble components into trees.
  • The same component and template serve both full-page and partial rendering through the same code path.
  • Templates stay thin — they render their own markup and place children, nothing more.

Quick start

pip install pyjoist
# with FastAPI integration:
pip install "pyjoist[fastapi]"

1. Define a component

Components are @dataclass subclasses of Component. Fields become template variables automatically.

# components.py
from dataclasses import dataclass
from typing import ClassVar
from joist import Component


@dataclass(kw_only=True)
class UserCard(Component):
    template_path: ClassVar = "user_card.html"
    name: str = ""
    email: str = ""
    role: str = ""

    def get_props(self):
        return {"initial": self.name[0] if self.name else "?"}

2. Write its template

<!-- templates/user_card.html -->
<div class="card">
  <div class="avatar">{{ initial }}</div>
  <h3>{{ name }}</h3>
  <p class="email">{{ email }}</p>
  <span class="badge">{{ role }}</span>
</div>

3. Build a component tree with a builder function

Builders are ordinary Python functions. They receive domain data and return component instances. This is where UI composition happens.

# builders.py
from joist import Page
from components import UserCard


def build_profile_page(user: dict, stats: dict) -> Page:
    profile = UserCard(
        name=user["name"],
        email=user["email"],
        role=user["role"],
    )
    # Page.content accepts a Component directly (auto-rendered).
    # Equivalent to: Page(page_title=..., content=profile.render_html())
    return Page(
        page_title=f"Profile — {user['name']}",
        content=profile,
    )

4. Wire up the route

Routes load data, call a builder, and return the result. They do not assemble context dicts or choose between fragment/page rendering — that is handled by the framework integration.

# app.py
from fastapi import FastAPI, Request
from joist.integrators.fastapi import setup_fastapi

app = FastAPI()
joist = setup_fastapi(app, template_dirs=["templates"])


@app.get("/users/{uid}")
async def user_profile(request: Request, uid: str):
    user = load_user(uid)          # data loading
    stats = load_user_stats(uid)   # data loading
    page = build_profile_page(user, stats)  # builder — pure composition
    return joist.fragment_or_page(request, "Profile", page)

Architecture

joist divides UI construction into three distinct layers:

Routes

Routes handle HTTP concerns: request parsing, authentication, authorization, and data loading. They call builders and return the result. Routes do not construct template context dicts or decide between fragment and page rendering.

@router.get("/items")
async def list_items(request: Request):
    items = await load_items()
    return joist.fragment_or_page(
        request,
        "Items",
        build_item_table(items),
    )

Builders

Builders are the composition layer. They take domain data and produce component instances. Builders can be tested independently, reused across different routes, and composed with each other.

def build_item_table(items: list[Item]) -> Table:
    """Builder — assembles a table from domain data."""
    rows = [build_item_row(item) for item in items]
    return Table(
        title=f"{len(items)} items",
        rows=rows,
    )


def build_item_row(item: Item) -> Row:
    """Builder — assembles a single row."""
    return Row(
        name=item.name,
        status="active" if item.is_active else "archived",
        price=f"${item.price:.2f}",
    )

Templates

Templates render their own markup and place children. They receive typed data from component fields. They do not fetch data, compute values, or make rendering decisions.

<!-- table.html -->
<section>
  <h2>{{ title }}</h2>
  <div class="table-wrap">
    {% for row in rows %}
    {{ row }}  {# pre-rendered Markup — no |safe needed #}
    {% endfor %}
  </div>
</section>

Core API

Component — base class

Subclass with @dataclass(kw_only=True). Dataclass fields become template context. Override get_props() for computed properties.

@dataclass(kw_only=True)
class MyComponent(Component):
    template_path: ClassVar = "my_template.html"
    count: int = 0

    def get_props(self):
        return {"label": f"Count: {self.count}"}
Method Returns Purpose
render_html() Markup Safe HTML markup for embedding
render() str Plain HTML string
props() dict Full template context (for testing/debugging)
has_any bool Whether component has content (override to skip empty states)

Nested Component instances are automatically pre-rendered to Markup. Templates use {{ child }} directly.

Fragment — HTMX partials

Fragment is identical to Component except template_path is an instance field. This makes it convenient for inline use in HTMX partial routes.

from joist import Fragment

# Inline — no subclass needed
frag = Fragment(
    template_path="users/_row.html",
    name=user.name,
    role=user.role,
)

Or subclass for reuse:

@dataclass(kw_only=True)
class UserRow(Fragment):
    template_path: str = "users/_row.html"
    user_id: str = ""
    name: str = ""
    role: str = ""

Page — document shell

Page wraps content in a <!DOCTYPE html> document. Ships with a minimal built-in template that you can override or replace by subclassing.

from joist import Page

page = Page(
    page_title="Users",
    content=table.render_html(),
    head_extras=Markup('<link rel="stylesheet" href="app.css">'),
)

HTMX

from joist.htmx import (
    is_hx_request,           # bool: is this an HTMX request?
    HX,                      # response header builders
    render_page_or_fragment, # one route for both HTMX and direct
)
HX.* call Header produced
HX.trigger("evt") HX-Trigger: evt
HX.trigger("evt", {...}) HX-Trigger: {"evt": {...}}
HX.trigger("evt", after="swap") HX-Trigger-After-Swap
HX.retarget("#el") HX-Retarget: #el
HX.redirect("/url") HX-Redirect: /url
HX.refresh() HX-Refresh: true
HX.push_url("/url") HX-Push-Url: /url
HX.replace_url("/url") HX-Replace-Url: /url
HX.reselect(".sel") HX-Reselect: .sel

render_page_or_fragment(request, page_title, content) returns a dict that your framework integration uses to produce the right kind of response — fragment for HTMX, full page for direct navigation.


Framework integration

FastAPI

from joist.integrators.fastapi import setup_fastapi

app = FastAPI()
joist = setup_fastapi(app, template_dirs=["templates"])

# Methods on the joist helper:
joist.response(component)                          # bare HTML
joist.page_response(request, "Title", component)    # full page shell
joist.fragment_or_page(request, "Title", component) # HTMX-aware

Any framework

The core has no framework dependency. Use render() to get an HTML string and return it however your framework expects:

from joist import Component

html = MyComponent(name="test").render()

# Works with Django, Flask, aiohttp, etc.:
return HttpResponse(html)

Example: three layers in practice

# ── 1. Components (data contracts) ─────────────────────────────────
@dataclass(kw_only=True)
class ArticleCard(Component):
    template_path: ClassVar = "article_card.html"
    title: str = ""
    summary: str = ""
    author: str = ""
    tags: list = field(default_factory=list)


@dataclass(kw_only=True)
class ArticleList(Component):
    template_path: ClassVar = "article_list.html"
    section_title: str = ""
    articles: list = field(default_factory=list)


# ── 2. Builder (composition) ────────────────────────────────────────
def build_article_page(articles: list[dict]) -> Page:
    cards = [ArticleCard(**a) for a in articles]
    listing = ArticleList(section_title="Latest", articles=cards)
    return Page(
        page_title="Articles",
        content=listing,  # Component auto-rendered — no .render_html() needed
    )


# ── 3. Routes (HTTP + data) ──────────────────────────────────────────
@router.get("/articles")
async def articles_page(request: Request):
    articles = await fetch_articles()               # data
    page = build_article_page(articles)             # build
    return joist.fragment_or_page(request, "Articles", page)  # render

Design notes

No component state or lifecycle. Components are render-and-forget. They receive data, produce HTML, and are done. There is no mounting, updating, or unmounting. This keeps the mental model simple and avoids an entire class of bugs.

Builder functions are the right place for composition. Putting composition in the route handler embeds presentation logic in HTTP code. Putting it in the component couples the component to specific children. Builders — plain functions that receive data and return components — are the natural middle ground.

Templates are pure markup. Conditionals, formatting, and data transformation happen in Python (get_props(), builder functions). Templates only interpolate values and iterate over lists.

The same component renders the same HTML everywhere. Whether it is the initial page load, an HTMX partial swap, or an out-of-band update, the rendering path is identical. This eliminates markup drift.


Requirements

  • Python 3.13+
  • Jinja2 3.1+

Optional: FastAPI 0.115+, Starlette 0.40+


License

MIT

Download files

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

Source Distribution

pyjoist-0.1.0.tar.gz (22.5 kB view details)

Uploaded Source

Built Distribution

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

pyjoist-0.1.0-py3-none-any.whl (17.2 kB view details)

Uploaded Python 3

File details

Details for the file pyjoist-0.1.0.tar.gz.

File metadata

  • Download URL: pyjoist-0.1.0.tar.gz
  • Upload date:
  • Size: 22.5 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.16 {"installer":{"name":"uv","version":"0.11.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"26.04","id":"resolute","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for pyjoist-0.1.0.tar.gz
Algorithm Hash digest
SHA256 752996b0563ae682cd5b87ff3c0c951b8862b48b25b3551ca5b5f649ac21d998
MD5 859a070a6ce3489f8d4dba7ac77064dc
BLAKE2b-256 ec21a59f57c2b459227b8e30bb18e9ab896af8ab39e6e66d19d7adeaef037c9b

See more details on using hashes here.

File details

Details for the file pyjoist-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: pyjoist-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 17.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.16 {"installer":{"name":"uv","version":"0.11.16","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"26.04","id":"resolute","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for pyjoist-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 e71a9f1e636cf6f4faa7321990274a6921b1b8892148cfe318650cb6f7f6735a
MD5 e3d759f9f846d3c149f893cc3c2a7952
BLAKE2b-256 9192f4058cb716f44b7a7943d808140431fe3ba889d12bef57fafed0bfa91634

See more details on using hashes here.

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