django-themekit
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.
- PyPI: https://pypi.org/project/django-themekit/
- Source: https://github.com/fifoa-labs/django-themekit
- Issues: https://github.com/fifoa-labs/django-themekit/issues
- License: MIT
Contents
- The core idea
- What ThemeKit does and does not do
- Requirements
- Installation
- Quick start: one configured theme
- Recommended complete configuration
- Template layout conventions
- Exact template resolution order
- Root templates such as
base.html - Template inheritance
- Partial themes and transparent fallback
- Theme chains
- Theme selection
- Static selection with
THEMEKIT_THEME - Dynamic selection with middleware
- Session-based selection
- User-based selection
- Custom selectors
- Request-local state
- Template context variables
- Debug response headers
- Error templates
- Template loader configuration
- Project template directories
- Installed-app templates
- Custom loaders
- Template caching
- Performance
- Settings reference
- Public Python API
- Django system checks
- Complete configuration recipes
- Testing your integration
- Troubleshooting
- Migrating an existing local theme implementation
- Internal architecture
- Security and trust boundaries
- Non-goals
- Contributing
- License
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
DjangoTemplatesbackend.
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_THEMEsupplies a configured theme even without middleware.- Middleware is required for request-specific selection.
- The context processor is required only when templates need
themeortheme_chainvariables. - 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:
- sibling overrides for each theme, left to right;
- global overrides for each theme, left to right;
- 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:
Nonebecomes 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_THEMEsetting; - 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_THEMEconfigured;- no ThemeKit middleware;
- no sessions or authentication required.
Important distinctions:
THEMEKIT_THEMEdoes not install the loader.- Defining the setting alone does not alter template resolution.
- The loader must be present in
TEMPLATESfor 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:
- selects and normalizes the theme chain;
- stores it in request-local
ContextVarstate; - sets
request.themeto the first selected theme orNone; - sets
request.theme_chainto the complete tuple; - calls the remainder of Django's request stack;
- optionally adds debug headers;
- restores the previous context state in a
finallyblock.
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_THEMEunset 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_HEADERisFalseor 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:
- ThemeKit's loader is configured.
APP_DIRSisFalse.- The relevant source loader is inside ThemeKit's wrapper.
- The themed path exactly matches the generated candidate.
- The active chain contains the expected theme.
- 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.themeandrequest.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
skiporigins; - chains
TemplateDoesNotExistfailures.
themekit.context_processors
- exposes
themeandtheme_chainto 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
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
e90d182cdcbb5a5b2f09d239031af42a843aab0c45b06a142dd265e23f12ba7a
|
|
| MD5 |
e2068e923d9a28812de8d931cd2ed03a
|
|
| BLAKE2b-256 |
1825f7b7f919e64c37f8541b69e339a63190f263061724ad2e44896e2ea66496
|
Provenance
The following attestation bundles were made for django_themekit-0.1.0.tar.gz:
Publisher:
publish.yml on fifoa-labs/django-themekit
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_themekit-0.1.0.tar.gz -
Subject digest:
e90d182cdcbb5a5b2f09d239031af42a843aab0c45b06a142dd265e23f12ba7a - Sigstore transparency entry: 2333426535
- Sigstore integration time:
-
Permalink:
fifoa-labs/django-themekit@cbb0f55b2a2e46b41f5cce00ce2140c4f1c4100b -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/fifoa-labs
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@cbb0f55b2a2e46b41f5cce00ce2140c4f1c4100b -
Trigger Event:
release
-
Statement type:
File details
Details for the file django_themekit-0.1.0-py3-none-any.whl.
File metadata
- Download URL: django_themekit-0.1.0-py3-none-any.whl
- Upload date:
- Size: 26.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f440bcb97902ae0f224351e5f6608faca5e0eacd271c46239ede6c337dc2351c
|
|
| MD5 |
928f2fc9ddd78bbdc87eb65228427387
|
|
| BLAKE2b-256 |
b40679539bbf471b2ffecaccb45206b68880a97e4aa870cee6fddc2c6e577ffe
|
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
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_themekit-0.1.0-py3-none-any.whl -
Subject digest:
f440bcb97902ae0f224351e5f6608faca5e0eacd271c46239ede6c337dc2351c - Sigstore transparency entry: 2333426570
- Sigstore integration time:
-
Permalink:
fifoa-labs/django-themekit@cbb0f55b2a2e46b41f5cce00ce2140c4f1c4100b -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/fifoa-labs
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@cbb0f55b2a2e46b41f5cce00ce2140c4f1c4100b -
Trigger Event:
release
-
Statement type: