SEO con Vue.js: come rendere indicizzabile un’app Vue 3

L'ottimizzazione per i motori di ricerca, o SEO, è un elemento cruciale di qualsiasi sito o applicazione web. Le applicazioni e i siti che i motori faticano a indicizzare, o che sono mal ottimizzati, finiscono nascosti dietro pagine e pagine di risultati.

SEO con Vue.js: come rendere indicizzabile un’app Vue 3
Risposta rapida

Parti dal verificare che cosa riceve un crawler con curl: se l'HTML è vuoto, nessun meta tag aggiunto lato client servirà a qualcosa. Il prerendering o il rendering lato server è la vera risposta. Solo dopo, gestisci titoli, descrizioni e dati strutturati con unhead, che sostituisce vue-meta.

Un’applicazione Vue costruisce la sua pagina nel browser. L’HTML servito dal server è quasi vuoto, e tutto dipende poi dall’esecuzione del JavaScript. Per Google può funzionare, molto meno per gli altri crawler, a cominciare da quelli degli assistenti conversazionali. Ecco come affrontare il problema nell’ordine giusto.

Il vero problema non sono i meta tag

La prima domanda da farsi non è «come cambio il titolo» ma «che cosa riceve il crawler». Verificalo senza browser:

bash
curl -s https://mon-site.com/ma-page | head -40

Se la risposta è un <div id="app"></div> vuoto, nessun meta tag aggiunto lato client cambierà qualcosa per un crawler che non esegue JavaScript. Il rendering deve allora avvenire prima dell’invio. È l’argomento dell’ultima sezione, di gran lunga il più importante.

Gli esempi presuppongono un progetto creato con create-vue e Vite. Le sezioni che seguono partono dai metadati, perché sono utili in ogni caso, anche con il rendering lato server.

I metadati con unhead

Ogni pagina deve avere il proprio titolo e la propria descrizione. unhead si installa come qualsiasi plugin Vue. In una single page application devono cambiare a ogni navigazione.

vue-meta appartiene al passato

vue-meta era la risposta ai tempi di Vue 2 e la maggior parte degli articoli online la cita ancora. La sua versione per Vue 3 non è mai stata stabilizzata. La libreria usata oggi, anche da Nuxt, è unhead.

bash
npm install @unhead/vue
src/main.js
import { createApp } from 'vue'
import { createHead } from '@unhead/vue/client'
import App from './App.vue'

const app = createApp(App)
app.use(createHead())
app.mount('#app')

In un componente, useHead e useSeoMeta scrivono nel <head>:

src/views/ProduitView.vue
<script setup>
import { useHead, useSeoMeta } from '@unhead/vue'

useHead({
  title: 'T-Shirt Gekkode',
  titleTemplate: '%s — Boutique Gekkode',
  link: [{ rel: 'canonical', href: 'https://mon-site.com/t-shirt' }]
})

useSeoMeta({
  description: 'Un T-shirt en coton, laine et polyester.',
  ogTitle: 'T-Shirt Gekkode',
  ogDescription: 'Un T-shirt en coton, laine et polyester.',
  ogImage: 'https://mon-site.com/images/t-shirt.png',
  ogUrl: 'https://mon-site.com/t-shirt',
  twitterCard: 'summary_large_image'
})
</script>
Due percorsi di import diversi

I composable arrivano da @unhead/vue, ma createHead arriva da @unhead/vue/client, o da @unhead/vue/server in rendering lato server. Importare tutto dallo stesso percorso dà l’errore createHead is not a function.

useSeoMeta evita di scrivere a mano gli array di tag: ogni chiave corrisponde a un tag noto, e gli errori di battitura saltano fuori con l’autocompletamento.

I valori possono essere reattivi: passa una ref o una computed, e il tag segue il caricamento dei dati.

javascript
const produit = ref(null)

useHead({
  title: computed(() => produit.value?.nom ?? 'Chargement…')
})

La stessa scheda raggiungibile da più URL, con o senza parametri di tracciamento, diluisce il segnale. Dichiara l’indirizzo di riferimento:

javascript
useHead({
  link: [{ rel: 'canonical', href: `https://mon-site.com${route.path}` }]
})

Costruiscilo a partire dal path della route, mai da window.location.href, che si porterebbe dietro i parametri di campagna.

La sitemap

Un file sitemap.xml elenca gli URL che consideri importanti. Su una single page application non può essere scoperto tramite crawling: va generato.

Generalo dalla stessa sorgente delle tue route, al momento della build, invece che a mano:

scripts/sitemap.js
import { writeFileSync } from 'node:fs'
import { routes } from '../src/router/routes.js'

const base = 'https://mon-site.com'
const urls = routes
  .filter((r) => !r.meta?.noindex && !r.path.includes(':'))
  .map((r) => `  <url><loc>${base}${r.path}</loc></url>`)
  .join('\n')

writeFileSync(
  'public/sitemap.xml',
  `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>`
)

Le route con parametro sono escluse: i loro URL reali devono venire dai tuoi dati, non dalla definizione delle route. Dichiara poi il file in robots.txt:

