
Developer Portfolio
OVERVIEW
Bilingual portfolio built with Nuxt 3 in SSR + static prerendering — per-URL bilingual (FR / EN), Markdown content, live Spotify integration and native page transitions (View Transitions API).
- Status
- Live
- Period
- 2023 — present
- Role
- Solo — agentic development workflow (Claude Code)
STACK
- Nuxt 3
- TypeScript
- @nuxt/content
- @nuxt/image
- @nuxtjs/i18n
- @nuxtjs/sitemap
- Netlify
The very site you're reading right now — bilingual by URL, statically prerendered, and built with as much care for SEO and accessibility as for motion.
What's the context behind this portfolio?
Personal portfolio covering projects, work experience, and contact information. The site is bilingual by URL (FR at /, EN under /en/…), embeds a live Spotify integration showing the track I'm listening to right now, and is deployed on Netlify with SSR + static prerendering: every page of both languages is generated at build time, with hreflang and canonical tags. The Spotify API route runs as a Netlify Edge Function (Deno). All content lives in Markdown so it can be kept up to date without touching the code.
What's the tech stack?
- Nuxt 3 (
ssr: true+ prerendering) — the build prerenders every route of both languages to full HTML (nitro.prerender.crawlLinks), served statically by Netlify then hydrated client-side. The HTML carries content, OG meta and JSON-LD on first paint (LCP + indexability), with no Node server at runtime. - @nuxt/content v3 — all content in Markdown, split into collections typed by a Zod schema (
content.config.ts): one collection per page and per language (home_fr,projects_en…), queried withqueryCollection(). No database, no REST API —.mdfiles are the source of truth. Content paths carry no locale prefix (/projects/finixa), so queries target the slug rather thanroute.path(which is/en-prefixed in English). - URL-based i18n (
@nuxtjs/i18n,prefix_except_default) — FR at/, EN under/en/…, each language with its own crawlable URL. Per-localehreflang+ canonical viauseLocaleHead(), with thelangcookie kept for the visitor's preference. Switching language is a navigation (switchLocalePath()). - Auto sitemap (
@nuxtjs/sitemap) — a per-locale index (fr-FR.xml+en-US.xml) generated from the prerendered routes, fed by the canonical site URL (site.url). IndexNow submission through a dedicated script. - SCSS without a CSS framework — custom design system: SCSS tokens injected into every component (
space($n)=n × 4px,transition()mixin), and a single global file emitting the custom properties (light/dark theme, easings, durations, fluidclamp()type scale). - Self-hosted fonts — PP Formula Condensed, Strawford and JetBrains Mono as subsetted
woff2, the three above-the-fold files preloaded, and a metric-adjusted fallback@font-face(size-adjust,ascent-override…) per family to keep CLS ≈ 0. - @nuxt/image with Netlify provider — images optimized at delivery by the Netlify CDN;
.webpfiles are stored through Git LFS. - Netlify Edge Function (Deno) —
netlify/edge-functions/spotify.tsserves the/api/spotifyroute. Exchanges a refresh token for an access token on every request, without storing tokens or exposing credentials client-side. On the page, the widget hydrates lazily (hydrate-on-idle) to stay off the critical path. - Umami Analytics — async script, no cookies, no personal data sent to third parties.
Notable technical points
- CSS Grid with
display: contents: the main grid is defined on.page(4–5 columns).AppHeaderandAppSectionusedisplay: contents, making their child cells direct participants of the parent grid regardless of depth in the Vue component tree. Editorial layout without wrapper noise — butdisplay: contentsremoves elements from the accessibility tree, requiring explicit ARIA handling on affected components.
- Native page transitions, with a fallback: when the browser supports the View Transitions API, navigation is animated natively and the project card clicked on the home page morphs straight into the project page hero, thanks to per-slug
view-transition-names shared by both pages. Without support, a CSS curtain takes over: each cell's.cell:beforepseudo-element slides across the cell during the Vue page transition. The curtain is also kept on first load and on language switches, where it reads better than a crossfade. - One motion system, honoring
prefers-reduced-motion: two shared directives drive most of it —v-reveal(scroll-triggered reveal through a single sharedIntersectionObserver, with an optional stagger delay) andv-decode(a Matrix-style decode effect on headings, whose accessible name is pinned viaaria-labelbefore scrambling). Classes are only added client-side, so the prerendered HTML stays fully visible and readable. The theme switch is a circular reveal from the button (View Transition +clip-path), the experience timeline is a scrollytelling with a year counter, and the reading progress bar on project pages is pure CSS (animation-timeline: scroll()). - Slug validation middleware:
middleware/project.tsruns aqueryCollection()before rendering the project page. If the slug doesn't exist,abortNavigation()is called — navigation is cancelled cleanly instead of rendering an empty page. The check is locale-agnostic (projects exist in both FR and EN), which keeps the middleware out of the i18n lifecycle. - Inlined SVGs for diagrams:
ProseImg.vuedetects.svgimages and fetches them viauseFetchto inject as raw HTML inside a.svg-wrapper. Diagrams are therefore themable via CSS — hence the explicitfill,strokeandcoloroverrides on internal SVG elements in[slug].vuefor dark mode. - Content that keeps itself up to date: the years of experience shown are computed from the experience timeline (apprenticeship + permanent positions), each position's duration from its dates, and the projects' last-updated date (JSON-LD
dateModified) is taken from the file's last Git commit at build time.
What I learned
Designing a CSS Grid layout that flows through multiple Vue component levels via display: contents requires a precise understanding of its accessibility tree impact: elements with display: contents are transparent to visual rendering but not to screen readers. On the motion side, the challenge was stacking several mechanisms (View Transitions, fallback curtain, scroll reveals) without any of them firing twice, while keeping complete prerendered HTML for SEO and a site that is fully usable with no animation at all.