Skip to content
Fusion

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

DjangoTanStack Start (this app)Notes
urls.py route tablefiles in src/routes/the filename is the URL; no table to maintain
def view(request)a server functioncalled like a function — no URL, no DRF endpoint
MyModel.objects.filter(...)db.select().from(table).where(...)Drizzle — a typed SQL query builder
models.pysrc/db/schema/*.ts (pgTable(...))tables as typed objects
makemigrations / migratedrizzle-kit generate / migratethe app owns its migrations
Jinja templates + render()React components (SSR) + loader dataHTML rendered on the server, then hydrated
forms.py + CSRF tokencontrolled React + a POST server fn + a Zod validatorCSRF is middleware
django.contrib.authBetter Auth (via fusion-auth)session cookie; user on route context
@login_requirednest under _authed.tsx (its beforeLoad guard)redirects to /login
object perms / django-guardiana typed predicate registry + SQL filtersAuthorization
Django REST Frameworkthere's no separate API; server functions are the RPC
settings.pyenv + createDb/createAuth compositionConventions
manage.py runserverbun run dev (Vite)
custom manage.py commandsbun scripts/*.tse.g. bun run db:seed
Celery tasksHatchet jobs + the proactive agentasync 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.

pulsn-apps/src/processn/schema.ts
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:

pulsn-apps/src/processn/index.ts
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"):

  1. a SQL WHERE so the query only returns rows the viewer may see (get_queryset),
  2. a per-object check before a write (has_object_permission),
  3. a boolean returned to the UI so the React side can disable a button ({% if perms %}).
pulsn-apps/src/processn/permissions.ts
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