
Empieza por comprobar qué recibe un rastreador con curl: si el HTML está vacío, ninguna etiqueta meta añadida desde el cliente servirá de nada. El prerenderizado o el renderizado en servidor es la respuesta de verdad. Solo después, gestiona títulos, descripciones y datos estructurados con unhead, que sustituye a vue-meta.
Una aplicación Vue construye su página en el navegador. El HTML que sirve el servidor está casi vacío y todo depende después de la ejecución del JavaScript. Con Google puede colar, con el resto de rastreadores mucho menos, empezando por los de los asistentes conversacionales. Así se ataca el problema, por orden.
El problema de verdad no son las etiquetas meta
La primera pregunta no es «cómo cambio mi título», sino «qué recibe el rastreador». Compruébalo sin navegador:
curl -s https://mon-site.com/ma-page | head -40Si la respuesta es un <div id="app"></div> vacío, ninguna etiqueta meta añadida desde el cliente cambiará nada para un rastreador que no ejecuta JavaScript. Entonces el renderizado tiene que ocurrir antes del envío. Es el tema de la última sección y, con diferencia, el más importante.
Los ejemplos dan por hecho un proyecto creado con create-vue y Vite. Las secciones que siguen tratan primero los metadatos, porque sirven en todos los casos, también con renderizado en servidor.
Los metadatos con unhead
Cada página necesita su título y su descripción. unhead se instala como cualquier plugin de Vue. En una aplicación de página única tienen que cambiar en cada navegación.
vue-meta era la respuesta en tiempos de Vue 2 y la mayoría de los artículos que circulan siguen remitiendo a él. Su versión para Vue 3 nunca llegó a estabilizarse. La biblioteca que se usa hoy, también en Nuxt, es unhead.
npm install @unhead/vueimport { createApp } from 'vue'
import { createHead } from '@unhead/vue/client'
import App from './App.vue'
const app = createApp(App)
app.use(createHead())
app.mount('#app')Dentro de un componente, useHead y useSeoMeta escriben en el <head>:
<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>Los composables vienen de @unhead/vue, pero createHead viene de @unhead/vue/client, o de @unhead/vue/server con renderizado en servidor. Importarlo todo desde la misma ruta da el error createHead is not a function.
useSeoMeta evita escribir a mano los arrays de etiquetas: cada clave corresponde a una etiqueta conocida y las erratas se ven en el autocompletado.
Los valores pueden ser reactivos: pasa una ref o una propiedad computada y la etiqueta seguirá la carga de los datos.
const produit = ref(null)
useHead({
title: computed(() => produit.value?.nom ?? 'Chargement…')
})El enlace canónico
Una misma ficha accesible desde varias URL, con o sin parámetros de seguimiento, diluye la señal. Declara la dirección de referencia:
useHead({
link: [{ rel: 'canonical', href: `https://mon-site.com${route.path}` }]
})Constrúyela a partir de la ruta del router, nunca de window.location.href, que arrastraría los parámetros de campaña.
El sitemap
Un archivo sitemap.xml lista las URL que consideras importantes. En una aplicación de página única no se puede descubrir rastreando: hay que generarlo.
Genéralo a partir de la misma fuente que tus rutas, en el momento del build, en lugar de a mano:
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>`
)Las rutas con parámetro quedan fuera: sus URL reales tienen que salir de tus datos, no de la definición de las rutas. Después declara el archivo en robots.txt:
User-agent: *
Allow: /
Sitemap: https://mon-site.com/sitemap.xmlLos datos estructurados
Un bloque JSON-LD describe de forma explícita lo que contiene la página. Es lo que alimenta los resultados enriquecidos, y se lee mucho más fácilmente que un texto por interpretar.
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'
}
})
}]
})Comprueba el resultado con la prueba de resultados enriquecidos de Google, sobre la URL publicada y no sobre el código fuente.
El rendimiento
Las Core Web Vitals pesan en el posicionamiento. Tres palancas dan casi toda la ganancia en una aplicación Vue.
Dividir el bundle por ruta. Basta con un import dinámico en la definición de las rutas: Vite produce un archivo aparte, cargado bajo demanda.
const routes = [
{ path: '/', component: () => import('../views/AccueilView.vue') },
{ path: '/produit/:id', component: () => import('../views/ProduitView.vue') }
]Dimensionar las imágenes. Un width y un height explícitos evitan el desplazamiento del diseño que degrada el CLS. El formato AVIF o WebP reduce mucho el peso.
Medir antes de optimizar. Ejecuta Lighthouse sobre el sitio construido, no sobre el servidor de desarrollo: las cifras no tienen nada que ver.
npm run build
npm run preview
npx lighthouse http://localhost:4173 --viewEl renderizado en servidor (SSR), la respuesta de verdad
Todo lo anterior mejora una aplicación que ya es indexable. Si tu HTML inicial está vacío, aquí es donde se juega el partido.
Tres opciones, por orden de esfuerzo creciente.
El prerenderizado genera un archivo HTML completo por ruta en el momento del build. Es la solución más sencilla, y basta en cuanto el contenido no depende del visitante: páginas de presentación, documentación, blog. Un plugin como vite-plugin-prerender se añade a la configuración existente, sin tocar el código de la aplicación.
La generación de sitios estáticos lleva la misma idea más lejos, con la gestión de los datos y del enrutado. Nuxt en modo nuxt generate cubre esa necesidad.
El renderizado en servidor, o SSR por server-side rendering, construye la página en cada petición. Hace falta cuando el contenido depende del visitante o cambia sin parar. Eso obliga a tener un servidor Node y a vigilar el código que da por hecho que existen window o document. Nuxt sigue siendo el camino más corto, montar el renderizado en servidor a mano es un proyecto en sí mismo.
Si el contenido es el mismo para todo el mundo, prerenderiza: obtienes el mejor resultado con el menor esfuerzo. El renderizado en servidor solo se justifica si la página depende de verdad de quién la pide.
El posicionamiento en las respuestas de los asistentes
Los bots de los asistentes conversacionales no suelen ejecutar JavaScript. Una aplicación renderizada por completo en el cliente les resulta invisible, tenga las etiquetas meta que tenga.
Lo que cuenta para ellos coincide con las buenas prácticas de siempre: HTML servido tal cual, una estructura de títulos que siga el discurso, datos estructurados y una respuesta clara al principio de la página en lugar de después de tres párrafos de introducción.
El orden de prioridades
- Servir HTML completo, por prerenderizado o renderizado en servidor. Sin eso, lo demás no sirve de nada.
- Un título y una descripción propios de cada página, con unhead.
- Un enlace canónico en las páginas accesibles desde varias URL.
- Un sitemap generado y declarado en
robots.txt. - Los datos estructurados en las páginas que describen una entidad identificable.
- El rendimiento, medido sobre el sitio construido.
Si estás empezando con Vue, el tutorial de Vue 3 para principiantes sienta las bases necesarias antes de meterse con el enrutado y el renderizado en servidor.
Errores frecuentes
@unhead/vue, y createHead de @unhead/vue/client. Importarlo todo desde la misma ruta da «useHead is not a function».curl.npm run build.

