Skip to main content

Build UIs quickly with Jinga2, FastAPI, Pydantic, htmx, and a little bit of magic

Project description

QuikUI

Contextual server-side rendering of Pydantic models into HTML components

QuikUI is a library designed for seamless integration of Pydantic models with HTML rendering using Jinja2 templates. It's built specifically for modern web applications using FastAPI and HTMX, enabling powerful server-side rendering with fragment updates.

Key Features

  • 🎯 Zero Boilerplate: Subclass BaseComponent and your Pydantic models automatically render to HTML
  • 🔄 HTMX Native: Built-in support for fragment rendering with template variants
  • 📊 SQLModel Compatible: Works seamlessly with SQLModel for ORM + rendering in one model
  • 🎨 Template Variants: Multiple template views per model (e.g., Task.html, Task.table.html, Task.form.html)
  • 🔒 Safe by Default: Automatic HTML escaping through Jinja2's autoescape
  • 📡 SSE Streaming: Built-in support for Server-Sent Events streaming
  • 🎭 Smart Detection: Automatic HTML vs JSON response based on request headers
  • ⚠️ HTMX Error Handling: Automatic error handling with native HTMX retargeting for validation errors and exceptions

Installation

pip install quikui

Quick Start

1. Define Your Models

Combine BaseComponent with your Pydantic models:

import quikui as qk
from pydantic import Field

class Component(qk.BaseComponent):
    """Base class for all your renderable models"""
    quikui_template_package_name = "myapp"

class Task(Component):
    id: int
    title: str
    description: str = ""
    status: str = "todo"

# For SQLModel integration (in production):
# from sqlmodel import SQLModel
# class Task(Component, SQLModel, table=True):
#     # Your model definition

2. Create Templates

Create myapp/templates/Task.html:

<div class="task">
  <h3>{{ title }}</h3>
  <p>{{ description }}</p>
  <span class="status">{{ status }}</span>
</div>

Create variant templates for different contexts like Task.table.html:

<tr>
  <td>{{ title }}</td>
  <td>{{ description }}</td>
  <td>{{ status }}</td>
</tr>

3. Use in FastAPI Routes

from fastapi import FastAPI
import quikui as qk

app = FastAPI()

@app.get("/tasks/{task_id}")
@qk.render_component()
def get_task(task_id: int, session: Session = Depends(...)) -> Task:
    return session.get(Task, task_id)
    # Returns HTML when a browser client requests it
    # Falls back to FastAPI JSONResponse when API client requests it

Core Concepts

Template Discovery

QuikUI automatically finds templates based on your model's class name:

  • TaskTask.html (default view)
  • Task + template_variant="table"Task.table.html
  • Task + template_variant="form"Task.form.html

Templates are searched in the package specified by quikui_template_package_name under the templates/ directory.

HTMX Fragment Rendering

Use the Qk-Variant header to specify which template variant to render:

<!-- Create form that returns a table row -->
<form
  hx-post="/api/tasks"
  hx-target="#tasks-tbody"
  hx-swap="afterbegin"
  hx-headers='{"Qk-Variant": "table"}'
>
  <input name="title" required />
  <button type="submit">Create</button>
</form>

<!-- Table in a template that displays tasks -->
<tbody id="tasks-tbody">
  {% for task in tasks %} {{ task|variant("table") }} {% endfor %}
</tbody>

The |variant("table") filter renders each task using Task.table.html.

IMPORTANT

If creating your own template environment (such as with FastAPI templating feature), use register_filters to register our filters (like variant) into your templates.

from fastapi.templating import Jinja2Templates
templates = Jinja2Templates(directory="myapp/templates")
qk.register_filters(templates.env)

DELETE Requests with HTMX

QuikUI automatically handles DELETE operations for both REST and HTMX clients:

@app.delete("/api/tasks/{task_id}", status_code=status.HTTP_204_NO_CONTENT)
@qk.render_component()
def delete_task(task_id: int):
    del tasks_db[task_id]
    # No return needed - None is implicit

Behavior:

  • JSON clients: 204 No Content (standard REST)
  • HTML/HTMX clients: 200 OK with empty string (enables element removal via HTMX)
<button
  hx-delete="/api/tasks/{{ id }}"
  hx-target="#task-{{ id }}"
  hx-swap="outerHTML"
>
  Delete
</button>

Template Context

All model fields are automatically available in templates:

class Task(Component):
    title: str
    status: TaskStatus  # Enum
    created_at: datetime
    tags: list[str]  # Complex types work too

    @computed_field  # Any computed fields are added
    @property
    def display_name(self) -> str:
        return f"Task: {self.title}"

In your template:

<div>
  <h3>{{ title }}</h3>
  <span>{{ status.value }}</span>
  <time>{{ created_at.strftime('%Y-%m-%d') }}</time>
  <ul>
    {% for tag in tags %}
    <li>{{ tag }}</li>
    {% endfor %}
  </ul>
  <p>{{ display_name }}</p>
