Rewriting my portfolio in Next.js 16

I rewrote my portfolio this week. The old one was an Angular 19 SPA with a NestJS server holding an in-memory blog API — the kind of stack you build when you're learning, not when you're trying to land roles. The new one is a Next.js 16 App Router site with MDX content, no separate API, and an editorial-minimal design that I committed to before writing a line of code. Here's what stuck.

What I dropped

The Angular client and the NestJS API both went. Two reasons.

Angular SPAs are bad at SEO. The old site shipped an empty shell to crawlers; everything rendered after JavaScript loaded. For a portfolio whose entire job is to be found, that's a bad trade. I could have added Angular Universal, but the developer experience for SSR/SSG content sites is rougher than what App Router gives you out of the box. So I migrated rather than retrofitted.

The blog API was theatre. It was a NestJS service holding posts in an in-memory array — restart and they were gone. Real persistence would have meant a real database, an admin UI, auth — none of which a portfolio needs. MDX files in the repo are simpler, version-controlled, and search-engine-friendly. The server got deleted. The Angular app got moved to archive/client/ with git mv so its history stays reachable via git log --follow.

What I picked, and why

The new stack is small and intentional:

  • Next.js 16 App Router — server components by default, typed routes, and a metadata API that reads like a real spec (generateMetadata, JSON-LD, hreflang, OpenGraph, Twitter — all via the same metadata export).
  • Tailwind v4 with OKLCH tokens — perceptually uniform color space means a deep teal stays the same hue when you adjust lightness for dark mode. The brand palette is cream oklch(97.5% 0.012 85) and a low-chroma teal oklch(48% 0.075 195) — no Tailwind defaults touch the bundle.
  • Newsreader (serif) + Geist (sans) + Geist Mono — loaded via next/font/google with display: swap and adjustFontFallback: true. Inter was deliberately excluded; in 2026 it reads as the AI-vibe-coded portfolio default.
  • shadcn/ui (Button + DropdownMenu only) — the rest of the components are hand-rolled. Default shadcn looks generic; constrained shadcn lets me reach for primitives without committing to the aesthetic.
  • MDX for case studies and posts — gray-matter frontmatter, server-rendered with next-mdx-remote/rsc for posts and direct imports for case studies. The same component map (mdx-components.tsx) styles both.
  • Vercel for hosting — the platform the framework was designed against. Anything else would have been masochistic.

The design system that wasn't

I almost wrote a token system. Then I read Lee Robinson's site, Rauchg's, Brittany Chiang's v5, and noticed how spare they all are. The senior IC portfolio register is editorial. Restraint is the moat.

So instead of a system, I wrote an anti-pattern checklist. It's a CI-greppable list of fifteen rules:

  • No Inter
  • No Tailwind default colors (bg-slate-*, text-cyan-*, bg-gray-*)
  • No gradient text headings
  • No emoji
  • No Hi, I'm openers
  • No passionate, crafting, pixel-perfect, modern, scalable, performant
  • No section title in title case
  • No skill bars, chip clouds, or three-column logo grids
  • No contact form
  • No carousels, testimonials, /services page, animated typewriter
  • No card shadows; hairline --rule dividers between rows
  • No icons on list items
  • /now has a real updated date, not older than 90 days
  • /writing has either real posts or an empty state — never fake "Coming soon"

The list is the design system. Every PR self-audits against it before it ships.

SEO without the LARP

The portfolio is small enough that "SEO" mostly means: don't ship the site like a brochure. What that meant in practice:

  • Static metadata everywhere, with a typed metadataBase that reads from NEXT_PUBLIC_SITE_URL. The Vercel preview URL and the production canonical share one code path.
  • Person and WebSite JSON-LD in the root layout. Every page also emits a BreadcrumbList. Case studies emit CreativeWork with the stack as keywords. Writing posts emit BlogPosting. All of it validates against schema.org's Rich Results Test.
  • app/sitemap.ts that pulls case-study slugs from getAllCaseStudies() and writing slugs from getAllPosts(), so adding a post or a case study auto-extends the sitemap.
  • app/robots.ts allowing everything except /api/, with a sitemap pointer.
  • app/rss.xml/route.ts serving Atom 1.0 with a Cache-Control: public, max-age=3600.
  • A single /api/og Edge route that takes ?title= and ?dek= and renders a 1200×630 PNG with the same cream/teal palette, so social shares don't look generic.
  • hreflang on /about and /contact because those are the only pages with French mirrors. The other routes deliberately omit alternates.languages so crawlers don't go looking for a /fr/now that doesn't exist.

Lighthouse on production lands at 100 / 100 / 100 / 100 desktop and 93 / 100 / 100 / 100 mobile. The mobile Performance miss is LCP at 3.1s on simulated 3G — bound by Geist font cold-load on the body paragraph in the hero. I tried optimizing, decided I cared more about the visual than the score, and stopped.

The accessibility round trip

The first Lighthouse run flagged two things I'd missed:

  1. The RL mark in the top-left of the nav had aria-label="Renel Lherisson — home" but only showed RL — visible label and accessible name didn't match. Fix: visible RL inside aria-hidden, full name in an sr-only span. Same shape on the page, screen readers get the full thing.
  2. The theme toggle had aria-label="Toggle theme" but its visible content was the current theme name (light / dark / system). Same mismatch. Fix: drop the aria-label entirely and let the visible text "Theme: light" or "Theme: dark" be the accessible name. One less thing to maintain.

Both fixes pushed mobile accessibility from 96 → 100. They were also the kind of mistake you can only catch with a real audit; eyeballing the page I'd never have noticed.

What I'd do differently

If I were starting over:

  • Pick a single language earlier. I shipped English-primary with /fr/about and /fr/contact mirrors because I'm based in Montréal. In hindsight I'd ship English-only and put a downloadable French CV on /about instead. The bilingual signal is earned via having a French CV, not by translating two pages.
  • Don't pre-build the writing route. It launched with zero posts. Empty /writing reads as abandoned, not minimal. I should have shipped without the route and added it when I had something to say. (This post is me making it real.)
  • Skip the OG image for v1. I built an /api/og route on day three. Nothing was being shared yet. It would have been fine to ship the static og:image and add the dynamic version when I had reason to.

What you can borrow

The repo is at github.com/relhe/portfolio. The pieces most worth lifting are:

  • mdx-components.tsx — one place to style every MDX surface (case studies, blog posts, /now, /uses).
  • app/api/og/route.tsx — a small Edge route that takes title and dek query params, renders a 1200×630 card, and caches immutably.
  • app/sitemap.ts and app/rss.xml/route.ts — both read from the MDX directories so adding content extends them automatically.

Most portfolios don't ship enough proof. The trick isn't more components or more animation — it's writing copy that names what you actually did and surfacing the metrics in display position. Everything else is preference.