Skip to main content

django-themekit

PyPI version Python versions CI Coverage License

Flexible theme engine, loader, and selector for Django.

ThemeKit is a template-resolution engine for Django. It never creates themes, builds CSS, manages assets, or dictates how a theme should look. It answers one focused question:

Given a Django template name, which existing template file should Django render for the active theme?

Most Django projects eventually need some form of theming. A site may need a branded customer skin, a tenant-specific layout, a user-selected interface, a preview theme, or a project-wide redesign that can fall back to the previous implementation one template at a time.

Many projects solve that problem with local middleware, a custom template loader, request state, and project-specific fallback rules. django-themekit packages those responsibilities into a small, reusable Django integration.


Contents


The core idea

Assume a Django view renders the ordinary template:

return render(request, "pages/home.html")

Without ThemeKit, Django resolves pages/home.html through its configured template loaders.

With ThemeKit configured and the active theme set to phoenix, ThemeKit first looks for optional themed overrides. A project can add either of these files:

templates/pages/themes/phoenix/home.html

or:

templates/themes/phoenix/pages/home.html

If neither override exists, Django still renders the original:

templates/pages/home.html

The view does not change. The original template name does not change. The ordinary template remains the final fallback.

This is the central guarantee of ThemeKit:

Adding a theme never requires replacing the existing template structure. Themed templates are optional overrides layered on top of normal Django template loading.

A theme may override one template, several templates, only base.html, or an entire application. Missing theme files are expected and are not errors as long as the ordinary template exists.


What ThemeKit does and does not do

ThemeKit provides:

  • an ordered theme-chain model;
  • normalization of theme names and fallback chains;
  • a request-aware default selector;
  • a pluggable custom-selector hook;
  • request-local theme state backed by ContextVar;
  • middleware that activates the selected theme for one request;
  • a template loader that tries themed candidates before ordinary templates;
  • optional context variables for templates;
  • optional response headers for debugging;
  • configurable handling of Django's standard error templates;
  • Django system checks for common configuration mistakes;
  • transparent fallback to ordinary Django templates.

ThemeKit does not provide:

  • CSS, JavaScript, images, icons, or other static assets;
  • a CSS framework or design system;
  • a theme registry or database model;
  • theme manifests;
  • theme installation or download tooling;
  • admin screens;
  • automatic theme discovery;
  • a required directory containing a complete theme;
  • assumptions about Bootstrap, Tailwind, Phoenix, AdminLTE, or another UI kit;
  • Jinja2 theme resolution;
  • automatic mutation of your Django settings.

Your project remains responsible for deciding what a theme means visually and how its static assets are built and delivered. ThemeKit only affects Django template-name resolution.


Requirements

django-themekit currently supports:

  • Python 3.11, 3.12, 3.13, and 3.14;
  • Django 5.2 and Django 6.0;
  • Django's DjangoTemplates backend.

The package has no runtime dependency other than Django.

Jinja2 and other rendering backends are outside the current package contract. If a project configures multiple template backends, ThemeKit affects only the DjangoTemplates backend or backends in which its loader is explicitly configured.


Installation

Using uv:

uv add django-themekit

Using pip:

python -m pip install django-themekit

Add ThemeKit's app configuration to INSTALLED_APPS:

INSTALLED_APPS = [
    # Django and project applications...
    "themekit.apps.ThemeKitConfig",
]

The loader, middleware, and context processor can technically be imported by Django without installing the app configuration. Installing ThemeKitConfig is nevertheless recommended because its ready() hook registers ThemeKit's Django system checks.

Adding ThemeKit to INSTALLED_APPS by itself does not change template resolution. Template behavior changes only after the ThemeKit loader is added to the relevant DjangoTemplates backend.


Quick start: one configured theme

This is the smallest useful setup for a project with one site-wide theme. Middleware is not required because the theme does not vary by request.

1. Configure the active theme

THEMEKIT_THEME = "phoenix"

2. Configure the template loader

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
        "APP_DIRS": False,
        "OPTIONS": {
            "loaders": [
                (
                    "themekit.loaders.ThemedLoader",
                    [
                        "django.template.loaders.filesystem.Loader",
                        "django.template.loaders.app_directories.Loader",
                    ],
                ),
            ],
            "context_processors": [
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.contrib.messages.context_processors.messages",
            ],
        },
    },
]

APP_DIRS must be False because the loader list is being configured explicitly. The app-directories loader is not lost; it is placed inside ThemeKit's wrapper so it receives each themed candidate name.

3. Keep the ordinary template

templates/pages/home.html

4. Add an optional themed override

templates/pages/themes/phoenix/home.html

The view remains unchanged:

return render(request, "pages/home.html")

When the Phoenix override exists, Django renders it. If it is removed, renamed, or never created, Django renders pages/home.html normally.


Recommended complete configuration

The following configuration supports:

  • project-level templates from TEMPLATES[...]["DIRS"];
  • templates packaged inside installed Django apps;
  • a configured default theme;
  • session and user selection;
  • request attributes;
  • theme context variables;
  • optional debugging.
INSTALLED_APPS = [
    # Django applications...
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    # Project applications...
    # ThemeKit registers its Django system checks here.
    "themekit.apps.ThemeKitConfig",
]

THEMEKIT_THEME = "phoenix"

MIDDLEWARE = [
    # Existing middleware...
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    # ThemeKit must run after the middleware that provides any request state
    # used by the default selector.
    "themekit.middleware.ThemeMiddleware",
]

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
        "APP_DIRS": False,
        "OPTIONS": {
            "loaders": [
                (
                    "themekit.loaders.ThemedLoader",
                    [
                        "django.template.loaders.filesystem.Loader",
                        "django.template.loaders.app_directories.Loader",
                    ],
                ),
            ],
            "context_processors": [
                "django.template.context_processors.debug",
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.template.context_processors.i18n",
                "django.template.context_processors.media",
                "django.template.context_processors.static",
                "django.template.context_processors.tz",
                "django.contrib.messages.context_processors.messages",
                "themekit.context_processors.theme",
            ],
        },
    },
]