</div>

SQLModel Relationships

SQLModel relationships are automatically included in the template context:

class User(Component, table=True):
    id: int = Field(primary_key=True)
    name: str
    tasks: list["Task"] = Relationship(back_populates="user")

class Task(Component, table=True):
    id: int = Field(primary_key=True)
    title: str
    user_id: int = Field(foreign_key="users.id")
    user: User = Relationship(back_populates="tasks")

In User.html:

<div class="user">
  <h2>{{ name }}</h2>
  <h3>Tasks:</h3>
  <ul>
    {% for task in tasks %} {{ task|variant("list") }} {% endfor %}
  </ul>
</div>
When using with SQLModel's lazy-loading relationship attributes, please do not use an async driver.
An async driver will not work and will cause async handling errors when attempting to render!

Jinja2 templating cannot work in an asynchronous context, so only synchronous rendering of async fields will work.
Alternatively, use `selectinload` or another method to eagerly load lazy relationship attributes that you need.

See: https://docs.sqlalchemy.org/en/14/orm/loading_relationships.html

Streaming with SSE

Stream components as Server-Sent Events:

@app.get("/notifications")
@qk.render_component(streaming=True)
async def stream_notifications() -> AsyncIterator[Notification]:
    while True:
        await asyncio.sleep(1)
        yield Notification(message="Update", timestamp=datetime.now())

In your HTML:

<div
  hx-ext="sse"
  sse-connect="/notifications"
  sse-swap="message"
  hx-swap="beforeend"
>
  <!-- Notifications appear here -->
</div>

Global Template Context

Share context across all component renders:

from quikui import set_context_provider

def get_global_context():
    return {
        "current_user": get_current_user(),
        "app_version": "1.0.0"
    }

set_context_provider(get_global_context)

Now all templates have access to current_user and app_version.

Advanced Usage

HTMX-Friendly Error Handling

QuikUI provides automatic error handling that works seamlessly with HTMX. When errors occur (validation errors, 404s, etc.), QuikUI automatically detects whether the request is from HTMX or a JSON client and responds appropriately.

Basic Setup (with built-in templates):

from fastapi import FastAPI
import quikui as qk

app = FastAPI()

# Use QuikUI's minimal built-in error templates
qk.setup_error_handlers(app)

Custom Templates (to style your own):

from fastapi import FastAPI
from fastapi.templating import Jinja2Templates
import quikui as qk

app = FastAPI()
templates = Jinja2Templates(directory="templates")

# Use your own error templates
qk.setup_error_handlers(app, template_env=templates.env)

Template Lookup Strategy:

  1. TemplatedHTTPException → Uses exception's own template with custom HTMX targeting
  2. HTTPException → Tries HTTPException.html from your templates, then our basic one
  3. RequestValidationError → Tries RequestValidationError.html from your templates, then our basic one
  4. Fallback → If no templates found, simply falls back to FastAPI's own error strategy

Creating Custom Error Templates (for basic FastAPI exceptions):

Create templates/HTTPException.html:

<div class="alert alert-danger">
  <strong>{{ status_text }}</strong>
  <p>{{ detail }}</p>
</div>

Create templates/RequestValidationError.html:

<div class="alert alert-warning">
  <strong>Validation Error</strong>
  <ul>
    {% for error in errors %}
    <li><strong>{{ error.loc|join('.') }}:</strong> {{ error.msg }}</li>
    {% endfor %}
  </ul>
</div>

HTMX Configuration (Required):

By default, HTMX doesn't swap content on 4xx responses. See https://htmx.org/docs/#response-handling for more information on how to set it up.

In your HTML template:

<!-- Error container where errors will be displayed -->
<div id="error-container"></div>

<!-- Your form -->
<form hx-post="/api/tasks" hx-target="#task-list">
  <input name="title" required />
  <button type="submit">Create</button>
</form>

Custom Exceptions with HTMX Targeting:

For advanced use cases, create custom exceptions that control exactly where and how errors appear:

class TaskInProgressError(qk.TemplatedHTTPException):
    """Custom exception with toast notification."""

    quikui_template_package_name = "myapp"
    error_container = "#toast-container"  # Where to display
    error_swap = "beforeend"              # How to insert
    template_variant = "toast"            # Which template variant

    def __init__(self, task_title: str):
        super().__init__(
            status_code=409,
            detail=f"Cannot delete '{task_title}' while in progress"
        )
        self.task_title = task_title

# Then create templates/TaskInProgressError.toast.html

How it works:

  • HTML/HTMX requests: Returns rendered templates with optional HX-Retarget and HX-Reswap headers
  • JSON requests: Returns standard FastAPI JSON error responses
  • Handles all FastAPI errors: HTTPException (404, 403, etc.), RequestValidationError (422), and TemplatedHTTPException