public/robots.txt
User-agent: *
Allow: /

Sitemap: https://mon-site.com/sitemap.xml

I dati strutturati

Un blocco JSON-LD descrive in modo esplicito che cosa contiene la pagina. È quello che alimenta i risultati avanzati, e si legge molto più facilmente di un testo da interpretare.

javascript
useHead({
  script: [{
    type: 'application/ld+json',
    innerHTML: JSON.stringify({
      '@context': 'https://schema.org',
      '@type': 'Product',
      name: 'T-Shirt Gekkode',
      offers: {
        '@type': 'Offer',
        price: '25.00',
        priceCurrency: 'EUR',
        availability: 'https://schema.org/InStock'
      }
    })
  }]
})

Verifica il risultato con il test dei risultati avanzati di Google, sull’URL online e non sul codice sorgente.

Le prestazioni

I Core Web Vitals pesano sul posizionamento. Tre leve danno la gran parte del guadagno su un’applicazione Vue.

Dividere il bundle per route. Basta un import dinamico nella definizione delle route: Vite produce un file separato, caricato su richiesta.

src/router/index.js
const routes = [
  { path: '/', component: () => import('../views/AccueilView.vue') },
  { path: '/produit/:id', component: () => import('../views/ProduitView.vue') }
]

Dimensionare le immagini. Un width e un height espliciti evitano lo spostamento del layout che peggiora il CLS. Il formato AVIF o WebP riduce molto il peso.

Misurare prima di ottimizzare. Lancia Lighthouse sul sito compilato, non sul server di sviluppo: i numeri non hanno niente a che vedere tra loro.

bash
npm run build
npm run preview
npx lighthouse http://localhost:4173 --view

Il rendering lato server (SSR), la vera risposta

Tutto quello che precede migliora un’applicazione già indicizzabile. Se il tuo HTML iniziale è vuoto, è qui che si gioca la partita.

Tre opzioni, in ordine di impegno crescente.

Il prerendering genera un file HTML completo per ogni route al momento della build. È la soluzione più semplice, e basta appena il contenuto non dipende dal visitatore: pagine di presentazione, documentazione, blog. Un plugin come vite-plugin-prerender si aggiunge alla configurazione esistente, senza toccare il codice dell’applicazione.

La generazione di sito statico spinge la stessa idea più avanti, con la gestione dei dati e del routing. Nuxt in modalità nuxt generate copre questa esigenza.

Il rendering lato server, o SSR per server-side rendering, costruisce la pagina a ogni richiesta. Serve quando il contenuto dipende dal visitatore o cambia di continuo. Impone un server Node e attenzione al codice che dà per scontata l’esistenza di window o document. Nuxt resta la strada più corta, un SSR montato a mano è un progetto a sé.

Scegliere senza sbagliare

Se il contenuto è lo stesso per tutti, fai prerendering: ottieni il risultato migliore con il minimo sforzo. Il rendering lato server si giustifica solo se la pagina dipende davvero da chi la chiede.

Il posizionamento nelle risposte degli assistenti

I crawler degli assistenti conversazionali di solito non eseguono JavaScript. Un’applicazione renderizzata interamente lato client per loro è invisibile, qualunque siano i suoi meta tag.

Quello che conta per loro coincide con le buone pratiche classiche: HTML servito così com’è, una struttura di titoli che segue il discorso, dati strutturati, una risposta chiara in cima alla pagina invece che dopo tre paragrafi di introduzione.

L’ordine delle priorità

  1. Servire HTML completo, con prerendering o rendering lato server. Senza questo, il resto non serve a niente.
  2. Un titolo e una descrizione propri di ogni pagina, con unhead.
  3. Un link canonico sulle pagine raggiungibili da più URL.
  4. Una sitemap generata e dichiarata in robots.txt.
  5. I dati strutturati sulle pagine che descrivono un’entità identificabile.
  6. Le prestazioni, misurate sul sito compilato.

Se stai scoprendo Vue adesso, il tutorial Vue 3 per principianti pone le basi necessarie prima di affrontare il routing e il rendering lato server.

Errori frequenti

Installare vue-meta La sua versione per Vue 3 non è mai stata stabilizzata. La libreria usata oggi è unhead, anche da Nuxt.
Due percorsi di import per unhead I composable arrivano da @unhead/vue, createHead da @unhead/vue/client. Importare tutto dallo stesso percorso dà « useHead is not a function ».
Curare i meta tag su un'applicazione vuota Un crawler che non esegue JavaScript non vede niente. Controlla prima l'output di curl.
Costruire il canonical da window.location.href I parametri di campagna finiscono nel link canonico. Usa il path della route.
Misurare le prestazioni sul server di sviluppo I numeri non hanno niente a che vedere con la produzione. Misura sul risultato di npm run build.

JavaScriptSEOVue.js

Damien Flandrin Sviluppatore web dal 2010, creatore di Gekkode e di Email Impact. Ogni articolo è testato su un progetto reale prima della pubblicazione. Contatti
Newsletter

I nuovi test, tutorial e progetti, via e-mail.

Test riproducibili, codice versionato, risultati datati. Mai spam.