# Extending Admin

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

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

---

The admin panel is designed for extension. Add custom pages, server actions, and UI components while maintaining the security model.

## Adding Admin Pages

### Step 1: Create Page Component

Create your page component in the admin package:

```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 { loadReportsData } from './lib/loaders/reports.loader';

export async function AdminReportsPage() {
  const data = await loadReportsData();

  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 'server-only';
import { cache } from 'react';
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 = cache(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,
  };
});
```

### Step 3: Add Package Export

Add the export path 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"
  }
}
```

### Step 4: Create Route

Create the Next.js route in the app:

```typescript {% title="apps/web/app/[locale]/admin/reports/page.tsx" %}
import { AdminReportsPage } from '@kit/admin/reports';

export default function Page() {
  return <AdminReportsPage />;
}
```

### 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 Actions

### Basic Admin Action

Use `adminActionClient` for automatic admin verification:

```typescript {% title="packages/admin/src/reports/lib/actions/reports-server-actions.ts" %}
'use server';

import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { adminActionClient } from '@kit/action-middleware';
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 resolveReportAction = adminActionClient
  .inputSchema(resolveReportSchema)
  .action(async ({ parsedInput, ctx }) => {
    const logger = await getLogger();
    const { reportId, resolution } = parsedInput;
    const { user } = ctx; // 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');

      revalidatePath('/admin/reports');

      return { success: true };
    } catch (error) {
      logger.error({ error, reportId }, 'Failed to resolve report');
      throw new Error('Failed to resolve report');
    }
  });
```

### Action with Permission Check

Add granular permission requirements:

```typescript
'use server';

import { adminActionClient, withAdminPermission } from '@kit/action-middleware';

export const deleteReportAction = adminActionClient
  .use(withAdminPermission({ reports: ['delete'] }))
  .inputSchema(deleteReportSchema)
  .action(async ({ parsedInput, ctx }) => {
    // Only admins with 'reports:delete' permission reach here
    await db.delete(reports).where(eq(reports.id, parsedInput.reportId));
    revalidatePath('/admin/reports');
    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 next-safe-action Hook

```typescript
'use client';

import { useAction } from 'next-safe-action/hooks';
import { toast } from 'sonner';
import { Button } from '@kit/ui/button';

import { resolveReportAction } from '../lib/actions/reports-server-actions';

interface ResolveReportButtonProps {
  reportId: string;
  onSuccess?: () => void;
}

export function ResolveReportButton({
  reportId,
  onSuccess,
}: ResolveReportButtonProps) {
  const { execute, status } = useAction(resolveReportAction, {
    onSuccess: () => {
      toast.success('Report resolved');
      onSuccess?.();
    },
    onError: ({ error }) => {
      toast.error(error.serverError ?? 'Failed to resolve report');
    },
  });

  const isPending = status === 'executing';

  return (
    <Button
      disabled={isPending}
      onClick={() => execute({ reportId, resolution: 'Resolved by admin' })}
    >
      {isPending ? 'Resolving...' : 'Resolve'}
    </Button>
  );
}
```

### With Permission-Based UI

```typescript
'use client';

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 Middleware

The middleware in `apps/web/proxy.ts` blocks non-admin access to `/admin/*` routes automatically. No additional configuration needed for new pages under `/admin/`.

### 2. Server Components

Use `requireAdmin` in server components and loaders:

```typescript
import { requireAdmin } from '@kit/auth/require-admin';

export async function AdminOnlyComponent() {
  await requireAdmin(); // Redirects to 403 if not admin
  return <div>Admin content</div>;
}
```

### 3. Server Actions

Use `adminActionClient` for all admin actions:

```typescript
import { adminActionClient } from '@kit/action-middleware';

export const adminOnlyAction = adminActionClient
  .inputSchema(schema)
  .action(async ({ ctx }) => {
    // ctx.user is guaranteed to be admin
  });
```

### 4. Permission Middleware

Add granular checks with `withAdminPermission`:

```typescript
import { adminActionClient, withAdminPermission } from '@kit/action-middleware';

export const restrictedAction = adminActionClient
  .use(withAdminPermission({ resource: ['action'] }))
  .inputSchema(schema)
  .action(async ({ ctx }) => {
    // Only admins with 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 'server-only';
import { cache } from 'react';
import { requireAdmin } from '@kit/auth/require-admin';
import { db } from '@kit/database';
import { reports } from '@kit/database/schema';

export const loadReportsData = cache(async (params: {
  page?: number;
  status?: string;
}) => {
  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 Actions

```typescript {% title="packages/admin/src/reports/lib/actions/reports-server-actions.ts" %}
'use server';

import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { adminActionClient, withAdminPermission } from '@kit/action-middleware';
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 resolveReportAction = adminActionClient
  .use(withAdminPermission({ reports: ['resolve'] }))
  .inputSchema(resolveReportSchema)
  .action(async ({ parsedInput, ctx }) => {
    await db.update(reports)
      .set({
        status: 'resolved',
        resolution: parsedInput.resolution,
        resolvedBy: ctx.user.id,
        resolvedAt: new Date(),
      })
      .where(eq(reports.id, parsedInput.reportId));

    revalidatePath('/admin/reports');
    return { success: true };
  });

const deleteReportSchema = z.object({
  reportId: z.string().uuid(),
});

export const deleteReportAction = adminActionClient
  .use(withAdminPermission({ reports: ['delete'] }))
  .inputSchema(deleteReportSchema)
  .action(async ({ parsedInput }) => {
    await db.delete(reports).where(eq(reports.id, parsedInput.reportId));

    revalidatePath('/admin/reports');
    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';
import { loadReportsData } from './lib/loaders/reports.loader';

interface AdminReportsPageProps {
  searchParams: Promise<{ page?: string; status?: string }>;
}

export async function AdminReportsPage({ searchParams }: AdminReportsPageProps) {
  const params = await searchParams;
  const data = await loadReportsData({
    page: params.page ? parseInt(params.page) : 1,
    status: params.status,
  });

  return (
    <PageBody>
      <PageHeader>
        <AppBreadcrumbs />
      </PageHeader>

      <ReportsTable
        reports={data.reports}
        total={data.total}
        page={data.page}
        pageSize={data.pageSize}
      />
    </PageBody>
  );
}
```

## Key Imports Reference

| Import | Package | Purpose |
|--------|---------|---------|
| `requireAdmin` | `@kit/auth/require-admin` | Server-side admin check with redirect |
| `isUserAdmin` | `@kit/auth/require-admin` | Server-side admin check without redirect |
| `adminActionClient` | `@kit/action-middleware` | Protected action client |
| `withAdminPermission` | `@kit/action-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 them in packages/admin/src/[feature]/page.tsx following the existing pattern. Add the export to packages/admin/package.json, then create the Next.js route in apps/web/app/[locale]/admin/[feature]/page.tsx that imports your component."},
     {"question": "Do I need to add middleware for new admin pages?", "answer": "No. The middleware in apps/web/proxy.ts automatically protects all routes under /admin/*. However, you should still call requireAdmin() in your server components 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 actions 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 [Next.js Drizzle SaaS Kit](/drizzle).

---

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