Skip to main content
Pre-release

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

  1. Create a Django project with django-admin startproject, or use one you already have.
  2. pip install django-website-widgets, and add 'website_widgets' to INSTALLED_APPS.
  3. python manage.py ww_create_app writes a working site into a new website app, with its npm, SCSS and TypeScript setup, and prints the next steps.
  4. Make your first page and widget with python manage.py ww_create_page and python 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:

Lessons, with worked code:

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:

  1. Add to INSTALLED_APPS in settings.py:
INSTALLED_APPS = [
    ...
    'website_widgets',
]
  1. 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>
  1. Create an AsyncWebPage subclass 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.

  1. 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>
  1. 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>
  1. 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: its set_context() is async def and awaits the ORM's async methods. It renders in an AsyncWebPage or with await arender(). ww_create_widget writes one unless told --sync.
  • SyncWidget: its set_context() is plain def, or it has none. It renders everywhere: in either page, with render() from sync code (a plain view, an email, the admin) and with arender(). A widget that loads nothing is a SyncWidget.

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 populate self.context.data and 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.request from set_context() onwards (not in __init__()). Whether a page can read self.request.user depends on the project's middleware: with Django's AuthenticationMiddleware it's lazy, and its first read queries the database, so load the user with await self.request.auser() instead (docs/rendering_pipeline.md)
  • An AsyncWebPage renders in an async view, so database access in a SyncWidget's set_context() there raises SynchronousOnlyOperation; load data in an AsyncWidget with the async ORM. A widget rendered with render() from sync code, or in a SyncWebPage, is the reverse: it is a SyncWidget, 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.

  • AsyncWebPage is the one to use, and what the starter site and ww_create_page write. Its set_context() is async def and awaits the ORM's async methods; its widgets may be AsyncWidgets or SyncWidgets.
  • SyncWebPage is for a page with no async work and a team that would rather write the ORM as in any Django view. Its set_context() is def, it runs with no event loop, and every widget in it is a SyncWidget.

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_path for 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 in urls.py with .as_view()
  • Override handle_request() for authentication, redirects or conditional responses, and build_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 None
  • page: 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)

Source distribution for django-website-widgets 1.0.0rc2
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)

Table of built distributions (wheels) for django-website-widgets 1.0.0rc2
File Interpreter ABI Platform Actions
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.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page