Only the pieces a project uses are required:

  • The loader enables themed template resolution.
  • THEMEKIT_THEME supplies a configured theme even without middleware.
  • Middleware is required for request-specific selection.
  • The context processor is required only when templates need theme or theme_chain variables.
  • Debug headers require middleware and THEMEKIT_DEBUG_HEADER = True.

Template layout conventions

ThemeKit supports two complementary override layouts.

Sibling or app-scoped overrides

A themed override can live beside the ordinary template's directory tree:

templates/
└── pages/
    ├── home.html
    └── themes/
        └── phoenix/
            └── home.html

The ordinary name:

pages/home.html

maps to the Phoenix sibling override:

pages/themes/phoenix/home.html

This layout is useful when a theme override conceptually belongs to one app or one template group.

Nested paths work the same way. The ordinary template:

accounts/profile/detail.html

may be overridden by:

accounts/profile/themes/phoenix/detail.html

ThemeKit inserts themes/<theme>/ immediately before the final path component.

Global theme trees

A theme may also mirror the entire ordinary template namespace under a global themes/<theme>/ directory:

templates/
├── pages/
│   └── home.html
└── themes/
    └── phoenix/
        └── pages/
            └── home.html

The ordinary name:

pages/home.html

maps to:

themes/phoenix/pages/home.html

This layout is useful when a theme is maintained as one coherent tree.

Both layouts may coexist

A project can use sibling overrides for app-owned templates and global overrides for shared layouts. ThemeKit defines a deterministic order when both exist.


Exact template resolution order

For a requested template with at least one slash, ThemeKit resolves candidates in three phases:

  1. sibling overrides for each theme, left to right;
  2. global overrides for each theme, left to right;
  3. the original template name.

Given:

THEMEKIT_THEME = ["customer", "phoenix"]

and:

pages/home.html

ThemeKit tries exactly:

1. pages/themes/customer/home.html
2. pages/themes/phoenix/home.html
3. themes/customer/pages/home.html
4. themes/phoenix/pages/home.html
5. pages/home.html

Each candidate is passed to the configured wrapped loaders in their configured order. With the recommended filesystem and app-directories loaders, the search conceptually becomes:

pages/themes/customer/home.html
    -> filesystem.Loader
    -> app_directories.Loader

pages/themes/phoenix/home.html
    -> filesystem.Loader
    -> app_directories.Loader

themes/customer/pages/home.html
    -> filesystem.Loader
    -> app_directories.Loader

themes/phoenix/pages/home.html
    -> filesystem.Loader
    -> app_directories.Loader

pages/home.html
    -> filesystem.Loader
    -> app_directories.Loader

The first successfully loaded template wins.

Important priority detail

All sibling candidates are tried before any global candidates. Therefore:

pages/themes/phoenix/home.html

wins before:

themes/customer/pages/home.html

although customer appears earlier in the theme chain. Theme priority is preserved within each layout class; sibling layout as a class has priority over global layout.

This ordering is intentional and is covered by the test suite.

Explicitly themed template names

If the requested template name already contains a path segment named themes, ThemeKit treats it as explicit and does not rewrite it again.

For example:

render(request, "themes/phoenix/base.html")

is loaded exactly as requested. ThemeKit does not construct recursive paths such as:

themes/phoenix/themes/phoenix/base.html

The same rule applies to sibling paths such as:

pages/themes/phoenix/home.html

Root templates such as base.html

A root template has no directory prefix, so there is no sibling location into which ThemeKit can insert themes/<theme>/.

Given:

base.html

and:

THEMEKIT_THEME = ["customer", "phoenix"]

ThemeKit tries:

1. themes/customer/base.html
2. themes/phoenix/base.html
3. base.html

A typical layout is:

templates/
├── base.html
└── themes/
    ├── customer/
    │   └── base.html
    └── phoenix/
        └── base.html

This behavior is especially useful because a project can theme most of its site by overriding only base.html while every page template keeps its ordinary {% extends "base.html" %} statement.


Template inheritance

ThemeKit works with Django template inheritance because {% extends %} loads the named parent through Django's template engine.

An unchanged child template:

{% extends "base.html" %}

{% block content %}
  Home
{% endblock %}

can inherit the ordinary base:

templates/base.html

or, while Phoenix is active, the themed base:

templates/themes/phoenix/base.html

No conditional logic is needed in the child template.

The same resolution engine is applied whenever Django asks the configured loader for a template name, including templates loaded during normal inheritance. ThemeKit passes Django's skip origin list through to its wrapped loaders so Django can preserve its normal inheritance and recursion behavior.

A theme can independently override:

  • only the parent layout;
  • only the child page;
  • both the parent and child;
  • neither, allowing both ordinary templates to render.

Partial themes and transparent fallback

Themes do not need to be complete.

This is valid:

templates/
├── base.html
├── pages/
│   ├── home.html
│   ├── reports.html
│   └── users.html
└── themes/
    └── phoenix/
        └── base.html

Phoenix overrides only base.html. All page templates remain ordinary and inherit the themed base automatically.

This is also valid:

templates/
└── pages/
    ├── home.html
    ├── reports.html
    └── themes/
        └── phoenix/
            └── reports.html

Only pages/reports.html is themed. pages/home.html falls back to its ordinary file.

Renaming:

pages/themes/phoenix/home.html

to:

pages/themes/phoenix/home2.html

removes it from the candidate set for pages/home.html; the ordinary pages/home.html renders again.

ThemeKit does not scan theme directories, validate completeness, or require a registry of available themes. A file participates only when its path matches a candidate generated for the requested template name.


Theme chains

A theme setting or selector may return one theme:

