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 samemetadataexport). - 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 tealoklch(48% 0.075 195)— no Tailwind defaults touch the bundle. - Newsreader (serif) + Geist (sans) + Geist Mono — loaded via
next/font/googlewithdisplay: swapandadjustFontFallback: 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/rscfor 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'mopeners - 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,
/servicespage, animated typewriter - No card shadows; hairline
--ruledividers between rows - No icons on list items
/nowhas a real updated date, not older than 90 days/writinghas 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
metadataBasethat reads fromNEXT_PUBLIC_SITE_URL. The Vercel preview URL and the production canonical share one code path. PersonandWebSiteJSON-LD in the root layout. Every page also emits aBreadcrumbList. Case studies emitCreativeWorkwith the stack askeywords. Writing posts emitBlogPosting. All of it validates against schema.org's Rich Results Test.app/sitemap.tsthat pulls case-study slugs fromgetAllCaseStudies()and writing slugs fromgetAllPosts(), so adding a post or a case study auto-extends the sitemap.app/robots.tsallowing everything except/api/, with a sitemap pointer.app/rss.xml/route.tsserving Atom 1.0 with aCache-Control: public, max-age=3600.- A single
/api/ogEdge 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. hreflangon/aboutand/contactbecause those are the only pages with French mirrors. The other routes deliberately omitalternates.languagesso crawlers don't go looking for a/fr/nowthat 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:
- The
RLmark in the top-left of the nav hadaria-label="Renel Lherisson — home"but only showedRL— visible label and accessible name didn't match. Fix: visibleRLinsidearia-hidden, full name in ansr-onlyspan. Same shape on the page, screen readers get the full thing. - The theme toggle had
aria-label="Toggle theme"but its visible content was the current theme name (light/dark/system). Same mismatch. Fix: drop thearia-labelentirely 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/aboutand/fr/contactmirrors because I'm based in Montréal. In hindsight I'd ship English-only and put a downloadable French CV on/aboutinstead. 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
/writingreads 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/ogroute on day three. Nothing was being shared yet. It would have been fine to ship the staticog:imageand 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 takestitleanddekquery params, renders a 1200×630 card, and caches immutably.app/sitemap.tsandapp/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.