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
BaseComponentand 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:
Task→Task.html(default view)Task+template_variant="table"→Task.table.htmlTask+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 OKwith 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:
- TemplatedHTTPException → Uses exception's own template with custom HTMX targeting
- HTTPException → Tries
HTTPException.htmlfrom your templates, then our basic one - RequestValidationError → Tries
RequestValidationError.htmlfrom your templates, then our basic one - 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-RetargetandHX-Reswapheaders - 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
Release files for QuikUI 0.4.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| quikui-0.4.0.tar.gz | 39.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| quikui-0.4.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 68.7 kB
Release files / quikui-0.4.0.tar.gz
| Download URL | quikui-0.4.0.tar.gz |
|---|---|
| Size | 39.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
129560a9f1c8355b7d054fe8561e863d6fdc7046e82f6d177c9cd16f7291f1a1
|
|
BLAKE2b-256 checksum How to use checksums |
daeeab35c5cbf6e80e39d72a2eb817d10fd1a2252c6bb8f6792bb30b1ce136b7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.7
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Feb 22, 2026.
Transparency logRelease files / quikui-0.4.0-py3-none-any.whl
| Download URL | quikui-0.4.0-py3-none-any.whl |
|---|---|
| Size | 28.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
a1fb790a728c166357ddd28dc40f76afc07ef64da8a96cddc2661f4f2e0e2fb7
|
|
BLAKE2b-256 checksum How to use checksums |
b5ade3c4c1a87ffed514758ade135bfcf7abeb0ba2f4e4dfe0a2852b40d320a1
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/6.1.0 CPython/3.13.7
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Feb 22, 2026.
Transparency log