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.tsand 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 moreShow fewer
- 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.
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
| Where | Call | What it stops |
|---|---|---|
src/app/(admin)/layout.tsx | requireRole("admin", returnTo) | A non-admin ever seeing the shell |
Every page.tsx | requireRole("admin", "/admin/<path>") | A stale layout: layouts do not re-render on client navigation |
| Every server action | authorise() in src/lib/admin/actions.ts | Anyone 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'sowner): userequireRole, ornavItemsForfor 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:
requireRoleanswers 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 withimport "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:
UserActionsgets{ id, name, email, role, banned }, never a database row. - Never send a session token to the browser. The page sends a session id;
findSessionTokenturns it into a token on the server. - Filters and cursors live in the URL, parsed by
parseUserQueryandparseAuditQuery. 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:
| Port | File | Job |
|---|---|---|
AdminUserStore | src/lib/admin/user-store.ts | Reads: list, get, sessions, counts |
AdminProvider | src/lib/admin/provider.ts | Writes: ban, unban, role, sessions, impersonation |
AuditStore | src/lib/admin/audit-store.ts | The 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):
authorise(): the role check plus a fresh read of the admin.- Parse the form with zod. Return
fieldErrorsfromz.flattenError. - Re-read the target with
adminUserStore.get(id). The page may be stale. - 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. - Call the provider inside
try/catch, and turn errors into one sentence withadminProvider.errorMessage(). recordAdminAction()fromsrc/lib/admin/audit.ts.- 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 inACTION_LABELSinsrc/lib/admin/format.ts. recordAdminActionadds both emails, the IP and the user agent. Put only what explains the change inmetadata: the reason, the duration,fromandto. 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_logrows.
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, becauseauth.api.listUserssearch is case-sensitive. An impersonation session is read-only at/api/authtoo (the endpoint guard insrc/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.banandadmin:unban-expiredlifts timed bans. Roles live inpublicMetadata.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 fromgenerateLinkplus a server-sideverifyOtp, signs the admin out, and confirms an unconfirmed email. Its session getsnot_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.
- Designing an audit log for an admin panelOne append-only table, namespaced past-tense actions, emails copied in, and a clear rule for when the audit row can share a transaction with the change and when it cannot.docs/solutions/admin-panel/adding-an-audit-log.md
- Bans that actually sign people outSetting banned = true stops the next sign-in, not the session already open. What Better Auth, Clerk and Supabase do on a ban, where caches and tokens let a banned user linger, and how to say so.docs/solutions/admin-panel/bans-and-session-revocation.md
- Empty states that are not sadAn empty state that only says "No data available" is a dead end. It should say what belongs here, why it is missing, and what to do next.docs/solutions/admin-panel/empty-states-that-are-not-sad.md
- Making the first admin without a back doorA fresh deploy has no admin, and the admin page needs one to add one. Use a terminal script with database or API credentials, never an env list of emails or a first-user-wins rule.docs/solutions/admin-panel/first-admin-without-a-backdoor.md
- impersonating-users-safelydocs/solutions/admin-panel/impersonating-users-safely.md
Show all 8Show fewer
- Paginating admin tables without a client libraryOffset pages with a total for the users table, keyset cursors for the audit log, both in the URL and rendered on the server. When to use which, and the details that make each correct.docs/solutions/admin-panel/paginating-a-users-table.md
- Role checks that survive a layout refactorA check that lives only in a layout disappears the day someone moves the page. Put the boundary where the route is, and check again where the work happens.docs/solutions/admin-panel/role-checks-that-survive-a-refactor.md
- Route group or path segment: how to lay out an admin sectionA route group shares a layout without touching the URL; a path segment is the URL. Admin panels need both, and confusing them produces public pages and 404s.docs/solutions/admin-panel/route-group-vs-path-segment.md
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.