Why build a “real” portfolio site
I didn’t want a portfolio that felt like a graveyard of half-finished ideas or a template I’d never touch again.
The goal was to treat the site like a small product:
- Content-first: adding a project should feel like writing, not “building a page”.
- Fast + readable: minimal UI friction, strong typography rhythm, and no layout jank.
- Honest engineering: shipping-minded details (SEO, security headers, error states) without turning the site into a framework science experiment.
Architecture
High-level shape
- Next.js App Router
- Server components by default (cheap pages, small client footprint)
- Dynamic routes for Projects and Writing (
/projects/[slug],/writing/[slug])
- Content layer (MDX on disk)
content/projects/*.mdxcontent/writing/*.mdx- Frontmatter drives listing cards, badges, metadata, and visibility
- Presentation layer
- Tailwind CSS with a few custom global styles (MDX rhythm, scrollbar polish, view transitions)
- A small set of “product-y” components:
Badge,HeroImage,Lightbox,ScrollProgress,FluidBackdrop
Data flow (content → page)
- A project/post starts life as an
.mdxfile with frontmatter. lib/content.jsloads the collection, applies visibility rules, and sorts:featuredfirst- then
date(descending) - then
title
- The route (
/projects/[slug]) fetches the entry and compiles MDX server-side vianext-mdx-remote/rsc. - MDX renders through
components/mdx-components.jsso images/code/tables look consistent.
Notable technical details
1) A content model with “privacy” baked in
Every content file can be:
- public: listed and routable
- unlisted: routable by direct URL, but not listed
- private: not routable and not listed
That gives me a clean workflow for drafts, “client work I can’t publicize yet”, and works-in-progress without juggling branches.
2) MDX that behaves like a real reading experience
MDX compilation uses:
remark-gfmfor GitHub-flavored markdown (tables, task lists, etc.)rehype-pretty-code(Shiki) for code highlighting
On top of that, mdx-components.js provides opinionated rendering:
- Inline code vs block code styling
- A-link styling that doesn’t look like 1998
- Tables that don’t explode the layout
- A consistent “premium rhythm” for headings, paragraphs, and spacing
3) Images: low-jank by default + optional lightbox
For local images in public/, the MDX image component tries to infer width/height on the server (using image-size). When it can, it renders next/image with real dimensions to reduce layout shift. If it can’t, it gracefully falls back to a normal <img>.
On top of that, any image can be wrapped in a Lightbox:
- Accessible dialog semantics (
role="dialog",aria-modal) - Escape-to-close
- Body scroll lock while open
- Uses a portal so the overlay sits above everything cleanly
4) View transitions without the “my whole page just flashed” vibe
I wanted transitions between pages to feel smooth, but I didn’t want the animated backdrop to restart or crossfade weirdly.
So the CSS explicitly:
- Transitions only the main content container (
.vt-main) - Disables the default root snapshot crossfade
Result: navigation feels “native”, but the background stays stable.
5) The goo backdrop is performance-gated (on purpose)
The animated background is fun… until it turns into a laptop fan benchmark.
FluidBackdrop is written like a polite guest:
- Respects
prefers-reduced-motion - Checks for a hardware WebGL context (using
failIfMajorPerformanceCaveat) - Uses passive scroll listeners +
requestAnimationFrame - Stops animation when the tab is hidden, resumes when visible
- Still keeps the scroll-based darkening effect even when not animating (so readability stays consistent)
So the site looks “alive” on capable machines, and still looks good (but calmer) on weaker ones.
6) SEO + feeds + robots (because it’s 2026)
Baseline SEO is baked in:
- Per-page metadata (title/description/canonical/OG/Twitter)
- JSON-LD on the root layout
/sitemap.xml,/rss.xml,/robots.txtgenerated via route handlers- Canonical URL resolver that behaves across local dev, Vercel previews, and production
7) Security headers by default
next.config.mjs sets headers globally:
X-Content-Type-Options: nosniffX-Frame-Options: DENYReferrer-Policy: strict-origin-when-cross-originPermissions-Policy: …(locked down)X-XSS-Protection: 0(explicitly disabled; modern browsers ignore it anyway)
No, it’s not a bank. But it’s also not a WordPress blog from 2009.