Extending Admin

Add custom pages, actions, 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.query.reports.findMany({
orderBy: (reports, { desc }) => [desc(reports.createdAt)],
limit: 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 Action

Apply the adminFunctionMiddleware tuple 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, eq } from '@kit/database';
import { reports } from '@kit/database/schema';
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.update(reports)
.set({
status: 'resolved',
resolution,
resolvedBy: user.id,
resolvedAt: new Date(),
})
.where(eq(reports.id, reportId));
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.

Action with Permission Check

Add granular permission requirements by spreading withAdminPermission into the adminFunctionMiddleware tuple:

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.delete(reports).where(eq(reports.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 Actions 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

Apply the adminFunctionMiddleware tuple 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 by spreading withAdminPermission into the tuple:

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: ['action'] })])
.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 check
if (isAdminRole(user.role)) {
// Show admin UI
}
// Permission check
const { 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/schema/reports.ts

import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
export const reports = pgTable('reports', {
id: uuid('id').primaryKey().defaultRandom(),
title: text('title').notNull(),
description: text('description'),
status: text('status').notNull().default('pending'),
resolution: text('resolution'),
reportedBy: uuid('reported_by').notNull(),
resolvedBy: uuid('resolved_by'),
createdAt: timestamp('created_at').defaultNow().notNull(),
resolvedAt: timestamp('resolved_at'),
});

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 { count, db, eq } from '@kit/database';
import { reports } from '@kit/database/schema';
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 conditions = status ? eq(reports.status, status) : undefined;
const [data, countResult] = await Promise.all([
db.query.reports.findMany({
where: conditions,
orderBy: (r, { desc }) => [desc(r.createdAt)],
limit,
offset,
}),
db.select({ count: count() }).from(reports).where(conditions),
]);
return {
reports: data,
total: countResult[0]?.count ?? 0,
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, eq } from '@kit/database';
import { reports } from '@kit/database/schema';
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.update(reports)
.set({
status: 'resolved',
resolution: data.resolution,
resolvedBy: context.user.id,
resolvedAt: new Date(),
})
.where(eq(reports.id, data.reportId));
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.delete(reports).where(eq(reports.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

ImportPackagePurpose
requireAdmin@kit/auth/require-adminLoader/server-fn admin check (redirect / notFound)
isUserAdmin@kit/auth/require-adminServer-side admin check without throwing
requireAdminAuth#/lib/auth/guardsRoute beforeLoad admin guard
adminFunctionMiddleware@kit/function-middlewareAdmin middleware tuple for createServerFn mutations
withAdminPermission@kit/function-middlewarePermission middleware
isAdminRole@kit/auth/is-admin-roleClient-safe role check
useAdminPermissions@kit/admin/hooks/use-admin-permissionsClient permission hook

Frequently Asked Questions

Where should I create custom admin page components?
Create a presentational component in packages/admin/src/[feature]/page.tsx and a loader in lib/loaders. Add both export paths to packages/admin/package.json, add a createServerFn wrapper in apps/web/src/lib/admin.functions.ts, then create the file route in apps/web/src/routes/admin/[feature].tsx whose loader calls the wrapper and renders your component with Route.useLoaderData().
Do I need to add a guard for new admin pages?
No. The /admin route's beforeLoad runs requireAdminAuth, which protects every nested /admin/* route. However, you should still call requireAdmin() inside your loader/server function as a defense-in-depth measure.
How do I add a new permission for my custom feature?
Update packages/rbac/src/admin-rbac.config.ts: add the resource to the resources object, define valid actions in accessController, then assign permissions to roles. Use withAdminPermission() middleware in your server functions to enforce the new permission.
Can I use the existing admin UI components for my custom pages?
Yes. Import PageBody, PageHeader from @kit/ui/page, AppBreadcrumbs from @kit/ui/app-breadcrumbs, and other shared components. Follow the existing admin pages as examples for consistent styling.
How do I add my custom page to the admin sidebar?
Update the sidebar configuration in packages/admin/src/admin-sidebar.tsx. Add a new entry with label, path, Icon (from lucide-react), and highlightMatch regex pattern.

This admin panel is part of the TanStack Start Drizzle SaaS Kit.


Previous: RBAC Permissions