Extending Admin

Add custom functionality to the admin panel.

To extend the admin panel, you can create a new page in the apps/web/app/[locale]/(internal)/admin folder. You can then add the page to the sidebar by adding it to the admin-sidebar.tsx file.

Admin Protection

There are several layers of protection you can use to protect the admin panel. This is already enforced using the proxy/middleware, but we recommend always adding the required checks in pages, layouts, API routes, and Server Actions.

Server Components and Loaders

Use requireAdmin to protect server components and data loaders:

import 'server-only';
import { cache } from 'react';
import { requireAdmin } from '@kit/auth/require-admin';
export const loadFeatureData = cache(async () => {
await requireAdmin(); // Redirects if not admin
// Load admin-only data
return await fetchData();
});

Server Actions

Use adminActionClient to protect server actions:

'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { adminActionClient } from '@kit/auth/admin-action-middleware';
const updateSettingSchema = z.object({
key: z.string().min(1),
value: z.string(),
});
export const updateSettingAction = adminActionClient
.inputSchema(updateSettingSchema)
.action(async ({ parsedInput, ctx }) => {
const { user } = ctx; // Guaranteed to be admin
// Perform admin action
await updateSetting(parsedInput.key, parsedInput.value);
revalidatePath('/admin', 'layout');
return { success: true };
});

Client-Side Role Checking

Use isAdminRole for conditional UI rendering:

import { isAdminRole } from '@kit/auth/is-admin-role';
function UserActions({ user }) {
const showAdminLink = isAdminRole(user.role);
return (
<div>
{showAdminLink && <Link href="/admin">Admin Panel</Link>}
</div>
);
}

Server-Side Admin Check (Non-Redirecting)

Use isUserAdmin when you need to check admin status without redirecting:

import { isUserAdmin } from '@kit/auth/require-admin';
export async function getPageData() {
const isAdmin = await isUserAdmin();
return {
showAdminFeatures: isAdmin,
// ... other data
};
}

Adding Admin Pages

1. Create Page Component

Create a page in the admin package:

// packages/admin/src/reports/page.tsx
import { loadReportsData } from './lib/loaders/reports.loader';
import { ReportsTable } from './components/reports-table';
export async function ReportsPage() {
const data = await loadReportsData();
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Reports</h1>
<ReportsTable data={data} />
</div>
);
}

2. Create 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';
export const loadReportsData = cache(async () => {
await requireAdmin();
// Fetch reports data
return await reportsService.getAll();
});

3. Export from Package

// packages/admin/src/index.ts
export { ReportsPage } from './reports/page';

4. Create Route

// apps/web/app/[locale]/(internal)/admin/reports/page.tsx
import { ReportsPage } from '@kit/admin';
export default function Page() {
return <ReportsPage />;
}

5. Add to Sidebar

Update the sidebar config in packages/admin/src/admin-sidebar.tsx:

import { FileText } from 'lucide-react';
const config = {
routes: [
{
label: 'Overview',
children: [
{
label: 'Dashboard',
path: '/admin',
Icon: <Home className="h-4 w-4" />,
end: true,
},
],
},
{
label: 'Management',
children: [
{
label: 'Users',
path: '/admin/users',
Icon: <Users className="h-4 w-4" />,
end: true,
},
{
label: 'Organizations',
path: '/admin/organizations',
Icon: <Building2 className="h-4 w-4" />,
end: true,
},
// Add new item
{
label: 'Reports',
path: '/admin/reports',
Icon: <FileText className="h-4 w-4" />,
end: true,
},
],
},
],
};

Creating Admin Actions

Follow the existing pattern for admin 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 } from '@kit/action-middleware';
import { getLogger } from '@kit/shared/logger';
const deleteReportSchema = z.object({
reportId: z.string().min(1),
});
export const deleteReportAction = adminActionClient
.inputSchema(deleteReportSchema)
.action(async ({ parsedInput, ctx }) => {
const logger = await getLogger();
const { reportId } = parsedInput;
const { user } = ctx;
try {
await reportsService.delete(reportId);
logger.info({ reportId, adminId: user.id }, 'Deleted report');
revalidatePath('/admin/reports');
return { success: true };
} catch (error) {
logger.error({ error, reportId }, 'Failed to delete report');
throw new Error('Failed to delete report');
}
});

Using Actions in Components

