django-admin-react is a Django package that ships a pre-built React
single-page application against the stable, conservative JSON REST API
exposed by the sibling package
django-admin-rest-api
(its own PyPI release). The Django ModelAdmin is the only source of
truth for permissions, querysets, forms, and field configuration — both
packages honour that contract.
This document is the architectural contract for the SPA super-layer. The wire-contract document (what the API actually emits / accepts) lives in the API repo — this file refers to it but does not duplicate it.
The codebase split into three repos so each layer can be reasoned about and consumed independently:
django-admin-rest-api— every JSON endpoint, the serializer denylist, the permission gates, the wire contract. SameModelAdmin, same permissions, no new features.django-admin-react(this repo) — the React SPA super-layer on top of that API. Frontend, build pipeline, pre-built assets, the SPA mount + PWA glue. Depends ondjango-admin-rest-api.django-admin-mcp-api(PyPI) — a wire-protocol-only MCP adapter overdjango-admin-rest-api(call, manifest, …). Reuses the same API classes, adds no new functionality / permissions / validation. Installed as a sibling dependency of this package.Migration of the API code out of this repo (Phases 1–7) is tracked in META #544. Sections of this document that describe
django_admin_react/api/internals describe transient state during that migration; the long-term home for those details is the API repo.
- Plug-and-play replacement for
django.contrib.admin's HTML views. - Single-page, responsive React UI built with Tailwind.
- Shared authentication with
django.contrib.admin(Django session + CSRF). Staff-only by default. The package never invents its own user or permission model. - Extensible without writing React. Developers extend the Django
ModelAdminthey already write; the UI reflects the changes automatically. - Configurable URL mount point. The consumer chooses where the package
is served (e.g.,
/admin/,/admin-react/,/staff/). - Open-source, PyPI-publishable, security-audited.
Explicit non-goals for v1: custom admin widgets (a backend↔frontend
widget-registry contract) and runtime theme/config swapping. Several
features deferred in early drafts — inlines, custom & bulk admin actions,
autocomplete / raw_id_fields, ManyToMany, and a React-side panel
extension surface — have since shipped; §8 carries the current split. The
Project board
tracks remaining work by Phase.
┌───────────────────────────────┐ ┌─────────────────────────────┐
│ Browser (React SPA) │ │ Consumer Django project │
│ - React + React Router │ HTTPS │ - urls.py includes │
│ - React Query │ ◄────► │ django_admin_react.urls │
│ - Tailwind │ session │ - settings has the package │
│ - Reads only API metadata │ + CSRF │ - django.contrib.admin is │
└──────────────▲────────────────┘ │ still the source of │
│ │ truth for permissions │
│ └──────────────┬──────────────┘
│ │
│ static index.html + JS/CSS │
│ │
┌──────────────┴────────────────┐ ┌──────────────▼──────────────┐
│ django_admin_react (pkg) │ │ admin.site._registry │
│ - urls.py (mountable) │ reads │ ModelAdmin instances │
│ - api/v1/* DRF-style views │ ◄────── │ (the consumer's) │
│ - templates/admin_react/... │ │ │
│ - static/admin_react/... │ │ │
└───────────────────────────────┘ └─────────────────────────────┘
The package never imports the consumer's models directly. It works
exclusively through Django's AdminSite._registry to discover models and
through each ModelAdmin instance's public methods to act on them.
django-admin-react/
├── django_admin_react/ # The Python package (PyPI artifact)
│ ├── __init__.py
│ ├── apps.py
│ ├── urls.py # Mountable. Splits api/ and SPA/ subroutes.
│ ├── views.py # Index view (serves React index.html).
│ ├── api/
│ │ ├── __init__.py
│ │ ├── permissions.py # IsStaffUser, model-level checks
│ │ ├── registry.py # AdminSite introspection helpers
│ │ ├── serializers.py # Conservative field serialization
│ │ ├── urls.py
│ │ └── views/
│ │ ├── registry.py # GET /api/v1/registry/
│ │ ├── list.py # GET /api/v1/{app}/{model}/
│ │ ├── detail.py # GET /api/v1/{app}/{model}/{pk}/
│ │ ├── create.py # POST /api/v1/{app}/{model}/
│ │ ├── update.py # PATCH /api/v1/{app}/{model}/{pk}/
│ │ └── delete.py # DELETE
│ ├── conf.py # settings.DJANGO_ADMIN_REACT loader
│ ├── templates/admin_react/index.html
│ └── static/admin_react/ # Compiled React bundle drops here
│
├── frontend/ # pnpm workspace (not shipped via PyPI)
│ ├── pnpm-workspace.yaml
│ ├── package.json
│ └── packages/
│ ├── shell/ # App shell, router, auth boundary
│ ├── ui/ # Generic, reusable, props-driven primitives
│ ├── api/ # React Query hooks; typed API client
│ ├── data/ # Context providers + localStorage cache;
│ │ # the only data layer UI packages talk to
│ ├── list/ # List-view components
│ ├── details/ # Detail/edit/create components
│ └── models/ # Model navigation, registry browsing
│
├── examples/ # Demo Django projects, not packaged
│ ├── fintech/ # Account, Transaction, Statement, ...
│ ├── library/ # Author, Book, Loan, ...
│ ├── blog/ # Post, Comment, Tag, ...
│ └── shared_project/ # Settings/urls glue that mounts the lib
│
├── tests/ # Pytest suite for the Python package
│ ├── conftest.py
│ ├── test_project/ # Minimal Django project for tests
│ └── ...
│
├── docs/ # Long-form documentation
│ ├── api-contract.md
│ ├── installation.md
│ └── agents/
│ ├── decisions.md
│ └── open-questions.md
│
├── ARCHITECTURE.md # ← you are here
├── SECURITY.md
├── README.md # product sales page + install + 3-repo links
├── LICENSE
└── pyproject.toml # Poetry-managed
Live status / backlog / coordination lives on GitHub (Issues, the Project board, Discussions, PR review comments), not in committed markdown.
Every folder above has its own README.md describing its purpose and what
belongs there.
For every API operation, the backend resolves the target ModelAdmin
through admin.site._registry and delegates:
| Operation | Methods consulted on ModelAdmin |
|---|---|
| List models | admin.site._registry (and has_module_permission, has_view_permission) |
| List objects | get_queryset(request), get_search_results(...), get_list_display(request) |
| Detail | get_queryset(request), has_view_permission(request, obj), get_form(request, obj) |
| Create | has_add_permission(request), get_form(request), save_model(...) |
| Update | has_change_permission(request, obj), get_form(request, obj), save_model(...) |
| Delete | has_delete_permission(request, obj), delete_model(request, obj) |
| Field visibility | get_fields, get_readonly_fields, get_exclude, get_fieldsets |
The package never calls Model.objects.all() directly. The starting
queryset always comes from ModelAdmin.get_queryset(request).
The package never trusts a client-provided app_label/model_name
without round-tripping through admin.site._registry. Unregistered models
return 404.
- Reuses Django's session middleware. If a user is logged into
django.contrib.admin, they are logged into the React SPA at the same cookie scope. - CSRF enforcement is on for all unsafe methods (
POST,PATCH,PUT,DELETE). - Default access policy:
request.user.is_active and request.user.is_staff. - Per-model and per-object access is never decided by the package; it is
always the answer of the appropriate
has_*_permissionon theModelAdmin. - If a consumer customizes their
AdminSite.has_permission()(e.g., to allow non-staff users), this package follows that customization — it does not claim its own access policy beyond the staff default fallback.
Conservative, list in SECURITY.md and codified in
api/serializers.py:
- Pass-through types:
str,int,float,bool,None,Decimal(as string),UUID(as string),date(ISO),datetime(ISO with timezone). ForeignKey→{ "id": ..., "label": str(obj) }.ManyToMany→ list of{ "id": ..., "label": str(obj) }envelopes (Issue #55).FileField/ImageField→{ "name", "url", "size" };urldefers to the consumer's storage backend so signed-URL backends work unchanged (Issue #57).- Callable
list_displayvalues are invoked with the object and serialized viastr(value). - Anything else falls back to
str(value)rather than crashing. - Passwords, secrets, tokens, API keys, hashed fields, and any field listed
in
ModelAdmin.exclude/get_excludeare never serialized.
- Creates and updates must go through
ModelAdmin.get_form(). PATCHis implemented as: load the bound form's initial data from the existing object, merge the incoming payload, validate, then save throughModelAdmin.save_model(...).DELETEcallsModelAdmin.delete_model(request, obj), neverobj.delete().- The package refuses to write fields that the admin form excludes, marks readonly, or otherwise hides — even if the client provides them.
django_admin_react.urls is a standard urlpatterns list that the consumer
includes wherever they like:
# consumer project urls.py
from django.urls import include, path
urlpatterns = [
path("admin-react/", include("django_admin_react.urls")),
]The package does not hardcode /admin-react/. Internally it derives all
links from request.path and reverse(), so the SPA works at any mount
point. The mount point is exposed to the SPA via a <meta> tag in
index.html so the React client knows where its API lives.
A single optional dict settings.DJANGO_ADMIN_REACT controls package
behaviour. v1 keys (all optional):
DJANGO_ADMIN_REACT = {
"ADMIN_SITE": "django.contrib.admin.site", # dotted path; default = the
# global admin site
"DEFAULT_PAGE_SIZE": 25, # fallback only; the model's
# ModelAdmin.list_per_page is the source (#281).
"MAX_PAGE_SIZE": 200,
"ENABLE_PROFILING": False,
# Branding (consumer-facing SPA shell) — all optional overrides;
# the defaults derive from the AdminSite (#281), so a consumer who
# already branded their admin needs nothing here.
"BRAND_TITLE": None, # str | None — overrides BOTH brand strings.
# None → sidebar header ← ADMIN_SITE.site_header,
# browser tab title ← ADMIN_SITE.site_title
# (else site_header), then "Django Admin".
"BRAND_LOGO_URL": None, # str | None — favicon + sidebar logo.
# None → ADMIN_SITE.site_logo attribute if set.
# Absolute URL or a path under STATIC_URL.
}Page size derives from the model's ModelAdmin.list_per_page (Django's
changelist source of truth / Rule #1, #281), so the SPA pages like the
HTML admin with no extra setting; the global DEFAULT_PAGE_SIZE is the
fallback only when list_per_page is missing/invalid. MAX_PAGE_SIZE
always caps ?page_size (a DoS guard).
The package reads these via django_admin_react/conf.py (a thin lazy
wrapper). Nothing in the package may read settings.DJANGO_ADMIN_REACT
directly.
BRAND_TITLE and BRAND_LOGO_URL are rendered server-side into the
SPA index template (templates/admin_react/index.html) as
<meta name="dar-brand-title"> and <meta name="dar-brand-logo">.
The React shell reads them at boot — first paint already carries the
consumer's brand; no FOUC against the package defaults. They are
plain text / URL; no markup is interpolated.
The frontend is a pnpm workspace. Packages are intentionally small and
single-purpose:
@dar/ui— Tailwind-styled primitives (Button, Input, Select, Table, Pagination, Toast, FormField, Skeleton, Empty, Error). No business logic. No knowledge of Django, models, or the API.@dar/api— Thin REST client + React Query hooks (useRegistry,useObjectList,useObject,useCreateObject,useUpdateObject,useDeleteObject). One source of typing for everything the API returns. UI packages never import@dar/apidirectly — they go through@dar/data(see below).@dar/data— The single source of truth UI components read from. Implemented as React Context providers (RegistryProvider,ObjectListProvider,ObjectProvider). Each provider:- On mount, hydrates synchronously from
localStorageso the UI has something to render on first paint, even offline. - Calls into
@dar/api(React Query) to fetch the canonical server state; once it arrives, reconciles the local cache and dispatches a re-render. - Debounces user-initiated mutations (a small configurable window; default 300ms for field edits, 0ms for explicit submit/delete) so the UI feels responsive without flooding the backend with PATCHes.
- Writes through to
@dar/apimutations on debounce flush; on server rejection, rolls back the local cache and surfaces a toast via@dar/ui. - Owns conflict resolution for concurrent edits in v1.x; in v1 it
uses "last write wins" with a warning.
This is the only layer UI packages (
@dar/list,@dar/details,@dar/models) talk to. They never import React Query or@dar/apidirectly. This rule keeps the data-flow boundary explicit and lets us swap the cache backend later without touching UI code.
- On mount, hydrates synchronously from
@dar/list— Generic list page. Reads metadata from@dar/data, renders headers fromlist_display, wires search/pagination.@dar/details— Generic detail/create/update page. Renders a form from the field metadata returned by@dar/data.@dar/models— Sidebar/registry navigation. Lists apps and models the current user can view, sourced from@dar/data.@dar/web— App entry point, router, auth boundary, layout, theme bootstrap. This is what gets built into the Django package'sstatic/.
No frontend package may know about Account, Book, Transaction, or any
example model. The UI is driven entirely by API metadata. If something
cannot be rendered generically from the metadata the backend returns, the
metadata is incomplete — fix the backend, not the frontend.
There is exactly one data path inside the SPA:
@dar/api ◄── network ──► Django REST endpoints
▲
│ (only @dar/data may import @dar/api)
▼
@dar/data ◄── React Context ──► @dar/list, @dar/details, @dar/models, @dar/web
- UI packages must read from
@dar/datahooks (useRegistry,useObjectList,useObject,useMutateObject). They must notimport "@dar/api"directly. @dar/datais the persistence + reconciliation layer: localStorage on first paint, React Query in the background, debounced writes.- A misuse is a CI-failing lint rule (added in PR #6).
- The package ships a
tailwind.config.jswith a minimal, modern, neutral palette and CSS-variable-backed colors for trivial recoloring. - Consumers can extend it via their own
tailwind.config.js. - Partial replacement is supported (e.g., override
colors,fontFamily,spacing). - Full replacement of the config is not a v1 goal — it would mean every component is on a yet-undefined contract. We support theming through CSS variables, which is enough for ~90% of branding needs without rebuilding the bundle.
- An "extend the bundle yourself" escape hatch is documented for advanced users who want to rebuild from source against a custom Tailwind config.
pnpm --filter @dar/web buildproduces adist/withindex.html, hashed JS, and hashed CSS.- A
make build-frontend(orpoetry run dar-build) command copies the artifact intodjango_admin_react/static/admin_react/anddjango_admin_react/templates/admin_react/index.html. - The Python package on PyPI ships the pre-built assets so consumers do not need Node to install it.
For every model and every object the UI receives, the API returns a
permissions object reflecting the ModelAdmin answer:
{
"view": true,
"add": true,
"change": true,
"delete": false
}The React app uses these booleans to hide/disable buttons. Even if the
client tries to call a forbidden endpoint anyway, the backend re-checks via
has_*_permission and responds 403. The UI is a courtesy; the backend is
the gate.
- The API namespace is
api/v1/. Breaking changes require a new namespace. - The SPA always targets the API namespace it was built against. Mismatched versions return a clear error rather than rendering broken pages.
- Python package version follows SemVer, tracked in
pyproject.toml.
Several features were deferred in early drafts and have since shipped. This section tracks the current split — not the original plan — so the contract stays true as v1 nears.
Shipped (now in v1 scope):
- Inlines — read + write via a formset round-trip, with per-row
permission gates (
api/inlines.py,api/inlines_write.py; Issue #54). - Custom admin actions — run through
ModelAdmin.get_actionsbehind a per-action permission gate (api/views/actions.py; Issue #101). - Bulk actions — the same action surface applied to a selection (Issue #103).
- Autocomplete /
raw_id— backed byModelAdmin.get_search_results(api/views/autocomplete.py; Issue #97). ManyToMany— serialized as{id, label}envelopes (Issue #55), superseding the original "unsupported" stub.- React panel extension surface — a metadata-driven panel hook
(
api/panels.py; Issue #111). Extensions are still declared on the Django side; the package never asks consumers to write React.
Still out of scope for v1:
- Custom widgets — would force a widget-registry contract between backend and frontend. Deferred to v1.x.
- Runtime theme / config swapping — theming is build-time via Tailwind / CSS variables (§5.3); live swap is v1.x.
- Concurrent-edit conflict resolution — owned by the
@dar/datadebounce buffer in v1.x (§5.2a).
See the Project board for the sequencing of remaining work.