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/adminroute guards it with a single permission and builds the nav from the resource registry.- The
AdminResourceframework — 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:
| Django | Here |
|---|---|
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 / ModelForm | editableFields(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 ProtectedError | hooks.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_staff | requireSiteAdmin() 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:
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.
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: akey, asection, alabel, akeyColumn(the business key used to match CSV rows), and an array offields. It carries no Drizzle table, db client, or crypto, so routes and components import it freely. These live in*.view.tsfiles and are collected inviews.ts.AdminResource— the view plus how to read/write the table: the Drizzletable, thepkcolumn, anidType("number"for domain ids,"string"for Better Auth user ids), anorderBy, optional CSV mappers /writeSchema, and optionalhooks. These live in*.tsfiles and are collected inregistry.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 registries — views.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:
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
labelis a thunk. It's() => string, notstring, because the label must resolve in the active locale at render time. Calling Paraglide'sm.*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 fn | Method | Does |
|---|---|---|
adminList | GET | SELECT … ORDER BY orderBy LIMIT 500, rows serialized to wire-safe cells |
adminUpsert | POST | Validate via writeSchema, then insert (no id) or update (with id) |
adminDelete | POST | beforeDelete guard, then delete (or a custom hooks.delete) |
adminExportCsv | GET | All rows → a CSV string (BOM-friendly), columns derived from fields |
adminPlanImport | POST | Dry-run: parse the CSV, diff against current rows, return an ImportPlan |
adminCommitImport | POST | Re-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.
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"), skippinginCsv: falsefields.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 arequiredfield is blank.genericWriteSchema— a lenient Zod object derived from the fields (z.coerce.boolean(), numeric-FK preprocessors,required→.min(1)), used byadminUpsertto 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 toplanImportfrom fusion-import-export. That returns anImportPlanclassifying each row:
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 kind — Textarea, 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:
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.createinserts theuserrow and a Better Auth credentialaccountrow with a scrypt hash, inside a transaction. It does not callauth.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.updatenever touches a password column (the password lives onaccount, notuser).fromCsvsets 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 anupdatedAtcolumn needs ahooks.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'tNumber()it) and the form control (aTextInput, not aNumberInput).idType: "string"plays the same role for the resource's own PK;coercePkreads it. guidas the match key — join/through tables (e.g.processnProjectUser) have no natural unique business column, so theirkeyColumnisguid— a readonly,inList: false,inCsv: true,requiredfield used purely to match CSV rows to existing records on import.- The shell swap —
AdminShellis rendered instead ofAppShellLayout, not nested inside it. Mounting both would double the chrome and fight over Mantine'sAppShellCSS 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:
/** 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
- Authorization — the
site.administerpredicate behind the guard, and the registry pattern the gates enforce. - Packages — fusion-ui, fusion-import-export, fusion-settings, fusion-audit, fusion-mailer.
- Example app tour → Admin — the admin in the running PulsN app.
- Internationalization — why every label is a Paraglide thunk.