Next.js Admin Dashboard Tutorial | Coding Capybaras

A Next.js admin dashboard tutorial for founders: what to build, how to gate access at the data layer, safe user impersonation, audit logging, and the AI prompt.

· Justin Boggs

A close-up of an analogue control panel covered in labelled switches and dials

Photo by iSawRed on Unsplash

Most of what you'll find under nextjs admin dashboard tutorial is a template gallery — charts, sidebars, a dark mode toggle. That's the part that doesn't matter. The internal tool you actually use every day is four screens (a user list, one user detail page, a refund button, a feature-flag toggle) plus the one thing templates never ship: an access model that holds when someone guesses the URL. This post covers the screens worth building, where the permission check has to live in the App Router, how to impersonate a customer without opening a backdoor, and the prompt to hand your AI assistant.

TL;DR

  • Build four screens, not forty: user search, user detail, a billing action, and a flag toggle. Add screens only after you've done the same manual query three times.
  • Route protection is not access control. Proxy/middleware runs before your page, but it should never be the only check — put the real one next to the data.
  • Admin reads need a Supabase secret key, which bypasses Row Level Security entirely. That key belongs in a server-only module and nowhere else.
  • Impersonation is a write you're allowed to make on someone else's behalf. Log who, whom, when, and why — before the session starts, not after.
  • The audit log is the feature, not the paperwork. Without it, "did I refund that twice?" has no answer.

What actually belongs in a founder's admin dashboard

Start from the support inbox, not from a template.

Every admin screen worth building traces back to a moment when you opened a database client to answer a customer. Somebody wrote in saying their subscription shows as canceled but their card was charged. You ran a query. Then you ran a similar query the next week. The third time you run it, that query is a screen.

That gives you a build order, and it's short. A founder's admin dashboard is the smallest set of screens that lets you answer support tickets without opening a SQL client. For most indie SaaS that's four:

  1. User search — find an account by email, fast. Fuzzy match, because customers write in from the wrong address constantly.
  2. User detail — one page showing plan, signup date, subscription status, last login, feature flags, and the last twenty events on the account.
  3. A billing action — usually "open this customer in Stripe" plus a refund or a comp-a-month button.
  4. A flag toggle — turn a feature on for one account so you can unblock someone without a deploy.

That's it. No revenue charts. Your Stripe dashboard already has those, it's better at them, and it doesn't need you to maintain it. If you want a metrics view for yourself, that's a different tool with different requirements — the dashboard I actually check in month one is about deciding what to build next, not about answering tickets.

The trap here is real, and I fell in it. My first admin build had a beautiful MRR chart nobody looked at and no way to see why a customer's webhook was failing. The chart took a weekend. The webhook view took an hour and got used forty times.

One more filter before you write code: if a screen exists to look at things, it's probably optional. If it exists to change something for a specific customer, build it. Read-only curiosity is what your database client is for. Write actions are what you need audited, permissioned, and repeatable — which is exactly what a screen gives you and a raw SQL prompt doesn't.

Where does the admin permission check actually go?

Here's the part templates get wrong.

In the Next.js App Router there are at least four places you could check "is this person an admin," and three of them are insufficient on their own. The Next.js authentication guide is unusually blunt about this, and it's worth reading before you write a line of admin code.

Route-level interception is an optimistic check, not a security boundary. Next.js 16 renamed this file convention to Proxy (proxy.ts); on Next.js 15 and earlier it's middleware.ts. Either way the docs say the same thing: it "should not be your only line of defense in protecting your data," and "the majority of security checks should be performed as close as possible to your data source." Use it to redirect a logged-out visitor away from /admin so they get a login page instead of a flash of empty UI. Don't use it to decide who can read the users table.

Layouts are worse than they look. A layout doesn't re-render on client-side navigation, so a check there won't re-run when someone moves between routes. And a layout doesn't control whether the rest of the route renders — the router renders route segments independently, so hiding a child in a layout doesn't stop that child from running or from appearing in the RSC payload. The Next.js docs explicitly call out return null in a layout as a pattern that is not recommended for auth.

