yurii.
back to work
CASE STUDY

How I Built This Site: From a Static Portfolio to a Self-Hosted CMS

SHIPPED
Started: Nov, 2025Shipped: July, 2026Role: Sole Engineer

Why I Even Started This

I'm an Android engineer by trade — Jetpack Compose, architecture, performance, the whole stack. But every time I wanted to talk about the projects I'd built or the decisions behind them, I was stuck writing README files that nobody but me would ever read. I wanted a real portfolio. Not a template, not a page builder — something I actually engineered, the same way I'd approach a production Android app: a real design system, real tests, a real deployment pipeline, and eventually, a real backend I own end to end.

This post is the story of how that turned into a much bigger project than "a personal website."

Chapter 1: A Design System Before a Single Page

The first version wasn't a page. It was a design system: a Vite + React SPA, organized with Feature-Sliced Design (shared / widgets / feature / pages), with its own design tokens, a light/dark theme engine, and an i18n layer that supported English and Russian from day one. I built Button, Card, Tag, IconBadge, ProgressBar, and a syntax-highlighted CodeBlock from scratch, with a small internal playground to see every variant side by side before it ever touched a real page.

It felt slow at the time — building buttons before building a "site" — but it meant every page after that was assembly, not invention.

Chapter 2: Making It Trustworthy: Visual Regression and CI/CD

A design system is only as good as your ability to know when you've broken it. So before I let myself add more pages, I wired up Playwright visual regression tests across both themes and multiple viewports, plus a GitHub Actions workflow that could accept new baseline screenshots on demand (/update-snapshots) instead of me eyeballing diffs by hand. Alongside that came the actual deploy pipeline — a script that shipped the built SPA straight to a VPS I manage myself.

At this point the site looked finished. It wasn't.

Chapter 3: The Itch That Wouldn't Go Away

Here's the problem: this was a static site. Every time I wanted to publish a journal entry or add a project, I had to edit a TypeScript file, open a PR, wait for CI, and redeploy. For a "journal," that's backwards — the entire point of a journal is that you can write in it without a release cycle.

So I decided to do the harder thing: migrate the whole site to a real, database-backed CMS, with an admin panel only I could reach, while keeping the live site online the entire time.

Chapter 4: Choosing Next.js and a Single-Process Architecture

I moved the project to Next.js (App Router), built fresh in a new web/ directory so the old SPA (frontend/) kept deploying untouched until the cutover was actually proven.

The architectural question that mattered most: where does the backend live? I didn't want a second service to operate, monitor, and pay for — just for a personal site. So backend/ became an npm workspace package (@portfolio/backend), imported directly into the Next.js process. One process, one systemd service, no network hop between "frontend" and "backend" for a single request.

Getting there wasn't clean on the first try. My first attempt was a plain tsconfig.json path alias into ../backend/src — and Turbopack (the default bundler in Next.js) flatly refused to resolve an import outside the project directory. I could have downgraded to Webpack to make it work, but that meant giving up Turbopack everywhere, forever, for one workaround. npm workspaces turned out to be the real fix: once backend/ had its own package.json, npm install symlinked it into node_modules, and Turbopack treated it like any other library.

Chapter 5: Auth, and a Content Model That Doesn't Need Migrations for New Ideas

