The build story, part 1: the architecture and the bets
- build-story
- architecture
AI summary
This post details a solo developer's portfolio rebuild into a robust multi-app platform. It connects a Next.js frontend, Vue admin dashboard, NestJS/Postgres API, and Astro showcase using shared packages. The architecture prioritizes maintainability, strict constraints, and automated deployments over a simple redesign.
Excerpt: Four apps, one release: how the core fits together, what every big technology choice actually beat, and the constraints that shaped all of it, kept for last.
---
The previous version of this site still exists, frozen on a branch called old-version. It was the usual developer portfolio: designed once, shipped once, then quietly abandoned, because updating anything — a project, a job title, a line in the CV — meant editing code, rebuilding and redeploying. Every developer portfolio dies this exact death. So the rebuild was not allowed to be a redesign. It had to be a platform: content is data, publishing is a form in an admin panel, and the public site is only the most visible surface.
The core of that platform is four applications and a handful of shared packages. This post is about how they fit together, and about what each big choice beat out, because the code only ever shows what won. The picture first.
source
# title: The core — four apps, one release
direction: down
caddy: Caddy · 443, TLS
web: web · Next.js 15 · SSR
dashboard: dashboard · Vue 3 · SPA
showcase: showcase · Astro 7 · the starter pack
api: api · NestJS 11 on Fastify
db: Postgres · Drizzle
shared: Shared packages — one owner each {
tokens: tokens · CSS variables, 8 palettes
ui: ui · React primitives
uivue: ui-vue · Vue primitives
apiclient: api-client · typed from OpenAPI
content: content · prose model + fallback copy
}
caddy -> web: reverse_proxy
caddy -> api: reverse_proxy
caddy -> dashboard: file_server
caddy -> showcase: file_server
web -> api: read, SSR + ISR
api -> web: revalidate on publish
dashboard -> api: writes everything
showcase -> api: reads write-ups
api -> db
shared.tokens -> shared.ui
shared.tokens -> shared.uivue
shared.ui -> web
shared.ui -> showcase
shared.uivue -> dashboard
shared.uivue -> showcase
shared.apiclient -> web
shared.apiclient -> dashboard
shared.apiclient -> showcase
shared.content -> api
shared.content -> web: fallback when the api is silent
api.class: keyFour apps, one release. web is the site you are reading: Next.js 15.5 on React 19, server-rendered, in English and Ukrainian from day one. api is NestJS 11 on Fastify, the only process that talks to Postgres. dashboard is a Vue 3.5 single-page app, the private admin that writes everything the platform publishes. The arrow from api back to web is the publish moment: a save in the dashboard makes the api tell web to revalidate, so pages update when something is published, not on a timer. showcase is Astro 7, hosting React and Vue islands on the same pages. It spent a while cut from the MVP; it is back now, live, and demoted on purpose — it lives under the blog, as the open-source starter pack for the UI library. Underneath them sit the shared packages, one owner each: tokens at the root, two UI packs built on it, an api-client typed from the API's OpenAPI document, and content, which carries the prose model and the seed copy. Anything the four apps must agree on lives in a package. That way agreement is a dependency edge the build can check, not a convention someone has to remember.
The bets, and what they displaced
Next.js for the site
The site is the shop window. It has to be indexed, and it has to paint on the first visit, on a slow connection, before any JavaScript arrives. That rules out the default a React developer reaches for: a client-rendered SPA, which serves a blank page and a promise. With the App Router and full server rendering, every route arrives as HTML; React then makes it interactive rather than making it exist. For an app behind a login the SPA trade is acceptable. For the one page a stranger sees first, it is the wrong one.
NestJS on Fastify for the API
Here I almost went the other way — a micro-framework: less ceremony, a faster first endpoint, total freedom in how the code is laid out. That freedom is precisely the problem. This platform is maintained by one person who will leave the API alone for months and then come back to it, and a Nest module answers the questions a returning maintainer asks — where the route is declared, where its logic lives, where its dependencies come from — before a single line is read. Dependency injection and enforced structure are a tax when you write the code and a subsidy every time you re-read it, and a solo API is re-read far more often than it is written. Underneath, Fastify is the HTTP adapter, picked over Express for throughput; Nest makes that a configuration choice rather than a rewrite. Validation is zod at the edges, logging is pino.
Postgres and Drizzle
The data is relational and was never going to pretend otherwise, so the database is Postgres. Drizzle won the ORM seat for one property: the schema is TypeScript, in a shared package, so the shape the database enforces is the same shape every app that touches the data compiles against. What it beat was an ORM with its own schema language and a code-generation step, where every generation is a window in which the types and the database can disagree. Here there is no generation. The schema is the types.
Vue for the dashboard
The honest half of the answer: I wanted to build something real in Vue. The defensible half is that the choice pays for itself. The showcase promises every UI element in React, in Vue and in plain HTML/CSS, and a Vue component pack that no real application consumes would be demo-ware, correct in a vacuum and untested by actual use. The dashboard is that application: a Vue 3.5 SPA on Vite 8, with pinia and vue-router, exercising the Vue primitives daily. It is what keeps the Vue side of the design system honest. The price is real and stated up front: every primitive exists twice, once in @eakz/ui and once in @eakz/ui-vue. They are not even the same construction anymore — the Vue primitives wrap reka-ui, a headless library, while the React ones stay hand-built — and where the twins differ, the showcase says so instead of hiding it. The cheap alternative, a React dashboard reusing the existing kit, would have cost half as much and proven nothing.
Astro for the showcase
The showcase's job is to put the same element on one page three times: the React variant, the Vue variant, and the plain HTML/CSS variant. My first description of it was wrong — I called it a microfrontend playground, and the word does not survive inspection: microfrontends mean independent deploys and independent runtime ownership, and the showcase has neither. What the job actually needs is an islands host that does not care about frameworks, and that is what Astro 7 is: the page itself is static HTML, and each embedded component hydrates on its own — a React 19 island next to a Vue 3.5 island next to markup that never hydrates at all. Neither of the two frameworks could natively host the other; Astro hosts both indifferently. It is the only app that depends on @eakz/ui and @eakz/ui-vue at once, which makes it a parity check as much as a gallery.
Since the first draft of this post, the showcase has also grown a firmer identity. It is not a catalogue; it is a starter pack. It exhibits the elements web and dashboard are actually built from, shows their source, and shows how to lift them. The code on an exhibit is extracted from the real file at build time — a reference that stops resolving fails the build — so what you read is what runs, and it cannot drift. And each element's write-up is CMS content like everything else on the platform: publishing the write-up is what puts the element on the list.
Tailwind v4, with tokens underneath
Styling is Tailwind v4 everywhere, but the utilities are only a delivery mechanism: every colour resolves to a CSS variable from @eakz/tokens, so the entire platform re-themes by swapping one attribute. Eight palettes, each in dark and light, and every one passes an automated WCAG AA contrast test inside the token package. A palette that fails cannot ship, and the gate has already killed one: obsidian, the planned default, was scrapped when its light mode failed, and dracula took over as the identity palette. The alternative was Tailwind as most projects use it, colours hardcoded class by class per app — free on day one, a rewrite on the day a second theme is wanted.
One seam, one fallback
The site reads the API through a single seam — apps/web/src/lib/editorial.ts — and nowhere else. When the API is absent, or answers with something the renderers cannot draw, the seam falls back to the copy in @eakz/content; both branches return the same shape, so no component knows which one it got. The usual alternative is fetch calls scattered through components, each with its own idea of an error state, and a site that degrades unpredictably. Here, failure is designed in one place.
The CV as data
The CV is a JSON Resume file: one source of truth that renders both the timeline on the site and the PDF. The rejected alternative was scraping LinkedIn to stay in sync. It violates their terms of service, and a portfolio should not open with a ToS violation.
The constraints, kept for last
Every choice above was shaped by three constraints, and I have kept them for the end on purpose: read first, they sound like excuses; read last, they explain the shape of everything at once. One person builds this, in the evenings — anything that needs a team to maintain is out, which is why agreement lives in packages, why the schema is shared types rather than tribal knowledge, and why the API has a structure that survives my own absence. The platform stays shippable at every step: nothing merges until lint, typecheck, tests and build pass, and the AA contrast gate is the same rule applied to colour. And the server only runs containers. Builds never happen on it; they live in CI, so the machine serving this page has exactly one job.
That is the core — four apps and their packages, a complete product on its own. Part 3 of this series is about what plugs into it.