Extending Admin
Add custom pages, functions, and features to the admin panel.
The admin panel is designed for extension. Add custom pages, server functions, and UI components while maintaining the security model.
Adding Admin Pages
Step 1: Create Page Component
Create a presentational page component in the admin package. It receives the data as props — the route loader does the fetching (Step 4):
packages/admin/src/reports/page.tsx
import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';import { PageBody, PageHeader } from '@kit/ui/page';import { ReportsTable } from './components/reports-table';import type { ReportsPageData } from './lib/loaders/reports.loader';export function AdminReportsPage({ data }: { data: ReportsPageData }) { return ( <PageBody> <PageHeader> <AppBreadcrumbs /> </PageHeader> <ReportsTable data={data} /> </PageBody> );}Step 2: Create Data Loader
Protect your data loader with requireAdmin:
packages/admin/src/reports/lib/loaders/reports.loader.ts
import { requireAdmin } from '@kit/auth/require-admin';import { db } from '@kit/database';export interface ReportsPageData { reports: Array<{ id: string; title: string; createdAt: Date; status: 'pending' | 'resolved'; }>; total: number;}export const loadReportsData = async (): Promise<ReportsPageData> => { await requireAdmin(); // Fetch your data const reports = await db.report.findMany({ orderBy: { createdAt: 'desc' }, take: 25, }); return { reports, total: reports.length, };};requireAdmin() redirects anonymous users to sign-in and throws notFound() for authenticated non-admins. Per-request session lookups are deduped by getSession(), so no React cache() wrapper is needed.
Step 3: Add Package Exports
Add the page and loader export paths to the package configuration:
packages/admin/package.json
{ "exports": { "./dashboard": "./src/dashboard/page.tsx", "./users": "./src/users/page.tsx", "./organizations": "./src/organizations/page.tsx", "./reports": "./src/reports/page.tsx", "./reports/loader": "./src/reports/lib/loaders/reports.loader.ts" }}Step 4: Create the Route
Add a createServerFn wrapper for the loader, then create the file route that calls it. The component reads the result with Route.useLoaderData():
apps/web/src/lib/admin.functions.ts
import { createServerFn } from '@tanstack/react-start';import { loadReportsData } from '@kit/admin/reports/loader';export const fetchAdminReports = createServerFn({ method: 'GET' }).handler(() => loadReportsData(),);apps/web/src/routes/admin/reports.tsx
import { createFileRoute } from '@tanstack/react-router';import { AdminReportsPage } from '@kit/admin/reports';import { fetchAdminReports } from '#/lib/admin.functions';export const Route = createFileRoute('/admin/reports')({ loader: () => fetchAdminReports(), head: () => ({ meta: [{ title: 'Admin | Reports' }] }), component: AdminReportsRoute,});function AdminReportsRoute() { const data = Route.useLoaderData(); return <AdminReportsPage data={data} />;}The /admin parent route already gates access via requireAdminAuth in beforeLoad, so any route nested under /admin/ is admin-protected.
Step 5: Add to Sidebar
Update the sidebar configuration in the admin package:
packages/admin/src/admin-sidebar.tsx
import { FileText, Home, Users, Building2 } from 'lucide-react';const config = { routes: [ { label: 'Overview', children: [ { label: 'Dashboard', path: '/admin', Icon: <Home className="h-4 w-4" />, highlightMatch: '^/admin$', }, ], }, { label: 'Management', children: [ { label: 'Users', path: '/admin/users', Icon: <Users className="h-4 w-4" />, highlightMatch: '^/admin/users$', }, { label: 'Organizations', path: '/admin/organizations', Icon: <Building2 className="h-4 w-4" />, highlightMatch: '^/admin/organizations$', }, // Add your new item { label: 'Reports', path: '/admin/reports', Icon: <FileText className="h-4 w-4" />, highlightMatch: '^/admin/reports', }, ], }, ],};Creating Server Functions
Basic Admin Function
Use createServerFn({ method: 'POST' }).middleware(adminFunctionMiddleware) for automatic admin verification:
packages/admin/src/reports/lib/functions/reports.functions.ts
import { z } from 'zod';import { createServerFn } from '@tanstack/react-start';import { adminFunctionMiddleware } from '@kit/function-middleware/functions';import { getLogger } from '@kit/shared/logger';import { db } from '@kit/database';const resolveReportSchema = z.object({ reportId: z.string().min(1), resolution: z.string().min(10),});export const resolveReportFunction = createServerFn({ method: 'POST' }).middleware(adminFunctionMiddleware) .validator(resolveReportSchema) .handler(async ({ data, context }) => { const logger = await getLogger(); const { reportId, resolution } = data; const { user } = context; // Guaranteed to be admin try { await db.report.update({ where: { id: reportId }, data: { status: 'resolved', resolution, resolvedBy: user.id, resolvedAt: new Date(), }, }); logger.info({ reportId, adminId: user.id }, 'Report resolved'); return { success: true }; } catch (error) { logger.error({ error, reportId }, 'Failed to resolve report'); throw new Error('Failed to resolve report'); } });After the mutation resolves on the client, refresh the affected route with router.invalidate() (or a TanStack Query invalidation) in the mutation's onSuccess callback — there is no server-side revalidatePath in TanStack Start.
Function with Permission Check
Add granular permission requirements with withAdminPermission (or layer withAdminPermission on createServerFn({ method: 'POST' }).middleware(adminFunctionMiddleware)):
import { createServerFn } from '@tanstack/react-start';import { adminFunctionMiddleware } from '@kit/function-middleware/functions';import { withAdminPermission } from '@kit/function-middleware/server';export const deleteReportFunction = createServerFn({ method: 'POST' }).middleware([ ...adminFunctionMiddleware, withAdminPermission({ reports: ['delete'] }), ]) .validator(deleteReportSchema) .handler(async ({ data, context }) => { // Only admins with 'reports:delete' permission reach here await db.report.delete({ where: { id: data.reportId } }); return { success: true }; });Don't forget to register the permission in your RBAC config:
packages/rbac/src/admin-rbac.config.ts
export default defineAdminRBACConfig({ resources: { REPORTS: 'reports', SUBSCRIPTIONS: 'subscriptions', }, accessController: { reports: ['list', 'view', 'resolve', 'delete'], subscriptions: ['list'], },});Using Functions in Components
With TanStack Query useMutation
Call the server function as a mutation. Drive pending/success/error UI from the mutation, and refresh data in onSuccess:
import { useMutation } from '@tanstack/react-query';import { useRouter } from '@tanstack/react-router';import { toast } from 'sonner';import { Button } from '@kit/ui/button';import { resolveReportFunction } from '../lib/functions/reports.functions';interface ResolveReportButtonProps { reportId: string; onSuccess?: () => void;}export function ResolveReportButton({ reportId, onSuccess,}: ResolveReportButtonProps) { const router = useRouter(); const mutation = useMutation({ mutationFn: resolveReportFunction, onSuccess: async () => { toast.success('Report resolved'); await router.invalidate(); onSuccess?.(); }, onError: () => { toast.error('Failed to resolve report'); }, }); return ( <Button disabled={mutation.isPending} onClick={() => mutation.mutate({ data: { reportId, resolution: 'Resolved by admin' }, }) } > {mutation.isPending ? 'Resolving...' : 'Resolve'} </Button> );}With Permission-Based UI
import { useAdminPermissions } from '@kit/admin/hooks/use-admin-permissions';export function ReportActions({ reportId }: { reportId: string }) { const { hasPermission, isLoading } = useAdminPermissions(); if (isLoading) return <Spinner />; return ( <div className="flex gap-2"> {hasPermission({ reports: ['resolve'] }) && ( <ResolveReportButton reportId={reportId} /> )} {hasPermission({ reports: ['delete'] }) && ( <DeleteReportButton reportId={reportId} /> )} </div> );}Admin Protection Layers
Always implement multiple layers of protection:
1. Route Guard (beforeLoad)
The /admin route's beforeLoad runs requireAdminAuth, blocking non-admin access to every nested /admin/* route automatically. No additional configuration is needed for new pages under /admin/.
2. Loaders / Server Functions
Use requireAdmin in loaders and server functions:
import { requireAdmin } from '@kit/auth/require-admin';export const loadAdminOnlyData = async () => { await requireAdmin(); // redirect / notFound if not admin return getData();};3. Mutations
Use createServerFn({ method: 'POST' }).middleware(adminFunctionMiddleware) for all admin mutations:
import { createServerFn } from '@tanstack/react-start';import { adminFunctionMiddleware } from '@kit/function-middleware/functions';export const adminOnlyFunction = createServerFn({ method: 'POST' }).middleware(adminFunctionMiddleware) .validator(schema) .handler(async ({ data, context }) => { // context.user is guaranteed to be admin });4. Permission Middleware
Add granular checks with withAdminPermission (or withAdminPermission):
import { createServerFn } from '@tanstack/react-start';import { adminFunctionMiddleware } from '@kit/function-middleware/functions';import { withAdminPermission } from '@kit/function-middleware/server';export const restrictedFunction = createServerFn({ method: 'POST' }).middleware([ ...adminFunctionMiddleware, withAdminPermission({ resource: ['manage'] }), ]) .validator(schema) .handler(async ({ data, context }) => { // Only admins with the specific permission reach here });5. Client-Side Checks
Use for UI rendering only (not security):
import { isAdminRole } from '@kit/auth/is-admin-role';import { useAdminPermissions } from '@kit/admin/hooks/use-admin-permissions';// Role checkif (isAdminRole(user.role)) { // Show admin UI}// Permission checkconst { hasPermission } = useAdminPermissions();if (hasPermission({ reports: ['delete'] })) { // Show delete button}Complete Example: Reports Feature
Here's a complete example of adding a reports management feature:
1. Database Schema
packages/database/src/prisma/schema.prisma
model Report { id String @id @default(uuid()) title String description String? status String @default("pending") resolution String? reportedBy String resolvedBy String? createdAt DateTime @default(now()) resolvedAt DateTime? @@map("reports")}2. RBAC Configuration
packages/rbac/src/admin-rbac.config.ts
import { defineAdminRBACConfig } from './admin/factory';export default defineAdminRBACConfig({ resources: { REPORTS: 'reports', SUBSCRIPTIONS: 'subscriptions', }, accessController: { reports: ['list', 'view', 'resolve', 'delete'], subscriptions: ['list'], }, roles: { support: 50, }, permissions: { support: { reports: ['list', 'view', 'resolve'], // Can resolve but not delete user: ['list', 'get'], dashboard: ['view'], }, },});3. Data Loader
packages/admin/src/reports/lib/loaders/reports.loader.ts
import { requireAdmin } from '@kit/auth/require-admin';import { db } from '@kit/database';export interface ReportsPageParams { page?: number; status?: string;}export const loadReportsData = async (params: ReportsPageParams) => { await requireAdmin(); const { page = 1, status } = params; const limit = 25; const offset = (page - 1) * limit; const where = status ? { status } : undefined; const [data, total] = await Promise.all([ db.report.findMany({ where, orderBy: { createdAt: 'desc' }, take: limit, skip: offset, }), db.report.count({ where }), ]); return { reports: data, total, page, pageSize: limit, };};4. Server Functions
packages/admin/src/reports/lib/functions/reports.functions.ts
import { z } from 'zod';import { createServerFn } from '@tanstack/react-start';import { adminFunctionMiddleware } from '@kit/function-middleware/functions';import { withAdminPermission } from '@kit/function-middleware/server';import { db } from '@kit/database';const resolveReportSchema = z.object({ reportId: z.string().uuid(), resolution: z.string().min(10, 'Resolution must be at least 10 characters'),});export const resolveReportFunction = createServerFn({ method: 'POST' }).middleware([ ...adminFunctionMiddleware, withAdminPermission({ reports: ['resolve'] }), ]) .validator(resolveReportSchema) .handler(async ({ data, context }) => { await db.report.update({ where: { id: data.reportId }, data: { status: 'resolved', resolution: data.resolution, resolvedBy: context.user.id, resolvedAt: new Date(), }, }); return { success: true }; });const deleteReportSchema = z.object({ reportId: z.string().uuid(),});export const deleteReportFunction = createServerFn({ method: 'POST' }).middleware([ ...adminFunctionMiddleware, withAdminPermission({ reports: ['delete'] }), ]) .validator(deleteReportSchema) .handler(async ({ data }) => { await db.report.delete({ where: { id: data.reportId } }); return { success: true }; });5. Page Component
packages/admin/src/reports/page.tsx
import { PageBody, PageHeader } from '@kit/ui/page';import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs';import { ReportsTable } from './components/reports-table';interface AdminReportsPageProps { data: { reports: Array<{ id: string; title: string; status: string }>; total: number; page: number; pageSize: number; };}export function AdminReportsPage({ data }: AdminReportsPageProps) { return ( <PageBody> <PageHeader> <AppBreadcrumbs /> </PageHeader> <ReportsTable reports={data.reports} total={data.total} page={data.page} pageSize={data.pageSize} /> </PageBody> );}The page is presentational; pagination params and data come from the route loader. Read URL search params in the route with validateSearch + Route.useSearch() (see apps/web/src/routes/admin/users.tsx for a paginated example).
Key Imports Reference
| Import | Package | Purpose |
|---|---|---|
requireAdmin | @kit/auth/require-admin | Loader/server-fn admin check (redirect / notFound) |
isUserAdmin | @kit/auth/require-admin | Server-side admin check without throwing |
requireAdminAuth | #/lib/auth/guards | Route beforeLoad admin guard |
adminFunctionMiddleware / withAdminPermission | @kit/function-middleware | Protected server-function middleware tuples |
withAdminPermission | @kit/function-middleware | Permission middleware |
isAdminRole | @kit/auth/is-admin-role | Client-safe role check |
useAdminPermissions | @kit/admin/hooks/use-admin-permissions | Client permission hook |
Frequently Asked Questions
Where should I create custom admin page components?
Do I need to add a guard for new admin pages?
How do I add a new permission for my custom feature?
Can I use the existing admin UI components for my custom pages?
How do I add my custom page to the admin sidebar?
This admin panel is part of the TanStack Start Prisma SaaS Kit.
Previous: RBAC Permissions