Shipping faster with a unified admin platform

F
f1939d0a-70f1-70cc-f3d9-f5b08111f653
May 26, 2026
3 min read
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.

Our team during a planning sprint

Three constraints we set on day one

  1. One auth provider. No second login screen for an internal feature, ever.
  2. One data model. Clients, projects, invoices, employees — all share the same key conventions.
  3. 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

typescript
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.