Skip to content

Nuxt Landing - i18n e SEO multilíngue

A landing (contrasync-nuxt-landing) é servida em três idiomas: pt-BR (padrão), en e es. Cada idioma tem URL própria e HTML pré-renderizado, para ser indexado de forma independente pelos buscadores.


Decisões

DecisãoEscolhaMotivo
Módulo@nuxtjs/i18n v10Suporte nativo a Nuxt Layers e a hreflang/canonical
Estratégia de rotaprefix_except_defaultpt-BR em /, demais em /en/** e /es/**: HTML estático por idioma
Detecção de idiomaNão existeRedirect por idioma do navegador custava CLS 0,27 e joga o Googlebot (en-US) para /en. A URL manda; o switcher é manual
PersistênciaCookie contrasync_locale (1 ano)Escolha manual do usuário vence a detecção do browser
Sitemap@nuxtjs/sitemap v8Gera sitemap_index.xml + um sitemap por idioma, com xhtml:link de alternates

Estrutura de arquivos

Os locales seguem a arquitetura de Nuxt Layers: cada layer tem os seus, e o módulo faz o merge.

src/
├── base/locales/{pt-BR,en,es}.json      # site, common, language, cookies, leadForm
├── base/i18n.config.ts                  # fallbackLocale
├── base/plugins/locale.client.ts        # persiste o cookie do switcher
├── base/composables/useSiteSeo.ts       # title/description/hreflang/canonical
├── base/components/LanguageSwitcher.vue # dropdown de bandeiras (links reais)
├── landing/locales/{pt-BR,en,es}.json   # landing.*
├── auth/locales/{pt-BR,en,es}.json      # auth.*
├── contact/locales/{pt-BR,en,es}.json   # contact.*
└── presentation/locales/{pt-BR,en,es}.json  # demo.*, studio.*

Cada src/<layer>/nuxt.config.ts declara os seus locales:

ts
export default defineNuxtConfig({
  i18n: {
    restructureDir: '.',
    langDir: 'locales',
    locales: [
      { code: 'pt-BR', file: 'pt-BR.json' },
      { code: 'en', file: 'en.json' },
      { code: 'es', file: 'es.json' }
    ]
  }
})

restructureDir: '.' é obrigatório: sem ele o módulo procuraria em src/<layer>/i18n/locales/.


Regras de negócio

RN-001 — Não redirecionar por idioma do navegador. O visitante fica no idioma da URL que acessou. O plugin locale.client.ts apenas persiste a escolha do switcher no cookie contrasync_locale. Reintroduzir a detecção automática reintroduz CLS de 0,27 (a página pinta em pt-BR e pula para /en depois da hidratação) e manda o Googlebot, que rastreia em en-US, para a versão errada.

RN-002 — A escolha do switcher é gravada no cookie e vale para a próxima visita, mas nunca dispara redirect automático.

RN-003 — URL vence cookie. Quem entra direto em /en/** ou /es/** é servido naquele idioma, e o cookie passa a refletir essa escolha. Nunca há redirect a partir de uma URL já prefixada.

RN-004 — Nada muda durante a hidratação. O HTML pré-renderizado é sempre o do idioma da rota. Trocar conteúdo depois de montado gera Hydration completed but contains mismatches e destrói o CLS.

RN-005 — Todo link interno é localizado. Usar <NuxtLinkLocale> no template e useLocalePath() no script (navigateTo(localePath('/demo'))). Um <NuxtLink to="/demo"> cru joga o visitante de /en para a página em português.

RN-006 — Sem afirmação de "no Brasil" em en/es. A copy pt-BR usa "o primeiro SCLM do Brasil"; as versões em inglês e espanhol falam apenas "o primeiro SCLM". A única menção mantida é a da página de LGPD, por precisão jurídica.

RN-007 — Dados enviados ao backend permanecem em pt-BR. O rótulo de faixa de prestadores enviado no lead usa t(key, {}, { locale: LEAD_LOCALE }), para o time comercial receber sempre o mesmo valor, independentemente do idioma da interface.


SEO

Cada página emite, por idioma:

  • <html lang> e dir corretos;
  • <link rel="canonical"> apontando para a própria URL do idioma;
  • <link rel="alternate" hreflang> para pt-BR, en, es e x-default (→ pt-BR);
  • og:locale + og:locale:alternate;
  • title, description e keywords traduzidos (site.* em cada locale);
  • JSON-LD (SoftwareApplication, Organization, FAQPage) com o texto do idioma ativo.

Tudo isso vem de useSiteSeo() (que compõe useLocaleHead({ seo: true })), chamado uma única vez em src/base/app.vue.

O switcher de idioma renderiza <NuxtLink> de verdade (via useSwitchLocalePath()), não botões: são âncoras rastreáveis pelo Google e pelo prerenderer do Nitro.

Sitemap

npm run generate produz:

/sitemap_index.xml           # índice (referenciado no robots.txt)
/__sitemap__/pt-BR.xml       # com <xhtml:link rel="alternate"> para en e es
/__sitemap__/en-US.xml
/__sitemap__/es-ES.xml

/demo/studio (e suas variantes por idioma) fica fora do sitemap e do índice: é sala de apresentação com token, marcada noindex, nofollow. /login e /register também ficam fora do sitemap e emitem robots: noindex, follow: são telas de aplicação, não conteúdo de busca.


Páginas de keyword

A home cobre a marca e o termo institucional (SCLM, CLM, gestão de contratos). Ela não rankeia sozinha para busca de cauda longa ("modelos de contrato de prestação de serviços", "integração com Omie"). Para isso existem páginas de keyword: uma URL por intenção de busca, com H1 na keyword, conteúdo próprio e FAQ próprio.

Fonte única

src/landing/constants/seo-pages.ts é a única fonte da verdade. Cada entrada define a rota, o caminho localizado nos três idiomas, as seções, as FAQs e as páginas relacionadas:

ts
{
  key: 'omie',
  route: 'integrations-omie',        // nome da rota Nuxt (usado por localePath({ name }))
  file: 'integrations/omie',         // caminho em pages/, usado por i18n.pages
  parent: 'integrations',            // breadcrumb
  paths: {
    'pt-BR': '/integracoes/omie',
    en: '/integrations/omie',
    es: '/integraciones/omie'
  },
  sections: [{ key: 'what', points: ['mirror', 'billing', 'noRework'] }, ...],
  faqKeys: ['omieHow', 'omieSync', 'omieSetup'],
  related: ['integrations', 'pipedrive', 'contractManagement']
}

Desse mesmo array saem, sem duplicação:

ConsumidorO que deriva
nuxt.config.tsi18n.pagesURLs localizadas (customRoutes: 'config')
nuxt.config.tsnitro.prerender.routesas 39 rotas (13 páginas x 3 idiomas) pré-renderizadas
useSeoContentPage()title/description/keywords/og + JSON-LD + breadcrumb + relacionados
useLandingHeader()mega-menu "Soluções" e "Integrações" (dropdown desktop + menu mobile)
useLandingFooter()grupos "Soluções" e "Integrações" do rodapé (link interno é o que faz o Google chegar)
SeoContentPage.vueH1, hero com mockup, H2 por seção, bloco da Zelor, FAQ e cards de relacionados

Anatomia da página

Hero em duas colunas (texto + mockup do app), seções de conteúdo com H2 por intenção, bloco da Zelor (a IA é o diferencial do produto e aparece em todas as páginas, com pontos específicos daquele contexto via aiPoints), relacionados, FAQ e CTA.

O texto vive em landing.seo.<key>.* nos três locales. A página .vue é só a casca:

vue
<template>
  <SeoContentPage />
</template>

<script setup lang="ts">
definePageMeta({ seoKey: 'omie' })
</script>

Como adicionar uma keyword nova

  1. Adicionar a entrada em SEO_PAGES (com route, file e os três paths).
  2. Criar src/landing/pages/<file>.vue com definePageMeta({ seoKey }).
  3. Escrever landing.seo.<key>.* nos três locales (pt-BR, en, es).
  4. Se a keyword merecer aparecer no rodapé, incluir a key em SOLUTION_KEYS ou INTEGRATION_KEYS (useLandingFooter.ts).
  5. Se a busca também for pergunta direta ("o Contrasync integra com Omie?"), adicionar a FAQ à home em FAQ_KEYS + landing.faq.items.*: a home tem FAQPage schema e ganha rich snippet.

Não há passo de sitemap: a rota entra no prerender e o @nuxtjs/sitemap a recolhe.

JSON-LD

PáginaSchemas
HomeSoftwareApplication, Organization, FAQPage (18 perguntas)
Página de keywordWebPage (+ isPartOf WebSite, about SoftwareApplication), BreadcrumbList, FAQPage

Menção vira link. useContentLinks()landing.seo.<key>.linkTerms (lista de termos separados por vírgula, por idioma), resolve a URL localizada de cada página e enriquece o HTML das respostas de FAQ e dos textos de seção: a primeira ocorrência de "Omie", "modelos de contrato" ou "assinatura eletrônica" vira <a> para a página correspondente. É o que dá anchor text com a keyword e distribui autoridade entre as páginas, em vez de deixar todo o link interno no rodapé.

Salvaguardas: no máximo 3 links por bloco de texto (mais que isso lê como spam), nunca auto-linka a página atual, e o link nunca entra no JSON-LD (o schema usa t() cru, sem HTML).

Logos das integrações

Os logos vivem em public/integrations/*.png (mesmos arquivos do vue-spa, em src/assets/integrations/). O campo logo do SeoPage alimenta o mega-menu, o hero da página, os cards de relacionados e a grade do hub. Página de integração nova sem logo cai no ícone genérico, mas o certo é copiar o arquivo do vue-spa.

Imagens

Nenhum alt é hardcoded. Logo e avatar da Zelor usam site.logoAlt / site.agentAvatarAlt; as telas do app usam landing.mobileApp.tabs.<key>.alt; as páginas de keyword usam landing.seo.<key>.imageAlt. O alt descreve a imagem e carrega a keyword da página, porque o Google Imagens é fonte de tráfego para busca de produto. Toda <img> declara width/height (evita CLS) e as que não estão no primeiro scroll usam loading="lazy".


Blog

src/blog é um layer próprio. O conteúdo é markdown puro em src/blog/content/<slug>.md (sem frontmatter e sem H1: o título vem do registry), renderizado por markdown-it em tempo de build.

PeçaPapel
src/blog/constants/posts.tsRegistry: slug, título, descrição, keyword, categoria, data, relacionados e CTA. É a fonte do índice, do prerender (blogRoutes no nuxt.config) e dos links internos
src/blog/content/*.mdCorpo do artigo. Carregado por import.meta.glob lazy, então cada artigo vira um chunk próprio
useBlogPost()Carrega o markdown do slug, renderiza, e emite title/description/keywords + JSON-LD Article
BlogPostCta.vueGancho para a página de produto correspondente (campo cta do registry)

O blog é pt-BR apenas. As páginas usam defineI18nRoute(false), então não existem /en/blog nem /es/blog. O mercado é o Brasil e a SERP pesquisada é toda em português.

Tabelas rolam sozinhas. O renderMarkdown envolve cada <table> num .table-scroll com overflow-x: auto. Sem isso, tabela larga quebra o layout no celular.


Agentes de IA (llms.txt e nome acessível)

O Lighthouse tem uma categoria de navegação agêntica: ela audita o quanto o site é legível para um agente de IA. Duas regras nasceram dela.

RN-010 — public/llms.txt é obrigatório e precisa ser Markdown de verdade. Sem o arquivo, o CloudFront devolve o HTML da home para /llms.txt e o auditor reprova ("não tem H1, não tem links"). O formato exigido: # Contrasync, um resumo em blockquote, e seções ## com links absolutos. Hoje ele lista as 16 páginas de keyword e os 35 artigos. Página nova ou artigo novo entra também no llms.txt — ele não é gerado no build, é um arquivo estático e desatualiza calado.

RN-011 — Botão só com ícone precisa de aria-label. Um <button> que contém apenas um <svg> não tem nome acessível: leitor de tela e agente de IA não sabem o que ele faz. O rótulo vem do i18n, nunca hardcoded. Onde houver estado (aba selecionada, slide atual), usar aria-pressed ou aria-current, que é o que diz ao agente qual opção está ativa.


Performance (o que já custou caro)

Quatro armadilhas que derrubaram o PageSpeed mobile para 41 e como estão resolvidas:

A detecção automática de idioma era o maior CLS do site. O plugin rodava em app:mounted, lia o navegador e redirecionava. Resultado: a página pintava em pt-BR e pulava para /en (o Lighthouse e o Googlebot rodam em en-US). Sozinha, essa troca respondia por CLS 0,27 e metade do TBT. Hoje locale.client.ts só persiste o cookie da escolha manual; não redirecione por idioma do navegador.

Analytics no bundle de entrada. O Amplitude era import estático: 610 KB de JS em toda página, mesmo sem consentimento. Agora é await import() depois do aceite, com fila de eventos. O Sentry carrega em requestIdleCallback.

Fonte do Google bloqueando o render. A Lora vinha do CDN. Hoje é @nuxt/fonts (self-host + fallback com métricas). O nome do fallback tem dois-pontos, então precisa de aspas no tailwind.config: '"Lora Fallback: Georgia"'.

Naive UI no layout padrão. Só os formulários usam Naive; o layout default não o envolve mais.

Resultado medido no build, com compressão: mobile 41 → 85, desktop 85 → 100, CLS 0,27 → 0,07.


Hospedagem (por que o prefixo funciona)

O bucket contrasync-landing é servido pelo S3 website endpoint atrás do CloudFront, que resolve diretório para index.html (/en//en/index.html) e redireciona /en/en/. É isso que viabiliza prefix_except_default sem CloudFront Function. Se algum dia o origin virar S3 REST + OAC, /en/ passa a dar 403 e cai no fallback da home — quebrando o SEO por idioma.


Erros conhecidos

E-001 — | corta a string. O pipe é separador de plural no vue-i18n. Títulos como "Login - Contrasync | Gestão de Prestadores" são truncados. Escapar: {'|'}.

E-002 — @ quebra o build. É o caractere de mensagem linkada. seu@empresa.com derruba o build do compilador de mensagens. Escapar: seu{'@'}empresa.com.

E-003 — Chave dinâmica sem tradução. $t(\landing.faq.items.${key}.question`)` só funciona se a chave existir nos três locales. Ao adicionar item numa lista, atualizar os três arquivos.


Exemplos

Feliz — visitante com browser em espanhol acessa contrasync.com: hidrata em pt-BR (HTML estático), o plugin detecta es-*, navega para /es, cookie contrasync_locale=es. Canonical https://contrasync.com/es.

Borda — visitante brasileiro recebe o link contrasync.com/en/contact: a URL vence, a página é servida em inglês e o cookie passa a en. Nenhum redirect.

Falha — desenvolvedor adiciona <NuxtLink to="/lgpd"> numa página nova: o visitante em /es/privacy-policy clica e cai na LGPD em português, com canonical de outro idioma. Correção: <NuxtLinkLocale to="/lgpd">.


Como adicionar um idioma

  1. Criar src/<layer>/locales/<code>.json nos cinco layers.
  2. Registrar o código em cada src/<layer>/nuxt.config.ts (metadados language/name só em base).
  3. Adicionar a bandeira em src/base/components/FlagIcon.vue.
  4. Adicionar o padrão de destaque em src/landing/utils/highlight.util.ts.
  5. Mapear o og:locale em src/base/composables/useSiteSeo.ts.
  6. Rodar npm run generate e conferir /, /<code>/, sitemap e hreflang.