Skip to main content
Developer Portfolio

ProjectWebsite

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)
Git Repository View site

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 with queryCollection(). No database, no REST API — .md files are the source of truth. Content paths carry no locale prefix (/projects/finixa), so queries target the slug rather than route.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-locale hreflang + canonical via useLocaleHead(), with the lang cookie kept for the visitor's preference. Switching language is a navigation (switchLocalePath()). URL-based language switch
  • 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, fluid clamp() 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; .webp files are stored through Git LFS.
  • Netlify Edge Function (Deno) — netlify/edge-functions/spotify.ts serves the /api/spotify route. 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.

Global architecture

Notable technical points

  • CSS Grid with display: contents: the main grid is defined on .page (4–5 columns). AppHeader and AppSection use display: contents, making their child cells direct participants of the parent grid regardless of depth in the Vue component tree. Editorial layout without wrapper noise — but display: contents removes elements from the accessibility tree, requiring explicit ARIA handling on affected components.

CSS Grid layout structure

  • 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:before pseudo-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 shared IntersectionObserver, with an optional stagger delay) and v-decode (a Matrix-style decode effect on headings, whose accessible name is pinned via aria-label before 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.ts runs a queryCollection() 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.vue detects .svg images and fetches them via useFetch to inject as raw HTML inside a .svg-wrapper. Diagrams are therefore themable via CSS — hence the explicit fill, stroke and color overrides on internal SVG elements in [slug].vue for 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.