Skip to content
Karan Kumar
About MeExperinceSkillsProjectsWorking OnContact
Resume
Back to Projects// case study

Portfolio CMS Platform

A Next.js portfolio with a self-built content system underneath it — draft/publish revisions, a section-based page composer, role-based admin access, and an admin-triggered JSON import pipeline for content — running on Vercel against a managed Postgres database.

Live site

The Problem

Every existing option for a portfolio site pushed me toward giving up control I actually wanted: a hosted CMS meant a schema I didn't own and a recurring bill for a single-user site; a static site generator meant no real admin panel, so any content change was a commit and a redeploy. I wanted the opposite — a real login-gated admin panel, a real draft/publish workflow, and content stored in a database I control — and I wanted to build it myself rather than wire up a page builder, for the same reason I've built other things from scratch: I'd rather understand every layer between "I click publish" and "the page changes" than trust a black box to get it right.

System Overview

The project is a Turborepo/pnpm monorepo: apps/web (Next.js App Router, the actual site and admin UI), packages/db (Prisma schema, auth, and every server-side content operation), and packages/sections (the typed registry of page-section content shapes shared between the admin composer and the renderer).

Section-based page composer

Pages aren't free-form blocks of HTML — they're an ordered list of typed sections (Hero, About, Experience, Projects, Skills, Contact, and others), each with its own Zod schema in packages/sections. The admin composer and the public renderer both read from the same schema registry, so a section's shape can only ever be defined in one place. Adding a new section type means adding one entry to the registry, not touching rendering logic in two places that can drift apart.

Draft/publish content model

Every content change — a project, a post, the home page's sections — goes through a Revision row before it touches the live, published record. Saving a draft and publishing it are two distinct, explicit operations, with an audit log entry written at each step. Preview tokens let a draft be viewed at a private URL before it goes live.

Auth and access control

Sessions are opaque tokens hashed and stored server-side, not JWTs — a session can be revoked by deleting a row, with no token-expiry-window tradeoff. Role checks happen twice: once at the page/layout level (redirect if not logged in) and again inside the database-layer functions themselves (assertHasActiveRole), so a mutation is safe even if some future route forgets the outer check. Passwords are hashed with scrypt, and login attempts are rate-limited per email over a rolling window.

Deployment

The app runs on Vercel against a managed Postgres instance (Aiven), through Prisma's driver-adapter mode with a connection pool explicitly capped at one connection per serverless function instance — a deliberate constraint to avoid a burst of concurrent cold starts exhausting the database's connection limit. That single design decision turned out to matter a lot more than it looked like at first (see below).

The Incident That Changed the Architecture

For a while, the site seeded its own content — projects, site settings, the home page's sections — automatically, once, the first time each server process started. It worked in every local test. In production, it intermittently didn't: a fresh deploy would sometimes come up with some content missing entirely, as if the seed routine had simply stopped partway through.

What was happening. Next.js runs a register() hook once per server process cold start, and Vercel can spin up several process instances concurrently right after a deploy. All of them raced through the same seed routine at once. Locally, this never surfaced, because a local Postgres on the same machine has near-zero latency between queries — any race window was too small to hit. Against the real, network-separated production database, the window was wide enough that two instances would sometimes both decide a row didn't exist yet and both try to create it.

How I diagnosed it. I stopped guessing and reproduced it: a small script that fires the seed routine from several genuinely separate OS processes at once (not just concurrent async calls in one process, which — I learned the hard way — share a single connection under the pooling setup above and can't actually race each other) against a freshly reset Postgres in Docker. With 6–8 real concurrent processes, the failure reproduced reliably: Unique constraint failed, thrown from Prisma's upsert() — for a plain, non-nullable unique column, not just an edge case. Prisma's upsert wasn't compiling to a single atomic INSERT ... ON CONFLICT in this setup; under real concurrency it was a check that could lose a race, not a guarantee.

How I fixed it — and un-fixed it. The first fix was a Postgres advisory lock around the whole seed routine, so concurrent cold starts would queue instead of interleave. It solved the reproduction case cleanly in testing. In production, it caused something worse than the original bug: the very first real seed run — against actual production network latency, doing dozens of sequential writes for the first time — ran long enough to hit Vercel's function timeout mid-seed. The killed function never reached its unlock call. Every request after that hung indefinitely, waiting on a lock nobody would ever release, until I found and manually terminated the orphaned database session. A lock is only as safe as the guarantee that whoever holds it will eventually let go — and a serverless platform that can kill your process at any line makes that guarantee impossible to keep.

I removed the lock entirely rather than trying to bound its wait time, and instead made every write in the seed path safe under real concurrency on its own terms: catch the unique-constraint conflict, retry once. By the time of the retry, the row the other process just created is visible, so the retry correctly falls through to an update instead of colliding again — no blocking, and nothing left holding a resource across a process that might get killed.

That fix worked. But it was still solving the wrong-level problem: nothing about site content needs to be re-evaluated on every cold start of every server process. So the real fix was architectural, not defensive — content seeding came out of the request path entirely. instrumentation.ts now does exactly one thing on boot: make sure the admin account exists, so the owner can always log in. Everything else — projects, site settings, the home page, resume content — moved to an explicit, admin-authenticated action: upload a JSON file through the admin panel, see a validated preview of what it contains, confirm, and it's applied. The database only ever changes when a logged-in admin deliberately asks it to, on their own schedule, with a change they can review first — not on every cold start racing every other cold start.

What I Learned

The lock felt correct when I shipped it, and it was correct for the failure mode I'd reproduced — I just hadn't reproduced the failure mode that actually mattered, which only exists under real network latency and a platform that kills processes mid-request. That gap between "verified locally" and "true in production" is the whole lesson: a fix's correctness has to be checked against the constraints of where it actually runs, not just against the bug you already know about. The second-order fix — moving the mutation out of the automatic request path instead of making the automatic path merely safer — was also the better one on its own merits, independent of the outage: code that only runs when a human deliberately triggers it is easier to reason about than code that runs unattended on every cold start and has to defend itself against every process that might ever call it concurrently.

© 2026 Karan Kumar

GitHubLinkedIn