Skip to main content
cases

Athena Telecom — Enterprise CMS & Portal

Enterprise Next.js 16 application for a satellite communications company in Dubai. Built a 37-section-type drag-and-drop CMS, complete auth system, and production deployment stack from the ground up.

View Live
37CMS Section Types
30+API Endpoints
30%Load Time Reduction
21MongoDB Models
Next.js 16TypeScriptMongoDBRedux Toolkit@dnd-kitGoogle OAuthJWTNodemailerPM2 + Nginx

The Problem

Athena Telecom is a satellite communications company in Dubai. They needed a public-facing website that non-technical marketing staff could update without developer involvement. That is the standard brief for a headless CMS.

The problem with the standard answer: their pages don't have content types. A typical page opens with a hero banner, transitions into a technical solution brief, embeds a product pricing table, includes a quote request form, and closes with a blog post excerpt. Those five things don't share a schema. In Contentful, each is a separate content type and the page template conditionally renders all of them. With 37 section types, that's 37 content types and a template that has to know about every possible combination. Any new section type means a schema change, a component change, and a template update in three places simultaneously.

I evaluated Contentful, Sanity, and a WordPress + ACF setup. All three could technically handle it. All three would have required building custom logic on top of constraints that existed for use cases that weren't ours. The decision to build a custom page builder wasn't ideological — it was that the off-the-shelf options were trading one kind of complexity for another, and the custom approach gave us control over the exact shape of the problem.

The CMS Page Builder

Each of the 37 section types is a Zod-validated schema paired with a React component, registered in a central registry. The editor uses @dnd-kit for drag interactions. Section editor panels are lazy-loaded — they only mount when a given type is actually in use, which kept the initial bundle manageable at 37 types.

Adding a new section type requires three separate files: the schema, the component, and the editor panel. The registry then needs to know about all three. That three-file coupling is the main thing I'd want to change, and I'll come back to it.

The drag-and-drop itself was straightforward. The state management around it was not.

The Race Condition

For several days in development, sections would occasionally snap back to a previous order after dragging. The symptom looked like a drag bug — the drag completed visually, then the order reverted. I spent time in the @dnd-kit documentation. I tried different modifiers. I looked at open issues in the repo. None of it pointed anywhere useful, because it wasn't a drag bug.

I added logging to both the drag handlers and the Redux save handlers simultaneously and looked at the timestamps. The drag was completing correctly. A debounced save was firing, completing, and the response was arriving 200–400ms after the drag finished — at which point the response handler was updating Redux state with the old order from the server, overwriting the new order that the drag had already written.

The fix was a guard: if a drag operation is in progress when a save response arrives, discard the response and queue a fresh save on drag end. This adds one extra round-trip in the race condition edge case. The drag state flag is two lines. Getting to those two lines took three days.

The broader problem it revealed: optimistic UI updates in an editor with concurrent operations need explicit "operation in progress" state — not just optimistic state, but a guard that decides whether incoming server responses should apply to the UI or be discarded. I built the optimistic updates first and the guard second. The sequence should be reversed.

Authentication

The client specified enterprise auth requirements: JWT with refresh token rotation, Google OAuth, OTP email verification, device tracking with per-session revocation, four-level RBAC, and reCAPTCHA v3 on auth forms.

The most technically involved piece was refresh token rotation. The old token must be revoked before issuing a new one, and the revocation must be atomic — if two requests simultaneously attempt a token refresh, one of them must fail cleanly rather than both succeeding and issuing two valid refresh tokens from one. I used a MongoDB findOneAndUpdate with a match condition on the existing token. If the update finds no matching document, the token has already been rotated and the request is rejected as a replay attempt. The atomicity is guaranteed by MongoDB's single-document operation guarantees.

Production Deployment

The application runs on an Ubuntu VPS with PM2 for process management, Nginx as reverse proxy with SSL via Let's Encrypt, Fail2Ban for brute-force protection, and daily automated backups to Google Drive with 30-day retention.

Two CVEs affecting production dependencies were disclosed during the project lifecycle. Patching them was not the problem. The problem was that I found out about both by accident — one from a newsletter, one from a GitHub notification I happened to notice. Neither came from any systematic monitoring. I added npm audit to the CI pipeline and a weekly advisory review schedule afterward. The tooling was available the whole time. I just hadn't wired it up.

What Shipped

The public website launched with the full 37-section page builder. Marketing staff update pages without developer involvement. Load time improved 30% against the previous site.

What I'd Change

The three-file structure for adding a section type creates coupling that gets quietly expensive over time. I'd redesign it as co-located definitions — one file per section type exporting the schema, the component, and the editor config together, with the registry discovering them dynamically. I'm fairly confident this would reduce the friction of adding new types. What I'm less certain about is whether it handles the lazy-loading requirements as cleanly as the current approach. The current lazy-loading relies on the editor panels being separate files that can be dynamically imported. Co-location might complicate that. I haven't worked through it fully, which is part of why the refactor hasn't happened.

The observability gap is the thing I'm more certain about. I'd wire up structured logging, query monitoring, and error rate tracking in the first week on any project of this scale — before there's anything interesting to observe — so the baseline exists when something eventually goes wrong. On this project, I was building those tools reactively, which meant the first few incidents were harder to diagnose than they needed to be.