neptay
Todos os insights

Engenharia

Como construir um site bilíngue com Next.js App Router: i18n, hreflang e dados estruturados do jeito certo

Um passo a passo prático de como entregamos sites bilíngues (EN/TR) com Next.js App Router — roteamento por locale, alternates hreflang, JSON-LD por locale e os pequenos truques de middleware que tratam os dois idiomas como protagonistas.

Neste artigo

A maioria dos sites bilíngues é, na verdade, um site monolíngue com uma camada de tradução acoplada. A versão em inglês é a que sai primeiro, a que tem os formulários funcionando e a que os mecanismos de busca conhecem primeiro. A edição traduzida é um arquivo JSON despejado de uma memória de tradução e um menu suspenso no cabeçalho que ninguém testou.

Não era isso que queríamos construir. O neptay.com começou em inglês e turco e hoje roda em dez idiomas a partir da mesma camada de conteúdo, com roteamento, metadados, sitemaps, hreflang e dados estruturados que levam o idioma em conta. A edição em turco não é uma adaptação feita depois — é o mesmo site em outra voz. Este artigo é o passo a passo de engenharia: como montamos tudo no Next.js App Router, o que colocar no middleware, como o hreflang precisa ser para o Google confiar nele e as pequenas decisões estruturais que se pagam mais adiante.

Se você vai lançar um site bilíngue pequeno — a página de um estúdio, o cardápio de um restaurante, uma landing page regional —, dá para copiar este padrão quase inteiro. Se vai lançar uma loja em cinquenta idiomas, esta é a base; você só vai trocar o dicionário por um feed de CMS.

O formato do espaço de URLs

Existem basicamente três opções para organizar as URLs de um site bilíngue em Next.js:

  1. Subdomínio por locale (en.example.com, tr.example.com). Separação forte, mas caro de gerenciar em TLS e analytics, e visualmente fragmentado para um site pequeno.
  2. TLD de país por locale (example.com, example.com.tr). Excelente para segmentação geográfica, mas só se justifica quando você realmente opera um negócio voltado à Turquia — caro nos demais casos.
  3. Prefixo de locale por caminho (example.com/en/, example.com/tr/). Um único domínio, um único deploy, uma única propriedade de analytics. É o que usamos, e o que a maioria dos sites bilíngues pequenos deveria usar.

O Next.js App Router lida bem com prefixos de locale porque as rotas são definidas pelo sistema de arquivos. O site inteiro vive em app/[locale]/, e cada página final recebe o locale como parâmetro de rota. Não há página inicial tratada como caso especial nem divisão oculta entre rotas “traduzidas” e “não traduzidas” — o locale é só mais um segmento.

A estrutura de diretórios que usamos

Exemplo de código
app/
  [locale]/
    layout.tsx       ← root layout: <html lang>, nav, footer, JSON-LD, hreflang
    page.tsx         ← home
    services/page.tsx
    work/
      page.tsx       ← work index
      [slug]/page.tsx← case study
    studio/page.tsx
    contact/page.tsx
    insights/
      page.tsx
      [slug]/page.tsx
    [...rest]/page.tsx ← catch-all → notFound()
    not-found.tsx    ← localized 404
  sitemap.ts
  robots.ts

Repare no que falta: não existe app/layout.tsx. O layout de locale é o layout raiz — ele controla o elemento <html> diretamente, então <html lang="…"> vem direto do parâmetro de rota. Nada de truques com cabeçalhos, nada de correções no lado do cliente e, principalmente, nenhuma chamada a headers(), que tiraria silenciosamente todas as rotas da geração estática. Seções que ficam fora do sistema de locales (nossas demos ao vivo) ganham seu próprio layout raiz — o Next.js suporta vários layouts raiz, e a fronteira de locale é exatamente o lugar para usá-los.

Middleware: detectar o locale certo e redirecionar

Quando alguém acessa o domínio puro, você precisa decidir: para onde essa pessoa vai? Há três sinais — a URL (se ela digitou /tr/ explicitamente), o cabeçalho Accept-Language (o que o navegador pede) e um cookie (o que ela escolheu da última vez). A regra que usamos é a mais sem graça, e funciona:

  1. Se a URL já tem prefixo de locale, respeite-o. Grave um cookie para lembrar a escolha.
  2. Senão, se houver um cookie de uma visita anterior, redirecione para esse locale.
  3. Senão, se o cabeçalho Accept-Language corresponder a um locale que suportamos, redirecione para ele.
  4. Caso contrário, use o locale padrão (inglês).

Fazemos isso no middleware.ts, não em um layout — é mais rápido, roda na edge e evita divergências de hidratação. Esta é uma versão simplificada do middleware que roda no neptay.com:

middleware.tsTypeScript
// middleware.ts
import { NextResponse, type NextRequest } from "next/server";
import { defaultLocale, isLocale, locales } from "@/lib/i18n";

const LOCALE_COOKIE = "neptay.locale";

function pickLocaleFromAcceptLanguage(header: string | null): "en" | "tr" {
  if (!header) return defaultLocale;
  const lower = header.toLowerCase();
  if (/(^|[,;\s])tr(-|;|,|$)/.test(lower)) return "tr";
  return defaultLocale;
}