Example with validation:

@app.post("/api/tasks")
@qk.render_component()
def create_task(
    title: str = Form(..., min_length=1, max_length=200),
    description: str = Form(""),
) -> Task:
    # Validation happens automatically
    # Errors use your RequestValidationError.html template for HTMX
    # JSON clients get standard 422 response
    return Task(title=title, description=description)

Custom Template Package

Override where QuikUI looks for templates:

class Component(qk.BaseComponent):
    quikui_template_package_name = "myapp"
    quikui_template_package_path = "templates"  # default

HTML-Only Routes

Force routes to only accept HTML requests (useful for rendering full pages):

@app.get("/dashboard")
@qk.render_component(html_only=True, template="dashboard.html", env=templates)
def dashboard():
    return {"tasks": get_tasks(), "stats": get_stats()}

Manual Template Selection

Use templates with regular Pydantic models:

@app.get("/report")
@qk.render_component(template="report.html", env=templates)
def generate_report() -> ReportModel:
    return ReportModel(data=get_report_data())

Wrapper Components

Wrap list results in a container:

@app.get("/tasks")
@qk.render_component(
    wrapper=lambda *items: {"tasks": items},
    template="tasks_list.html",
    env=templates
)
def list_tasks() -> list[Task]:
    return session.query(Task).all()

Example Application

The example/ directory contains a complete task management application demonstrating:

  • ✅ CRUD operations with HTMX
  • ✅ Template variants for different contexts
  • ✅ Inline editing with Alpine.js
  • ✅ Server-sent events for notifications
  • ✅ Form validation with HTMX-friendly error handling
  • ✅ Production-ready patterns

Run the example:

uvicorn example:app --reload

Then visit http://localhost:8000/tasks

Note: The example uses in-memory storage for simplicity. In production, you would integrate with SQLModel or another ORM for database persistence.

Why QuikUI?

Before QuikUI:

@app.get("/tasks/{task_id}")
def get_task(task_id: int, request: Request):
    task = session.get(Task, task_id)
    if "text/html" in request.headers.get("accept", ""):
        return templates.TemplateResponse(
            "task.html",
            {"request": request, "task": task.model_dump()}
        )
    return task

With QuikUI:

@app.get("/tasks/{task_id}")
@qk.render_component()
def get_task(task_id: int) -> Task:
    return session.get(Task, task_id)

QuikUI eliminates boilerplate while providing powerful features for modern HTMX-based applications.

Security

QuikUI uses Jinja2's autoescape by default, protecting against XSS attacks. However, you are responsible for:

  • Properly escaping user content in custom templates
  • Validating and sanitizing user input
  • Following OWASP security guidelines

See: HTMX Security Basics

Requirements

  • Python 3.10+
  • FastAPI
  • Pydantic 2.0+
  • Jinja2

Contributing

Issues and pull requests welcome at github.com/fubuloubu/QuikUI

License

MIT License - see LICENSE file for details

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

quikui-0.4.0.tar.gz (39.8 kB view details)

Uploaded Source

Built Distribution

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

quikui-0.4.0-py3-none-any.whl (28.9 kB view details)

Uploaded Python 3

File details

Details for the file quikui-0.4.0.tar.gz.

File metadata

  • Download URL: quikui-0.4.0.tar.gz
  • Upload date:
  • Size: 39.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for quikui-0.4.0.tar.gz
Algorithm Hash digest
SHA256 129560a9f1c8355b7d054fe8561e863d6fdc7046e82f6d177c9cd16f7291f1a1
MD5 0921ed5968e2143d315334f255f34bb3
BLAKE2b-256 daeeab35c5cbf6e80e39d72a2eb817d10fd1a2252c6bb8f6792bb30b1ce136b7

See more details on using hashes here.

Provenance

The following attestation bundles were made for quikui-0.4.0.tar.gz:

Publisher: publish.yaml on fubuloubu/QuikUI

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

File details

Details for the file quikui-0.4.0-py3-none-any.whl.

File metadata

  • Download URL: quikui-0.4.0-py3-none-any.whl
  • Upload date:
  • Size: 28.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for quikui-0.4.0-py3-none-any.whl
Algorithm Hash digest
SHA256 a1fb790a728c166357ddd28dc40f76afc07ef64da8a96cddc2661f4f2e0e2fb7
MD5 1538e02983a0450e9c32a5bf014defec
BLAKE2b-256 b5ade3c4c1a87ffed514758ade135bfcf7abeb0ba2f4e4dfe0a2852b40d320a1

See more details on using hashes here.

Provenance

The following attestation bundles were made for quikui-0.4.0-py3-none-any.whl:

Publisher: publish.yaml on fubuloubu/QuikUI

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