The admin area
/admin is the fullest composition example in the repo: the
admin-ui blocks assembled against the app's site-admin-gated
server functions. Sign in as admin@example.com / password123 and type the URL —
the area is deliberately unlinked and 404s for non-admins.
The whole area is ~50 lines of composition
The route file: nav items into the shell, the gate in beforeLoad, sections as child
routes, the app's account controls in the shell's accountSlot. Live code:
import { createFileRoute, notFound, Outlet } from "@tanstack/react-router";
import {
Building2,
FileStack,
FlaskConical,
LayoutDashboard,
Mail,
ScrollText,
Settings2,
Shield,
Users,
} from "lucide-react";
import { AdminShell, type AdminNavItem } from "@repo/admin-ui";
import { AccountControls } from "#/components/AccountMenu";
import { NotFoundScreen } from "#/components/system/SystemScreens";
import { m } from "#/paraglide/messages";
/**
* Site administration — the jazzmin/unfold replacement, on-prem by
* construction (runs inside the app: no CDN, no cloud console, no license
* activation). Its own chrome (AdminShell: left nav, mobile drawer) — admin
* is deliberately not dressed like the app. The shell is string-free by
* contract; every label comes from Paraglide here.
*
* Gated on `isSiteAdmin` both here (404 — don't reveal the area exists) and
* in every server function (`requireSiteAdmin`). Project-scoped admin (roles,
* templates) stays inside each project's AdminPanel.
*/
export const Route = createFileRoute("/_authed/admin")({
beforeLoad: ({ context }) => {
const user = (context as { user?: { isSiteAdmin?: boolean } }).user;
if (!user?.isSiteAdmin) throw notFound();
},
notFoundComponent: () => <NotFoundScreen />,
component: AdminLayout,
});
function AdminLayout() {
const nav: AdminNavItem[] = [
{ to: "/admin", label: m.sadmin_nav_overview(), icon: LayoutDashboard, exact: true },
{ to: "/admin/users", label: m.sadmin_nav_users(), icon: Users },
{ to: "/admin/groups", label: m.sadmin_nav_groups(), icon: Shield },
{ to: "/admin/workspaces", label: m.sadmin_nav_workspaces(), icon: Building2 },
{ to: "/admin/media", label: m.sadmin_nav_media(), icon: FileStack },
{ to: "/admin/config", label: m.sadmin_nav_config(), icon: Settings2 },
{ to: "/admin/audit", label: m.sadmin_nav_audit(), icon: ScrollText },
{ to: "/admin/outbox", label: m.sadmin_nav_outbox(), icon: Mail },
// Lives outside /admin but shares the gate: site admins only in prod.
{ to: "/sandbox", label: m.sadmin_nav_sandbox(), icon: FlaskConical },
];
return (
<AdminShell
items={nav}
title={m.sadmin_title()}
subtitle={m.sadmin_subtitle()}
back={{ to: "/", label: m.sadmin_back() }}
menuLabel={m.sadmin_menu_open()}
closeMenuLabel={m.sadmin_menu_close()}
collapseLabel={m.sadmin_nav_collapse()}
expandLabel={m.sadmin_nav_expand()}
accountSlot={<AccountControls withSignOut compact />}
>
<Outlet />
</AdminShell>
);
}Gating happens twice, on purpose: here (404 — the area's existence is not revealed)
and in every server function (requireSiteAdmin()); the UI gate is convenience,
the server gate is security.
The sections
| Section | Does | Django parallel |
|---|---|---|
| Users | search, select-many → edit simultaneously (status/site-admin/name), bulk activate/deactivate, password resets, two-phase CSV import (preview → confirm), selection-aware export | User admin + import-export + actions |
| Groups | global capability groups + membership | Groups |
| Workspaces | site-wide overview + creation | — |
| Media | every attachment, filename search + status filter, queue AI re-indexing for a selection | file management + admin actions |
| Config | runtime keys without a deploy | constance admin |
| Audit | the global change history | LogEntry, but real |
| Outbox | every recorded/sent email | — |
Behaviors worth knowing
- Deactivation takes effect immediately — the session choke point treats deactivated users as anonymous, so even live sessions are locked out (Auth).
- You cannot lock yourself out — the server refuses self-deactivation and self-demotion from site admin.
- Bulk edit is tri-state — editing N users writes only the fields you explicitly set; "keep" is the default for everything.
- Every mutation is audited — check
/admin/auditafter any admin action.
Adding a section
- Server functions in
example/src/lib/admin-<entity>-server.ts, each opening withrequireSiteAdmin(). - A section component in
example/src/components/site-admin/— columns forDataTable, actions inSelectionBar, optionally anEditSheet. - A route
_authed.admin.<entity>.tsxrendering the section; add it to theNAVlist in the layout. - Extend the journey spec (
e2e/site-admin.spec.ts) to walk through the section.