Why build a "real" portfolio site
I did not want a portfolio that felt like a graveyard of half-finished ideas or a template I would 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 cannot 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 does not look like 1998
- Tables that do not 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 cannot, 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 did not 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 - Tries a hardware-capable WebGL context with
failIfMajorPerformanceCaveatbefore enabling the expensive SVG goo path - Falls back to a much cheaper animated soft-blob layer when that probe fails
- 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 intentional on weaker ones instead of trying to fake the same effect badly.
6) SEO + feeds + robots (because it is 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 is not a bank. But it is also not a WordPress blog from 2009.