Django Website Widgets
A reusable Django app for building component-based web interfaces with widgets and pages.
A widget can also render on its own into the Django you already have: an ordinary view and
template, an email, the admin. So a site can adopt widgets one piece at a time, with no Page
and no build step.
Examples
The repository's examples/
folder holds small Django projects you can run, each showing one part of the library: pages and
widgets, requests and routing, widgets in plain Django views, an email and the admin, and a small
project tracker using all of it. They aren't installed with the package; clone the repository,
cd into one, then poetry install, migrate and runserver.
examples/README.md
lists them all. Gametabs is a real site built on it.
Documentation
This README introduces the library. How each part works, and why, is in the docs that ship
inside the package, indexed by
website_widgets/AGENTS.md:
rendering_pipeline.md: how a request becomes HTML, and rendering a widget outside a pagerouting.md:@register_url, route discovery and orderingfrontend_controllers.md: the TypeScriptController,AppandCompleterreverse_template.md: URL templates for the frontendgenerate_component.md: generating a page or widget, and overriding the generator's templatesinit_website_widgets.md: copying the framework's TypeScript and these docs into a projectnpm_watch.md: startingnpm run watchwithrunserver
AGENTS.md is written for AI coding assistants as well as people: an index plus the rules
every change follows.
Installation
Requires Python 3.12+ and Django 5.2+.
pip install django-website-widgets
Or with Poetry:
poetry add django-website-widgets
Quick Start
- Add to
INSTALLED_APPSinsettings.py:
INSTALLED_APPS = [
...
'website_widgets',
]
- Create a document template for all pages to use: the whole HTML document,
<head>included. Customize it as needed with stylesheets, an appbar, a footer, etc.
website/document_template.html
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ page.title|default:"Foo" }}</title>
<link rel="stylesheet" href="{% static 'styles.css' %}">
</head>
<body>
{{ children.appbar }}
<main id="main-content">
{{ page_content_html }}
</main>
</body>
</html>
- Create a
Pagesubclass for every other page to extend, pointing at that template:
website/app_page.py
import os
from website_widgets import Page
from website.widgets.appbar import Appbar
class AppPage(Page):
# Absolute: a relative path resolves beside each concrete page, not beside this file.
document_template_path = os.path.join(os.path.dirname(__file__), 'document_template.html')
async def set_context(self):
# Construct widget - system will render it automatically
self.context.children['appbar'] = Appbar()
A page renders two templates against the same context: its own, then the document template, which
receives the first as {{ page_content_html }}. So children.appbar can render in either one.
Here it sits outside the page's content but still reads context.page, which every page fills
before either template renders:
website/widgets/appbar/appbar.html
<header class="appbar">
<a href="/">Home</a>
<span>{{ page.title }}</span>
</header>
Where the appbar goes is the project's choice: every page can set its own
children['appbar'], a page can leave it out, or the document template can hold the markup
directly.
- Create a widget and a template next to it, named after the widget's Python file:
website/widgets/project_details_card/project_details_card.py
from website_widgets import Widget
from project.models import Project
class ProjectDetailsCard(Widget):
"""Widget that displays project details."""
def __init__(self, project: Project):
super().__init__()
self.context.data['project'] = project
def set_context(self):
"""Called before rendering. Can be sync or async."""
super().set_context()
website/widgets/project_details_card/project_details_card.html
<div class="project-details-card">
<p>Name: {{ data.project.name }}</p>
</div>
- Create a page and its template the same way:
website/pages/project_page/project_page.py
from project.models import Project
from website.app_page import AppPage
from website.widgets.project_details_card import ProjectDetailsCard
class ProjectPage(AppPage):
"""Page that displays a single project."""
def __init__(self, pk: int):
super().__init__()
self.pk = pk
async def set_context(self):
await super().set_context()
project = await Project.objects.aget(pk=self.pk)
self.context.page['title'] = f'Project - {project.name}'
self.context.data['project'] = project
# Construct widgets - system will render them automatically
self.context.children['project_details_card'] = ProjectDetailsCard(
project=project
)
website/pages/project_page/project_page.html
<div id="project-details-page">
{{ children.project_details_card }}
<p>Other content here</p>
</div>
- Register in
urls.py:
from django.urls import path
from .pages.project_page import ProjectPage
urlpatterns = [
path('project/<int:pk>', ProjectPage.as_view(), name='project_page'),
]
Core Concepts
Widget
Base class for reusable UI components. Uses a declarative, constructor-based pattern similar to Flutter or React.
Key Pattern:
- Accept data via
__init__()parameters - Store data on instance variables
- Implement
set_context()to populateself.context.dataand create child widgets - System automatically renders widgets after initialization
Example:
class InfoCard(Widget):
def __init__(self, title: str, content: str):
super().__init__()
self.title = title
self.content = content
def set_context(self):
self.context.data['title'] = self.title
self.context.data['content'] = self.content
Widgets:
- Accept data parameters in
__init__()and store them - Use
set_context()to create child widgets (Widget objects or lists) - Store children in
self.context.children- system renders them - Access
self.requestandself.request.userfromset_context()onwards (not in__init__()) - Can be sync or async - use
async def set_context(self):only when awaiting. A page renders in an async view, so database access in a syncset_context()there raisesSynchronousOnlyOperation; use the async ORM. A widget rendered withrender()from sync code is the reverse: it must usedef, and the sync ORM works
Page
Extends Widget and represents a full page.
Key Pattern:
- URL parameters passed to
__init__()- store them on self set_context()loads data and creates child widgets- Assign Widget objects (or lists) to
self.context.children - System calls
set_context()recursively, then renders all widgets
Example with URL parameters:
class ProfilePage(AppPage):
def __init__(self, username: str):
super().__init__()
self.username = username
async def set_context(self):
await super().set_context()
user = await User.objects.aget(username=self.username)
self.context.page['title'] = f'Profile: {user.username}'
# Construct widgets; the page renders them
self.context.children['profile_card'] = ProfileCard(user=user)
Pages:
- Set
document_template_pathfor the HTML document wrapping their own template - Receive URL parameters in
__init__() - Use
set_context()to load data and construct child widgets - Route with
@register_url(see below), or by hand inurls.pywith.as_view() - Override
handle_request()for authentication, redirects or conditional responses, andbuild_response()for headers
WidgetContext
Each widget has self.context, a WidgetContext, with:
data: Template variables (dict)children: Child widgets - values can be Widget, list[Widget], or Nonepage: Shared page-level data (title, meta, etc.)
System renders widgets to HTML automatically. Lists iterate in templates via {% for item in children.items %}.
Rendering a widget on its own
A widget doesn't need a Page. render() returns its HTML as SafeText, so it drops into any
ordinary Django template, and a project can adopt widgets piece by piece without converting its
views. Construct the widget, adjust it, and render it once:
from django.shortcuts import render
def project_list(request):
tiles = []
for project in Project.objects.all():
widget = ProjectListTile(project=project)
# Provide any custom values the widget might need.
widget.context.page['foo'] = bar
tiles.append(widget.render(request))
return render(request, 'projects/list.html', {'tiles': tiles})
{% extends 'base.html' %}
{% block content %}
{% for tile in tiles %}{{ tile }}{% endfor %}
{% endblock %}
render() runs with no event loop, so its widgets use plain def set_context() and the sync
ORM; it raises on an async def set_context(). The request is optional: an email body or an
admin hook has none, and a widget that reads self.request without one raises rather than
getting a fake. The 03_widgets_in_plain_django example has all three: a
traditional view and template, emails and an admin tool.
In async code, such as an endpoint returning HTML fragments for an infinite scroller, await
arender() instead. It runs sync and async set_context()s alike, as a page does:
html = await ProjectListTile(project=project).arender(request)
A widget's template can be a whole document. An HTML email is a widget holding what every email
repeats, the <head>, a header and a footer, with the email's own part passed in as a child:
html = EmailDocument(subject='Welcome', content=WelcomeMessage(user=user)).render()
Tests render a widget against a mock GET /, optionally as a given user:
html = ProjectListTile(project=project).render__test(user=user) # def test
html = await ProjectListTile(project=project).arender__test(user=user) # async def test
Each runs the widget's set_context() and every descendant's, then the templates. No document
template wraps the result.
Routing pages with @register_url
A page can declare its own routes instead of being listed in urls.py. The decorator takes
path()'s arguments, and stacks one per route:
from website_widgets import register_url
@register_url('')
class HomePage(AppPage): ...
@register_url('forum/<int:post_id>', name='forum_post')
@register_url('tab-requests/<int:post_id>', name='tab_request')
class ForumPostPage(AppPage): ...
@register_url('forum/create-post', name='forum_create_post', kwargs={'post_type': 'forum_post'})
class ForumCreatePostPage(AppPage): ...
Then include every decorated page with one line, the way the admin is included:
from website_widgets import PageRoutes
urlpatterns = [
path('', PageRoutes.urls()),
...
]
Pages are found by importing every module under the website app's pages/ folder, and their
routes reverse as website:<name>, the name defaulting to the class name. Routes are tried most
specific first, never in file order, so profile/settings always beats
profile/<str:username>. python manage.py show_page_resolve_order lists them in that order
and checks that each reaches its page. Routing a page by hand with MyPage.as_view() still
works, and the two mix freely. See
docs/routing.md
for discovery, the ordering rule, the tie check and the command's options.
URL templates for the frontend
reverse_template() stands in for reverse() when the frontend fills in the parameters. It
reverses a URL name into a template with a {name} placeholder per route parameter:
from website_widgets import reverse_template
# path('projects/<int:project_id>/tasks/<slug:task_slug>', ..., name='project_task')
reverse_template('project_task') # '/projects/{project_id}/tasks/{task_slug}'
reverse_template('project_list') # '/projects', as reverse() returns it
Placeholders take the route's own parameter names, and namespaces, include() prefixes and the
script prefix apply as they do in reverse(). See
docs/reverse_template.md
for paramnames, which a re_path() with unnamed groups needs.
Starting the frontend watcher with runserver
A project that builds its frontend with npm can start npm run watch whenever Django's
development server starts. Call start_npm_watch() in manage.py, after
DJANGO_SETTINGS_MODULE is set:
from website_widgets import start_npm_watch
def main():
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'mysite.settings')
start_npm_watch()
from django.core.management import execute_from_command_line
execute_from_command_line(sys.argv)
It acts only on runserver, and runs npm run watch in the folder of the app named by
website_app_name, once per folder: not again when the autoreloader restarts the server, and
not when one is already running there. See
docs/npm_watch.md
for how it tells watchers apart.
Templates
A widget's template sits next to its Python file and shares its name: my_widget.py renders
my_widget.html. Set template_filename to use a different file, either on the class or in
set_context() to pick between files at runtime.
template_filename is a bare file name, never a path. A template lives beside the code that
chooses it, so a /, a \ or .. raises ValueError. The file is looked up beside the class's
own .py file first, then beside each base class's file in inheritance order, and the first match
wins. That lets a base class keep a variant beside its own module and switch any subclass to it.
A name found nowhere raises FileNotFoundError listing every folder searched.
class ProjectPage(AppPage):
async def set_context(self):
await super().set_context()
project = await Project.objects.filter(pk=self.pk).afirst()
if project is None:
self.status_code = 404
self.template_filename = 'project_page__not_found.html'
return
...
Suggested convention: name a variant <module>__<variant>.html, with a double underscore,
beside the main template:
project_page/
project_page.py
project_page.html
project_page__not_found.html
project_page__archived.html
The double underscore marks where the module name ends, the way it separates the parts of a Django
lookup (author__name__startswith): module and variant names both contain single underscores, so
one alone can't. The prefix keeps every variant sorted next to the template it replaces, and an
editor tab or a search result names its component without the folder. Give the variant the same
root element as the main template, so the component's styles still apply to it.
A page's document template is the one template set by path: document_template_path names a file
shared by every page, so it lives beside the shared Page subclass rather than beside any one page.
Override get_template_path() when the choice needs more than an attribute.
<div class="my-widget">
<h1>{{ data.title }}</h1>
{{ children.my_widget }}
</div>
Templates are opened directly rather than through Django's template loader, so context processors
do not run. The context holds only request, user, page, data and children. See
docs/rendering_pipeline.md
for the full render order.
Configuration
Configure django-website-widgets in your settings.py:
from website_widgets.config import GenerateComponentConfig, WebsiteWidgetsConfig
WEBSITE_WIDGETS = WebsiteWidgetsConfig(
show_debug_tree=True, # Show widget tree visualization on pages (default: False)
website_app_name='website', # Label of the app holding pages/ and package.json (default: 'website')
generate_component=GenerateComponentConfig(
# Dotted path of a generated page's base class (default: '<website_app_name>.pages.app_page.AppPage')
app_page_class='website.pages.app_page.AppPage',
# Your own generator templates, each replacing the package's of the same name (default: None)
templates_dir=BASE_DIR / 'website' / 'generator_templates',
),
)
If WEBSITE_WIDGETS is not defined, a default configuration is used automatically. The setting is
read once per process, so changing it at runtime has no effect.
Debug Tree Visualization
Enable show_debug_tree=True to display a visual representation of your widget hierarchy after each page's main content. This is useful during development to understand the structure of your widget tree.
Example output:
CompositionPage
stylesheet:
AdvancedStylesheet
constructor_card:
InfoCard
list_cards: [
InfoCard
InfoCard
InfoCard
]
skipped_card: None
page_title_note:
PageTitleNote
Adding Debug Labels
For better debugging, add a _debug_label to your widgets:
class InfoCard(Widget):
def __init__(self, title: str, content: str):
super().__init__()
self._debug_label = title # Shows as "InfoCard(My Title)" in debug tree
self.title = title
self.content = content
Without _debug_label, widgets display only their class name (e.g., InfoCard). With the label, they display as InfoCard(My Title), making it easier to identify specific widget instances in complex trees.
Recommendation: Only enable show_debug_tree in development environments, not in production.
Management Commands
Check installation:
python manage.py test_website_widgets --verbose
List the @register_url routes in the order they are tried, and check each reaches its page
(see "Routing pages with @register_url"):
python manage.py show_page_resolve_order
python manage.py show_page_resolve_order --columns=url,name,page,reaches_page --path-param-type=type
Copy the framework's TypeScript (App, Controller, Completer) and the package's docs into
the website app's ts/website_widgets/ folder, and rerun after every upgrade:
python manage.py init_website_widgets
It overwrites only the files the package ships, deletes ones it no longer ships, and leaves
anything else in the folder alone. Commit the folder, so an upgrade shows up as a diff. It also
offers to point your CLAUDE.md, .claude/CLAUDE.md or AGENTS.md at the copied docs. See
docs/init_website_widgets.md.
Generate a page or widget, with an optional TypeScript controller and stylesheet registered in
ts/main.ts (it asks when a flag is left out):
python manage.py generate_component widget like_button --tscontroller=true --stylesheet=true
python manage.py generate_component page profile_page --tscontroller=false --stylesheet=false
It needs init_website_widgets to have run, and for a page, the base class at
GenerateComponentConfig.app_page_class. A project overrides any of its templates with its own,
in templates_dir. See
docs/generate_component.md.
License
MIT
Metadata
Release files for django-website-widgets 0.5.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| django_website_widgets-0.5.1.tar.gz | 51.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| django_website_widgets-0.5.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 111.3 kB
Release files / django_website_widgets-0.5.1.tar.gz
| Download URL | django_website_widgets-0.5.1.tar.gz |
|---|---|
| Size | 51.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9ee4b6266aae3c16b82639faa895b22ba1640c413ffa9e2aff125a2e4d22485d
|
|
BLAKE2b-256 checksum How to use checksums |
af9f2778bfd0c2cefe3dad7abf3affbf630798ef645e091a415d32aaf29293c8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
poetry/2.4.1 CPython/3.14.8 Linux/7.2.9-200.fc44.x86_64
|
Release files / django_website_widgets-0.5.1-py3-none-any.whl
| Download URL | django_website_widgets-0.5.1-py3-none-any.whl |
|---|---|
| Size | 59.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
a09db840eb0ddbb8c432bbecb57e2edcbc8cdc51b9150b39f4a42c0f3ad02c0c
|
|
BLAKE2b-256 checksum How to use checksums |
14762c34d97ba293e6fea4e7a4c3c69a2be25b7283e908b3d0ff0574ed3a90d2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
poetry/2.4.1 CPython/3.14.8 Linux/7.2.9-200.fc44.x86_64
|