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:
- 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.
- 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.
- 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
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.tsRepare 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:
- Se a URL já tem prefixo de locale, respeite-o. Grave um cookie para lembrar a escolha.
- Senão, se houver um cookie de uma visita anterior, redirecione para esse locale.
- Senão, se o cabeçalho Accept-Language corresponder a um locale que suportamos, redirecione para ele.
- 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.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:
- Definir title e description no locale atual.
- Definir uma URL canônica apontando para este caminho exato (sem problemas de barra final, sem esquisitices de protocolo).
- 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:
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.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:
- 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.
- 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.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:
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.