# Extending Admin

> Add custom pages, actions, and features to the admin panel.

*Canonical: https://makerkit.dev/docs/tanstack-drizzle/admin/extending-admin*

---

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):

```typescript {% title="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`:

```typescript {% title="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:

```json {% title="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()`:

```typescript {% title="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(),
);
```

```typescript {% title="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:

```typescript {% title="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:

```typescript {% title="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:

```typescript
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:

```typescript {% title="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`:

```typescript
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

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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):

```typescript
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

```typescript {% title="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

```typescript {% title="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

```typescript {% title="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

```typescript {% title="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

```typescript {% title="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` | `@kit/function-middleware` | Admin middleware tuple for `createServerFn` mutations |
| `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 |

{% faq
   title="Frequently Asked Questions"
   items=[
     {"question": "Where should I create custom admin page components?", "answer": "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()."},
     {"question": "Do I need to add a guard for new admin pages?", "answer": "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."},
     {"question": "How do I add a new permission for my custom feature?", "answer": "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."},
     {"question": "Can I use the existing admin UI components for my custom pages?", "answer": "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."},
     {"question": "How do I add my custom page to the admin sidebar?", "answer": "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](/drizzle).

---

**Previous:** [RBAC Permissions](./rbac-permissions)