THEMEKIT_THEME = "phoenix"

or an ordered fallback chain:

THEMEKIT_THEME = [
    "customer",
    "phoenix",
]

Theme values are normalized into immutable tuples internally.

Normalization rules:

  • None becomes an empty chain;
  • an empty or whitespace-only string becomes an empty chain;
  • a string becomes a one-item chain;
  • a sequence preserves left-to-right order;
  • surrounding whitespace is stripped;
  • empty items are discarded;
  • duplicate names are removed while preserving the first occurrence;
  • non-string items in a configured chain raise TypeError.

Examples:

"phoenix"
# -> ("phoenix",)
[" customer ", "", "phoenix", "customer"]
# -> ("customer", "phoenix")

ThemeKit does not currently enforce a slug format. Theme names become path components, so simple trusted identifiers such as phoenix, customer, or tenant_acme are strongly recommended.


Theme selection

ThemeKit separates template resolution from theme selection.

The loader asks only:

What is the active theme chain right now?

The chain may come from:

  • the configured THEMEKIT_THEME setting;
  • request middleware using the built-in selector;
  • a project-defined selector;
  • request-local state already established by middleware.

This separation allows a static site to use the loader without middleware and a multi-tenant application to supply its own request policy without replacing the loader.


Static selection with THEMEKIT_THEME

For one site-wide theme:

THEMEKIT_THEME = "phoenix"

For a site-specific override with a reusable fallback:

THEMEKIT_THEME = [
    "my_site",
    "phoenix",
]

The configured chain is available to the loader even when ThemeMiddleware is not installed.

This makes the following setup valid:

  • ThemeKit loader configured;
  • THEMEKIT_THEME configured;
  • no ThemeKit middleware;
  • no sessions or authentication required.

Important distinctions:

  • THEMEKIT_THEME does not install the loader.
  • Defining the setting alone does not alter template resolution.
  • The loader must be present in TEMPLATES for themed overrides to be used.

When THEMEKIT_THEME is omitted, None, empty, or whitespace-only and no request-local chain exists, ThemeKit generates only the original template name. It then behaves as a transparent wrapper around the configured loaders.


Dynamic selection with middleware

Use middleware when the theme can vary by request.

MIDDLEWARE = [
    # Existing middleware...
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "themekit.middleware.ThemeMiddleware",
]

The default selector uses this priority:

1. request.session[THEMEKIT_SESSION_KEY]
2. getattr(request.user, THEMEKIT_USER_ATTRIBUTE)
3. THEMEKIT_THEME
4. empty chain

The defaults are equivalent to:

THEMEKIT_SESSION_KEY = "theme"
THEMEKIT_USER_ATTRIBUTE = "theme"

During a request, middleware:

  1. selects and normalizes the theme chain;
  2. stores it in request-local ContextVar state;
  3. sets request.theme to the first selected theme or None;
  4. sets request.theme_chain to the complete tuple;
  5. calls the remainder of Django's request stack;
  6. optionally adds debug headers;
  7. restores the previous context state in a finally block.

State is restored even when the view, template rendering, or later middleware raises an exception.

Middleware ordering

Place ThemeKit after any middleware that supplies data used by the selector. For the built-in policy, that normally means after:

"django.contrib.sessions.middleware.SessionMiddleware"

"django.contrib.auth.middleware.AuthenticationMiddleware"

ThemeKit is defensive when request.session or request.user is absent, but placing it correctly is necessary for those selection sources to participate.


Session-based selection

With the default session key:

request.session["theme"] = "phoenix"

A session may hold a fallback chain:

request.session["theme"] = [
    "preview",
    "phoenix",
]

To use another key:

THEMEKIT_SESSION_KEY = "ui_theme"

Then:

request.session["ui_theme"] = "phoenix"

An empty or invalid session theme is ignored by the built-in selector, which then tries the user attribute and configured theme.

To stop a session from overriding lower-priority sources:

request.session.pop("theme", None)

or use the configured custom key.

Session data should contain trusted theme identifiers selected from an application-controlled allowlist. Avoid copying arbitrary URL parameters or unvalidated user input directly into a theme path.


User-based selection

By default, ThemeKit reads:

request.user.theme

The attribute may be a model field, property, or other attribute returning:

  • a string;
  • a sequence of strings;
  • None.

Example user model field:

class User(AbstractUser):
    preferred_theme = models.CharField(
        max_length=64,
        blank=True,
    )

Configure ThemeKit to read it:

THEMEKIT_USER_ATTRIBUTE = "preferred_theme"

The default selector then behaves like:

value = getattr(request.user, "preferred_theme", None)

If the user attribute is missing, empty, or invalid, selection falls through to THEMEKIT_THEME.

Session selection has higher priority than user selection. This makes a session useful for temporary previews while a user attribute stores the persistent preference.


Custom selectors

For tenants, hostnames, organizations, experiments, feature flags, or another project-specific rule, configure a dotted callable path:

THEMEKIT_SELECTOR = "config.themes.select_theme"

Create the selector:

"""
config/themes.py

Project-specific ThemeKit selection policy.
"""

from __future__ import annotations

from django.http import HttpRequest


def select_theme(request: HttpRequest) -> list[str]:
    """Select tenant branding with Phoenix as the fallback."""
    tenant = request.tenant

    return [
        tenant.theme,
        "phoenix",
    ]

The callable receives the current HttpRequest and may return:

"phoenix"
["tenant_acme", "phoenix"]
("preview", "tenant_acme", "phoenix")

or None/an empty value.

The configured custom selector replaces the built-in session -> user -> configured-theme selection policy. Therefore, a custom selector should return the complete chain the project wants for that request.

Example: hostname selection

"""
config/themes.py

Host-based ThemeKit selection policy.
"""

from __future__ import annotations

from django.http import HttpRequest


