
Portfolio Développeur
EN BREF
Portfolio bilingue construit avec Nuxt 3 en SSR + prérendu statique — bilingue par URL (FR / EN), contenu Markdown, intégration Spotify live et transitions de page natives (View Transitions API).
- Statut
- En ligne
- Période
- 2023 — aujourd'hui
- Rôle
- Solo — workflow de développement agentique (Claude Code)
STACK
- Nuxt 3
- TypeScript
- @nuxt/content
- @nuxt/image
- @nuxtjs/i18n
- @nuxtjs/sitemap
- Netlify
Le site que vous lisez en ce moment : bilingue par URL, prérendu statique, et pensé autant pour le SEO et l'accessibilité que pour le soin du mouvement.
Quel est le contexte de ce portfolio ?
Portfolio personnel qui regroupe projets, expériences et informations de contact. Le site est bilingue par URL (FR à /, EN sous /en/…), embarque une intégration Spotify temps réel qui affiche le morceau que j'écoute à l'instant, et est déployé sur Netlify en SSR + prérendu statique : chaque page des deux langues est générée à la build, avec hreflang et canonical. La route API Spotify tourne via une Netlify Edge Function (Deno). Le contenu est entièrement en Markdown pour pouvoir le tenir à jour sans toucher au code.
Quelle stack technique ?
- Nuxt 3 (
ssr: true+ prérendu) — le build prérend chaque route des deux langues en HTML complet (nitro.prerender.crawlLinks), servi statiquement par Netlify puis hydraté côté client. Le HTML contient le contenu, les meta OG et le JSON-LD dès la première peinture (LCP + indexabilité), sans serveur Node à l'exécution. - @nuxt/content v3 — contenu entièrement en Markdown, découpé en collections typées par un schéma Zod (
content.config.ts) : une collection par page et par langue (home_fr,projects_en…), requêtées viaqueryCollection(). Pas de base de données ni d'API REST — les fichiers.mdsont la source de vérité. Les chemins de contenu ne portent pas de préfixe de locale (/projects/finixa), donc les requêtes ciblent le slug et nonroute.path(préfixé/enen anglais). - i18n par URL (
@nuxtjs/i18n,prefix_except_default) — FR à/, EN sous/en/…, chaque langue ayant sa propre URL crawlable.hreflang+ canonical par locale viauseLocaleHead(), cookielangconservé pour la préférence du visiteur. Changer de langue est une navigation (switchLocalePath()). - Sitemap auto (
@nuxtjs/sitemap) — un index par locale (fr-FR.xml+en-US.xml) généré depuis les routes prérendues, alimenté par l'URL canonique (site.url). Soumission IndexNow via un script dédié. - SCSS sans framework CSS — design system maison : tokens SCSS injectés dans chaque composant (
space($n)=n × 4px, mixintransition()), et un unique fichier global qui émet les custom properties (thème clair/sombre, easings, durées, échelle typographique fluide enclamp()). - Polices auto-hébergées — PP Formula Condensed, Strawford et JetBrains Mono en
woff2subsettés, les trois fichiers au-dessus de la ligne de flottaison préchargés, et un@font-facede repli à métriques ajustées (size-adjust,ascent-override…) par famille pour garder un CLS ≈ 0. - @nuxt/image avec provider Netlify — images optimisées à la livraison par le CDN Netlify ; les
.webpsont stockées via Git LFS. - Netlify Edge Function (Deno) —
netlify/edge-functions/spotify.tssert la route/api/spotify. Échange le refresh token contre un access token à chaque appel, sans stocker de token ni exposer les credentials côté client. Côté page, le widget est hydraté en différé (hydrate-on-idle) pour rester hors du chemin critique. - Umami Analytics — script async, sans cookie, sans transmission de données personnelles à des tiers.
Points techniques notables
- CSS Grid avec
display: contents: la grille principale est définie sur.page(4–5 colonnes).AppHeaderetAppSectionutilisentdisplay: contents, ce qui rend leurs cellules enfants participants directs de la grille du parent, quelle que soit la profondeur dans l'arbre Vue. Layout éditorial sans wrappers parasites — maisdisplay: contentsretire les éléments de l'arbre d'accessibilité, ce qui a demandé des ajustements ARIA sur les composants concernés.
- Transitions de page natives, avec repli : quand le navigateur supporte la View Transitions API, la navigation est animée nativement et la carte projet cliquée sur l'accueil se transforme directement en hero de la page projet, grâce à des
view-transition-namepar slug partagés entre les deux pages. Sans support, un rideau CSS prend le relais : le pseudo-élément.cell:beforede chaque cellule glisse sur toute la cellule pendant la transition de page Vue. Le rideau est aussi conservé au premier chargement et au changement de langue, où il se lit mieux qu'un fondu. - Un système de mouvement unique, qui respecte
prefers-reduced-motion: deux directives partagées pilotent l'essentiel —v-reveal(apparition au scroll via un seulIntersectionObserverpartagé, avec un délai optionnel pour décaler les éléments) etv-decode(effet de décodage façon Matrix sur les titres, dont le nom accessible est figé viaaria-labelavant le brouillage). Les classes ne sont ajoutées que côté client : le HTML prérendu reste entièrement visible et lisible. Le changement de thème est une révélation circulaire depuis le bouton (View Transition +clip-path), la frise des expériences un scrollytelling avec compteur d'années, et la barre de progression de lecture des pages projet est en pur CSS (animation-timeline: scroll()). - Middleware de validation de slug :
middleware/project.tsexécute unqueryCollection()avant le rendu de la page projet. Si le slug n'existe pas,abortNavigation()est appelé — la navigation est annulée proprement plutôt que de rendre une page vide. La vérification est indépendante de la locale (les projets existent en FR et EN), ce qui garde le middleware hors du cycle de vie i18n. - SVG inlinés pour les diagrammes :
ProseImg.vuedétecte les images.svget les récupère viauseFetchpour les injecter en HTML brut dans un.svg-wrapper. Les diagrammes sont ainsi thémables via CSS — d'où les overrides explicites defill,strokeetcolorsur les éléments SVG internes dans[slug].vuepour le mode sombre. - Un contenu qui se tient à jour tout seul : les années d'expérience affichées sont calculées depuis la frise des expériences (alternance + CDI), les durées de chaque poste depuis leurs dates, et la date de mise à jour des projets (
dateModifieddu JSON-LD) est tirée du dernier commit Git du fichier au moment de la build.
Ce que j'ai appris
Concevoir un layout CSS Grid qui traverse plusieurs niveaux de composants Vue via display: contents demande une connaissance précise de l'impact sur l'arbre d'accessibilité : les éléments avec display: contents sont transparents pour le rendu visuel mais pas pour les lecteurs d'écran. Côté mouvement, la difficulté a été d'empiler plusieurs mécanismes (View Transitions, rideau de repli, révélations au scroll) sans qu'ils se déclenchent en double, tout en gardant un HTML prérendu complet pour le SEO et un site entièrement utilisable sans animation.