
Começa por verificar o que um robô recebe com o curl: se o HTML vier vazio, nenhuma meta tag acrescentada do lado do cliente vai servir. A pré-renderização ou a renderização no servidor é a verdadeira resposta. Só depois trata dos títulos, das descrições e dos dados estruturados com o unhead, que substitui o vue-meta.
Uma aplicação Vue constrói a página no navegador. O HTML servido pelo servidor está quase vazio, e tudo depois depende da execução do JavaScript. Isso ainda passa com a Google, muito menos com os outros robôs, a começar pelos dos assistentes conversacionais. Eis como atacar o problema pela ordem certa.
O verdadeiro problema não são as meta tags
A primeira pergunta não é «como mudo o meu título» mas «o que é que o robô recebe». Verifica-o sem navegador:
curl -s https://mon-site.com/ma-page | head -40Se a resposta for um <div id="app"></div> vazio, nenhuma meta tag acrescentada do lado do cliente muda seja o que for para um robô que não executa JavaScript. A renderização tem então de acontecer antes do envio. É o tema da última secção, e de longe o mais importante.
Os exemplos partem de um projeto criado com create-vue e Vite. As secções seguintes tratam primeiro dos metadados, porque são úteis em todos os casos, mesmo com renderização no servidor.
Os metadados com unhead
Cada página deve ter o seu título e a sua descrição. O unhead instala-se como qualquer plugin Vue. Numa aplicação de página única, título e descrição têm de mudar a cada navegação.
O vue-meta foi a resposta no tempo do Vue 2 e a maioria dos artigos online ainda o refere. A sua versão para Vue 3 nunca chegou a estabilizar. A biblioteca usada hoje, inclusive pelo Nuxt, é o 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')Num componente, useHead e useSeoMeta escrevem no <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>Os composables vêm de @unhead/vue, mas o createHead vem de @unhead/vue/client, ou de @unhead/vue/server em renderização no servidor. Importar tudo do mesmo caminho dá o erro createHead is not a function.
O useSeoMeta evita escrever à mão os arrays de tags: cada chave corresponde a uma tag conhecida, e as gralhas saltam à vista no autocompletar.
Os valores podem ser reativos: passa uma ref ou uma propriedade computada, e a tag acompanha o carregamento dos dados.
const produit = ref(null)
useHead({
title: computed(() => produit.value?.nom ?? 'Chargement…')
})O link canónico
A mesma ficha acessível por vários URL, com ou sem parâmetros de seguimento, dilui o sinal. Declara o endereço de referência:
useHead({
link: [{ rel: 'canonical', href: `https://mon-site.com${route.path}` }]
})Constrói-o a partir do caminho da rota, nunca de window.location.href, que arrastaria consigo os parâmetros de campanha.
O sitemap
Um ficheiro sitemap.xml lista os URL que consideras importantes. Numa aplicação de página única não pode ser descoberto por exploração: tem de ser gerado.
Gera-o a partir da mesma fonte que as tuas rotas, no momento do build, em vez de o escrever à mão:
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>`
)As rotas com parâmetro ficam de fora: os seus URL reais têm de vir dos teus dados, não da definição das rotas. Declara depois o ficheiro no robots.txt:
User-agent: *
Allow: /
Sitemap: https://mon-site.com/sitemap.xmlOs dados estruturados
Um bloco JSON-LD descreve explicitamente o que a página contém. É o que alimenta os resultados enriquecidos, e lê-se muito mais facilmente do que um texto a 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'
}
})
}]
})Verifica o resultado com o teste de resultados enriquecidos da Google, sobre o URL em linha e não sobre o código-fonte.
O desempenho
Os Core Web Vitals pesam no ranking. Três alavancas dão o essencial do ganho numa aplicação Vue.
Dividir o bundle por rota. Basta um import dinâmico na definição das rotas: o Vite produz um ficheiro separado, carregado a pedido.
const routes = [
{ path: '/', component: () => import('../views/AccueilView.vue') },
{ path: '/produit/:id', component: () => import('../views/ProduitView.vue') }
]Dimensionar as imagens. Um width e um height explícitos evitam o deslocamento do layout que degrada o CLS. O formato AVIF ou WebP reduz muito o peso.
Medir antes de otimizar. Corre o Lighthouse sobre o site construído, não sobre o servidor de desenvolvimento: os números não têm nada a ver.
npm run build
npm run preview
npx lighthouse http://localhost:4173 --viewA renderização no servidor (SSR), a verdadeira resposta
Tudo o que vem antes melhora uma aplicação já indexável. Se o teu HTML inicial está vazio, é aqui que o jogo se decide.
Três opções, por ordem de esforço crescente.
A pré-renderização gera um ficheiro HTML completo por rota no momento do build. É a solução mais simples, e chega assim que o conteúdo não depende do visitante: páginas de apresentação, documentação, blogue. Um plugin como o vite-plugin-prerender acrescenta-se à configuração existente, sem tocar no código da aplicação.
A geração de site estático leva a mesma ideia mais longe, com a gestão dos dados e do routing. O Nuxt em modo nuxt generate cobre esta necessidade.
A renderização no servidor, ou SSR de server-side rendering, constrói a página a cada pedido. É necessária quando o conteúdo depende do visitante ou muda em permanência. Impõe um servidor Node e obriga a ter atenção ao código que pressupõe a existência de window ou document. O Nuxt continua a ser o caminho mais curto, SSR montado à mão é um projeto por si só.
Se o conteúdo é o mesmo para toda a gente, pré-renderiza: obténs o melhor resultado com o menor esforço. A renderização no servidor só se justifica se a página depender mesmo de quem a pede.
Aparecer nas respostas dos assistentes
Os robôs dos assistentes conversacionais geralmente não executam JavaScript. Uma aplicação inteiramente renderizada do lado do cliente é invisível para eles, tenha as meta tags que tiver.
O que conta para eles junta-se às boas práticas clássicas: HTML servido tal e qual, uma estrutura de títulos que segue o discurso, dados estruturados, uma resposta clara no topo da página em vez de três parágrafos de introdução.
A ordem das prioridades
- Servir HTML completo, por pré-renderização ou renderização no servidor. Sem isso, o resto não serve de nada.
- Um título e uma descrição próprios de cada página, com o unhead.
- Um link canónico nas páginas acessíveis por vários URL.
- Um sitemap gerado e declarado no
robots.txt. - Os dados estruturados nas páginas que descrevem uma entidade identificável.
- O desempenho, medido sobre o site construído.
Se estás a descobrir o Vue, o tutorial de Vue 3 para principiantes assenta as bases necessárias antes de atacar o routing e a renderização no servidor.
Erros frequentes
@unhead/vue, o createHead de @unhead/vue/client. Importar tudo do mesmo caminho dá «useHead is not a function».curl.npm run build.

