Extending Admin
Add custom pages, actions, and features to the admin panel.
Extend MakerKit's admin panel by adding custom pages, server actions, and components. The five-step process: create a page component in packages/admin, add a protected data loader with requireAdmin(), export from package.json, create a Next.js route, and add to the sidebar. All admin extensions inherit the security model automatically when using adminActionClient and withAdminPermission().
Always add
requireAdmin()to loaders and useadminActionClientfor actions. Client-side checks are for UI only, not security.
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';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 prisma.report.findMany({ orderBy: { createdAt: 'desc' }, take: 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:
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';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 prisma.report.update({ where: { id: reportId }, data: { status: 'resolved', resolution, resolvedBy: user.id, resolvedAt: new Date(), }, }); 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 prisma.report.delete({ where: { 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', }, accessController: { reports: ['list', 'view', 'resolve', 'delete'], },});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
1. Route Middleware
The middleware in apps/web/middleware.ts blocks non-admin access to /admin/* routes automatically.
2. Server Components
Use requireAdmin in server components and loaders:
import { requireAdmin } from '@kit/auth/require-admin';export async function AdminOnlyComponent() { await requireAdmin(); // Redirects 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}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
Do I need to add middleware protection for new admin routes?
How do I add a new permission for my custom admin feature?
Can I use regular actionClient instead of adminActionClient?
Why use isAdminRole client-side if it's not for security?
How do I test my admin extension locally?
Previous: RBAC Permissions