export function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;

  // Live demos live outside the locale system — they handle their own language.
  if (pathname === "/demo" || pathname.startsWith("/demo/")) {
    return NextResponse.next();
  }

  const matchedLocale = locales.find(
    (l) => pathname === `/${l}` || pathname.startsWith(`/${l}/`)
  );
  if (matchedLocale) {
    const res = NextResponse.next();
    // Remember the locale so the next bare-domain visit lands on it.
    if (req.cookies.get(LOCALE_COOKIE)?.value !== matchedLocale) {
      res.cookies.set(LOCALE_COOKIE, matchedLocale, {
        path: "/",
        maxAge: 60 * 60 * 24 * 365,
        sameSite: "lax",
      });
    }
    return res;
  }

  // Honour the user's manual choice if it was already remembered.
  const cookieLocale = req.cookies.get(LOCALE_COOKIE)?.value;
  const remembered = cookieLocale && isLocale(cookieLocale) ? cookieLocale : null;
  const detected = remembered ?? pickLocaleFromAcceptLanguage(req.headers.get("accept-language"));

  const url = req.nextUrl.clone();
  url.pathname = `/${detected}${pathname === "/" ? "" : pathname}`;
  return NextResponse.redirect(url);
}

export const config = {
  // Skip Next internals + static assets so they aren't redirected.
  matcher: [
    "/((?!_next/|api/|favicon\\.|robots\\.txt|sitemap\\.xml|.*\\.(?:png|svg|jpg|jpeg|gif|webp|ico|txt|xml)).*)",
  ],
};

Dois detalhes aqui fazem toda a diferença. A verificação do Accept-Language faz um parse de verdade da lista separada por vírgulas do cabeçalho, não um startsWith — um navegador que envia "en-GB,tr;q=0.8" não deve ser tratado como turco só porque "tr" aparece em algum lugar. E o cookie é gravado em toda visita com prefixo de locale, não só na troca manual, então a próxima visita ao domínio puro cai no idioma que a pessoa leu por último. Sem isso, trocar de idioma parece quebrado: quem volta ao site depois de escolher turco explicitamente é jogado de volta para o palpite do Accept-Language.

Metadados por locale, por rota

Toda página no App Router pode exportar generateMetadata. Em um site bilíngue, três coisas precisam acontecer nessa função:

  1. Definir title e description no locale atual.
  2. Definir uma URL canônica apontando para este caminho exato (sem problemas de barra final, sem esquisitices de protocolo).
  3. Definir alternates hreflang para cada locale em que esta página existe, mais um x-default.

O Next.js torna isso trivial com o objeto alternates.languages. Este é o formato por rota:

Exemplo de códigoTypeScript
export async function generateMetadata({ params }: { params: { locale: string } }): Promise<Metadata> {
  if (!isLocale(params.locale)) return {};
  const seo = WORK_SEO[params.locale];
  return {
    title: seo.title,
    description: seo.description,
    alternates: {
      canonical: `/${params.locale}/work`,
      languages: {
        en: "/en/work",
        tr: "/tr/work",
        "x-default": "/en/work",
      },
    },
    openGraph: { title: seo.title, description: seo.description, type: "website" },
  };
}

Duas coisas que muita gente erra no hreflang:

  • Toda alternativa precisa apontar de volta. Se /en/work lista /tr/work como alternativa, /tr/work também precisa listar /en/work. Referências de volta ausentes fazem o Google ignorar o grupo hreflang inteiro.
  • x-default não é um locale. É a URL para usuários cujo idioma não corresponde a nenhum dos seus. Nós o apontamos para /en/work porque o inglês é o nosso fallback mais amplo — não porque o inglês seja mais importante.

Se você tem muitas páginas, escreva um pequeno helper que gere o objeto alternates para qualquer caminho. Nós fazemos isso inline porque temos só um punhado de páginas, e a versão explícita é mais fácil de depurar quando você está encarando o Search Console às 23h.

Sitemaps e hreflang no sitemap

O hreflang por rota nos metadados é metade do trabalho. O Google também lê hreflang do sitemap, e o sitemap é o sinal de maior peso porque cobre o site inteiro de uma vez. O App Router tem uma convenção nativa, o sitemap.ts; o nosso fica assim:

app/sitemap.tsTypeScript
// app/sitemap.ts
import type { MetadataRoute } from "next";
import { locales } from "@/lib/i18n";

const SITE = "https://neptay.com";
const PATHS = ["", "services", "work", "studio", "contact", "insights"] as const;

export default function sitemap(): MetadataRoute.Sitemap {
  const lastModified = new Date();
  return locales.flatMap((locale) =>
    PATHS.map((p) => ({
      url: `${SITE}/${locale}${p ? `/${p}` : ""}`,
      lastModified,
      changeFrequency: "monthly" as const,
      priority: p === "" ? 1 : 0.7,
      alternates: {
        languages: Object.fromEntries(
          locales.map((l) => [l, `${SITE}/${l}${p ? `/${p}` : ""}`])
        ),
      },
    }))
  );
}

