Construire un portfolio open-source moderne avec Nuxt 3 — thèmes CSS, données JSON, SEO automatique, et déploiement Docker.
OpenFolio cible un score Lighthouse 95+. Ça ne vient pas par hasard — c'est une combinaison de SSR, de balises méta dynamiques, et de quelques règles simples sur les images et les fonts.
Nuxt 3 expose useSeoMeta pour définir les balises <head> depuis le <script setup> d'une page :
<script setup lang="ts">
const { data: profile } = await useJson<Profile>('/data/profile.json')
useSeoMeta({
title: () => `${profile.value?.name} — Portfolio`,
description: () => profile.value?.bio ?? '',
ogTitle: () => `${profile.value?.name} — Portfolio`,
ogDescription: () => profile.value?.bio ?? '',
ogType: 'website',
twitterCard: 'summary_large_image',
twitterTitle: () => `${profile.value?.name} — Portfolio`,
twitterDescription: () => profile.value?.bio ?? '',
})
</script>Les valeurs en fonction () => ... sont réactives : si profile.value change, les balises méta se mettent à jour automatiquement.
Les bots de Google et les LLMs (Perplexity, ChatGPT) consomment le JSON-LD pour comprendre qui vous êtes. À ajouter dans index.vue :
<script setup lang="ts">
const { data: profile } = await useJson<Profile>('/data/profile.json')
useHead({
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Person',
name: profile.value?.name,
jobTitle: profile.value?.title,
url: 'https://votre-domaine.com',
sameAs: profile.value?.social.map(s => s.url) ?? [],
}),
},
],
})
</script>Le type Person est le bon schéma pour un portfolio. Google Search Console l'affiche dans le Knowledge Panel si les données sont cohérentes.
Nuxt n'a pas de composant d'image optimisé natif comme Next.js. Pour rester performant :
<!-- Toujours spécifier width et height pour éviter le Layout Shift -->
<img
:src="project.image"
:alt="project.title"
width="400"
height="300"
loading="lazy"
decoding="async"
/>loading="lazy" charge l'image seulement quand elle entre dans le viewport. decoding="async" débloque le thread principal pendant le décodage.
Pour aller plus loin, le module @nuxt/image ajoute un composant <NuxtImg> qui fait la conversion WebP et le redimensionnement automatique.
OpenFolio charge Inter depuis Google Fonts. Sans précaution, la police se charge après le CSS, provoquant un flash de police système.
Solution dans nuxt.config.ts :
app: {
head: {
link: [
{
rel: 'preconnect',
href: 'https://fonts.googleapis.com',
},
{
rel: 'preconnect',
href: 'https://fonts.gstatic.com',
crossorigin: '',
},
{
rel: 'stylesheet',
href: 'https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap',
},
],
},
},display=swap dans l'URL Google Fonts garantit que le texte reste visible avec la police de fallback pendant le chargement.
nitro: {
routeRules: {
'/data/**': {
headers: { 'cache-control': 'public, max-age=300, stale-while-revalidate=600' },
},
},
},stale-while-revalidate=600 : si le cache de 5 minutes est expiré, le navigateur sert la version stale pendant qu'il télécharge la nouvelle en fond. Zéro latence perçue pour l'utilisateur.
app: {
head: {
link: [{ rel: 'canonical', href: 'https://votre-domaine.com' }],
meta: [{ name: 'robots', content: 'index, follow' }],
},
},À adapter par page si plusieurs URLs pointent vers le même contenu (ex: paramètres de filtre dans l'URL de la page projets).
loading="lazy" sur toutes les images hors viewportpreconnect sur les domaines tiers (Google Fonts)display=swapCache-Controlwidth et height explicites sur les images pour éviter le Cumulative Layout Shift