La combinazione Cloudflare Pages + Nuxt 3 promette edge caching e deployment zero-config, ma per i Core Web Vitals non è sufficiente. In un progetto e-commerce in produzione, l'LCP era 10.2 secondi e il TBT 2190 millisecondi. Google Font, hidratazione client-side, CSS globale e rendering JavaScript sincrono bloccavano il rendering critico. Con font self-hosted, lazy hydration, la proprietà CSS content-visibility e una strategia di edge cache, abbiamo ridotto l'LCP a 2.1 secondi e il TBT a 180 millisecondi. In questo articolo condividiamo l'implementazione passo dopo passo e gli effetti collaterali.

Google Font Render Blocking: 3.8s Perso

I font scaricati dalla CDN di Google Fonts tramite @import o <link> bloccano il rendering. Il rischio FOIT (Flash of Invisible Text) e la latenza di 3+ round-trip impattano direttamente l'LCP. In Chrome DevTools, Lighthouse segnalava "Eliminate render-blocking resources" con 3.8 secondi di perdita.

Soluzione: font self-hosted. Abbiamo usato il pacchetto npm @fontsource/inter e posizionato i file Woff2 in public/fonts. Nella config di Nuxt abbiamo aggiunto preload:

// nuxt.config.ts
export default defineNuxtConfig({
  app: {
    head: {
      link: [
        {
          rel: 'preload',
          as: 'font',
          type: 'font/woff2',
          href: '/fonts/inter-latin-400-normal.woff2',
          crossorigin: 'anonymous'
        },
        {
          rel: 'preload',
          as: 'font',
          type: 'font/woff2',
          href: '/fonts/inter-latin-600-normal.woff2',
          crossorigin: 'anonymous'
        }
      ]
    }
  }
})

Nel CSS abbiamo definito @font-face solo per i pesi utilizzati:

/* assets/css/fonts.css */
@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/fonts/inter-latin-400-normal.woff2') format('woff2');
}

@font-face {
  font-family: 'Inter';
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url('/fonts/inter-latin-600-normal.woff2') format('woff2');
}

Con font-display: swap, il FOUT (Flash of Unstyled Text) è un trade-off accettabile — il font di sistema viene mostrato finché il font non è pronto. L'LCP è sceso a 6.4 secondi. L'aumento di bundle size è 72 KB (Woff2 compresso), ma il guadagno di 3.8 secondi ne valeva la pena.

Hidratazione Client-Side: TBT 2190ms

Nuxt 3 per default idrata tutti i component sul client. Con 40+ component in app.vue, stato globale (Pinia), composable e librerie terze (Swiper, vue-gtag), il main thread veniva bloccato. Nel tab Performance di Chrome DevTools vedevamo 8 "Long Tasks", la più lunga 1240 millisecondi.

Idratazione Lazy con Prioritizzazione

Abbiamo reso lazy i component non above-the-fold. Dopo aver integrato @nuxtjs/web-vitals per tracciare INP e TBT, abbiamo identificato il critical path:

<!-- pages/index.vue -->
<template>
  <div>
    <!-- Above-the-fold: idrata subito -->
    <HeroSection />
    <ProductGrid :products="products" />

    <!-- Below-the-fold: idrata lazy -->
    <LazyFooter v-if="mounted" />
    <LazyNewsletterForm v-if="mounted" />
    <client-only>
      <LazyReviewCarousel :reviews="reviews" />
    </client-only>
  </div>
</template>

<script setup lang="ts">
const mounted = ref(false)

onMounted(() => {
  requestIdleCallback(() => {
    mounted.value = true
  })
})
</script>

Con il wrapper <client-only> abbiamo escluso dalla SSR le librerie dipendenti dal DOM come Swiper. Con requestIdleCallback l'idratazione avviene quando il main thread è libero. Il TBT è sceso a 840 millisecondi.

Bundle Splitting e Code Splitting

Con vite-plugin-inspect abbiamo analizzato il bundle. La libreria Swiper da sola era 168 KB minificata, ma usata solo nel carousel di review. Invece di split dinamici, abbiamo ridotto l'uso — rimossi i moduli Virtual e Autoplay, lasciato solo Navigation:

// composables/useSwiper.ts
import { Navigation } from 'swiper/modules'
import 'swiper/css'
import 'swiper/css/navigation'

export const useSwiperModules = () => [Navigation]

Il bundle è sceso da 168 KB a 42 KB. Poiché <LazyReviewCarousel> è già lazy loaded, non entra nel bundle iniziale.

Content-Visibility: Ridurre il Periodo di Render