Repare que o sitemap emite uma entrada por combinação (locale, caminho), e cada entrada lista todas as alternativas de locale. É repetitivo — de propósito. Os mecanismos de busca preferem rastrear pares explícitos a adivinhar a partir de padrões de URL.

Dados estruturados: um grafo base, extensões por rota

O JSON-LD do Schema.org é o segundo pilar de como o Google entende um site multilíngue. Nós o dividimos em duas camadas:

  1. Um grafo base (Organization + WebSite) injetado uma única vez pelo layout raiz. Ele declara a quem o site pertence, onde está o logo e em quais idiomas o site é publicado.
  2. Schemas por rota (CreativeWork, BreadcrumbList, Article, FAQPage) injetados por cada página. São pequenos blocos JSON-LD que descrevem o que é essa página específica.

Ambos têm inLanguage definido com o locale atual. Não pule isso — sem inLanguage, o Google precisa inferir o idioma a partir do texto da página, o que é lento e impreciso em páginas curtas.

lib/structured-data.tsTypeScript
// lib/structured-data.ts
export const organizationLd = {
  "@type": "Organization",
  "@id": `${SITE_URL}/#organization`,
  name: "Neptay",
  url: SITE_URL,
  logo: { "@type": "ImageObject", url: `${SITE_URL}/neptay-logo.png` },
  knowsLanguage: ["en", "tr"],
  areaServed: "Worldwide",
};

export function articleLd(input: ArticleInput) {
  return {
    "@context": "https://schema.org",
    "@type": "Article",
    headline: input.headline,
    inLanguage: input.locale,            // ← critical for bilingual sites
    datePublished: input.datePublished,
    author: { "@id": `${SITE_URL}/#organization` },
    publisher: { "@id": `${SITE_URL}/#organization` },
    // …
  };
}

O padrão de dicionário

As strings de tradução ficam em um único dicionário tipado, exportado como um mapa const indexado por locale. Isso está fora de moda — a recomendação atual é usar um framework como next-intl ou react-intl com o formato de mensagens ICU. Para um site pequeno, o dicionário tipado é mais rápido, menor e detecta traduções ausentes em tempo de compilação:

Exemplo de códigoTypeScript
export type Locale = "en" | "tr";
export const locales: Locale[] = ["en", "tr"];

export interface Dictionary {
  locale: Locale;
  nav: { services: string; work: string; studio: string; contact: string };
  // …
}

export const dictionaries: Record<Locale, Dictionary> = {
  en: { /* … */ },
  tr: { /* … */ },
};

Se falta a tradução em turco de uma string, o TypeScript reclama durante o build. Se uma chave é adicionada em inglês mas não em turco, o build falha. Isso vale muito quando você tem dois idiomas — significa que a única forma de publicar é manter os dois em sincronia.

O padrão começa a falhar por volta de vinte idiomas ou quando pessoas não técnicas precisam editar os textos. Nesse ponto, leve o dicionário para um CMS e aceite que as traduções vão ficar para trás. Para dois idiomas e um site de estúdio, TypeScript puro vence.

Renderização estática, cache na edge e por que isso importa para SEO

Tudo isso é neutro em desempenho no carregamento de uma única página. Onde faz diferença é em escala. Mantendo todas as páginas renderizadas estaticamente (sem consultas a banco de dados em tempo de execução, sem chamadas de tradução por requisição), cada locale de cada página vira um documento HTML armazenável em cache na edge. O tempo até o primeiro byte é o que a sua CDN disser — normalmente de 30 a 80 ms em qualquer lugar do planeta.

Isso importa para SEO por dois motivos: as Core Web Vitals dão muito peso a TTFB e LCP, e o rastreador do Google tem um orçamento. Uma página estática em cache na edge custa ao rastreador uma ida e volta barata; uma página renderizada no servidor pode custar dez vezes isso. O orçamento de rastreamento não é problema para um site de estúdio de cinco páginas, mas vira problema rápido em um blog multilíngue com duzentos artigos.

O que ainda melhoraríamos

Uma lista honesta do que ainda vale a pena fazer em um site bilíngue com App Router depois do básico:

  • Imagens OpenGraph por locale. Hoje a imagem OG é a mesma nos dois locales; o ideal é que o título na imagem também seja traduzido.
  • Detecção automática de divergência entre traduções. Uma etapa de CI que compara as chaves dos dicionários EN e TR e falha quando elas não batem. Fácil e valioso.
  • Um seletor de idioma que mantenha o usuário na mesma página se existir tradução e, se não existir, faça um fallback elegante (com um aviso).

Se você está definindo o escopo de um site bilíngue e quer uma segunda opinião sobre a arquitetura — ou quer que a gente o construa —, fale com a gente em hello@neptay.com.

Neptay Media & Technology Services

Vamos conversar

Planejando algo parecido?

Os métodos destes artigos são os mesmos que usamos em projetos de clientes — software, automação com IA, conteúdo, redes sociais e produção de lives. Conte o que você tem em mente; respondemos em até 24 horas.