def select_theme(request: HttpRequest) -> list[str]:
    """Select a branded theme from the request hostname."""
    host = request.get_host().partition(":")[0].lower()

    by_host = {
        "customer-a.example.com": "customer_a",
        "customer-b.example.com": "customer_b",
    }

    selected = by_host.get(host)

    if selected is None:
        return ["phoenix"]

    return [selected, "phoenix"]

Example: preview query parameter with validation

"""
config/themes.py

Validated ThemeKit preview selection.
"""

from __future__ import annotations

from django.http import HttpRequest

_ALLOWED_THEMES = {
    "minimal",
    "phoenix",
}


def select_theme(request: HttpRequest) -> list[str]:
    """Allow staff to preview an approved theme."""
    preview = request.GET.get("theme")

    if request.user.is_staff and preview in _ALLOWED_THEMES:
        return [preview, "phoenix"]

    return ["phoenix"]

Empty custom-selector results

A custom selector should normally return an explicit, complete chain. The current state API distinguishes between the request-local selected chain and the configured active fallback through separate current and active helpers. When an empty current chain is observed by active-resolution code, THEMEKIT_THEME is used as the configured fallback.

For predictable behavior:

  • return the complete fallback chain from a custom selector; or
  • leave THEMEKIT_THEME unset when an empty selector result should mean a fully unthemed project.

Do not assume that returning an empty value is an application-wide replacement for a separately configured THEMEKIT_THEME.

Custom selectors are used only by ThemeMiddleware. Configuring THEMEKIT_SELECTOR without installing the middleware does not provide a request object to the loader and therefore does not perform dynamic selection.


Request-local state

ThemeKit stores the current selected chain in a ContextVar, not a process global and not Django settings.

This prevents one request's selected theme from being intentionally stored as global mutable configuration and allows state to be restored after nested or failed request processing.

ThemeKit exposes two related concepts:

Current theme

The current theme is the chain stored in the active execution context.

from themekit import get_current_theme
from themekit import get_current_theme_chain

Within ThemeKit middleware:

get_current_theme()
# -> "customer"
get_current_theme_chain()
# -> ("customer", "phoenix")

Outside request-local state:

get_current_theme()
# -> None
get_current_theme_chain()
# -> ()

These functions do not independently read the configured theme.

Active theme

The active theme is the request-local chain when one is available, otherwise the configured THEMEKIT_THEME chain.

from themekit import get_active_theme
from themekit import get_active_theme_chain

With no middleware state and:

THEMEKIT_THEME = "phoenix"

these return:

get_active_theme()
# -> "phoenix"
get_active_theme_chain()
# -> ("phoenix",)

The loader and context processor use the active-chain helpers so a static configured theme works without middleware.

Request attributes

When middleware is installed, downstream middleware and views may use:

request.theme

and:

request.theme_chain

For a chain ("customer", "phoenix"):

request.theme == "customer"
request.theme_chain == ("customer", "phoenix")

For no selected request chain:

request.theme is None
request.theme_chain == ()

For type annotations, ThemeKit exports ThemedHttpRequest:

from themekit import ThemedHttpRequest


def dashboard(request: ThemedHttpRequest) -> HttpResponse:
    return HttpResponse(request.theme or "un-themed")

Django itself creates an ordinary HttpRequest/WSGIRequest; the subclass is provided as a typing surface for code that runs after ThemeMiddleware.


Template context variables

Add ThemeKit's context processor when templates need access to the active chain:

TEMPLATES = [
    {
        # ...
        "OPTIONS": {
            # ...
            "context_processors": [
                # Existing processors...
                "themekit.context_processors.theme",
            ],
        },
    },
]

It exposes:

theme

and:

theme_chain

For a ("customer", "phoenix") chain:

{{ theme }}

renders:

customer

and:

{{ theme_chain|join:"," }}

renders:

customer,phoenix

Examples:

<body class="theme-{{ theme|default:'none' }}">
{% if theme == "phoenix" %}
  <meta name="theme-family" content="phoenix">
{% endif %}
{% if "phoenix" in theme_chain %}
  {# Phoenix participates as a fallback theme. #}
{% endif %}

Without an active or configured theme:

{
    "theme": None,
    "theme_chain": (),
}

The context processor does not choose a request theme. It reads existing request-local state or the configured fallback. Dynamic session, user, or custom-selector behavior still requires middleware.


Debug response headers

Enable debug headers:

THEMEKIT_DEBUG_HEADER = True

With the chain:

("customer", "phoenix")

middleware adds:

X-ThemeKit-Theme: customer
X-ThemeKit-Chain: customer,phoenix

Headers are omitted when:

  • THEMEKIT_DEBUG_HEADER is False or omitted;
  • middleware is not installed;
  • the request's selected chain is empty.

The setting must be a boolean. Debug headers can expose internal branding, tenant, or preview identifiers, so enable them only where that information is appropriate to disclose.


Error templates

By default, Django's standard root error template names may be themed:

400.html
403.html
404.html
500.html

With Phoenix active, 404.html resolves as:

1. themes/phoenix/404.html
2. 404.html

Disable themed resolution for these exact standard names:

THEMEKIT_DISABLE_ERROR_TEMPLATES = True

Then ThemeKit passes only the original name to its wrapped loaders:

404.html

This option is useful when a project wants the simplest possible error path or keeps error pages independent of request branding.

The setting affects only the exact root names listed above. A custom path such as:

errors/404.html

is an ordinary template name and follows normal ThemeKit resolution.

The setting must be a boolean.


Template loader configuration

ThemeKit's loader is a wrapper around the Django loaders your project already uses.

The wrapper is important because ThemeKit transforms names while Django's ordinary loaders decide where those names live.

Recommended configuration:

"loaders": [
    (
        "themekit.loaders.ThemedLoader",
        [
            "django.template.loaders.filesystem.Loader",
            "django.template.loaders.app_directories.Loader",
        ],
    ),
]

Do not configure this:

"loaders": [
    "themekit.loaders.ThemedLoader",
    "django.template.loaders.app_directories.Loader",
]

The direct string form gives ThemedLoader no wrapped loader configuration. The app-directories loader outside the wrapper receives only the original name, not ThemeKit's themed candidates.

For example, with the incorrect configuration:

ThemedLoader receives pages/home.html
    -> cannot search app templates correctly without wrapped loaders

app_directories.Loader receives pages/home.html
    -> may find the ordinary template
    -> never receives pages/themes/phoenix/home.html

All source loaders that should participate in themed resolution must be inside ThemeKit's wrapper.


Project template directories

Django's filesystem loader searches directories from TEMPLATES[...]["DIRS"].

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [
            BASE_DIR / "templates",
        ],
        "APP_DIRS": False,
        "OPTIONS": {
            "loaders": [
                (
                    "themekit.loaders.ThemedLoader",
                    [
                        "django.template.loaders.filesystem.Loader",
                    ],
                ),
            ],
        },
    },
]