So where does it go? Next to the data.

flowchart TD
  A[Request to /admin/users] --> B[Proxy / middleware]
  B -->|no session cookie| C[Redirect to sign-in]
  B -->|has cookie: optimistic pass| D[Admin page renders]
  D --> E[requireAdmin in the data access layer]
  E -->|not an admin| F[notFound]
  E -->|is an admin| G[Query with the secret-key client]
  G --> H[Write an audit_log row]
  H --> I[Return a DTO, not the raw record]

The pattern the docs recommend is a Data Access Layer: one module that owns every read and write, and that verifies the session before it touches the database. You write a requireAdmin() function there, and every admin query calls it first. Not the page. Not the layout. The function that talks to the database.

The same rule applies to writes, and this is the one founders miss. Server Actions are public HTTP endpoints — anyone who can find the action ID can invoke it, whether or not your UI ever rendered the button. The Next.js team's security post on Server Components and Server Actions makes the point directly: treat every Server Action like a public API route and authorize it inside the action body. A refund button that checks isAdmin in the component and not in the action is a refund endpoint on the open internet.

In the Coding Capybaras layout, that means requireAdmin() lives in the platform region and every admin action's first two lines are the same: parse the input with a Zod schema, then call requireAdmin(). Everything else comes after. If you're reviewing AI-written admin code, those two lines are the first thing to look for.

The service-role key problem nobody warns you about

Your admin dashboard has a structural conflict with your database security, and it's better to meet it on purpose than at 1am.

Row Level Security exists so a signed-in customer can only read their own rows. Your admin dashboard needs to read everyone's rows. Those two requirements can't both be satisfied by the same database client, which is why admin tooling needs a privileged connection — and why that connection is the single most dangerous thing in your codebase.

Supabase is explicit about the mechanics. Per the Supabase API keys documentation, a secret key authorizes through the service_role Postgres role, which carries the BYPASSRLS attribute — so it "skips every Row Level Security policy you attach." The docs list "admin and back-office tools that run authorization checks first" as a legitimate use, and that clause is doing all the work. The key is safe only because your check ran.

Two details worth knowing, both current as of 2026:

  • Supabase is deprecating the legacy anon and service_role JWT keys by the end of 2026, in favour of publishable (sb_publishable_...) and secret (sb_secret_...) keys. If a tutorial or an AI assistant tells you to copy a long string starting with eyJ, it was written for the old system.
  • The new secret keys refuse to work from a browser at all — Supabase matches on the User-Agent header and returns HTTP 401. That's a guardrail, not a permission slip. An attacker with the key can still use it from curl.

| | Publishable key | Secret key | | --- | --- | --- | | Postgres role | anon / authenticated | service_role | | Respects RLS | Yes | No — BYPASSRLS | | Safe in the browser bundle | Yes | Never | | Env var prefix | NEXT_PUBLIC_ | none, ever | | Who authorizes the request | Your RLS policies | Your requireAdmin() | | Blast radius if leaked | One user's own rows | Every row in the project |

Three rules I'd hold to, taken from the same docs and from having been careless once:

Never prefix the secret key with NEXT_PUBLIC_. That prefix is an instruction to your bundler to ship the value to the browser. This is the entire failure mode, and it happens because someone gets an undefined in a client component and fixes it by adding the prefix. If you need that value in a client component, you need a Server Action instead.

Put the privileged client behind import 'server-only'. One module, one export, that import at the top. If anything in your client bundle ever tries to reach it, the build breaks instead of the deploy leaking.

Use a separate secret key for the admin tool. Supabase supports multiple secret keys precisely so a leak in one backend component doesn't force you to rotate everything. And if you ever log a key by accident: the docs say log no more than six characters after the prefix, or store a SHA-256 hash if you need to record which key was used. If a key does get out, rotation order matters — fix the leak, issue the new key, verify every component uses it, then retire the old one. Doing that under pressure is much easier if you've written it down in advance, which is part of what a SaaS security incident plan is for.

