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 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/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
| 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 |
Frequently Asked Questions
Where should I create custom admin page components?
Do I need to add middleware 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 Next.js Drizzle SaaS Kit.
Previous: RBAC Permissions