Skip to content

Admin panel

Next.js boilerplate with Admin panel

Users, bans, impersonation and an audit log. Your code, any auth provider.

A working /admin for your users: search and filter every account, ban and unban with a reason and an expiry, change roles, end sessions, impersonate a user with a banner and a stop button, and an audit log of every admin action. Works with Better Auth, Clerk and Supabase Auth, on Drizzle, Prisma or no database.

What Admin panel adds to the agent layer: 2 rules · 2 skills · 8 solution docs

Maintained by @raviMITNext.js on Vercel

From the manifest

Should you pick Admin panel?

Pick it if

SaaS teams who need to answer support tickets from inside the app: find the account, see how they sign in, ban the abuser, view the app as the confused customer, and prove afterwards who did what.

Watch out for

  • Admins are one role. There are no per-page permissions or custom roles out of the box; add them in src/lib/admin/policy.ts and your auth battery's role list.
  • What each provider allows shapes the panel. Clerk cannot filter users by role or ban state and has no ban expiry (a script lifts timed bans). Supabase has no built-in impersonation, so the panel builds it from a sign-in link, and the admin signs in again afterwards.
Show 2 more
  • The audit trail needs a database. With Clerk and no database, entries go to server logs as JSON lines, which most hosts keep for days, not years.
  • It lives in your Next.js app, so an admin page can leak server-only data into a client component. The path-scoped rules exist to catch that.

What it costs

Free. It is your own code: no seats, no per-viewer pricing, no vendor bill. Clerk counts impersonations: 5 a month on its free plan.

Prices change. Check with Admin panel before you commit.

registry/tested.yaml

Tested with Admin panel

Each pair was installed, typechecked, linted, built and booted together.

Database
NeonSupabase
Error tracking
Sentry
Customer support
Crisp

What it adds

What Admin panel adds to the repo

Read straight from the admin-panel manifest, so it is exactly what lands in your repo.

Environment variables

No environment variables. Nothing to sign up for, nothing to paste.

Dependencies

  • server-only^0.0.1

Scripts

  • bun run admin:grant

    bun --conditions=react-server scripts/admin/grant.ts

Files it writes

72 files, at these exact paths.

  • scripts/2 files
    • admin/2 files
      • grant.ts
      • load-env.ts
  • src/48 files
    • app/13 files
      • (admin)/13 files
        • admin/12 files
          • audit/2 files
            • loading.tsx
            • page.tsx
          • settings/2 files
            • loading.tsx
            • page.tsx
          • users/4 files
            • [id]/2 files
              • loading.tsx
              • page.tsx
            • loading.tsx
            • page.tsx
          • error.tsx
          • loading.tsx
          • not-found.tsx
          • page.tsx
        • layout.tsx
    • components/23 files
      • admin/23 files
        • admin-header.tsx
        • admin-shell.tsx
        • admin-shortcut-card.tsx
        • admin-sidebar.tsx
        • audit-details.tsx
        • audit-feed.tsx
        • audit-filters.tsx
        • audit-table.tsx
        • ban-dialog.tsx
        • confirm-action-dialog.tsx
        • grant-admin-form.tsx
        • impersonation-banner.tsx
        • revoke-session-button.tsx
        • sessions-table.tsx
        • stat-card.tsx
        • stop-impersonating-button.tsx
        • use-admin-action.ts
        • user-actions.tsx
        • user-badges.tsx
        • user-identity.tsx
        • users-filters.tsx
        • users-pagination.tsx
        • users-table.tsx
    • lib/12 files
      • admin/12 files
        • actions.ts
        • audit.ts
        • contract.ts
        • cursor.ts
        • format.test.ts
        • format.ts
        • policy.test.ts
        • policy.ts
        • query.test.ts
        • query.ts
        • types.ts
        • users.ts
  • tests/1 file
    • e2e/1 file
      • admin.spec.ts
  • variants/21 files
    • auth-better-auth/3 files
      • src/2 files
        • components/1 file
          • admin/1 file
            • use-provider-sign-out.ts
        • lib/1 file
          • admin/1 file
            • provider.ts
      • tests/1 file
        • e2e/1 file
          • impersonation-lock.spec.ts
    • auth-better-auth-drizzle/1 file
      • src/1 file
        • lib/1 file
          • admin/1 file
            • user-store.ts
    • auth-better-auth-prisma/1 file
      • src/1 file
        • lib/1 file
          • admin/1 file
            • user-store.ts
    • auth-clerk/7 files
      • scripts/1 file
        • admin/1 file
          • unban-expired.ts
      • src/6 files
        • components/1 file
          • admin/1 file
            • use-provider-sign-out.ts
        • lib/5 files
          • admin/5 files
            • clerk-pages.test.ts
            • clerk-pages.ts
            • provider.test.ts
            • provider.ts
            • user-store.ts
    • auth-supabase/4 files
      • src/3 files
        • components/1 file
          • admin/1 file
            • use-provider-sign-out.ts
        • lib/2 files
          • admin/2 files
            • provider.ts
            • user-store.ts
      • supabase/1 file
        • migrations/1 file
          • 20250101000300_admin_panel.sql
    • orm-drizzle/1 file
      • src/1 file
        • lib/1 file
          • admin/1 file
            • audit-store.ts
    • orm-none/1 file
      • src/1 file
        • lib/1 file
          • admin/1 file
            • audit-store.ts
    • orm-prisma/1 file
      • src/1 file
        • lib/1 file
          • admin/1 file
            • audit-store.ts
    • payments-any/1 file
      • src/1 file
        • components/1 file
          • admin/1 file
            • billing-summary.tsx
    • payments-none/1 file
      • src/1 file
        • components/1 file
          • admin/1 file
            • billing-summary.tsx

