Skip to content
Fusion

The admin area

/admin is the site-admin back office — a Django-admin-style surface that gives you CRUD plus CSV import/export over your domain tables, plus four admin-adjacent service pages (settings, audit, outbox, overview). The headline move is that adding an entity to the admin is data, not code: you describe a resource once — its columns, an editability flag, and the server-side handlers — and the generic server functions and the generic page do the rest.

Two halves cooperate:

  • AdminShell — the chrome (the left nav + content layout), a presentational kit from fusion-ui. The /admin route guards it with a single permission and builds the nav from the resource registry.
  • The AdminResource framework — the example's own resource layer (example/src/lib/admin/*). A resource descriptor drives a registry; six generic server functions operate over that registry; one generic React page renders any resource's list + create/edit + delete + import/export.

Coming from Django?

This is the Django admin, with the same two superpowers (auto-CRUD over your models, plus bulk CSV in/out) — but expressed as TypeScript data instead of admin.py classes. If Django is your background, map it like this:

DjangoHere
admin.site.register(Thing, ThingAdmin)Add an AdminResource descriptor to the registry (registry.ts / views.ts)
class ThingAdmin(admin.ModelAdmin)The AdminResource object — a view (columns) plus table / pk / hooks
list_display = (...)fields with inList, rendered by AdminResourcePage
fields / ModelFormeditableFields(view) → form controls, validated by a derived Zod writeSchema
readonly_fields{ kind: "readonly", editable: false } on a field
ForeignKey rendered as a <select>{ kind: "fk", reference: { resource } } → a searchable Select
has_delete_permission / a ProtectedErrorhooks.beforeDelete returns a (translated) reason string to refuse
django-import-export (Resource, dry-run, skip-unchanged)The two-phase CSV import — adminPlanImport (dry run) → adminCommitImport
@admin.site.has_permission / is_staffrequireSiteAdmin() on every server fn + the site.administer route guard
LogEntry (the admin's own change log)An audit_log row written by recordAdminAudit() after every write

The one Django reflex to drop: there are no signals. Django auto-logs admin changes via LogEntry; here each generic write calls recordAudit explicitly (see Audit below). Nothing is implicit.

The shell — AdminShell + the guard

AdminShell (fusion-ui/src/admin/AdminShell.tsx) is the admin's layout: a sticky left nav on desktop (collapsible to an icon rail, persisted to localStorage), a burger-toggled Drawer on mobile, content to the right. It is deliberately router-agnostic and presentational — it takes nav items (label + active + onClick/href + icon), a title, a back link, and an accountSlot, and does no navigating itself. It is a sibling of the app's AppShellLayout, not composed with it: you render AdminShell at the root of the admin area instead of the app shell, and it uses a custom flex layout (rather than Mantine's AppShell) precisely so the icon rail and the surrounding app's CSS variables never collide.

The route that mounts it, example/src/routes/_authed.admin.tsx, does two jobs — guard and assemble the nav:

example/src/routes/_authed.admin.tsx
	beforeLoad: async () => {
		const session = await fetchSession();
		if (!session?.user || !(await can(session.user, "site.administer"))) {
			throw redirect({ to: "/hello" });
		}
		return { user: session.user };
	},

The gate is the site.administer permission, checked once on the layout route, so every nested section route (/admin/users, /admin/processn/$resource, …) inherits it — see Authorization for what can(...) resolves to (it's a named predicate in the typescript-rules registry, the same one the server functions enforce). The nav is built from the client-safe view registry, so a newly-registered ProcessN/ProtokollN resource appears in the sidebar automatically:

// example/src/routes/_authed.admin.tsx — fixed entries, then registry-driven ones
const items = [
	{ label: m.admin_nav_overview(), /* … */ active: pathname === "/admin" },
	{ label: m.admin_nav_users() /* … */ },
	{ label: m.admin_nav_settings() /* … */ },
	{ label: m.admin_nav_audit() /* … */ },
	{ label: m.admin_nav_outbox() /* … */ },
	...viewsForSection("processn").map((view) => ({
		label: view.label(),
		active: pathname === `/admin/processn/${view.key}`,
		onClick: () =>
			void router.navigate({ to: "/admin/processn/$resource", params: { resource: view.key } }),
	})),
	...viewsForSection("protokolln").map((view) => ({
		/* … */
	})),
];

The AdminResource framework

The framework lives in example/src/lib/admin/. The central idea is a two-file split per resource so server-only concerns never reach the browser bundle, plus a single set of generic mechanics on top.

Loading diagram...

The client-views / server-impls split

A resource is described by two types, both in example/src/lib/admin/resource.ts:

  • AdminResourceView — the client-safe shape: a key, a section, a label, a keyColumn (the business key used to match CSV rows), and an array of fields. It carries no Drizzle table, db client, or crypto, so routes and components import it freely. These live in *.view.ts files and are collected in views.ts.
  • AdminResource — the view plus how to read/write the table: the Drizzle table, the pk column, an idType ("number" for domain ids, "string" for Better Auth user ids), an orderBy, optional CSV mappers / writeSchema, and optional hooks. These live in *.ts files and are collected in registry.ts, which is imported only by the admin server functions.
// example/src/lib/admin/resource.ts (abridged)
export type AdminResourceView = {
	key: string;
	section: AdminSection; // "users" | "processn" | "protokolln"
	label: () => string; // a thunk — resolved in the active locale at render
	keyColumn: string; // business key for CSV upsert matching
	fields: AdminField[];
};
 
export type AdminResource = AdminResourceView & {
	table: PgTable;
	pk: PgColumn; // the JS key is always `id`
	idType: "number" | "string";
	orderBy: PgColumn;
	fromCsv?: (db, values) => Promise<Record<string, unknown> | null>; // else derived
	toCsv?: (db, row) => Promise<Record<string, string>>; // else derived
	writeSchema?: ZodType; // else derived
	hooks?: AdminResourceHooks;
};

Why bother with the split? Two reasons. Bundle safety — pulling a Drizzle table (and through it drizzle-orm and the credential-crypto hook) into the client bundle is exactly what TanStack Start's tree-shaking is meant to avoid; the view-only registry is the firewall. Two registriesviews.ts exports viewsForSection() (drives the nav + the overview cards), and registry.ts exports getResource() (the server's execution lookup, which 404s an unknown key as defence-in-depth).

A field is itself client-safe metadata — a JS key, a locale-thunk label, a kind, and a few flags:

example/src/lib/admin/resource.ts
export type AdminFieldKind =
	| "string"
	| "text"
	| "number"
	| "boolean"
	| "enum"
	| "fk"
	| "color"
	| "date"
	| "readonly";
 
export type AdminFieldOption = { value: string; label: () => string };
 
/** One column's UI shape — client-safe (no Drizzle/db references). */
export type AdminField = {
	/** JS column key on the row object (e.g. "localeKey"). */
	key: string;
	/** Resolved at render in the active locale (a thunk — never call at module scope). */
	label: () => string;
	kind: AdminFieldKind;
	/** For kind "enum": the stored value + its display label. */
	options?: AdminFieldOption[];
	/** For kind "fk": which resource to pick a value from. */
	reference?: { resource: string };
	/** For kind "fk": whether the foreign key is a numeric id (default) or a
	 * string id (e.g. a Better Auth user id). Drives parsing + the form control. */
	fkType?: "number" | "string";
	/** Shown in the list table? Default true. */
	inList?: boolean;
	/** Editable in the create/edit form? Default true. */
	editable?: boolean;
	/** Part of CSV import/export? Default true. */
	inCsv?: boolean;
	/** Required on create (NOT NULL with no DB default). */
	required?: boolean;
};

Why label is a thunk. It's () => string, not string, because the label must resolve in the active locale at render time. Calling Paraglide's m.* at module scope would freeze the string to whatever locale loaded the module — see Internationalization.

What you get for free: the six generic server functions

example/src/lib/admin/server.ts defines exactly six TanStack createServerFn endpoints. Every one gates first with requireSiteAdmin(), then looks the resource up in the server registry and operates generically:

Server fnMethodDoes
adminListGETSELECT … ORDER BY orderBy LIMIT 500, rows serialized to wire-safe cells
adminUpsertPOSTValidate via writeSchema, then insert (no id) or update (with id)
adminDeletePOSTbeforeDelete guard, then delete (or a custom hooks.delete)
adminExportCsvGETAll rows → a CSV string (BOM-friendly), columns derived from fields
adminPlanImportPOSTDry-run: parse the CSV, diff against current rows, return an ImportPlan
adminCommitImportPOSTRe-plan server-side from fresh rows, then write per-row

The generic write path loses Drizzle's column typing under the abstract PgTable, so it casts as never at that one boundary — and recovers runtime safety with the per-resource Zod writeSchema (forms) and fromCsv (CSV):

// example/src/lib/admin/server.ts — adminUpsert (abridged)
export const adminUpsert = createServerFn({ method: "POST" })
	.inputValidator(
		z.object({ resource: z.string(), id: idSchema.optional(), values: z.unknown() }).parse,
	)
	.handler(async ({ data }) => {
		const actor = await requireSiteAdmin();
		const resource = getResource(data.resource);
		const values = writeSchemaOf(resource).parse(data.values) as Row;
		if (data.id == null) {
			if (resource.hooks?.create) await resource.hooks.create(db, values);
			else await db.insert(resource.table).values(values as never);
			await recordAdminAudit(
				actor,
				`${resource.key}.create`,
				resource.key,
				`Created ${resource.key}`,
			);
		} else {
			const id = coercePk(resource, data.id);
			if (resource.hooks?.update) await resource.hooks.update(db, id, values);
			else
				await db
					.update(resource.table)
					.set({ ...values, updatedAt: new Date() } as never)
					.where(eq(resource.pk, id));
			await recordAdminAudit(
				actor,
				`${resource.key}.update`,
				resource.key,
				`Updated ${resource.key} ${String(data.id)}`,
			);
		}
		return { ok: true as const };
	});

The single guard — requireSiteAdmin

example/src/lib/admin/require.ts is the one auth gate for the admin. The generic fns call it first, then read and write across domains with no per-project membership check — "site admin sees everything" is expressed once, here, so the per-project predicates in #/permissions/* stay pure and the per-project pages keep their normal semantics.

example/src/lib/admin/require.ts
export async function requireSiteAdmin(): Promise<AdminActor> {
	const session = await getSession();
	if (!session) throw new Response("Unauthorized", { status: 401 });
	const row = await db.query.user.findFirst({
		where: (u, { eq }) => eq(u.id, session.user.id),
		columns: { id: true, name: true, isSiteAdmin: true },
	});
	if (!row) throw new Response("Unauthorized", { status: 401 });
	if (!(await can(row, "site.administer"))) throw new Response("Forbidden", { status: 403 });
	return { userId: row.id, name: row.name };
}

The route guard (in the layout) and this server-side guard are belt and braces: the redirect keeps non-admins out of the UI, and requireSiteAdmin() re-checks on every RPC so a hand-crafted request to a server fn is still rejected. See Authorization for the registry behind can(...).

Derived CSV columns + write schema

Most resources need no hand-written CSV mappers — the field metadata is enough. example/src/lib/admin/derive.ts turns fields into the three things a resource would otherwise have to spell out:

  • genericToCsv — serialize a DB row to lower-cased-key CSV values (Date → ISO, jsonb → JSON string, boolean → "true"/"false"), skipping inCsv: false fields.
  • genericFromCsv — build insert/update values from a CSV row: coerce booleans (true|1|yes), parse numeric FKs to numbers, reject a row (return null) if a required field is blank.
  • genericWriteSchema — a lenient Zod object derived from the fields (z.coerce.boolean(), numeric-FK preprocessors, required.min(1)), used by adminUpsert to validate the form payload.

registry.ts exposes the column helpers that keep CSV headers consistent (all lower-cased to match the import planner's header normalization):

// example/src/lib/admin/registry.ts
export function csvColumns(resource) {
	/* fields where inCsv !== false */
}
export function requiredCsvColumns(resource) {
	/* … && required */
}
export function updateCsvColumns(resource) {
	/* editable, non-key columns compared on update */
}
export function coercePk(resource, id) {
	return resource.idType === "number" ? Number(id) : String(id);
}

A resource overrides fromCsv/toCsv/writeSchema only for special cases — the worked Users descriptor below is the example.

The two-phase CSV import (≈ django-import-export)

Import is a dry-run then commit, exactly like django-import-export's preview

  • confirm. The dry run, adminPlanImport, reads every current row, serializes it to CSV, and hands the existing-row map plus the uploaded CSV to planImport from fusion-import-export. That returns an ImportPlan classifying each row:
Loading diagram...

The preview surface, AdminImportPanel (fusion-ui/src/admin/), is purely presentational: it shows the per-outcome counts, the per-row diff table, and a confirm button, but performs no IO — it only emits events (onPickFile/onCsvChange/onExport/onConfirm). The consumer (AdminImportSection in example/src/components/AdminResourcePage.tsx) owns the work: it re-runs adminPlanImport ~400 ms after the CSV text changes (the same dry-run the commit re-runs, so the preview matches reality), then calls adminCommitImport on confirm.

The critical safety property is in the commit: it never trusts a client-supplied plan. It re-reads the rows, re-plans server-side (TOCTOU-safe), and writes per row so one bad row doesn't sink the batch:

// example/src/lib/admin/server.ts — adminCommitImport (abridged)
const { existing, ids } = await buildExisting(resource); // fresh read
const plan = planFor(resource, data.csv, existing); // re-plan, don't trust the client
for (const row of plan.rows) {
	if (row.kind === "skip") {
		skipped += 1;
		continue;
	}
	if (row.kind === "error") {
		failed += 1;
		continue;
	}
	try {
		const values = await fromCsvOf(resource, row.values);
		if (!values) {
			failed += 1;
			continue;
		}
		if (row.kind === "new") {
			/* insert or hooks.create */ created += 1;
		} else {
			/* look up id by key, update or hooks.update */ updated += 1;
		}
	} catch {
		failed += 1;
	}
}
await recordAdminAudit(
	actor,
	`${resource.key}.import`,
	resource.key,
	`Imported …: +${created} ~${updated} (${failed} failed)`,
);

fusion-ui's import-plan.ts re-declares the ImportPlan shape structurally rather than importing it from fusion-import-export, so the UI package takes no dependency on the import engine and stays purely presentational — a real plan produced by planImport() is assignable to the UI's type by structure.

The generic page — AdminResourcePage

example/src/components/AdminResourcePage.tsx is one component that renders any resource. It reads the view, builds the table columns from listFields(view), builds the create/edit form from editableFields(view) (one FieldControl per field kindTextarea, NumberInput, Switch, ColorInput, Select for enums and FKs, …), and wires the toolbar to adminExportCsv / AdminImportSection. The route is a thin wrapper that guards the section, loads the rows, and hands them over:

example/src/routes/_authed.admin.processn.$resource.tsx
import { createFileRoute, redirect } from "@tanstack/react-router";
 
import { AdminResourcePage } from "#/components/AdminResourcePage";
import { adminList } from "#/lib/admin/server";
import { getView } from "#/lib/admin/views";
 
export const Route = createFileRoute("/_authed/admin/processn/$resource")({
	beforeLoad: ({ params }) => {
		const view = getView(params.resource);
		if (!view || view.section !== "processn") throw redirect({ to: "/admin" });
	},
	loader: ({ params }) => adminList({ data: { resource: params.resource } }),
	component: ProcessnResourcePage,
});
 
function ProcessnResourcePage() {
	const { resource } = Route.useParams();
	const { rows } = Route.useLoaderData();
	const view = getView(resource);
	if (!view) return null;
	return <AdminResourcePage view={view} rows={rows} />;
}

That is the whole per-resource route. ProcessN and ProtokollN share a single $resource route each; the resource is the URL parameter, validated against the registry in beforeLoad.

Worked entities

The descriptors that ship in the example show the spread — from a fully generic resource to one that needs custom crypto.

ProcessN / ProtokollN — fully generic

These declare a *.view.ts (fields) and pair it with a table in *.ts, declaring only table / pk / idType / orderBy. CSV mapping and validation are entirely derived:

// example/src/lib/admin/resources/processn.ts — one of nine, no CSV code
{
	...processnProjectView,
	table: processnProject,
	pk: processnProject.id,
	idType: "number",
	orderBy: processnProject.name,
}

ProtokollN adds the one common override: a delete guard on restrict-FK parents, returning a translated reason instead of letting a raw Postgres foreign key error surface as a 500:

// example/src/lib/admin/resources/protokolln.ts
{
	...protokollCompanyView,
	table: protokollCompany,
	pk: protokollCompany.id,
	idType: "number",
	orderBy: protokollCompany.name,
	hooks: {
		beforeDelete: async (db, id) =>
			(await refCount(db, protokollProject, protokollProject.companyId, Number(id))) > 0
				? m.admin_delete_blocked_company()
				: null, // null = allow the delete
	},
}

Users — custom create, custom CSV, a custom page

Users (example/src/lib/admin/resources/users.ts) is the descriptor that exercises every escape hatch:

  • hooks.create inserts the user row and a Better Auth credential account row with a scrypt hash, inside a transaction. It does not call auth.api.signUpEmail — inside a server fn that would attach the new user's session cookie to the admin's response and hijack the admin's session.
  • hooks.update never touches a password column (the password lives on account, not user).
  • fromCsv sets no password — there's no plaintext in spreadsheets — so a CSV-imported user has no credential account and must reset/claim before signing in.

Users is also the one resource with a custom page (example/src/routes/_authed.admin.users.tsx) rather than AdminResourcePage, because it keeps a bespoke bulk activate/deactivate/rename flow and a tri-state edit sheet. It still reuses the generic pieces: it imports usersView for the create form + CSV columns, and calls adminUpsert / adminDelete / adminExportCsv / AdminImportSection. adminDelete even special-cases "you can't delete your own account" server-side.

Gotchas to know

  • Timestamp columns — the generic update sets updatedAt: new Date() unconditionally. A table without an updatedAt column needs a hooks.update (or you accept the implicit assumption that domain tables have one).
  • FK as a string id — Better Auth user ids are strings, so FK fields that point at users carry fkType: "string" (e.g. processnAssignedTask.userId). This drives parsing (don't Number() it) and the form control (a TextInput, not a NumberInput). idType: "string" plays the same role for the resource's own PK; coercePk reads it.
  • guid as the match key — join/through tables (e.g. processnProjectUser) have no natural unique business column, so their keyColumn is guid — a readonly, inList: false, inCsv: true, required field used purely to match CSV rows to existing records on import.
  • The shell swapAdminShell is rendered instead of AppShellLayout, not nested inside it. Mounting both would double the chrome and fight over Mantine's AppShell CSS variables; the admin layout route deliberately renders its own shell at the root.

Admin-adjacent services

Four nav entries are not AdminResource-driven — they are thin admin views over three standalone packages. Each package is db-injected (it takes the db client, holds no singleton, never reads process.env) and the example wraps its read/write in a requireSiteAdmin()-gated server fn.

Settings

fusion-settings is the constance replacement: small key/value config an admin changes without a redeploy (copy, toggles, limits), stored as app_config jsonb rows and read back through an in-process cache with a short TTL. The admin surfaces it at /admin/settings, a typed editor over five keys (app.name, app.defaultLanguage, signups.enabled, app.supportEmail, app.bannerMessage). The server fn reads with a coercing schema (a malformed row falls back to its default rather than breaking the page) and writes with a strict per-key schema (a bad value is a 4xx, not a silent coerce) via setSetting, which invalidates the cache so the next read reflects the write on that instance:

example/src/lib/settings-server.ts
/** Upsert a single setting from the admin area. Site-admin only. */
export const adminUpdateSetting = createServerFn({ method: "POST" })
	.inputValidator(z.object({ key: settingKeySchema, value: z.unknown() }).parse)
	.handler(async ({ data }): Promise<AppSettings> => {
		await requireSiteAdmin();
		const value = writeSchemas[data.key].parse(data.value);
		await setSetting(db, data.key, value);
		return readSchema.parse(await getAllSettings(db));
	});

Audit

fusion-audit is the "who did what, when" trail — the analogue of Django admin's LogEntry, but the whole app writes to it, not just the admin. The admin surfaces it at /admin/audit via adminListAudit (a gated listAudit(db, { limit: 100 }), newest first). The write side, recordAudit, is best-effort — it swallows its own errors so history-keeping never fails the real work — and the read side prefers the live user name, falling back to the denormalized snapshot so a row stays readable even after its actor is deleted. This is the function the admin server fns call after every generic write, via the small recordAdminAudit wrapper in require.ts.

Mailer (the outbox)

fusion-mailer is an outbox-backed transactional mailer: every send is recorded in a mailbox table, and with no SMTP_HOST set there is no real delivery — outbox only (handy in dev and air-gapped deploys). The admin surfaces it at /admin/outbox via adminListOutbox (a gated read of the last 50, projected to a serializable OutboxEntry — the meta jsonb is dropped because createServerFn rejects it on the wire). Like audit, sendEmail never throws, so a failed send still records and the page reflects the durable state.

The overview

/admin itself (_authed.admin.index.tsx) is a card grid: four "foundation" cards (Users / Settings / Audit / Outbox) plus one card per registered resource, generated from viewsForSection("processn") and viewsForSection("protokolln") — the same registry that builds the nav, so it stays in sync for free.

See also