Personal Portfolio (V2)Content-driven Next.js SSG with multi-env CDN delivery

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 / surfaceResponsibility
content--paulserban.euPrivate authoring: MDX articles + JSON page shells; publish / in-progress / backlog trees
scripts/utils/clone-repo.jsToken-authenticated clone into the app repo at build time; .git stripped after copy
scripts/build.jsDataset x hosting matrix (live/test/prev x prod/test)
ContentRepositoryDomain data layer: five typed collections, tag index, pin/sort/status gates
PortfolioProjects + coursework hubs, lists, and detail pages
BlogPosts, snippets, and book notes with shared post template
/tags/[tag]Cross-collection discovery without a search backend
SEO post-stepsnext-sitemap, IndexNow URL submit, robots.txt Host-line strip for S3
PreviewGitHub Actions -> GitHub Pages (/prj--personal-portfolio--v2)
ProductionS3 origin + CloudFront edge + Route 53 aliases
personal portfolio v2 architecture diagram

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 getStaticProps adapters (hubs read pinned, lists read sorted published, details findOne). 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, optional repo_url / demo_url).
  • Dataset abstraction - live clones publish; test uses fixture content; prev merges fixtures with in-progress for draft previews - one CLI, no forked app code.
  • Fully static export - no Node runtime in production; trailingSlash: true for S3-friendly directory URLs; images unoptimized for CDN delivery.

Product surfaces

  • Portfolio - /portfolio hub (pinned projects + coursework), list and [slug] detail routes for each type.
  • Blog - /blog hub (pinned posts + book notes + snippets), plus dedicated list/detail trees per format.
  • Tags - /tags/[tag] aggregates all five content types via findByTag and getStaticPaths over 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-sitemap writes sitemap.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 to api.indexnow.org so 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 with target-hosting=test and a project basePath, so stakeholders review live content without touching production.
  • AWS for production - package the out/ tree, aws s3 sync to the production bucket, CloudFront invalidation, Route 53 aliases for paulserban.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

LayerChoices
AuthoringPrivate Git repo, MDX frontmatter, JSON page shells
BuildNode CLIs, content clone, dataset x hosting matrix
AppNext.js 13 Pages Router, React 18, next-mdx-remote, SCSS modules (atomic + ITCSS)
SEOnext-sitemap, IndexNow, Open Graph, semantic landmarks
PreviewGitHub Actions -> GitHub Pages
ProductionS3 + 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 fs in 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.eu with 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: