Binocs - 24h admin tool for 40+ users
The full story of shipping the Binocs internal admin tool in 24 hours - the scope conversation, the architecture decisions, the things I did not build, the boring stack, and the audit log that paid for itself.
The ask
Tuesday evening, the lead engineer pinged me. "Ops is drowning in dashboards. Sheet plus three Retool boards. Can we just have an admin panel." I asked what they needed by when. He said "before EOW would be great". I said "I will have something tomorrow night".
It was not bravado. It was a calculation. Ops needed seven things. Each thing was a Next.js page, a server action, and a Postgres query. With the right stack and the right scope, 24 hours was enough.
The scope conversation
Before I wrote a line, I sat with the head of ops for 20 minutes. The question I asked was not "what do you want in an admin panel". The question was "walk me through your Tuesday morning, in order, on the tools you have". The answer was the spec.
Her actual morning -
- New user signups overnight in a Slack channel, copy email to invite form, send invite.
- PE firm asks for deal access, look up the user, look up the deal, grant access.
- Customer reports a bug "feature X is not showing", check feature flags for their org.
- Account exec asks "is this customer still on the trial", check billing.
- Customer locked out, reset MFA.
- Customer churned, suspend account.
- Once a week, audit access changes for compliance.
Seven things. Each one had a clean ops verb (invite, grant, toggle, check, reset, suspend, audit). Each one became a page.
What I did not build -
- A reporting dashboard. Ops did not need it daily.
- Bulk import. Not in the daily flow.
- Custom RBAC. Two roles was enough.
- A "deal management" full CRUD. Out of scope, deals are managed in the main app.
- Real-time updates. Page refresh was fine.
Every "but wouldn't it be useful if" I parked. Most of those features I never built, and ops never asked for them again.
The stack
Boring on purpose. Every choice was "what is the lowest-risk option that gets me to ship tomorrow".
- Next.js App Router - the team already uses it for the main app, the deploy pipeline already exists, I know it cold.
- NextAuth with Google SSO restricted to our company domain - 30 minutes of setup, done.
- Postgres - same database as the main app, separate schema. I created read-only views for the data ops only needed to view, and wrote to the real tables through service functions.
- shadcn/ui - tables, forms, dialogs, all in styled-and-accessible components I do not have to design.
- Vercel - deploy on push to main, preview deploys on PR, zero infra to set up.
- Drizzle ORM - already used in the main app, type-safe queries, fast.
No new infra. No new dependencies the team had not seen. Postgres schema migration for the audit log table, NextAuth schema migration for accounts and sessions, everything else used existing tables.
The architecture
Every interaction is the same shape - user clicks a button, form submits to a server action, server action checks role, runs the query, writes to the audit log, returns. No client-side fetch code. No API route boilerplate. No JSON serialization to think about.
Auth and RBAC
NextAuth with the Google provider, configured to only accept emails from our company domain. The check is in the signIn callback - if the email does not match, return false, NextAuth blocks the session.
async signIn({ user }) {
if (!user.email?.endsWith("@binocs.co")) return false;
return true;
}(Note: example domain, not the real one.)
RBAC was two roles, admin and ops. The role lives on the user record in Postgres. A middleware function reads the session, looks up the role, and gates destructive actions (suspend, reset MFA, change feature flags) to admin-only.
I did not use a permissions library. Two roles, six gated actions, a switch statement was the right tool. I have used CASL in past projects and it was great. Here it would have been overkill.
The audit log
The audit log was the highest-leverage 8 lines of code I wrote that day. Every server action ended with a call to logAdminAction(actor, action, target, diff). One table, append only.
await db.insert(adminAuditLog).values({
actorId: session.user.id,
action: "grant_deal_access",
targetType: "user_deal",
targetId: `${userId}:${dealId}`,
diff: { granted: true },
createdAt: new Date(),
});The audit log paid for itself in week two when compliance asked "who granted this user access to this deal". One SQL query, answer in 10 seconds. Without the audit log that would have been a half-day archaeology project in CloudWatch.
The seven screens
Each screen was one route under /admin/<thing>. Each was a server component that fetched data, plus a client component (sometimes) for interactive bits, plus a server action for mutations.
/admin/users- paginated list, search by email, invite form./admin/users/[id]- profile, role, deals, suspend button, reset MFA button./admin/deals/[id]/access- list of users with access, add user form./admin/flags- feature flags per org, toggle switches./admin/billing/[orgId]- subscription state, plan, next billing date (read-only, source of truth is Stripe)./admin/audit- paginated audit log, filter by actor, action, target.
That was it. Six routes, one of them with a sub-route. Around 600 lines of TypeScript total, plus shadcn components.
The 24 hours
Wednesday morning 8am - schema migration for admin_audit_log and a role column on users. NextAuth setup. Domain check. Deployed a blank admin page protected by SSO. 2 hours.
10am to 1pm - users list, invite form, user detail page, suspend, reset MFA. Server actions for each. Audit log calls. 3 hours.
Lunch.
2pm to 5pm - deal access page. Feature flags page. Billing readout. 3 hours.
5pm to 7pm - audit log viewer. Pagination. Filters. 2 hours.
7pm to 10pm - polish. Empty states. Error states. Confirmation dialogs on destructive actions. Mobile responsive (ops sometimes uses their phone). 3 hours.
10pm - deployed to prod behind a feature flag, invited the head of ops to test, went to sleep.
Thursday morning - head of ops had tried it, found 2 small issues (a typo, a missing confirmation on suspend), I fixed both in 30 minutes, removed the feature flag, announced in #engineering and #ops. 40-plus users on it by lunch.
What I cut and never added back
- A search across all entities. Ops searched users and deals separately and that was fine.
- A "recently viewed" sidebar. Browser history did the job.
- Custom themes. shadcn default was fine.
- A "draft invite" workflow. Send-or-do-not-send was enough.
- An undo for destructive actions. Confirmation dialog plus audit log meant we could always reverse manually.
Two of these I almost built and convinced myself out of. The right move was always to wait until ops asked. Ops never asked.
What I added in week two
- Bulk invite (paste a list of emails). Ops asked on Friday.
- Audit log CSV export for compliance. Compliance asked the next week.
- A "user impersonation" view (read-only) so engineers could debug what a user was seeing. The team asked.
That was it. The day-one tool covered 90 percent of needs forever.
What this taught me
Scope is the single biggest lever in shipping. Every project I have shipped fast was a project where I cut hard. Every project that slipped was a project where I tried to do everything.
The skill is not "type fast" or "use a great stack". The skill is "have a real conversation with the user, hear what they actually do, build exactly that, ship it, watch them use it, then talk to them again".
The internal admin tool was not impressive engineering. It was Next.js, server actions, shadcn, Postgres. The impressive part was the scope conversation on Tuesday night, and the discipline to not add any feature ops did not need on Wednesday. Ship the small thing tomorrow, not the big thing in two weeks.
Learn more
- DocsNext.js App Router docsNext.js
- DocsNext.js server actionsNext.js
- DocsNextAuth.js docsNextAuth.js
- Docsshadcn/uishadcn
- DocsVercel deploymentsVercel