Four days. That was the constraint. Not "we'd like this in four days" — the kind of constraint where there's a demo on day five and you either have something real or you don't.
MediSync is a healthcare SaaS admin platform: doctor verification, earnings tracking, payout approvals, role-based access. It's live. It works. Here's exactly how I built it.
The First Decision: What Does "Done" Actually Mean?
Most failed sprints fail here. Everyone agrees on a goal but nobody agrees on what "done" means, and then day four arrives and you have a half-built feature that satisfies no one.
I defined done as: an admin can log in, see a list of doctors with their status, verify or suspend them, view earnings, approve or reject payouts, and every action is logged. That's the core loop. Everything else was a nice-to-have.
That ruthless definition of scope is the reason MediSync shipped on time.
The Stack and Why
Next.js 14 with App Router and Server Actions — I knew I'd want server-side mutations without building a separate API layer. Server Actions let me write the database call in the same file as the form component. Fewer round trips, no CORS configuration, and secrets stay server-side.
Clerk — Auth is the first thing I reach for when I need role-based access fast. Clerk's has() helper lets me gate entire route segments or individual UI components behind a permission claim:
// Server Component — hide the approve button entirely if no permission
const { has } = await auth();
const canApprovePayout = has({ permission: "org:payouts:approve" });
Setting up the roles (viewer, admin, super-admin) took about an hour. Building the same thing from scratch with JWTs and a custom claims table would have taken a day.
Prisma + PostgreSQL (Neon) — Prisma's type-safe query API saves time when you're moving fast. The schema was designed in one sitting:
model Doctor {
id String @id @default(cuid())
name String
email String @unique
status DoctorStatus @default(PENDING)
createdAt DateTime @default(now())
earnings Earning[]
payouts Payout[]
auditLogs AuditLog[]
}
enum DoctorStatus {
PENDING
VERIFIED
SUSPENDED
}
Tailwind CSS + ShadCN UI — I use ShadCN's component library as a baseline and extend it. The data tables, dialog modals, and status badges that would take hours to build from scratch are done in minutes.
The Critical Feature: The Audit Log
The temptation on a fast sprint is to skip the audit log. It's unsexy, users don't see it in demos, and it feels like infrastructure rather than product.
Don't skip it. In a healthcare context, every state change needs to be attributable to a specific admin. I built a simple AuditLog model from day one:
// Server Action — runs server-side, no client exposure
async function verifyDoctor(doctorId: string) {
const { userId } = await auth();
await db.$transaction([
db.doctor.update({
where: { id: doctorId },
data: { status: "VERIFIED" },
}),
db.auditLog.create({
data: {
action: "DOCTOR_VERIFIED",
targetId: doctorId,
performedBy: userId,
},
}),
]);
revalidatePath("/admin/doctors");
}
The $transaction is essential — if the audit log insert fails, the doctor status change rolls back. You never get a state change without a record.
What I Cut
- Email notifications — I planned to send emails on verification. Scoped out. The admin sees the status change in the UI immediately; emails are day-two work.
- Pagination on the earnings table — with under 100 doctors in the initial dataset, a full list render is fine. Optimise when you need to.
- Doctor self-serve portal — entirely separate product surface. Out of scope from the start.
- Automated payout scheduling — manual approval only. Automation is a feature on top of a working workflow, not a substitute for one.
The Payout Approval Guard
One thing I did not cut: duplicate-prevention on payout approvals. Without it, a double-click or a network retry can result in the same payout being processed twice. The guard is simple:
const existingApproval = await db.payout.findFirst({
where: { id: payoutId, status: { not: "PENDING" } },
});
if (existingApproval) {
throw new Error("Payout already processed");
}
Check before you write. Always.
What I'd Do Differently
Use optimistic UI updates from day one. The current setup revalidates the page on every Server Action, which adds a visible delay. Next time I'd use useOptimistic to update the UI immediately and confirm in the background.
Define the Prisma schema collaboratively before writing a single component. I had to migrate the schema twice because I added fields late. The schema is the contract — get it right before you build on top of it.
Add request logging on day one. When something goes wrong in production, you want a record of what the client sent and what the server returned. I added it later. It should have been there from the start.
The Lesson
Four-day sprints don't reward heroics. They reward ruthless prioritisation, a schema you'd be comfortable defending, and components you build once and reuse everywhere. The admin got a working platform. The audit trail gave them confidence in it. The payout guard saved them from a support headache.
That's what shipping looks like.