This is sufficient when all templates are reachable through DIRS.

A project may list many template roots:

APP_TEMPLATE_DIRS = [
    path for path in (BASE_DIR / "apps").rglob("templates") if path.is_dir()
]

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [
            BASE_DIR / "templates",
            *APP_TEMPLATE_DIRS,
        ],
        "APP_DIRS": False,
        "OPTIONS": {
            "loaders": [
                (
                    "themekit.loaders.ThemedLoader",
                    [
                        "django.template.loaders.filesystem.Loader",
                        "django.template.loaders.app_directories.Loader",
                    ],
                ),
            ],
        },
    },
]

ThemeKit supports this structure. The manual directory list is not required, however, when standard installed-app templates are covered by the wrapped app-directories loader.


Installed-app templates

Django's app-directories loader searches the templates/ directory of each application in INSTALLED_APPS.

Typical app structure:

myapp/
├── apps.py
├── views.py
└── templates/
    └── myapp/
        ├── dashboard.html
        └── themes/
            └── phoenix/
                └── dashboard.html

The view renders:

return render(request, "myapp/dashboard.html")

The themed sibling candidate is:

myapp/themes/phoenix/dashboard.html

For ThemeKit to try that candidate inside installed apps, configure app_directories.Loader inside the wrapper:

"loaders": [
    (
        "themekit.loaders.ThemedLoader",
        [
            "django.template.loaders.filesystem.Loader",
            "django.template.loaders.app_directories.Loader",
        ],
    ),
]

The order of INSTALLED_APPS remains meaningful for the app-directories loader, exactly as it is in ordinary Django template resolution.


Custom loaders

ThemeKit can wrap other Django-compatible loaders because its loader configuration accepts the same string and tuple forms Django uses.

Example with Django's in-memory loader:

"loaders": [
    (
        "themekit.loaders.ThemedLoader",
        [
            (
                "django.template.loaders.locmem.Loader",
                {
                    "pages/home.html": "ordinary",
                    "pages/themes/phoenix/home.html": "phoenix",
                },
            ),
        ],
    ),
]

A project-specific loader may also be wrapped:

"loaders": [
    (
        "themekit.loaders.ThemedLoader",
        [
            "project.templates.DatabaseLoader",
            "django.template.loaders.filesystem.Loader",
            "django.template.loaders.app_directories.Loader",
        ],
    ),
]

For every ThemeKit candidate, wrapped loaders are tried in the listed order. The custom loader must follow Django's template-loader interface.


Template caching

When OPTIONS["loaders"] is explicitly configured, use Django's cached loader inside ThemeKit when compiled-template caching is desired.

Recommended cached configuration:

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
        "APP_DIRS": False,
        "OPTIONS": {
            "loaders": [
                (
                    "themekit.loaders.ThemedLoader",
                    [
                        (
                            "django.template.loaders.cached.Loader",
                            [
                                "django.template.loaders.filesystem.Loader",
                                "django.template.loaders.app_directories.Loader",
                            ],
                        ),
                    ],
                ),
            ],
        },
    },
]

The order matters:

ThemedLoader
└── cached.Loader
    ├── filesystem.Loader
    └── app_directories.Loader

ThemeKit first creates a theme-specific candidate such as:

pages/themes/phoenix/home.html

The cached loader then uses that candidate name as its cache key.

Do not put cached.Loader outside ThemeKit:

cached.Loader
└── ThemedLoader

An outer cache sees only the original name, such as pages/home.html. A compiled template selected for one request theme could then be returned for a later request using another theme. Keeping caching inside ThemeKit gives each themed candidate its own key.


Performance

ThemeKit adds template-name attempts; it does not add database queries, network requests, asset compilation, or extra template rendering.

For a template containing a slash and n active themes, ThemeKit generates:

2n + 1 candidate names

For a root template such as base.html, it generates:

n + 1 candidate names

Examples:

1 theme, pages/home.html -> at most 3 candidate names
2 themes, pages/home.html -> at most 5 candidate names
2 themes, base.html -> at most 3 candidate names

Each candidate is tried against wrapped loaders until a match is found. The search stops immediately on the first successful load.

Development mode may perform several additional file existence checks on a cache miss. In production, configure Django's cached loader inside ThemeKit so compiled templates are cached under theme-specific candidate names.

Selector performance is controlled by the project. The built-in selector only reads request/session/user/settings state. A custom selector should avoid unnecessary database or network work, or rely on request state already loaded by earlier middleware.


Settings reference

THEMEKIT_THEME

Type:

str | Sequence[str] | None

Default:

None

Purpose:

The configured theme or ordered fallback chain. It is used by the loader even without middleware and is the final source in the built-in request selector.

Examples:

THEMEKIT_THEME = "phoenix"
THEMEKIT_THEME = ["customer", "phoenix"]
THEMEKIT_THEME = None

An omitted, empty, or whitespace-only value produces no configured chain.


