Coming from Django
Django and TanStack Start solve the same problem — render pages, load data, enforce
permissions — with the same server-first instinct. If you think in views, models, and
@login_required, this page maps those onto Start. The big mental shift is that it's
TypeScript end-to-end: the same language and types run from the database query to the
rendered component, and the "template" is React rendered on the server. Read How
TanStack Start works alongside this.
At a glance
| Django | TanStack Start (this app) | Notes |
|---|---|---|
urls.py route table | files in src/routes/ | the filename is the URL; no table to maintain |
def view(request) | a server function | called like a function — no URL, no DRF endpoint |
MyModel.objects.filter(...) | db.select().from(table).where(...) | Drizzle — a typed SQL query builder |
models.py | src/db/schema/*.ts (pgTable(...)) | tables as typed objects |
makemigrations / migrate | drizzle-kit generate / migrate | the app owns its migrations |
Jinja templates + render() | React components (SSR) + loader data | HTML rendered on the server, then hydrated |
forms.py + CSRF token | controlled React + a POST server fn + a Zod validator | CSRF is middleware |
django.contrib.auth | Better Auth (via fusion-auth) | session cookie; user on route context |
@login_required | nest under _authed.tsx (its beforeLoad guard) | redirects to /login |
object perms / django-guardian | a typed predicate registry + SQL filters | Authorization |
| Django REST Framework | — | there's no separate API; server functions are the RPC |
settings.py | env + createDb/createAuth composition | Conventions |
manage.py runserver | bun run dev (Vite) | |
custom manage.py commands | bun scripts/*.ts | e.g. bun run db:seed |
| Celery tasks | Hatchet jobs + the proactive agent | async background work |
Models → a typed schema
A Django model is a Python class; a Drizzle table is a typed object. Same idea — columns, types, defaults — but the table's TypeScript type is inferred, so every query result is typed without a serializer.
export const processnProject = pgTable("processn_project", {
...common(),
name: text("name").notNull(),
description: text("description").notNull().default(""),
// NOTE: the building identity (route key, number, coordinates) lives on the PulsN project hub
// (the consumer's `pulsn_project`), which points to this ProcessN project — not the reverse.
});The ...common() spread is the port of Django's CommonModel base — an integer PK, a
stable guid, free-form metadata, and created/updated timestamps — shared by every table
in the domain.
Unlike Django's app-registry, the schema is just modules you compose — the app assembles
its domains and hands them to createDb(schema, url). You "own your projects": there is no
framework-provided Project model, you define it. See Domains for a real
one and Conventions for
the migration story.
Views → server functions (no urls.py, no DRF)
A Django view reads request, queries models, and returns a response. A server function
does the same, but you don't register a URL and you don't write an API client to call it —
you import it. Read it against your Django reflexes: requireUserId() is request.user
(it throws a 401 if anonymous), visibleProcessnProjectsWhere(...) is a get_queryset()
filter pushed into SQL, and the loop tags each row with the viewer's capability before
returning plain JSON:
export async function listProcessnProjects(
ctx: AppCtx,
viewerId: string,
): Promise<ProcessnProjectRow[]> {
const { db } = ctx;
const filters = makeProcessnFilters(db);
// Visibility is filtered in SQL; the loop only attaches per-row canChange.
const projects = await db
.select()
.from(processnProject)
.where(filters.visibleProjectsWhere({ id: viewerId }))
.orderBy(processnProject.name);
const rows: ProcessnProjectRow[] = [];
for (const p of projects) {
const perms = await getProjectPermissions(db, viewerId, p.id);
rows.push({
id: p.id,
name: p.name,
description: p.description,
canChange: perms.canChange,
});
}
return rows;
}The route's loader awaits it server-side and
the component renders the result — so there's no template context dict and no
JsonResponse; the typed object flows straight into JSX.
Templates → React on the server
This is the part that feels most different. Instead of a Jinja template rendered to a string, the page is a React component rendered to HTML on the server, then hydrated in the browser so it's interactive. You get SSR's first-paint benefits like a template, plus client interactivity without a separate frontend build or a REST hand-off.
function ProjectIndex() {
const overview = Route.useLoaderData(); // the "context", but typed
const { user } = Route.useRouteContext(); // ≈ request.user
return <ProjectOverview overview={overview} />; // your "template", in JSX
}Because the component also runs on the server, the usual SSR rules apply (no window in
render, client-only libraries lazy-loaded) — the React page
lists the gotchas.
Auth & permissions
django.contrib.auth becomes Better Auth (session cookie, sign-up/in through fusion-auth).
@login_required becomes nesting your route under _authed.tsx, whose beforeLoad checks
the session and redirects anonymous visitors. Object-level permissions — your
django-guardian reflex — become a typed predicate registry plus SQL filters, applied
in three layers (the same pattern as guardian's "filter the queryset, then check the
object, then gate the template"):
- a SQL
WHEREso the query only returns rows the viewer may see (get_queryset), - a per-object check before a write (
has_object_permission), - a boolean returned to the UI so the React side can disable a button (
{% if perms %}).
export const processnRegistry = new Registry<User, ProjectContext>();
processnRegistry.add("project.view", isMember);
processnRegistry.add("project.change", isMember.and(memberCanChange));
processnRegistry.add("project.assignTasks", isMember.and(memberCanAssignTasks));The full model — including the site-wide isSiteAdmin flag (the one global role) and
ProtokollN's downward-inheriting tree — is in Authorization.
Async & background work
Django is synchronous by default and reaches for Celery when it isn't. Here, everything
is async/await natively — server functions, database calls, the lot. For scheduled or
background work (your Celery beat / workers), the stack uses Hatchet — the
proactive agent runs on its timer, and document processing runs as a
worker job.
Try it
- Stand it up: Getting started then the example tour.
- Build a view end-to-end: Building a feature.
- See real "apps" (Django apps, basically): ProcessN, ProtokollN, and the rest from the Portfolio.