Problem
A personal engineering site is not a brochure. It has to carry portfolio work (projects, coursework), long-form writing (posts, snippets, book notes), and discoverability (tags, sitemap, search-engine notification) - while staying cheap to host and safe to preview before going live.
The v1 Webpack/Handlebars site could showcase work, but it had no typed content model, no shared tag graph across collections, no multi-environment release path, and no deliberate SEO contract. Content lived next to the app; unpublished drafts risked shipping with the repo; "publish" meant redeploying the whole frontend for a prose change.
I needed a platform seam, not another theme: author once in a private content repo, build a fully static site that search engines can crawl, and promote from preview to production without rewriting the architecture.
Solution
I rebuilt the portfolio as a Next.js 13 static export (output: 'export') that treats content as a first-class build input. Authors work in a private content repo (content--paulserban.eu). At build time a clone script pulls the publish tree; a ContentRepository singleton walks typed MDX directories, parses frontmatter with next-mdx-remote, and exposes filtered, sorted, pinned, and tag-indexed views. Page shells stay as JSON; article bodies stay as MDX.
The same build CLI selects dataset (live / test / prev) and target hosting (test / prod). Preview deploys land on GitHub Pages; production syncs to S3, invalidates CloudFront, and sits behind Route 53 - with sitemap generation and IndexNow notification only on real production builds.
Architecture
content--paulserban.eu (private Git)
publish/{projects,coursework,posts,booknotes,snippets,pages}/
│ clone-repo (CONTENT_REPO_TOKEN)
▼
content/dist/ <- MDX collections + page JSON shells
│
▼
ContentRepository (singleton)
serialize -> tags -> published -> sorted -> pinned
│
▼
Next.js 13 Pages Router (getStaticPaths / getStaticProps)
portfolio · blog · tags/[tag] · CV · contact
│ next build -> out/
│
┌────┴────────────────────────┐
▼ ▼
target-hosting=test target-hosting=prod
GitHub Pages preview next-sitemap -> IndexNow
(basePath for project site) -> package -> S3 sync
-> CloudFront invalidate
-> Route 53 (paulserban.eu)
| Layer / surface | Responsibility |
|---|---|
content--paulserban.eu | Private authoring: MDX articles + JSON page shells; publish / in-progress / backlog trees |
scripts/utils/clone-repo.js | Token-authenticated clone into the app repo at build time; .git stripped after copy |
scripts/build.js | Dataset x hosting matrix (live/test/prev x prod/test) |
ContentRepository | Domain data layer: five typed collections, tag index, pin/sort/status gates |
| Portfolio | Projects + coursework hubs, lists, and detail pages |
| Blog | Posts, snippets, and book notes with shared post template |
/tags/[tag] | Cross-collection discovery without a search backend |
| SEO post-steps | next-sitemap, IndexNow URL submit, robots.txt Host-line strip for S3 |
| Preview | GitHub Actions -> GitHub Pages (/prj--personal-portfolio--v2) |
| Production | S3 origin + CloudFront edge + Route 53 aliases |
Approach
As architect and lead implementer, I owned the seams between authoring, static generation, SEO, and delivery - so content could evolve without inventing a CMS, and preview could fail safely without touching production DNS.
Platform boundaries first
- Two-repo model - content releases independently of the Next.js app; CI pulls with a token instead of coupling writers to frontend deploys.
- ContentRepository as a domain service - pages stay thin
getStaticPropsadapters (hubs read pinned, lists read sorted published, detailsfindOne). Publish status, date sort, pin flags, and tag indexes live in one place. - JSON shells + MDX bodies - layout/meta for hubs and marketing pages stay editable as JSON; article prose stays MDX with shared frontmatter (
status,pinned,tags,date, optionalrepo_url/demo_url). - Dataset abstraction -
liveclones publish;testuses fixture content;prevmerges fixtures with in-progress for draft previews - one CLI, no forked app code. - Fully static export - no Node runtime in production;
trailingSlash: truefor S3-friendly directory URLs; images unoptimized for CDN delivery.
Product surfaces
- Portfolio -
/portfoliohub (pinned projects + coursework), list and[slug]detail routes for each type. - Blog -
/bloghub (pinned posts + book notes + snippets), plus dedicated list/detail trees per format. - Tags -
/tags/[tag]aggregates all five content types viafindByTagandgetStaticPathsover the repository tag set - information architecture without a search service. - Atomic UI + ITCSS - atoms / molecules / organisms under
_04_library, composed into page templates, with MDX mapped through shared content components (MarkdownContent->MDXRemote).
SEO as a release concern
- Semantic HTML - document
lang, landmark regions (header/main/footer), articles in<article>, labelled interactive controls. - Crawl contract -
next-sitemapwritessitemap.xml+robots.txt; Open Graph and base meta (title, description, robots, author) on every page. - IndexNow on prod builds - after export, walk all HTML under
out/, publish the key file, POST URL lists toapi.indexnow.orgso Bing (and IndexNow partners) see new or changed pages without waiting on crawl luck. - Hosting-aware post-processing - sitemap + IndexNow + robots surgery run only when
--target-hosting=prod; preview builds skip notification noise and Host-line assumptions that break on GitHub Pages. - Webmaster readiness - static HTML, stable custom-domain topology on AWS, robots + sitemap, and IndexNow key at site root support Google Search Console and Bing Webmaster verification and ongoing indexing.
Multi-environment delivery
- GitHub Pages for preview - develop / main /
releases/*builds withtarget-hosting=testand a projectbasePath, so stakeholders review live content without touching production. - AWS for production - package the
out/tree,aws s3 syncto the production bucket, CloudFront invalidation, Route 53 aliases forpaulserban.eu/www. - Release hygiene - conventional commits, semantic-release on release branches, lint/unit/integration in the pipeline - delivery discipline expected of a lead-owned product, not a throwaway site.
Stack
| Layer | Choices |
|---|---|
| Authoring | Private Git repo, MDX frontmatter, JSON page shells |
| Build | Node CLIs, content clone, dataset x hosting matrix |
| App | Next.js 13 Pages Router, React 18, next-mdx-remote, SCSS modules (atomic + ITCSS) |
| SEO | next-sitemap, IndexNow, Open Graph, semantic landmarks |
| Preview | GitHub Actions -> GitHub Pages |
| Production | S3 + CloudFront + Route 53 |
Design decisions that mattered
- JAMstack over SSR. Personal publishing traffic does not justify a runtime content API. Build-time queries keep hosting cheap and failure modes simple - the same bet v3 later kept with SQLite-at-build.
- ContentRepository over ad-hoc
fsin every page. One domain index for publish/sort/pin/tag keeps information architecture coherent as collections grow. - Cross-cutting tags without a search backend. Tag pages are static paths; discoverability is a build artifact, not an ops surface.
- Preview ≠ production hosting. GitHub Pages absorbs preview cost and blast radius; AWS owns the live edge, SEO notify path, and custom domain.
- SEO post-steps only on prod. IndexNow and sitemap generation belong to the production contract - not every PR build.
- Trade-off named up front. One-way content sync (no CMS round-trip); companion learning folders (
questions, etc.) excluded from this site's publish set; deeper schema and multi-surface fan-out deferred to v3.
What shipped
- Five typed collections: projects, coursework, posts, snippets, book notes - plus JSON-driven marketing/CV/contact pages
- Cross-collection
/tags/[tag]discovery and pinned hubs for portfolio and blog - Build-time clone of
content--paulserban.euwith live / test / preview datasets - Static Next.js export with atomic SCSS UI and MDX rendering through shared components
- Production SEO path: semantic HTML, Open Graph, sitemap, robots, IndexNow on every prod build
- Dual hosting: GitHub Pages preview builds and AWS S3 + CloudFront + Route 53 production
- Years as the live personal presence (releases through 2026) and the direct ancestor of the v3 content pipeline
Outcome
Publishing became a build-time contract: change content in the private repo, choose dataset and hosting target, emit crawlable static HTML. The architecture treats a personal site like a small product platform - clear package boundaries between authoring and delivery, a domain content layer, SEO as part of the release, and preview/production isolation. That content-at-build idea is what prj--personal-portfolio--v3 later generalized into a SQLite artifact feeding four surfaces.
Predecessor to prj--personal-portfolio--v3
This stack is the direct predecessor of the current monorepo. The clone-content-at-build idea became the content pipeline SSG platform that now feeds four surfaces from one SQLite artifact. Related pieces of the current platform:
- AWS multi-subdomain hosting - the production Route 53 -> CloudFront -> S3 topology that succeeded this site's single-origin edge
- Local HTTPS Traefik + Docker Compose - local domain parity for the multi-surface platform
- The spaced-repetition quiz PWA (quiz.paulserban.eu)
- The shared newspaper design system UI kit