THEMEKIT_SELECTOR

Type:

str | None

Default:

None

Purpose:

A dotted path to a request selector callable. The callable replaces the built-in session -> user -> configured-theme policy and is invoked by ThemeMiddleware.

Example:

THEMEKIT_SELECTOR = "config.themes.select_theme"

The path must import successfully and reference a callable.


THEMEKIT_SESSION_KEY

Type:

str

Default:

"theme"

Purpose:

The session key read by the built-in selector.

Example:

THEMEKIT_SESSION_KEY = "ui_theme"

The value must be a non-empty string.


THEMEKIT_USER_ATTRIBUTE

Type:

str

Default:

"theme"

Purpose:

The attribute read from request.user by the built-in selector.

Example:

THEMEKIT_USER_ATTRIBUTE = "preferred_theme"

The value must be a non-empty string.


THEMEKIT_DEBUG_HEADER

Type:

bool

Default:

False

Purpose:

When middleware selects a non-empty request chain, add:

X-ThemeKit-Theme
X-ThemeKit-Chain

The value must be a boolean.


THEMEKIT_DISABLE_ERROR_TEMPLATES

Type:

bool

Default:

False

Purpose:

When True, bypass themed candidate generation for exact standard root error template names:

400.html
403.html
404.html
500.html

The value must be a boolean.


Public Python API

ThemeKit intentionally exposes a small package-root API:

from themekit import (
    ThemeMiddleware,
    ThemedHttpRequest,
    get_active_theme,
    get_active_theme_chain,
    get_current_theme,
    get_current_theme_chain,
)

ThemeMiddleware

Django middleware that selects and activates a request-local chain, annotates the request, adds optional debug headers, and restores previous state.

Usually referenced by dotted path in settings:

"themekit.middleware.ThemeMiddleware"

ThemedHttpRequest

Typing surface for requests after ThemeKit middleware. It declares:

theme: str | None
theme_chain: tuple[str, ...]

get_current_theme()

Returns the first request-local/current theme, or None.

get_current_theme_chain()

Returns the request-local/current chain as tuple[str, ...].

get_active_theme()

Returns the first current theme, or the first configured theme when current state does not supply one.

get_active_theme_chain()

Returns the current chain when available, otherwise the configured THEMEKIT_THEME chain.

Implementation helpers in modules such as themekit.conf, themekit.selectors, and themekit.resolution are intentionally not exported from the package root unless they are part of this supported public surface.


Django system checks

Run:

python manage.py check

ThemeKit registers checks through ThemeKitConfig.ready().

Current check identifiers:

themekit.E001

ThemeKit's loader is present while the backend has:

"APP_DIRS": True

Explicit loaders require:

"APP_DIRS": False

themekit.E002

ThemedLoader was configured directly without wrapped loaders:

"themekit.loaders.ThemedLoader"

Use tuple configuration:

(
    "themekit.loaders.ThemedLoader",
    [
        "django.template.loaders.filesystem.Loader",
        "django.template.loaders.app_directories.Loader",
    ],
)

themekit.E003

A validated ThemeKit setting is invalid, such as:

  • an invalid theme chain;
  • an invalid selector path or non-callable selector;
  • an invalid or empty session key;
  • an invalid or empty user attribute name.

themekit.W001

ThemeMiddleware is configured but Django's SessionMiddleware is absent.

This is a warning because a custom selector may intentionally not use sessions. With the built-in selector, add SessionMiddleware before ThemeKit if session selection is expected.

System checks require:

"themekit.apps.ThemeKitConfig"

in INSTALLED_APPS so Django calls the app's ready() hook.


Complete configuration recipes

Recipe 1: static site-wide theme

Use this when every request uses the same configured chain.

INSTALLED_APPS = [
    # ...
    "themekit.apps.ThemeKitConfig",
]

THEMEKIT_THEME = "phoenix"

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
        "APP_DIRS": False,
        "OPTIONS": {
            "loaders": [
                (
                    "themekit.loaders.ThemedLoader",
                    [
                        "django.template.loaders.filesystem.Loader",
                        "django.template.loaders.app_directories.Loader",
                    ],
                ),
            ],
        },
    },
]

No ThemeKit middleware is required.


Recipe 2: session preview over a user preference

THEMEKIT_THEME = "phoenix"
THEMEKIT_SESSION_KEY = "theme"
THEMEKIT_USER_ATTRIBUTE = "preferred_theme"

MIDDLEWARE = [
    # ...
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "themekit.middleware.ThemeMiddleware",
]

Priority:

request.session["theme"]
request.user.preferred_theme
"phoenix"

Start a preview:

request.session["theme"] = "minimal"

End it:

request.session.pop("theme", None)

Recipe 3: tenant theme with framework fallback

THEMEKIT_SELECTOR = "config.themes.select_theme"

MIDDLEWARE = [
    # Tenant middleware must attach request.tenant first.
    "project.tenants.middleware.TenantMiddleware",
    "themekit.middleware.ThemeMiddleware",
]
"""
config/themes.py

Tenant-aware ThemeKit selection.
"""

from __future__ import annotations

from django.http import HttpRequest


def select_theme(request: HttpRequest) -> list[str]:
    """Use tenant branding before the shared Phoenix theme."""
    return [
        request.tenant.theme,
        "phoenix",
    ]

Directory example:

templates/
├── pages/
│   └── home.html
└── themes/
    ├── tenant_acme/
    │   └── pages/
    │       └── home.html
    └── phoenix/
        ├── base.html
        └── pages/
            └── home.html

Recipe 4: project directories only

Use only the filesystem loader when installed-app templates are not needed:

"loaders": [
    (
        "themekit.loaders.ThemedLoader",
        [
            "django.template.loaders.filesystem.Loader",
        ],
    ),
]

Every template root must then appear in DIRS.


Recipe 5: production caching