How do you build impersonation without opening a backdoor?

Impersonation — "view the app as this customer sees it" — is the most useful admin feature and the easiest one to build badly.

It's useful because most support tickets are visual. The customer says the export button is missing. You look at your account: the button is there. You look at their plan, their flags, their org role, and you still can't reproduce it. One click into their view and the answer is obvious in four seconds.

It's dangerous because a bad implementation is an authentication bypass with a nice UI on top.

The version I'd build, and the one I'd want an AI assistant to write:

Read-only by default. The impersonated session carries a flag, and every write path checks it. If session.impersonatedBy is set, mutations return an error. You are looking, not acting. When you genuinely need to act — cancel a stuck subscription, say — do it through a named admin action with its own audit entry, not by pretending to be the customer.

Short-lived and separate. Fifteen minutes, its own cookie, never a mutation of your real session. When it expires it expires; there's no refresh. This keeps a forgotten browser tab from being a standing grant.

Visible. A loud persistent banner across the top: Viewing as sam@example.com — exit. Not a subtle badge. You want it impossible to forget which account you're in, because the failure mode is a founder typing a note into the wrong customer's workspace.

Recorded before it starts. The audit row is written when the impersonation session is created, not when it ends — otherwise a crash or a closed laptop loses the record entirely. Capture admin ID, target user ID, timestamp, and a required free-text reason. Making the reason mandatory feels like bureaucracy for a team of one. It isn't. It's the thing that turns "I have full access to every account" into "I have logged, justified access," which is the answer you'll want when an enterprise customer's security questionnaire asks, or when a data-protection request lands and you need to show who touched what.

Never impersonate another admin. Block it in the query. There's no support scenario that requires it and it removes any path where one compromised admin account escalates through another.

If your product has team accounts, the sharp edge is that impersonating a user means inheriting their org membership, which can expose data belonging to people who never contacted you. Scope the impersonated session to the specific workspace the ticket is about — the same tenancy boundaries described in adding team accounts to a single-user SaaS apply here, and they apply harder.

Why the audit log is the actual feature

Everything above assumes an audit log exists. Build it first, not last, because retrofitting one means you can't answer questions about anything that happened before you added it.

The rule is simple: every admin action that changes state writes a row before it returns. Same transaction where you can. Actor, action, target, timestamp, and a JSON blob of the change.

actor_id     admin user who performed the action
action       'refund.issued' | 'flag.toggled' | 'user.impersonated'
target_type  'user' | 'subscription' | 'organization'
target_id    the row that changed
metadata     { amount_cents: 4900, reason: 'duplicate charge' }
created_at   timestamptz, server clock

Four reasons this earns its keep for a solo founder, none of them compliance theatre:

You forget. Six weeks after refunding a customer you will not remember whether you also comped their next month. The log remembers. This is the reason you'll actually feel, and it shows up within the first month.

AI assistants act on your behalf. If you're running admin operations through Claude Code or Cowork — and you probably will, because "find every account on the legacy plan and flag them" is exactly the kind of thing you want to delegate — the log is how you verify what actually happened versus what you asked for. The feedback loop with an AI assistant works much better when there's an independent record of the writes.

It makes destructive actions reviewable instead of terrifying. A delete button you can trace is a button you'll actually ship. One you can't is one you'll avoid building, which means you'll keep doing it by hand in a SQL client, which is strictly worse.

It's the answer to the security questionnaire. The first customer big enough to send you one will ask who can access their data and how that access is recorded. "Every admin action writes an immutable audit row, and impersonation requires a stated reason" is a real answer.

Make the table append-only. No update path, no delete path, revoke those grants at the database level. An audit log your admin tool can edit is a log that proves nothing. And write to it from the same data access layer that holds requireAdmin() — if logging is a separate call the caller has to remember, someone eventually won't.

The prompt

Paste this into Claude Code, Cursor, or Cowork, adjusted for your stack. It's deliberately specific about the security properties, because those are the parts an assistant will otherwise fill in with the most common pattern rather than the correct one.

