Build an Admin Panel with Feature Flags and Audit Logs in Next.js
How OmniKit Pro ships a production-ready admin panel with user management, feature flags, audit logging, and per-plan limits — so you never build one from scratch.
Every SaaS needs an admin panel. Most founders build it last and regret it.
I've been there. You launch your product, get your first paying customers, and then someone asks to be refunded. Or someone reports abuse. Or you need to disable a half-broken feature at 2 AM without deploying. And you're sitting there with zero admin tooling, running raw SQL queries against production.
After building two SaaS products and watching this exact pattern repeat, I made admin tooling a first-class citizen in OmniKit Pro. Not an afterthought — a core module.
What You'll Learn
- How OmniKit structures its admin panel with role-based access
- User management: banning, impersonation, and account actions
- A feature flag system that works without third-party services
- Audit logging that captures every admin action
- Per-plan feature limits and how to enforce them
- Admin dashboard stats for real-time product health
The Architecture
The admin panel sits behind a role-based middleware check. If you're not an admin, you don't see it. Period.
OmniKit uses Better Auth's built-in role system. Every user has a role field — user, admin, or superadmin. The admin layout validates this server-side before rendering anything.
// app/admin/layout.tsx
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
if (session.user.role !== "admin" && session.user.role !== "superadmin") {
redirect("/dashboard");
}
return (
<div className="flex min-h-screen">
<AdminSidebar />
<main className="flex-1 p-6">{children}</main>
</div>
);
}No client-side checks. No "hide the button and hope nobody inspects the DOM." The server decides before a single byte of HTML reaches the browser.
Every API route under /api/admin/* runs the same check. Even if someone crafts a direct request, the middleware rejects it.
User Management
Your first 10 users won't cause problems. Your first 1,000 will. You need to be able to:
- View all users with search, sort, and filter
- Ban users who violate terms (with a reason attached)
- Impersonate users to debug their exact experience
- Manage roles to promote team members to admin
The Users Table
// components/admin/users-table.tsx
"use client";
import { useState } from "react";
import {
Table, TableBody, TableCell, TableHead,
TableHeader, TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
DropdownMenu, DropdownMenuContent,
DropdownMenuItem, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { MoreHorizontal, Search, Shield, Ban, Eye } from "lucide-react";
interface User {
id: string;
name: string;
email: string;
role: "user" | "admin" | "superadmin";
plan: "free" | "pro" | "enterprise";
banned: boolean;
createdAt: Date;
}
export function UsersTable({ users }: { users: User[] }) {
const [search, setSearch] = useState("");
const filtered = users.filter(
(u) =>
u.name.toLowerCase().includes(search.toLowerCase()) ||
u.email.toLowerCase().includes(search.toLowerCase())
);
return (
<div className="space-y-4">
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search users by name or email..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-10"
/>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Plan</TableHead>
<TableHead>Role</TableHead>
<TableHead>Status</TableHead>
<TableHead>Joined</TableHead>
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((user) => (
<TableRow key={user.id}>
<TableCell>
<div>
<p className="font-medium">{user.name}</p>
<p className="text-sm text-muted-foreground">{user.email}</p>
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{user.plan}</Badge>
</TableCell>
<TableCell>
<Badge variant={user.role === "admin" ? "default" : "secondary"}>
{user.role}
</Badge>
</TableCell>
<TableCell>
{user.banned ? (
<Badge variant="destructive">Banned</Badge>
) : (
<Badge variant="outline" className="text-green-600">Active</Badge>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{user.createdAt.toLocaleDateString()}
</TableCell>
<TableCell>
<UserActions user={user} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}Impersonation
Impersonation is the feature I use the most. When a user reports a bug, I can see their exact experience — their plan limits, their data, their permissions — without asking them for screenshots.
// lib/admin/impersonate.ts
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { cookies } from "next/headers";
export async function impersonateUser(targetUserId: string) {
const session = await auth();
if (session?.user.role !== "admin" && session?.user.role !== "superadmin") {
throw new Error("Unauthorized");
}
// Store original admin session so we can restore it
const cookieStore = await cookies();
cookieStore.set("admin_original_session", session.session.id, {
httpOnly: true,
secure: true,
sameSite: "strict",
maxAge: 60 * 60, // 1 hour max impersonation
});
// Log the impersonation (this is critical for audit trails)
await createAuditLog({
action: "user.impersonate",
actorId: session.user.id,
targetId: targetUserId,
metadata: { reason: "debugging" },
});
// Switch session to the target user
await auth.api.impersonateUser({ userId: targetUserId });
}A banner appears at the top of the page when you're impersonating someone — bright yellow, impossible to miss. One click to return to your admin session.
This is also why audit logging matters. Every impersonation is recorded. You want a paper trail for compliance, and honestly, for your own sanity.
Feature Flags
Most feature flag solutions are either expensive SaaS products (LaunchDarkly, Statsig) or require you to wire up a third-party SDK. For most indie hackers and early-stage startups, that's overkill.
OmniKit ships a self-hosted feature flag system. Flags live in your database. You toggle them from the admin panel. No external dependencies. No per-seat pricing.
The Flag Schema
// prisma/schema.prisma (relevant section)
model FeatureFlag {
id String @id @default(cuid())
key String @unique // e.g., "new_onboarding_flow"
name String // "New Onboarding Flow"
description String?
enabled Boolean @default(false)
rules Json? // Optional targeting rules
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}The rules field is where things get interesting. A simple boolean flag covers 80% of use cases. But sometimes you want to roll out a feature to specific plans, or a percentage of users, or only to users who signed up after a certain date.
{
"plans": ["pro", "enterprise"],
"percentage": 25,
"userIds": ["usr_abc123"]
}Evaluating Flags
// lib/feature-flags.ts
import { db } from "@/lib/db";
import { unstable_cache } from "next/cache";
interface FlagRules {
plans?: string[];
percentage?: number;
userIds?: string[];
}
// Cache flag lookups for 60 seconds to avoid hitting the DB on every request
export const getFlag = unstable_cache(
async (key: string) => {
return db.featureFlag.findUnique({ where: { key } });
},
["feature-flag"],
{ revalidate: 60 }
);
export async function isFlagEnabled(
key: string,
context?: { userId?: string; plan?: string }
): Promise<boolean> {
const flag = await getFlag(key);
if (!flag || !flag.enabled) return false;
// No rules means the flag is globally enabled
if (!flag.rules) return true;
const rules = flag.rules as FlagRules;
// Check user-specific override
if (rules.userIds?.includes(context?.userId ?? "")) return true;
// Check plan-based targeting
if (rules.plans && context?.plan) {
if (!rules.plans.includes(context.plan)) return false;
}
// Check percentage-based rollout
if (rules.percentage !== undefined && context?.userId) {
const hash = simpleHash(context.userId + key);
if (hash % 100 >= rules.percentage) return false;
}
return true;
}
function simpleHash(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = (hash << 5) - hash + char;
hash |= 0;
}
return Math.abs(hash);
}Using it in a server component is one line:
// app/dashboard/page.tsx
import { isFlagEnabled } from "@/lib/feature-flags";
import { auth } from "@/lib/auth";
export default async function DashboardPage() {
const session = await auth();
const showNewOnboarding = await isFlagEnabled("new_onboarding_flow", {
userId: session?.user.id,
plan: session?.user.plan,
});
return (
<div>
{showNewOnboarding ? <NewOnboardingBanner /> : <ClassicDashboard />}
</div>
);
}The Admin Toggle UI
// components/admin/feature-flags-panel.tsx
"use client";
import { Switch } from "@/components/ui/switch";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { toggleFlag } from "@/app/admin/actions";
import { toast } from "sonner";
interface Flag {
id: string;
key: string;
name: string;
description: string | null;
enabled: boolean;
rules: Record<string, unknown> | null;
}
export function FeatureFlagsPanel({ flags }: { flags: Flag[] }) {
async function handleToggle(flagId: string, enabled: boolean) {
const result = await toggleFlag(flagId, enabled);
if (result.success) {
toast.success(`Flag ${enabled ? "enabled" : "disabled"}`);
}
}
return (
<div className="grid gap-4">
{flags.map((flag) => (
<Card key={flag.id}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<div className="space-y-1">
<CardTitle className="text-base">{flag.name}</CardTitle>
<code className="text-xs text-muted-foreground">{flag.key}</code>
</div>
<Switch
checked={flag.enabled}
onCheckedChange={(checked) => handleToggle(flag.id, checked)}
/>
</CardHeader>
{flag.description && (
<CardContent>
<p className="text-sm text-muted-foreground">{flag.description}</p>
{flag.rules && (
<div className="mt-2 flex gap-2">
{(flag.rules as { plans?: string[] }).plans?.map((plan: string) => (
<Badge key={plan} variant="outline">{plan}</Badge>
))}
</div>
)}
</CardContent>
)}
</Card>
))}
</div>
);
}Kill switch at 2 AM? One toggle. No deployment. No waiting for CI. The flag disables server-side, so the feature vanishes immediately for all users.
Audit Logging
If you've ever dealt with enterprise customers, you know audit logs aren't optional. They want to know who did what and when. Even without enterprise requirements, audit logs save you when things go wrong.
"Who deleted that user?" "When was this feature flag toggled off?" "Did someone change the pricing plan for this account?"
Without an audit trail, you're guessing.
The Audit Log Schema
// prisma/schema.prisma (relevant section)
model AuditLog {
id String @id @default(cuid())
action String // e.g., "user.ban", "flag.toggle", "plan.change"
actorId String // Who performed the action
targetId String? // What was acted upon
metadata Json? // Additional context
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
actor User @relation(fields: [actorId], references: [id])
}Creating Audit Entries
Every admin action flows through a single function. No exceptions.
// lib/audit.ts
import { db } from "@/lib/db";
import { headers } from "next/headers";
interface AuditEntry {
action: string;
actorId: string;
targetId?: string;
metadata?: Record<string, unknown>;
}
export async function createAuditLog(entry: AuditEntry) {
const headersList = await headers();
const ipAddress =
headersList.get("x-forwarded-for")?.split(",")[0] ||
headersList.get("x-real-ip") ||
"unknown";
const userAgent = headersList.get("user-agent") || "unknown";
return db.auditLog.create({
data: {
action: entry.action,
actorId: entry.actorId,
targetId: entry.targetId,
metadata: entry.metadata ?? {},
ipAddress,
userAgent,
},
});
}The key design decision: audit logs are immutable. There's no update or delete endpoint. Once an entry is created, it stays forever. This is intentional — tamper-proof logs are the whole point.
Using Audit Logs in Admin Actions
Every server action that modifies data logs itself:
// app/admin/actions.ts
"use server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { createAuditLog } from "@/lib/audit";
import { revalidatePath } from "next/cache";
export async function banUser(userId: string, reason: string) {
const session = await auth();
if (session?.user.role !== "admin" && session?.user.role !== "superadmin") {
throw new Error("Unauthorized");
}
await db.user.update({
where: { id: userId },
data: { banned: true, bannedReason: reason },
});
await createAuditLog({
action: "user.ban",
actorId: session.user.id,
targetId: userId,
metadata: { reason },
});
revalidatePath("/admin/users");
}
export async function toggleFlag(flagId: string, enabled: boolean) {
const session = await auth();
if (session?.user.role !== "admin") throw new Error("Unauthorized");
const flag = await db.featureFlag.update({
where: { id: flagId },
data: { enabled },
});
await createAuditLog({
action: "flag.toggle",
actorId: session.user.id,
targetId: flagId,
metadata: { key: flag.key, enabled },
});
revalidatePath("/admin/feature-flags");
return { success: true };
}The Audit Log Viewer
// components/admin/audit-log-viewer.tsx
"use client";
import { formatDistanceToNow } from "date-fns";
import { Badge } from "@/components/ui/badge";
interface AuditEntry {
id: string;
action: string;
actor: { name: string; email: string };
targetId: string | null;
metadata: Record<string, unknown>;
ipAddress: string | null;
createdAt: Date;
}
const actionLabels: Record<string, { label: string; variant: "default" | "destructive" | "outline" }> = {
"user.ban": { label: "User Banned", variant: "destructive" },
"user.unban": { label: "User Unbanned", variant: "outline" },
"user.impersonate": { label: "Impersonation", variant: "default" },
"flag.toggle": { label: "Flag Toggled", variant: "default" },
"flag.create": { label: "Flag Created", variant: "outline" },
"plan.change": { label: "Plan Changed", variant: "default" },
};
export function AuditLogViewer({ entries }: { entries: AuditEntry[] }) {
return (
<div className="space-y-3">
{entries.map((entry) => {
const config = actionLabels[entry.action] ?? {
label: entry.action,
variant: "outline" as const,
};
return (
<div
key={entry.id}
className="flex items-start justify-between rounded-lg border p-4"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<Badge variant={config.variant}>{config.label}</Badge>
<span className="text-sm text-muted-foreground">
by {entry.actor.name}
</span>
</div>
{entry.metadata && Object.keys(entry.metadata).length > 0 && (
<pre className="text-xs text-muted-foreground">
{JSON.stringify(entry.metadata, null, 2)}
</pre>
)}
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{formatDistanceToNow(entry.createdAt, { addSuffix: true })}
</span>
</div>
);
})}
</div>
);
}Per-Plan Feature Limits
Feature flags handle on/off toggles. But SaaS pricing usually involves limits — "Free gets 3 projects, Pro gets 50, Enterprise gets unlimited."
OmniKit handles this with a plan-limits configuration that lives alongside your Stripe plans:
// lib/plan-limits.ts
export const PLAN_LIMITS = {
free: {
projects: 3,
teamMembers: 1,
apiRequests: 1000,
storage: 100, // MB
features: ["basic_analytics"],
},
pro: {
projects: 50,
teamMembers: 10,
apiRequests: 50000,
storage: 5000,
features: ["basic_analytics", "advanced_analytics", "api_access", "webhooks"],
},
enterprise: {
projects: Infinity,
teamMembers: Infinity,
apiRequests: Infinity,
storage: Infinity,
features: [
"basic_analytics", "advanced_analytics", "api_access",
"webhooks", "sso", "audit_logs", "priority_support",
],
},
} as const;
export type Plan = keyof typeof PLAN_LIMITS;
export function getPlanLimit(plan: Plan, resource: keyof typeof PLAN_LIMITS.free) {
return PLAN_LIMITS[plan][resource];
}
export function canAccess(plan: Plan, feature: string): boolean {
return PLAN_LIMITS[plan].features.includes(feature);
}Enforcement happens at the API layer. Before creating a new project, check the limit:
// app/api/projects/route.ts
import { getPlanLimit } from "@/lib/plan-limits";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { NextResponse } from "next/server";
export async function POST(request: Request) {
const session = await auth();
if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
const currentCount = await db.project.count({
where: { userId: session.user.id },
});
const limit = getPlanLimit(session.user.plan, "projects");
if (currentCount >= limit) {
return NextResponse.json(
{ error: "Plan limit reached. Upgrade to create more projects." },
{ status: 403 }
);
}
// ... create the project
}The admin panel shows a breakdown of usage vs. limits for every user, so you can spot users hitting walls and proactively reach out. That kind of insight is the difference between churn and an upgrade conversation. I wrote more about tracking meaningful product metrics in Beyond Pageviews.
Admin Dashboard Stats
The admin panel home page shows real-time product health at a glance:
// app/admin/page.tsx
import { db } from "@/lib/db";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Users, CreditCard, Activity, Flag } from "lucide-react";
export default async function AdminDashboard() {
const [totalUsers, activeSubscriptions, recentEvents, activeFlags] =
await Promise.all([
db.user.count(),
db.subscription.count({ where: { status: "active" } }),
db.auditLog.count({
where: {
createdAt: { gte: new Date(Date.now() - 24 * 60 * 60 * 1000) },
},
}),
db.featureFlag.count({ where: { enabled: true } }),
]);
const stats = [
{ label: "Total Users", value: totalUsers, icon: Users },
{ label: "Active Subscriptions", value: activeSubscriptions, icon: CreditCard },
{ label: "Events (24h)", value: recentEvents, icon: Activity },
{ label: "Active Flags", value: activeFlags, icon: Flag },
];
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold">Admin Dashboard</h1>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{stats.map((stat) => (
<Card key={stat.label}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{stat.label}
</CardTitle>
<stat.icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">{stat.value.toLocaleString()}</p>
</CardContent>
</Card>
))}
</div>
</div>
);
}Four cards. Total users, active subscriptions, audit events in the last 24 hours, active feature flags. No fancy charting library — just the numbers that matter when you open the admin panel at 7 AM with your coffee.
Why Build This Into the Boilerplate?
I keep coming back to the true cost of building SaaS from scratch. Admin tooling is one of those things that takes 20-40 hours to build properly. And every founder I talk to says the same thing: "I'll build it later."
Later turns into never. And then you're running UPDATE users SET banned = true WHERE id = '...' directly against production Postgres when someone is abusing your platform.
Here's what OmniKit's admin panel saves you:
| Component | DIY Hours | OmniKit |
|---|---|---|
| User management table with search/filter | 8-12 | Built-in |
| Ban/unban system with reasons | 4-6 | Built-in |
| User impersonation | 6-10 | Built-in |
| Feature flag system | 10-15 | Built-in |
| Audit logging | 8-12 | Built-in |
| Per-plan limits enforcement | 6-8 | Built-in |
| Admin dashboard stats | 4-6 | Built-in |
| Total | 46-69 hours | 0 |
That's 1-2 weeks of full-time work. Weeks you could spend building the features your customers are actually paying for.
The admin panel also ties into the security architecture that OmniKit already provides — rate limiting on admin API routes, CSRF protection on server actions, and proper authorization checks at every layer. And the API design principles we follow mean the admin endpoints are consistent, type-safe, and predictable.
Get Started
OmniKit Pro includes the full admin panel — user management, feature flags, audit logs, plan limits, and dashboard stats. All of it working together, tested, and ready to customize.
If you have questions about the admin module or want to see a demo, email me at raman@omnikit.dev or join the community on Discord. I'm happy to walk you through it.
Read more
Adding AI Features to Your SaaS in 30 Minutes - A Practical Guide
Add AI features to your SaaS fast. Pre-configured API routes for OpenAI, Anthropic, and Google with credit-based usage tracking and streaming responses.
Stripe Webhooks in Next.js — The Complete Guide
Stripe webhooks in Next.js: signature verification, handling checkout.session.completed, subscription events, idempotency, and local testing with Stripe CLI.
Transactional Emails That Don't Land in Spam — React Email + Resend
Transactional emails from new SaaS products often land in spam. Here's how to build type-safe email templates with React Email and deliver them reliably with Resend.