Stack slots it fills

The stack declares these injection points; this battery supplies the fragment, so the provider tree, the env check and the schema stay one file each instead of many.

  • @slot admin-nav
  • @slot app-banner
  • @slot app-nav
  • @slot bare-route-groups
  • @slot dashboard-cards
  • @slot middleware-matchers

The differentiator

What Admin panel teaches your agent

Other starter kits stop at the package. This is the part an agent reads: where it may work, what it must never do there, and the problems someone already solved.

Rules (2)

Loaded when the agent opens a matching file.

Admin pages check the role themselves and read data on the server

Loads onsrc/app/(admin)/**src/components/admin/**src/lib/admin/actions.tssrc/app/api/admin/**.claude/rules/admin-access.md
Three checks, each for a different question
WhereCallWhat it stops
src/app/(admin)/layout.tsxrequireRole("admin", returnTo)A non-admin ever seeing the shell
Every page.tsxrequireRole("admin", "/admin/<path>")A stale layout: layouts do not re-render on client navigation
Every server actionauthorise() in src/lib/admin/actions.tsAnyone with curl: an action is a public POST endpoint
export default async function AdminReportsPage() {
  await requireRole("admin", "/admin/reports");
  // load data, render
}

The page's call is free: each auth battery wraps its session read in React cache. Pass the page's own path so sign-in brings the admin back to it.

Every export of src/lib/admin/actions.ts starts with authorise(), before it reads a form field. It runs requireRole("admin"), then re-reads the admin from the provider, because a role removed a minute ago can still sit in a cached cookie (Better Auth, 5 minutes) or an unexpired token (Clerk, Supabase). The one exception is stopImpersonationAction: the impersonated session is not an admin session, so it checks adminProvider.getImpersonation() instead.

A route handler under src/app/api/admin/** uses requireApiRole("admin") (401 or 403, never a redirect) and catches with authErrorResponse.

Never:

  • rely on the proxy: /admin/:path* is in its matcher so signed-out visitors bounce early, but it has no role check worth trusting;
  • compare strings (user.role === "admin" misses Clerk's owner): use requireRole, or navItemsFor for what the UI shows;
  • move a page out of src/app/(admin)/admin/: it loses the shell and the layout's check, and nothing warns you;
  • render the shell for a refused user: requireRole answers with a 404 or the auth battery's no-access page, and the chrome would tell an outsider the page exists.
Pages load, components render

A page is a Server Component. It calls the read helpers (listUsers, getUser, getUserStats from @/lib/admin/users, listAuditEntries from @/lib/admin/audit) and passes results down. Load independent data with Promise.all. A slow section goes in its own async component inside <Suspense> with a skeleton, as /admin does for its counts.

Components under src/components/admin/** take props. Two kinds load their own data because a slot cannot pass them any: ImpersonationBanner (the app-banner fill) and the billing summary (AdminBillingStats, AdminBillingCard). Do not add a third without the same reason.

Keep data on the server
  • Modules in src/lib/admin/ that reach a provider open with import "server-only": users.ts, audit.ts, user-store.ts, provider.ts, audit-store.ts. Client components import only the pure ones (types.ts, policy.ts, format.ts, query.ts, cursor.ts) and the "use server" actions.
  • Pick fields for client components: UserActions gets { id, name, email, role, banned }, never a database row.
  • Never send a session token to the browser. The page sends a session id; findSessionToken turns it into a token on the server.
  • Filters and cursors live in the URL, parsed by parseUserQuery and parseAuditQuery. A value that does not parse means "no filter", never an error page.
Look like the rest of the app

Use the kit (@/components/ui/*), PageHeader and EmptyState from @/components/app/*, and token classes (bg-surface-card, text-muted, border-hairline). No palette classes, no hex, no dark: for colour: the panel must read well in every design, light and dark.

Admin writes go through the provider port, and every one is audited

Loads onsrc/lib/admin/**src/components/admin/**scripts/admin/**.claude/rules/admin-mutations.md

The panel is written once against three ports in src/lib/admin/contract.ts. Only the ports differ between auth providers and ORMs:

PortFileJob
AdminUserStoresrc/lib/admin/user-store.tsReads: list, get, sessions, counts
AdminProvidersrc/lib/admin/provider.tsWrites: ban, unban, role, sessions, impersonation
AuditStoresrc/lib/admin/audit-store.tsThe trail: audit_log, or server logs without a database

Pages, components and actions import the ports. They never import better-auth, @clerk/nextjs/server, @supabase/* or @/db directly. A port that grows a method grows it in every variant of that file, or some repos stop compiling.

The order inside every action

src/lib/admin/actions.ts does these seven steps, in this order. A new action does the same (the /add-admin-action skill walks through it):

  1. authorise(): the role check plus a fresh read of the admin.
  2. Parse the form with zod. Return fieldErrors from z.flattenError.
  3. Re-read the target with adminUserStore.get(id). The page may be stale.
  4. Run the rule from src/lib/admin/policy.ts (no self-ban, no demoting the last admin, no impersonating an admin). Rules are pure and unit-tested.
  5. Call the provider inside try/catch, and turn errors into one sentence with adminProvider.errorMessage().
  6. recordAdminAction() from src/lib/admin/audit.ts.
  7. Return AdminActionState; done() revalidates /admin.

redirect() and requireRole() throw to work. Keep them outside try.

Audit entries
  • Action names are past tense and namespaced: admin.user.banned, admin.user.role_changed, admin.impersonation.started. Add a label for a new one in ACTION_LABELS in src/lib/admin/format.ts.
  • recordAdminAction adds both emails, the IP and the user agent. Put only what explains the change in metadata: the reason, the duration, from and to. Never a password, a token or a session token.
  • The provider write and the audit insert are different systems, so the write goes first. If the insert fails, the action still reports success, says the audit entry failed, and the entry is logged as JSON. Do not "fix" this by auditing first: the log would then describe changes that never happened.
  • The trail is append-only. No code updates or deletes audit_log rows.
Provider facts that shape the code
  • Better Auth: writes go through auth.api.* with the request headers, so Better Auth checks the caller too. A ban deletes sessions, but a browser's 5 minute cookie cache can outlive it. Reads use the ORM, because auth.api.listUsers search is case-sensitive. An impersonation session is read-only at /api/auth too (the endpoint guard in src/lib/auth/auth.ts), so do not add an allowlisted endpoint for convenience.
  • Clerk: bans have no reason or expiry, so both live in privateMetadata.ban and admin:unban-expired lifts timed bans. Roles live in publicMetadata.role. Impersonation uses actor tokens and signs the admin out; the free plan allows 5 a month. Clerk documents no lock on account changes for an actor session, so the app's read-only settings are the lock.
  • Supabase Auth: reads go through the service-role SQL functions in supabase/migrations/*_admin_panel.sql. An issued access token survives a ban until it expires. Impersonation is built from generateLink plus a server-side verifyOtp, signs the admin out, and confirms an unconfirmed email. Its session gets not_after (admin_limit_session) so Supabase refuses to refresh it past the limit, and Stop deletes it by id. GoTrue has no read-only session: never tell an operator more than that.
Client components

Admin dialogs are client components that call the actions through useAdminAction (useActionState plus a toast). They receive plain props: ids, names, emails, flags. Never pass a provider object, a database row or a session into one; everything passed is readable in the page source.

Skills (2)

Invoked by name.

  • /add-admin-action

    Add an admin action (verify an email, reset a plan, delete an account) as a checked, validated, audited server action with a confirm dialog.

    .claude/skills/add-admin-action/SKILL.md

  • /add-admin-page

    Add a page to /admin with the role check, a sidebar entry, loading and empty states, and data read through the admin ports.

    .claude/skills/add-admin-page/SKILL.md

Solution docs (8)

Written before you hit the problem. Each one ships in the repo at docs/solutions/ and is published here as a cookbook page.

Show all 8

How it fits

What Admin panel needs, and what it goes well with

The resolver enforces this before it generates anything, and names every addition it makes.

Requires

  • An auth battery. The resolver adds the default one for you and tells you why.

Pairs well with

Nothing extra. Add any tested battery alongside Admin panel.

Cannot be combined with

No hard conflicts.

Build a repo with Admin panel

Free and MIT. The builder opens with Admin panel picked. You download the zip right away, and we email you the link too.

Presets

Presets that already include Admin panel

A tested selection with its own file tree and its own generated CLAUDE.md. Start from one instead of from blank.