"loaders": [
    (
        "themekit.loaders.ThemedLoader",
        [
            (
                "django.template.loaders.cached.Loader",
                [
                    "django.template.loaders.filesystem.Loader",
                    "django.template.loaders.app_directories.Loader",
                ],
            ),
        ],
    ),
]

Keep cached.Loader inside ThemeKit so candidate names remain theme-specific cache keys.


Testing your integration

ThemeKit is compatible with Django's normal testing tools.

Test configured-theme rendering

from __future__ import annotations

from django.test import Client, override_settings


@override_settings(THEMEKIT_THEME="phoenix")
def test_home_uses_phoenix_override(client: Client) -> None:
    response = client.get("/")

    assert response.status_code == 200
    assert b"phoenix home" in response.content

Test ordinary fallback

@override_settings(THEMEKIT_THEME="missing_theme")
def test_home_falls_back_to_ordinary_template(client: Client) -> None:
    response = client.get("/")

    assert response.status_code == 200
    assert b"ordinary home" in response.content

Test session selection

def test_session_theme_wins(client: Client) -> None:
    session = client.session
    session["theme"] = "preview"
    session.save()

    response = client.get("/")

    assert response.status_code == 200
    assert b"preview home" in response.content

Test context values

{{ theme|default:"none" }}|{{ theme_chain|join:"," }}
@override_settings(THEMEKIT_THEME=["customer", "phoenix"])
def test_context_contains_chain(client: Client) -> None:
    response = client.get("/")

    assert b"customer|customer,phoenix" in response.content

Test inheritance

Ordinary child:

{% extends "base.html" %}
{% block content %}content{% endblock %}

Files:

templates/base.html
templates/themes/phoenix/base.html

Test:

@override_settings(THEMEKIT_THEME="phoenix")
def test_child_inherits_themed_base(client: Client) -> None:
    response = client.get("/inherited/")

    assert b"phoenix base" in response.content

Run Django checks in CI

python manage.py check

For the package itself:

make check
make coverage
make release-check

Troubleshooting

The ordinary template renders even though a theme is active

Check all of the following:

  1. ThemeKit's loader is configured.
  2. APP_DIRS is False.
  3. The relevant source loader is inside ThemeKit's wrapper.
  4. The themed path exactly matches the generated candidate.
  5. The active chain contains the expected theme.
  6. The file is readable and within a configured template source.

For pages/home.html and phoenix, valid paths are:

pages/themes/phoenix/home.html
themes/phoenix/pages/home.html

This is not a matching override:

pages/themes/phoenix/home2.html

Enable:

THEMEKIT_DEBUG_HEADER = True

to inspect the request-selected theme and chain.


Project-level overrides work, but installed-app overrides do not

The filesystem loader sees DIRS; the app-directories loader sees <installed app>/templates/ directories.

Ensure both are inside the wrapper:

(
    "themekit.loaders.ThemedLoader",
    [
        "django.template.loaders.filesystem.Loader",
        "django.template.loaders.app_directories.Loader",
    ],
)

Putting app_directories.Loader after ThemeKit instead of inside it allows the ordinary app template to load but does not send themed candidate names to that loader.


Django reports that APP_DIRS and loaders cannot be used together

Set:

"APP_DIRS": False

Then include:

"django.template.loaders.app_directories.Loader"

inside ThemeKit's wrapped loader list.


ThemeMiddleware does not see the session

Ensure this order:

"django.contrib.sessions.middleware.SessionMiddleware"

"themekit.middleware.ThemeMiddleware"

Also verify THEMEKIT_SESSION_KEY matches the key written to the session.


ThemeMiddleware does not see the user preference

Ensure authentication middleware runs first:

"django.contrib.auth.middleware.AuthenticationMiddleware"

"themekit.middleware.ThemeMiddleware"

Verify THEMEKIT_USER_ATTRIBUTE matches the field or property name.


THEMEKIT_SELECTOR appears to do nothing

A custom selector is request-aware and is called by ThemeMiddleware. Configure both:

THEMEKIT_SELECTOR = "config.themes.select_theme"

and:

"themekit.middleware.ThemeMiddleware"

Check that middleware required by the selector, such as tenant, authentication, locale, or session middleware, runs before ThemeKit.


Debug headers are missing

Headers require:

THEMEKIT_DEBUG_HEADER = True

plus ThemeKit middleware and a non-empty request-selected chain.

No middleware means no response hook, even though a static configured theme may still affect the loader.


The wrong theme appears after enabling caching

Verify the cache is inside ThemeKit:

ThemedLoader -> cached.Loader -> source loaders

not:

cached.Loader -> ThemedLoader

An outer cache may cache the final template under the original unthemed name.


Error pages ignore theme overrides

Check:

THEMEKIT_DISABLE_ERROR_TEMPLATES

When True, the exact names 400.html, 403.html, 404.html, and 500.html use ordinary resolution only.


theme and theme_chain are missing in templates

Add:

"themekit.context_processors.theme"

to the relevant backend's context processors. Render with a request-aware API such as Django's render() or TemplateResponse so context processors run.


python manage.py check does not report ThemeKit checks

Ensure:

"themekit.apps.ThemeKitConfig"

is in INSTALLED_APPS.


Migrating an existing local theme implementation

A project with local settings such as:

DEFAULT_SITE_THEME = "phoenix"
THEMES_DISABLE_FOR_ERROR_TEMPLATES = False

can migrate to:

THEMEKIT_THEME = "phoenix"
THEMEKIT_DISABLE_ERROR_TEMPLATES = False

Replace local middleware:

"atlas.core.themes.middleware.ThemeMiddleware"

with:

"themekit.middleware.ThemeMiddleware"

Replace the local context processor:

"atlas.core.themes.context_processors.theme"

with:

"themekit.context_processors.theme"

Replace a local filesystem-only loader configuration such as:

