Shipping faster with a unified admin platform

Six months ago, our team was juggling four separate internal tools — one for clients, another for finance, a third for HR, and a hand-rolled job board on top of all of it. Switching between them ate more hours than the actual work. So we replaced them.
Why we built it
Every fast-growing company collects internal tools the way old sweaters collect lint. Each new requirement gets a new tab, a new login, a new way to forget where the data lives. We decided to stop accumulating and start consolidating.
Three constraints we set on day one
- One auth provider. No second login screen for an internal feature, ever.
- One data model. Clients, projects, invoices, employees — all share the same key conventions.
- One design system. New screens reuse existing components or we don't ship them.
What we shipped
Twelve modules in 90 days — clients, projects, milestones, invoices, expenses, payroll, leaves, attendance, employees, tasks, content, and analytics. All Cognito-authenticated, all served from a single Next.js admin app, all reading from one DynamoDB table.
- Clients & projects — onboarding, milestones, attachments
- Finance — invoices, expenses, approvals, GST tax engine
- HR — employees, leaves, attendance, payroll runs
- Content — blog, case studies, careers, all approvals-gated
The fastest internal tool is the one you don't have to context-switch into.
The architecture in 30 lines
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";import { DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";import { requirePermission } from "../shared/rbac";const client = new DynamoDBClient({});const docClient = DynamoDBDocumentClient.from(client);export async function appsyncHandler(event: AppSyncEvent) {const caller = callerFromIdentity(event);if (!caller.sub) throw new ForbiddenError("unauthenticated");// Single-table design — every entity lives in the same DDB table,// keyed by composite PK/SK. RBAC is enforced before any read or// write touches storage.await requirePermission(caller, "content.blog.manage");return invokeAsRest("POST", "/content", event.arguments.input);}
What didn't work
Two things we tried and dropped. First: a generic "low-code form builder" for non-engineers. It bloated the schema and nobody used it after week two. Second: per-feature React state libraries — Zustand here, Jotai there, Redux in one corner. We standardised on React Query for server state and component state for everything else.
What we'd do differently
- Start with permissions on day one — retrofitting RBAC costs 3× more.
- Write the migration script before the seed data, not after.
- Wire the notification bell before adding the third approval flow, not the eighth.
If you're starting an internal platform from scratch, here's the only real advice: decide what you're not building. The rest is just typing.