← All articles
Jun 12, 20263 min read

A Production Admin Panel in a Weekend: Next.js, Supabase Service Role, and One Password

  • Next.js
  • Supabase
  • admin
  • security
  • TypeScript
  • North
A Production Admin Panel in a Weekend: Next.js, Supabase Service Role, and One Password

Every product eventually needs a back office — rename a user, kick a troll from a circle, comp a subscription, see what's happening. The trap is treating it like a product: SSO, roles, an RBAC matrix for a team of one. North's admin panel takes the opposite bet: one shared password, maximum paranoia everywhere else. Here's the architecture, and where the actual security lives.

Auth that fits the threat model

The panel is a route group in the existing Next.js app — /admin, noindex, disallowed in robots. Auth works like this:

  • The client wraps everything in an AdminAuthProvider. Unauthenticated, you get a password prompt; the password is validated by firing a probe request (/api/admin/users?q=__ping__) and cached in sessionStorage.
  • Every API call goes through an adminFetch wrapper from the auth context, which injects the password as an x-admin-password header. No tokens, no sessions, no refresh logic.
  • Server-side, every route validates the header with timingSafeEqual from node:crypto — constant-time comparison, so the check doesn't leak prefix-match information through response timing. Cheap to do, embarrassing to skip.
  • Before any of that, a getEnv() guard verifies the required secrets exist (SUPABASE_SERVICE_ROLE_KEY, ADMIN_DASHBOARD_PASSWORD…) and returns 503 if not — a misconfigured deployment fails closed and diagnosably, rather than half-working.

Is a shared password "enough"? For one operator, behind HTTPS, gating an internal tool whose every mutation is audit-logged — yes, and anything more is ceremony. The honest security boundary isn't the password anyway. It's the next part.

The real boundary: server-only + DTOs

The panel talks to Supabase with the service-role key — the credential that bypasses row-level security entirely. The architecture treats it accordingly:

  • Every module that touches Supabase, RevenueCat, or Expo push is marked import "server-only" — if any client component ever transitively imports one, the build fails. The leak class is eliminated at compile time, not by code review vigilance.
  • Those modules never return raw database rows. Every response is mapped to a DTO type that simply has no fields for push tokens, device metadata, or anything else the browser has no business holding. The admin UI can't leak what it never receives.

One consequence worth knowing: server-only throws when imported under plain Node, which breaks Vitest. The fix is a one-line alias in vitest.config.ts stubbing it to an empty module — after which all the pure helpers (auth comparison, DTO mappers, merge logic) unit-test offline.

Patterns from the feature set

Activity feed as in-memory merge. The feed consolidates three tables — geofence events, invite redemptions, admin audit entries — via three parallel fetches merged, sorted, and capped in application code. No database view, no migration, trivially extensible. For internal-tool data volumes, the "proper" solution is just slower to change.

Audit everything. Every mutation — rename, kick, block, grant — writes an admin_audit row (who, what, target, when), and the audit trail shows up in the activity feed itself. The back office watching itself is a feature, not overhead: it's also how you remember what you did at 1am.

Notifications with EQ. The notify dashboard sends push messages through Expo with {name} personalization, batch-chunked at Expo's 100-message limit — and draws from 25 deliberately warm preset templates. That turned out to be its own topic: notification copy that doesn't sound like a robot.

Eventual consistency, surfaced. Premium grants go through RevenueCat rather than touching our database, then the UI polls until the webhook lands — covered in the promo entitlements post.

The takeaway

An internal admin panel earns its keep by existing this week, not by having enterprise auth. Spend the effort where the asymmetry is: constant-time checks cost one import; server-only costs one line per module; DTO mapping costs a type definition; an audit table costs one insert per mutation. Skip the SSO. Keep the paranoia.

WRITTEN BY

Shahzaib Muhammad Akram

Senior Frontend EngineerCyberjaya, Malaysia