With Prisma and PostgreSQL in place, I modeled User/Session for JWT-based auth (access + refresh tokens, hashed refresh tokens so a leaked DB dump alone can't impersonate a session), and then the actual content: Post and Work, each pointing at a Document, which is just an ordered list of Blocks.

The key decision was making Block.type a plain string, not a database enum, with the actual shape of each block type (paragraph, heading, quote, note, image, code, approachList) validated by Zod at the application layer instead of the schema layer. It means I can invent a new block type without ever writing a migration for it — the database doesn't need to know what a "note" callout is, only the code that renders one does.

Chapter 6: Building the Admin Panel, Then Actually Using It

Phase 4 was the payoff: /admin, protected by the same JWT session, with full CRUD for posts and projects — talking to /api/admin/* through a plain fetch(), deliberately not Next.js Server Actions, so the exact same contract could someday serve a mobile client, not just this browser session.

Then I actually tried to use it, and immediately found six things that only show up when a real human writes a real post: the slug should generate itself from the title, the publish date shouldn't be a form field at all (it's set automatically, once), status needed a color indicator instead of a plain dropdown, and read time should be estimated from the content instead of typed in by hand. Every one of those became a small, deliberate fix rather than something I "meant to get to later."

Chapter 7: Going Bilingual, and Swapping in a Real WYSIWYG Editor

Two harder problems came next: real localized routing (/ru/..., resolved on the server via a proxy.ts rewrite, not just a client-side language switch after the page already loaded), and a proper rich-text editor instead of a form with "select block type → fill in fields."

The editor became BlockNote — a real slash-menu, real bold/italic/link toolbar, drag-to-reorder blocks. It also gave me two of the more interesting bugs in this whole project:

BlockNote's underlying schema (ProseMirror) doesn't allow the same name to be both a block and an inline style — and my custom code block collided with the built-in inline code mark, crashing the editor with an error message that pointed nowhere near the real cause. Renaming the internal block type fixed it in five minutes, once I found it.

BlockNote needs a real browser document to construct itself, but Next.js renders client components on the server first for the initial HTML. The fix wasn't next/dynamic — it was a small "mounted gate": render a placeholder until a useEffect (which never runs during SSR) flips a flag, then mount the real editor. The BlockNote-dependent code becomes unreachable on the server, not just conditionally skipped.

Translations became their own dedicated screens (/admin/journal/[slug]/translate) rather than a second language field bolted onto the English form — a translator (future me, or an actual translator) works on a focused page, not a form twice the size.

Chapter 8: Even the Landing Page Comes From the Database Now

By the time content management worked, the landing page itself — hero copy, contact section, tech stack, principles — was still hardcoded TypeScript. So Phase 5 moved all of it into a generic SiteContent table (one row per section, validated per-key), with one small form per section in the admin. Now literally every visible string on this site can change without a deploy.

Chapter 9: The Bugs That Taught Me the Most

A few issues only surfaced once things were actually running in production-like conditions, and they're worth remembering:

Next.js silently pre-baked database content into static HTML at build time. The whole point of the migration was publishing without a redeploy — and the very first build after wiring up the database quietly generated static pages, defeating the purpose. The fix was one line per route: export const dynamic = "force-dynamic".

The Edge runtime doesn't run Node.js. My route-protection middleware imported the backend package's main entry point, which pulled in node:crypto transitively through the auth/session code — completely unsupported in Next.js's Edge runtime. The fix was a second, deliberately narrow entry point (@portfolio/backend/edge) exporting only what the Edge runtime actually needs.

instanceof** lies across bundle boundaries.** A custom error class thrown in one Turbopack bundle (Server Components) wasn't recognized by instanceof in another bundle (Route Handlers) — same file on disk, different compiled module identity. The fix was checking error.name instead, which survives the boundary.

Two copies of React in one workspace. A dependency pinned an exact React version without a caret, so web/'s own node_modules diverged from the version everything else in the workspace resolved to — render() mounted one React instance while my components called hooks on another, producing a null hook dispatcher. overrides in the root package.json pinned one version for the whole tree.

None of these were exotic. They were the ordinary cost of moving fast on real infrastructure — and every one of them is now a comment in the code explaining exactly why the fix looks the way it does, so it never gets "cleaned up" back into the bug.

Chapter 10: Taking It Live for Real

The last piece was making this whole thing operable, not just functional. I provisioned the VPS myself, step by step, verifying every invariant by hand over SSH before turning it into a script: PostgreSQL bound to localhost only, a dedicated non-superuser database role, a no-shell system account running the app (separate from my own SSH/deploy account, so a compromised app process can't reach root), a hardened systemd unit, nginx rate-limiting on the login endpoint, and nightly encrypted database backups shipped off-box.

Everything ships through GitHub Actions now: a push to master deploys to a staging domain first, a version tag ships to production. Even the deploy pipeline itself got hardened along the way — the first version relied on wildcard sudo rules for the deploy user, which got replaced with a single root-owned wrapper script that only performs the exact actions a deploy needs, nothing broader.

Where This Leaves Things

The post you're reading right now is proof the whole thing works: I wrote it in the admin panel I built, in the block editor I debugged, saved to the database I provisioned, on the VPS I hardened myself — without touching a single line of code or waiting for a deploy.

It didn't start as a CMS. It became one because a static portfolio couldn't keep up with wanting to actually write. That's usually how the interesting projects go.

Stack

TypeScriptNext.jsVitestReact

RELATED JOURNAL ENTRY

I just launched my first personal Portfolio Blog!
Congrats me on my very first Journal Post!
Read the post