'use client';
import { useAction } from 'next-safe-action/hooks';
import { toast } from 'sonner';
import { deleteReportAction } from '../lib/actions/reports-server-actions';
export function DeleteReportButton({ reportId }: { reportId: string }) {
const { execute, status } = useAction(deleteReportAction, {
onSuccess: () => {
toast.success('Report deleted');
},
onError: ({ error }) => {
toast.error(error.serverError ?? 'Failed to delete report');
},
});
return (
<Button
variant="destructive"
disabled={status === 'executing'}
onClick={() => execute({ reportId })}
>
{status === 'executing' ? 'Deleting...' : 'Delete'}
</Button>
);
}

Admin RBAC (Role-Based Access Control)

Admin permissions are configured in packages/rbac/src/admin-rbac.config.ts. By default, there is one role (admin) with full permissions.

Default Resources & Actions

Better Auth Resources (must use exact names):

ResourceActions
usercreate, list, get, update, set-role, set-password, ban, impersonate, delete
sessionlist, revoke, delete

Custom Resources (not controlled by Better Auth):

ResourceActions
organizationslist, view
dashboardview

Note: Better Auth expects singular resource names (user, session) and specific action names (set-role not change-role).

Adding Custom Admin Roles

Edit packages/rbac/src/admin-rbac.config.ts:

import { defineAdminRBACConfig } from './admin/factory';
export default defineAdminRBACConfig({
// Add custom roles (higher number = more authority)
roles: {
support: 50, // Can help users but not delete
moderator: 30, // Can ban but limited access
},
// Define permissions for each custom role
// (admin role gets ALL permissions automatically)
permissions: {
support: {
user: ['list', 'get', 'ban'], // No 'delete', 'set-role'
session: ['list', 'revoke'],
organizations: ['list', 'view'],
dashboard: ['view'],
},
moderator: {
user: ['list', 'get', 'ban'],
dashboard: ['view'],
},
},
});

Adding Custom Resources

export default defineAdminRBACConfig({
// Add custom resources
resources: {
REPORTS: 'reports',
SUBSCRIPTIONS: 'subscriptions',
},
// Add custom actions
actions: {
EXPORT: 'export',
REFUND: 'refund',
},
// Define which actions are valid for each resource
accessController: {
reports: ['list', 'view', 'export'],
subscriptions: ['list', 'view', 'cancel', 'refund'],
},
// Assign permissions to roles
permissions: {
support: {
reports: ['list', 'view'],
subscriptions: ['list', 'view'],
},
},
});

Protecting Server Actions with Permissions

Use withAdminPermission middleware:

'use server';
import { adminActionClient, withAdminPermission } from '@kit/action-middleware';
export const banUserAction = adminActionClient
.use(withAdminPermission({ user: ['ban'] }))
.inputSchema(banUserSchema)
.action(async ({ parsedInput, ctx }) => {
// Only executed if user has 'ban' permission on 'user'
await banUser(parsedInput.userId);
});

Client-Side Permission Checks

Use the useAdminPermissions hook to conditionally render UI:

'use client';
import { useAdminPermissions } from '@kit/admin/hooks/use-admin-permissions';
function UserActions({ userId }: { userId: string }) {
const { hasPermission, isLoading } = useAdminPermissions();
if (isLoading) return <Spinner />;
return (
<div>
{hasPermission({ user: ['get'] }) && (
<Button>View User</Button>
)}
{hasPermission({ user: ['ban'] }) && (
<Button>Ban User</Button>
)}
{hasPermission({ user: ['delete'] }) && (
<Button variant="destructive">Delete User</Button>
)}
</div>
);
}

Permission Hook API

const {
role, // Current user's admin role (or null)
isAdmin, // Whether user has admin role
isLoading, // Whether session is loading
hasPermission, // Check specific permission(s)
hasAnyPermission,// Check if user has any permission on resource
permissions, // All permissions for current role
getActionsFor, // Get all actions for a resource
} = useAdminPermissions();
// Check multiple permissions (all required)
hasPermission({ user: ['ban', 'delete'] });
// Check any permission on resource
hasAnyPermission('user'); // true if user has any user permission
// Get all user actions
getActionsFor('user'); // ['list', 'get', 'ban', ...]

Legacy: Admin Roles Array

Admin roles are also exported via @kit/better-auth/admin-config:

import { ADMIN_ROLES, isAdminRole } from '@kit/better-auth/admin-config';
// Check if a role is an admin role
if (isAdminRole(user.role)) {
// User is an admin
}

Key Imports Reference

ImportPurpose
@kit/auth/require-adminrequireAdmin, isUserAdmin for server-side protection
@kit/action-middlewareadminActionClient for protected actions
@kit/auth/is-admin-roleisAdminRole for client-safe role checking

Previous: Overview