Your first app¶
In this tutorial you'll build a tiny todo list. By the time you're done, you'll have touched every important part of reflex-django:
- Pages and routes (
@template) - State and event handlers (
AppState,@rx.event) - The Django ORM from inside an event handler
- Authentication — only logged-in users see their own todos
- The Django admin, still working at
/admin/
It takes about 15 minutes. We'll go slowly and explain why at each step.
What we're building¶
A page at / that lists your todos, lets you add one, and lets you tick them off. You sign in at /admin/ (Django's normal login). Each user only sees their own todos. Everything runs in one Python process on one port.
1. Create the project¶
mkdir myshop && cd myshop
uv init
uv add django reflex reflex-django
uv run django-admin startproject config .
uv run python manage.py startapp shop
You should now have a layout like this:
myshop/
├── manage.py
├── config/
│ ├── settings.py
│ ├── urls.py
│ └── asgi.py
└── shop/
├── models.py
├── views.py
└── admin.py
config/ is the Django project package (settings, top-level URLs, ASGI entry). shop/ is a Django "app" — a small feature module. Your code goes in shop/.
2. Edit settings.py¶
Three things we care about: add reflex_django and shop to INSTALLED_APPS, make sure session and auth middleware are present (so AppState can see the user), and add AsyncStreamingMiddleware at the end.
# config/settings.py
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = "dev-only-change-me"
DEBUG = True
ALLOWED_HOSTS = ["localhost", "127.0.0.1"]
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"reflex_django",
"shop",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"reflex_django.streaming_middleware.AsyncStreamingMiddleware",
]
ROOT_URLCONF = "config.urls"
ASGI_APPLICATION = "config.asgi.application"
STATIC_URL = "/static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
Why those middlewares matter: every middleware in that list runs on every Reflex button click too. The session middleware loads the session from the cookie. The auth middleware turns that session into request.user. Without them, your Reflex handlers couldn't tell who the user is.
3. Wire urls.py¶
# config/urls.py
from django.contrib import admin
from django.urls import path
from reflex_django.urls import reflex_mount
urlpatterns = [
path("admin/", admin.site.urls),
]
urlpatterns += [
reflex_mount(
app_name="shop",
django_prefix=("/admin",),
rx_config={"backend_port": 8000},
),
]
Three pieces:
app_name="shop"— Reflex will look for pages inshop/views.py.django_prefix=("/admin",)— Django owns anything under/admin/. Everything else falls through to the Reflex SPA.rx_config={"backend_port": 8000}— what port to bind on.
4. Point ASGI at reflex_django¶
# config/asgi.py
import os
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
from reflex_django.asgi_entry import application # noqa: E402,F401
This is the single entry point both the dev server and your future production server will use.
5. Define a Todo model¶
# shop/models.py
from django.conf import settings
from django.db import models
class Todo(models.Model):
title = models.CharField(max_length=200)
done = models.BooleanField(default=False)
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ["-created_at"]
def __str__(self):
return self.title
Standard Django. owner ties each todo to a user so each person only sees their own.
Register it with the admin (optional, but useful while we're here):
# shop/admin.py
from django.contrib import admin
from shop.models import Todo
admin.site.register(Todo)
Now run migrations and create yourself a user:
6. Write the page and the state¶
This is the part that's unique to reflex-django. We'll build it in three layers: state, page function, then wire them together.
# shop/views.py
import reflex as rx
from reflex_django import template
from reflex_django.state import AppState
from shop.models import Todo
class TodoState(AppState):
todos: list[dict] = []
new_title: str = ""
error: str = ""
@rx.event
async def on_load(self):
if not self.request.user.is_authenticated:
self.todos = []
return
self.todos = [
{"id": t.id, "title": t.title, "done": t.done}
async for t in Todo.objects.filter(owner=self.request.user)
]
@rx.event
async def add_todo(self):
self.error = ""
title = self.new_title.strip()
if not title:
self.error = "Type something first."
return
if not self.request.user.is_authenticated:
self.error = "Please log in at /admin/ first."
return
await Todo.objects.acreate(owner=self.request.user, title=title)
self.new_title = ""
await self.on_load()
@rx.event
async def toggle(self, todo_id: int):
if not self.request.user.is_authenticated:
return
todo = await Todo.objects.aget(pk=todo_id, owner=self.request.user)
todo.done = not todo.done
await todo.asave()
await self.on_load()
Three things to notice:
TodoState inherits from AppState, not plain rx.State. That's what gives us self.request.user inside the handlers. AppState is the bridge between Reflex events and Django. (More on AppState.)
Handlers are async def and use Django's async ORM (acreate, aget, asave, async iteration). If you used the blocking versions (create, get, save), you'd freeze the event loop for every other user.
We re-query in on_load and re-call it after mutations, so the UI always shows the current truth. This is simple and correct. Once you have lots of rows, you can move to the ModelState helper which does this for you.
Now the UI:
# shop/views.py — continued
def todo_row(todo: dict) -> rx.Component:
return rx.hstack(
rx.checkbox(
checked=todo["done"],
on_change=lambda _: TodoState.toggle(todo["id"]),
),
rx.text(
todo["title"],
text_decoration=rx.cond(todo["done"], "line-through", "none"),
),
spacing="3",
)
@template(route="/", title="My Todos", on_load=TodoState.on_load)
def index() -> rx.Component:
return rx.vstack(
rx.heading("My Todos"),
rx.cond(
TodoState.is_authenticated,
rx.vstack(
rx.hstack(
rx.input(
placeholder="What needs doing?",
value=TodoState.new_title,
on_change=TodoState.set_new_title,
),
rx.button("Add", on_click=TodoState.add_todo),
),
rx.cond(TodoState.error != "", rx.callout(TodoState.error, color_scheme="red")),
rx.foreach(TodoState.todos, todo_row),
spacing="3",
align="start",
),
rx.text("Please log in at ", rx.link("/admin/", href="/admin/"), " first."),
),
spacing="4",
padding="2em",
)
A few small things:
@template(route="/", on_load=...)registers the page and tells Reflex to runTodoState.on_loadwhenever the user visits this URL.TodoState.is_authenticatedis a reactive variable thatAppStateexposes for free — it mirrorsrequest.user.is_authenticated.rx.foreachiterates over thetodoslist and renders onetodo_rowper item.TodoState.set_new_titleis auto-generated by Reflex for thenew_title: strfield. You didn't write it.
7. Run it¶
The first time, this will:
- Build the Reflex SPA (a one-time compile step).
- Start
uvicornon port 8000. - Watch your Python files for changes and restart on edit.
Open it:
http://localhost:8000/— the todo page.http://localhost:8000/admin/— the Django admin (log in with the superuser you created).
Try this flow:
- Visit
/without logging in. You see "Please log in at /admin/". - Visit
/admin/, log in. - Go back to
/. You see the input box. - Type "Buy milk", hit Add. The row appears.
- Click the checkbox. The text goes line-through.
That's a full reactive CRUD page in about 80 lines of Python, with real Django auth, in one process, on one port.
What just happened?¶
When you clicked "Add", here's the actual sequence inside the server:
- The browser sent a WebSocket event: "call
TodoState.add_todo". reflex-djangosaw the event, built a syntheticHttpRequestfrom the cookies, and ransettings.MIDDLEWAREover it.SessionMiddlewareloaded your session row.AuthenticationMiddlewareresolvedrequest.user.reflex-djangoboundself.requestandself.useronto yourTodoStateinstance.- Your handler ran.
self.request.userwas the real you, soTodo.objects.acreate(owner=self.request.user, ...)worked. - Reflex shipped the updated
todoslist back over the WebSocket. - The browser re-rendered the list.
Most of that you didn't write. That's the whole point of reflex-django.
Faster reloads¶
When you only changed models.py or admin.py, you don't need to rebuild the Reflex SPA. Skip it:
If you want the classic Reflex hot-reload experience (a Vite dev server proxied through Django), use:
More flags in the CLI reference.
What to read next¶
You now have a working app. From here:
- AppState — your bridge to Django — what
self.request,self.user,self.sessionactually are, and how to use them well. - Talking to the database — the async ORM patterns to remember (and the blocking ones to avoid).
- CRUD with ModelState — the same todo app, but with
ModelStategenerating most of the handlers for you. - Login & sessions — built-in login/register pages, decorators, and the live-vs-snapshot rules.
Quick troubleshooting¶
| Symptom | Likely cause and fix |
|---|---|
/ returns 404 on first run |
The SPA wasn't built. Run python manage.py run_reflex once and wait for the export to finish; it stages the bundle into STATIC_ROOT/_reflex/. |
You see "Please log in" even after logging into /admin/ |
SessionMiddleware or AuthenticationMiddleware missing from MIDDLEWARE. Check step 2. |
AppRegistryNotReady at startup |
You're touching a Django model at class definition time. Move model access into your @rx.event handlers. |
ModuleNotFoundError: shop.shop |
A leftover rxconfig.py is referencing the old layout. Delete rxconfig.py — reflex_mount() is the only config you need. |
| Admin complains about streaming | Add reflex_django.streaming_middleware.AsyncStreamingMiddleware at the end of MIDDLEWARE. |
| Slow reload after every Python edit | Use --skip-rebuild for pure Python changes, or --with-vite for hot-reload on Reflex pages. |
Next: AppState — your bridge to Django → · Or: add to an existing Django project →