La product grid ha 48 schede prodotto, ognuna con immagine + titolo + prezzo + bottone. Durante il render iniziale, il browser calcola il layout di 48 schede contemporaneamente, allungando l'LCP. Con la proprietà CSS content-visibility: auto abbiamo escluso dal rendering le schede below-the-fold:

/* components/ProductCard.vue */
.product-card {
  content-visibility: auto;
  contain-intrinsic-size: 320px 420px;
}

contain-intrinsic-size comunica al browser le dimensioni del placeholder, evitando errori nel calcolo della posizione dello scroll. L'LCP è sceso da 6.4 a 3.9 secondi. Il trade-off: le schede fuori viewport vengono renderizzate al scroll, ma l'impatto sull'INP è 12 millisecondi (accettabile).

Edge Caching: TTFB da 1.2s a 40ms

Cloudflare Pages per default non cache l'HTML, ogni richiesta va all'origin. La risposta SSR di Nuxt 3 richiede mediamente 1200 millisecondi (API call + rendering). Con il file _headers abbiamo attivato l'edge caching:

# public/_headers
/*
  Cache-Control: public, max-age=0, s-maxage=600, stale-while-revalidate=86400
  X-Frame-Options: DENY
  X-Content-Type-Options: nosniff

Con s-maxage=600, Cloudflare cache su edge per 10 minuti. Con stale-while-revalidate=86400, quando la cache scade, la versione vecchia viene mostrata mentre il nuovo render avviene in background. Il TTFB è sceso a 40 millisecondi (edge hit). Le richieste all'origin avvengono solo su cache miss o revalidazione stale.

ISR con Rendering Ibrido

Per le pagine prodotto abbiamo usato Incremental Static Regeneration. In Nuxt si configura con routeRules:

// nuxt.config.ts
export default defineNuxtConfig({
  routeRules: {
    '/products/**': { 
      swr: 600,  // 10 minuti
      prerender: false
    },
    '/': { 
      swr: 300   // 5 minuti
    }
  }
})

La prima richiesta è SSR, poi edge cache. Per gli aggiornamenti di stock, facciamo purge manuale tramite webhook:

// server/api/purge-cache.post.ts
export default defineEventHandler(async (event) => {
  const { productId } = await readBody(event)
  
  await fetch(`https://api.cloudflare.com/client/v4/zones/${process.env.CF_ZONE_ID}/purge_cache`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.CF_API_TOKEN}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      files: [`https://example.com/products/${productId}`]
    })
  })
  
  return { success: true }
})

Benchmark Comparativi

MetricaPrimaDopoCambiamento
LCP10.2s2.1s-79%
TBT2190ms180ms-92%
TTFB1200ms40ms-97%
FCP4.8s1.2s-75%
CLS0.180.02-89%
Bundle (iniziale)284 KB186 KB-34%

Ambiente di test: Chrome 121, throttling 4G, Lighthouse CI. Media di 10 esecuzioni. L'LCP sotto 2.5 secondi (soglia "Good" di Google) è stato raggiunto.

Trade-off e Accorgimenti

I font self-hosted perdono la rete edge globale della CDN, ma Cloudflare Pages è già ospitato su edge. Con la compressione Woff2, la latenza aggiuntiva è minima. L'idratazione lazy comporta una perdita di interattività iniziale — i component below-the-fold diventano interattivi dopo il mount hook. Metriche come "time to interactive below fold" vanno aggiunte agli analytics.

content-visibility non è supportato su Safari 17.4 precedente; usare @supports per proteggere. L'edge caching può conflittare con la personalizzazione — contenuti dinamici come carrello e login state vanno protetti con Cache-Control: private o renderizzati lato client.

ISR webhook purge è un processo manuale; va integrato con automazione nel sistema di inventory management. Esiste rischio di contenuti stale — pagine critiche (checkout, pagamento) devono avere ISR disabilitato.

Architettura Composable e Scalabilità

Abbiamo testato queste ottimizzazioni in architettura Headless Commerce — frontend Nuxt 3, backend Shopify Storefront API. Lo stesso pattern funziona su Next.js + Hydrogen o Remix. La strategia di edge caching è framework-agnostic, estendibile con Cloudflare Workers KV o Vercel Edge Config. Per il monitoraggio performance, @nuxtjs/web-vitals dovrebbe cedere il passo a RUM (Real User Monitoring) — Cloudflare Web Analytics o Sentry Performance.

L'LCP di 2.1 secondi rientra nella categoria "Good" di Google, ma va testato su 4G lento in mobile. Con progressive enhancement, l'HTML SSR deve funzionare senza JavaScript — usare il component <NoScript> di Nuxt. I contenuti critici vanno renderizzati senza JavaScript.