Build an internal admin dashboard for my Next.js App Router + Supabase SaaS.

Scope — exactly four screens, nothing else:
1. /admin/users — search accounts by email (fuzzy), paginated
2. /admin/users/[id] — plan, subscription status, signup date, last login,
   feature flags, last 20 events on the account
3. A billing action on the detail page — link out to the Stripe customer,
   plus an "issue refund" server action
4. A per-account feature flag toggle

Security requirements — do not deviate:
- Create requireAdmin() in the data access layer. Every admin read AND every
  admin server action calls it as its first authorization step.
- Route middleware/proxy may redirect anonymous users, but must NOT be the
  only admin check. Do not put the admin check in a layout.
- Validate every server action input with a Zod schema before anything else.
- Use a Supabase SECRET key for admin queries, in a module that starts with
  import 'server-only'. Never prefix it with NEXT_PUBLIC_.
- Non-admins hitting an admin route get notFound(), not a 403 — don't confirm
  the route exists.

Audit log:
- Create an append-only audit table: actor_id, action, target_type, target_id,
  metadata jsonb, created_at. No update or delete path.
- Every state-changing admin action writes a row before returning.

Then show me: the requireAdmin implementation, the refund server action end to
end, and the audit table migration. Explain where the authorization check
happens in each one.

Read what comes back. Two things to check before anything else: that requireAdmin() is called inside every server action body and not just in the component that renders the button, and that the secret key never appears in a file that a client component imports. Those two are the failures that matter — the rest is fixable. If reading generated code still feels like guesswork, reading AI output as a non-coder is where I'd start.

Frequently asked questions

Should I build an admin dashboard or use Retool?

Use Retool or a similar internal-tool builder if your needs are read-heavy and your team is going to grow past you. Build your own if you want write actions gated by the same permission model as your app, an audit log that lives in your own database, and no monthly bill for a tool only you use. For a solo founder on a Next.js codebase, four screens is genuinely a weekend.

How do I make sure only I can access /admin?

Store an is_admin boolean or a role column on your user record, check it in a requireAdmin() function inside your data access layer, and call that function in every admin query and every admin server action. Don't gate on an email allowlist in an env var — it's tempting, but it breaks the moment you add a second admin and it can't be audited.

Should an admin route return 403 or 404?

Return notFound(). A 403 confirms the route exists, which tells anyone probing your app exactly what to keep poking at. A 404 tells them nothing. Log the attempt on your side so you can see it happening.

Is it safe to use the Supabase service role key in a Next.js app?

Yes, in server-only code that runs its own authorization check first — that's the documented use case for back-office tools. It is not safe in a client component, in a NEXT_PUBLIC_ variable, or in any module a client component imports. Put it behind import 'server-only' so the build fails loudly instead of the deploy failing quietly.

Do I need an audit log if I'm the only person with access?

Yes, and mostly for your own benefit. The log answers "did I already refund this?" six weeks later, verifies what an AI assistant actually changed when you delegated a bulk operation, and gives you a real answer when your first enterprise customer asks how admin access is controlled.

How should impersonation handle billing and analytics?

Exclude impersonated sessions from both. Tag the session and filter it out of your product analytics, or your activation metrics will quietly count your own support visits as customer engagement. Block anything that touches billing state entirely — subscription changes go through a named admin action with its own audit row, never through the impersonated session.

Wrapping up

A Next.js admin dashboard is a small feature with an outsized security surface, and the templates optimize for the wrong half. The screens are easy: search, detail, one billing action, one toggle. The part that takes real care is that route protection is not access control, that a privileged database client bypasses every policy you wrote, and that impersonation is a write path even when it feels like a read.

Get those three right and the rest is layout. Get them wrong and you've shipped an authentication bypass with a sidebar.

If you're building this into a Next.js SaaS, Coding Capybaras is the free boilerplate I built for exactly this kind of work — the region split, Zod-validated server actions, and append-only audit log described above ship with it, and the marketplace has copy-paste prompts for the Stripe and Supabase pieces.