Extending Admin

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

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:

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:

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:

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:

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:

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:

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:

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

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

'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

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

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:

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:

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

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 '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

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

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

ImportPackagePurpose
requireAdmin@kit/auth/require-adminServer-side admin check with redirect
isUserAdmin@kit/auth/require-adminServer-side admin check without redirect
adminActionClient@kit/action-middlewareProtected action client
withAdminPermission@kit/action-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 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.
Do I need to add middleware for new admin pages?
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.
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 actions 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 Next.js Drizzle SaaS Kit.


Previous: RBAC Permissions