This release is a pre-release and may not be stable for production use.
Django Website Widgets
A reusable Django app for building component-based web interfaces with widgets and pages, with npm, SCSS and TypeScript set up for you: each page and widget keeps its template, stylesheet and TypeScript controller in one folder, and webpack bundles them.
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 class and no build step.
Quick start
- Create a Django project with
django-admin startproject, or use one you already have. pip install django-website-widgets, and add'website_widgets'toINSTALLED_APPS.python manage.py ww_create_appwrites a working site into a newwebsiteapp, with its npm, SCSS and TypeScript setup, and prints the next steps.- Make your first page and widget with
python manage.py ww_create_pageandpython manage.py ww_create_widget.
Examples
The repository's examples/
folder holds Django projects you can run. 01_movie_manager is a whole site built the way the
package means one to be, from ww_create_app on, with TypeScript, SCSS and webpack: sign-in,
search through an endpoint, a cart and a Django Ninja API. 02_animal_shelter is a tiny site
with no build step. They aren't installed with the package; clone the repository and follow each
one's README.
examples/README.md
lists them, and the lessons in the docs below cover the techniques too narrow for an example.
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/docs/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 frontendcreating_and_upgrading_an_app.md:ww_create_appandww_upgrade, and the framework's TypeScript and these docs copied into a projectstarter_site.md: the working siteww_create_appwrites, and the conventions it showschecking_the_setup.md: the system checks andww_doctorcreating_pages_and_widgets.md:ww_create_pageandww_create_widget, and overriding their templatesnpm_watch.md: startingnpm run watchwithrunservernode_and_package_managers.md: installing Node, building without it in a container, and yarn, pnpm and the rest
Lessons, with worked code:
loading_data.md: the async ORM in a page, and gathering slow callswidgets_in_plain_django.md: widgets in an ordinary view and template, and in the adminhtml_emails.md: HTML emails built from widgetsetags_and_304s.md: answering 304 Not Modified from a pagedeploying_the_frontend.md: the production build, and cache busting
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
Pages and widgets by hand
What ww_create_app and the commands write for you, done by hand, with no build step:
- 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 an
AsyncWebPagesubclass for every other page to extend, pointing at that template:
website/app_page.py
import os
from website_widgets import AsyncWebPage
from website.widgets.appbar import Appbar
class AppPage(AsyncWebPage):
# 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 SyncWidget
from project.models import Project
class ProjectDetailsCard(SyncWidget):
"""Displays a project it is given; it loads nothing, so it is a SyncWidget."""
def __init__(self, project: Project):
super().__init__()
self.context.data['project'] = project
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):
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
AsyncWidget and SyncWidget
The base classes for reusable UI components, which use a declarative, constructor-based pattern similar to Flutter or React. A widget subclasses one of the two, the way a page does, so every widget says which kind it is:
AsyncWidget: itsset_context()isasync defand awaits the ORM's async methods. It renders in anAsyncWebPageor withawait arender().ww_create_widgetwrites one unless told--sync.SyncWidget: itsset_context()is plaindef, or it has none. It renders everywhere: in either page, withrender()from sync code (a plain view, an email, the admin) and witharender(). A widget that loads nothing is aSyncWidget.
The wrong kind of set_context() raises TypeError when the class is defined, naming the fix.
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(SyncWidget):
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 of them) - Store children in
self.context.children- system renders them - Access
self.requestfromset_context()onwards (not in__init__()). Whether a page can readself.request.userdepends on the project's middleware: with Django'sAuthenticationMiddlewareit's lazy, and its first read queries the database, so load the user withawait self.request.auser()instead (docs/rendering_pipeline.md) - An
AsyncWebPagerenders in an async view, so database access in aSyncWidget'sset_context()there raisesSynchronousOnlyOperation; load data in anAsyncWidgetwith the async ORM. A widget rendered withrender()from sync code, or in aSyncWebPage, is the reverse: it is aSyncWidget, and the sync ORM works
AsyncWebPage and SyncWebPage
Each is a widget that represents a full page answering a web request. A page subclasses one of
the two, so every page says which it is; neither is called plain WebPage, which would read as the
default with the other as an extra.
AsyncWebPageis the one to use, and what the starter site andww_create_pagewrite. Itsset_context()isasync defand awaits the ORM's async methods; its widgets may beAsyncWidgets orSyncWidgets.SyncWebPageis for a page with no async work and a team that would rather write the ORM as in any Django view. Itsset_context()isdef, it runs with no event loop, and every widget in it is aSyncWidget.
A page with the wrong kind of set_context(), handle_request() or build_response() raises
TypeError when its class is defined, naming the fix.
Key Pattern:
- URL parameters passed to
__init__()- store them on self set_context()loads data and creates child widgets- Assign widget objects (or lists of them) 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):
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 a widget, a list of widgets, 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 ww_show_routes 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 param_names, 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 base class 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, page, data and children. There's no user:
what request.user holds depends on the project's middleware, and with Django's default, reading
it inside an async page queries the database where Django forbids it. A page puts the user in
context.page itself, the way its middleware allows. 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(SyncWidget):
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
Every command is prefixed ww_.
Create the website app, named by WEBSITE_WIDGETS.website_app_name (website by default). It
lists every file and asks before writing:
python manage.py ww_create_app
The app holds a working starter site: a page whose TypeScript controller calls a JSON endpoint,
a page that is always refused, the document template, AppState and AppUrls, a web component,
and the webpack build. It also holds two folders the package manages: the framework's
TypeScript (App, Controller, Completer) in ts/website_widgets/, and the package's docs
in website_widgets_docs/. It prints the next steps,
each a one-line change: the app in INSTALLED_APPS, its URLs in urls.py, start_npm_watch()
in manage.py, and npm install. See
docs/starter_site.md.
The starter site's files are yours, the build files included. webpack.config.js and
tsconfig.json are a starting point to change: a site with distinct sections, for instance,
builds one entry point per section (main_website.js, main_reports.js), so no page loads
another section's code. The package is REST-library agnostic: the starter endpoint is a plain
Django view, and one written with Django REST Framework, Django Ninja or anything else is called
from a controller the same way.
After every upgrade of the package, bring the app up to the installed version (and run it once
on a website app that never ran ww_create_app):
python manage.py ww_upgrade
It overwrites only the files the package ships in its two folders, ts/website_widgets/ and
website_widgets_docs/, deletes ones it no longer ships, and leaves anything else in them alone.
Commit both, so an upgrade shows up as a diff. It asks before replacing a webpack.config.js or tsconfig.json that
differs from the starter's, never edits package.json (it prints the npm install command for
anything the build needs), and never touches the other starter files. Both commands offer to
point your CLAUDE.md, .claude/CLAUDE.md or AGENTS.md at the copied docs. See
docs/creating_and_upgrading_an_app.md.
When something doesn't work, check the setup: the package's system checks, then Node, npm,
package.json, node_modules and a built bundle, each failing line with its fix:
python manage.py ww_doctor
System checks also fail at runserver when the app is missing from INSTALLED_APPS or its
pages aren't in urls.py, and warn when either managed folder is from another version. See
docs/checking_the_setup.md.
Create a page or widget, with an optional TypeScript controller and stylesheet registered in
ts/main.ts (each asks when neither --x nor --no-x is given). A page is routed with @register_url, at a
route made from its name unless --route gives one:
python manage.py ww_create_widget like_button --tscontroller --stylesheet
python manage.py ww_create_page profile_page --no-tscontroller --no-stylesheet --route=profile
They need the framework TypeScript in ts/website_widgets/, and for a page, the base class at
GenerateComponentConfig.app_page_class. A project overrides any of their templates with its
own, in templates_dir. See
docs/creating_pages_and_widgets.md.
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 ww_show_routes
python manage.py ww_show_routes --columns=url,name,page,reaches_page --path-param-type=type
License
MIT
Metadata
Release files for django-website-widgets 1.0.0rc2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| django_website_widgets-1.0.0rc2.tar.gz | 101.2 kB | Without provenance | Details for django_website_widgets-1.0.0rc2.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| django_website_widgets-1.0.0rc2-py3-none-any.whl | Python 3 | none | any | Details for django_website_widgets-1.0.0rc2-py3-none-any.whl |
Total release size: 234.3 kB
Release files / django_website_widgets-1.0.0rc2.tar.gz
| Download URL | django_website_widgets-1.0.0rc2.tar.gz |
|---|---|
| Size | 101.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9d2525463901ce668f9ab98c3a2f196e64e8791ea80659315220b518b7df4d3f
|
|
BLAKE2b-256 checksum How to use checksums |
dee762207e48579e9745c6e74eb81196c56cc0ac4553a7356b0372f552c70d0c
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
poetry/2.4.1 CPython/3.14.8 Linux/7.2.13-200.fc44.x86_64
|
Release files / django_website_widgets-1.0.0rc2-py3-none-any.whl
| Download URL | django_website_widgets-1.0.0rc2-py3-none-any.whl |
|---|---|
| Size | 133.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
a3d336b7d2872f6254c0b12190acf3ab027078f2418465706f4a218e39072e64
|
|
BLAKE2b-256 checksum How to use checksums |
38c3443e6d1c220dfc68ab86f1c852ea2366750e172ff00222e4ab66d5695ab9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
poetry/2.4.1 CPython/3.14.8 Linux/7.2.13-200.fc44.x86_64
|
Security for django-website-widgets 1.0.0rc2
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.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.