"loaders": [
    "atlas.core.themes.loaders.ThemedLoader",
    "django.template.loaders.app_directories.Loader",
]

with the general wrapper:

"loaders": [
    (
        "themekit.loaders.ThemedLoader",
        [
            "django.template.loaders.filesystem.Loader",
            "django.template.loaders.app_directories.Loader",
        ],
    ),
]

The wrapper configuration handles both:

  • projects that manually place every app template root in DIRS;
  • conventional Django projects that rely on installed-app template discovery.

Existing paths remain valid:

pages/themes/phoenix/home.html
themes/phoenix/pages/home.html
themes/phoenix/base.html

Review template context variable names. ThemeKit exposes:

theme
theme_chain

rather than legacy names such as:

current_theme
current_theme_chain

Review legacy hard-coded fallback behavior as well. ThemeKit does not invent a built-in theme named default. When no source selects a theme, the active chain is empty and ordinary templates resolve normally.

Finally, run:

python manage.py check

and the project's full test suite.


Internal architecture

ThemeKit is intentionally split into small modules with one responsibility each.

themekit.conf

  • defines ThemeKit setting names;
  • normalizes theme values into ordered tuples;
  • validates boolean and string settings;
  • imports a configured selector callable;
  • exposes configured theme and selection settings.

themekit.state

  • stores request-local theme state in a ContextVar;
  • distinguishes current and active theme access;
  • supports token-based restoration for nested or failed execution.

themekit.selectors

  • implements the built-in session -> user -> configured-theme policy;
  • tolerates missing or partially initialized request state;
  • invokes a configured custom selector;
  • normalizes selector results.

themekit.middleware

  • activates the selected chain for one request;
  • adds request.theme and request.theme_chain;
  • adds optional debug headers;
  • restores previous context state in finally.

themekit.resolution

  • contains the pure template-name algorithm;
  • generates sibling candidates;
  • generates global candidates;
  • preserves ordinary fallback;
  • prevents recursive rewriting of explicitly themed names.

themekit.loaders

  • wraps arbitrary Django template loaders;
  • asks each loader to resolve each generated candidate;
  • preserves wrapped-loader ordering;
  • forwards Django's inheritance skip origins;
  • chains TemplateDoesNotExist failures.

themekit.context_processors

  • exposes theme and theme_chain to Django templates.

themekit.checks

  • validates common settings and loader mistakes through Django's checks framework.

themekit.apps

  • registers ThemeKit under Django;
  • imports system checks during app readiness.

themekit.__init__

  • defines the intentionally small public package-root API.

This separation is deliberate:

settings
   -> conf

request
   -> selectors
   -> middleware
   -> state

requested template name + active state
   -> resolution
   -> loader
   -> wrapped Django loaders

active state
   -> context processor
   -> template variables

Security and trust boundaries

Theme names are used as template path components. ThemeKit strips whitespace and validates chain item types, but it does not currently enforce a slug pattern or maintain an allowlist.

Applications should:

  • use simple theme identifiers;
  • validate preview parameters;
  • map tenants or users to approved theme names;
  • avoid accepting arbitrary untrusted path fragments;
  • avoid exposing debug headers when theme identifiers reveal sensitive tenant or experiment details.

Django's built-in filesystem loaders protect configured template roots, but an application should still treat theme selection as controlled configuration, not as a general-purpose user-supplied file path.

A custom selector executes during request handling. It should be deterministic, fast, and free of unsafe side effects.


Non-goals

ThemeKit intentionally does not attempt to become a complete frontend or theme marketplace framework.

The following remain project responsibilities:

  • theme CSS and JavaScript;
  • static-file names and storage;
  • build pipelines;
  • asset manifests;
  • design tokens;
  • user-facing theme-switcher views and forms;
  • database persistence of theme preferences;
  • tenant models;
  • permissions around theme previews;
  • theme completeness auditing;
  • visual documentation.

Keeping those concerns outside ThemeKit allows the package to remain useful across very different Django stacks.


Contributing

Contributions are welcome. See CONTRIBUTING.md for the local development workflow, quality checks, testing requirements, and pull-request guidelines.

Project changes are recorded in CHANGELOG.md.

Useful local commands include:

make sync
make format
make check
make coverage
make release-check

ThemeKit maintains complete statement and branch coverage for its supported behavior.


License

django-themekit is released under the MIT License. See LICENSE for the full text.


Django references

ThemeKit builds on Django's documented template extension points:

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

django_themekit-0.1.0.tar.gz (37.7 kB view details)

Uploaded Source

Built Distribution

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

django_themekit-0.1.0-py3-none-any.whl (26.2 kB view details)

Uploaded Python 3

File details

Details for the file django_themekit-0.1.0.tar.gz.

File metadata

  • Download URL: django_themekit-0.1.0.tar.gz
  • Upload date:
  • Size: 37.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for django_themekit-0.1.0.tar.gz
Algorithm Hash digest
SHA256 e90d182cdcbb5a5b2f09d239031af42a843aab0c45b06a142dd265e23f12ba7a
MD5 e2068e923d9a28812de8d931cd2ed03a
BLAKE2b-256 1825f7b7f919e64c37f8541b69e339a63190f263061724ad2e44896e2ea66496

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_themekit-0.1.0.tar.gz:

Publisher: publish.yml on fifoa-labs/django-themekit

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

File details

Details for the file django_themekit-0.1.0-py3-none-any.whl.

File metadata

File hashes

Hashes for django_themekit-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 f440bcb97902ae0f224351e5f6608faca5e0eacd271c46239ede6c337dc2351c
MD5 928f2fc9ddd78bbdc87eb65228427387
BLAKE2b-256 b40679539bbf471b2ffecaccb45206b68880a97e4aa870cee6fddc2c6e577ffe

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_themekit-0.1.0-py3-none-any.whl:

Publisher: publish.yml on fifoa-labs/django-themekit

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