The constraint
I wanted a portfolio that a Portuguese-speaking recruiter and an English-speaking engineering manager could both land on and feel at home in. What I did not want was /en/… and /pt/… route trees, a middleware translating URLs, or a runtime that fetches JSON dictionaries.
The site is small. The audience toggles languages rarely. Optimizing for that reality removes an entire category of framework.
What I built
A fully typed Translations object, indexed by Locale = "en" | "pt", provided through a single React context. A useTranslation() hook returns the current dictionary. Switching locales flips the context; nothing navigates, nothing refetches.
export type Translations = {
nav: { about: string; projects: string /* … */ }
hero: { viewMyWork: string }
// …
}
export type Locale = "en" | "pt"
Each locale file exports a Translations object. TypeScript refuses to compile if either locale drifts from the shape. That single guarantee is worth more than any lint rule I could bolt on later.
Trade-offs I accepted
Every architectural choice is a trade, the question is whether you are trading knowingly.
- No URL-based locale. SEO gets a single canonical per page instead of
hreflang alternates. Fine for a personal site; not fine for a marketplace.
- Full dictionary shipped to the client. ~15 KB gzipped, and it is used on every page. A larger site would need code-splitting per route.
- No inflection/pluralization engine. Portuguese has grammatical gender; I write both variants by hand where needed. When copy grows, this will hurt.
The MDX escape hatch
Long-form content, like this blog, lives as MDX files under src/content/blog/{slug}/{en,pt}.mdx. A synchronous loader reads both variants at build time, validates that frontmatter matches, and hands the compiled trees to a client component that toggles which one is visible based on the current locale.
The rendered HTML doubles for each post. For 200–800 word articles, that costs nothing meaningful. If a post ever grows past a few kilobytes of prose, I will split the render and accept a route-level refresh on locale change.
What I would do differently at scale
If this were a product with hundreds of pages and dozens of authors, I would reach for next-intl or next-i18next on day one. Not because the pattern above breaks, it does not, but because coordinating translations across a team needs tooling I would otherwise reinvent.
For one engineer, one audience, and two languages, the typed-context pattern earns its place.
A restrição
Eu queria um portfólio no qual uma recrutadora que fala português e um gerente de engenharia que fala inglês pudessem cair e se sentir em casa. O que eu não queria eram árvores de rotas /en/… e /pt/…, um middleware traduzindo URLs, ou um runtime buscando dicionários JSON.
O site é pequeno. A audiência troca de idioma raramente. Otimizar para essa realidade elimina uma categoria inteira de framework.
O que eu construí
Um objeto Translations totalmente tipado, indexado por Locale = "en" | "pt", disponibilizado através de um único contexto React. Um hook useTranslation() retorna o dicionário atual. Trocar de locale muda o contexto; nada navega, nada é refeito.
export type Translations = {
nav: { about: string; projects: string /* … */ }
hero: { viewMyWork: string }
// …
}
export type Locale = "en" | "pt"
Cada arquivo de locale exporta um objeto Translations. O TypeScript se recusa a compilar se algum dos dois desviar do formato. Essa única garantia vale mais do que qualquer regra de lint que eu pudesse adicionar depois.
Trade-offs que aceitei
Toda escolha arquitetural é uma troca, a pergunta é se você está trocando de forma consciente.
- Sem locale na URL. SEO ganha um canonical único por página em vez de alternates com
hreflang. Ok para um site pessoal; não ok para um marketplace.
- Dicionário inteiro entregue ao cliente. ~15 KB gzipped, e é usado em toda página. Um site maior precisaria de code-splitting por rota.
- Sem motor de flexão/pluralização. Português tem gênero gramatical; eu escrevo as duas variantes na mão onde necessário. Quando o texto crescer, isso vai doer.
A rota de fuga com MDX
Conteúdo longo, como este blog, vive como arquivos MDX em src/content/blog/{slug}/{en,pt}.mdx. Um loader síncrono lê as duas variantes em tempo de build, valida que o frontmatter combina, e entrega as árvores compiladas para um componente cliente que alterna qual delas está visível de acordo com o locale atual.
O HTML renderizado dobra para cada post. Para artigos de 200 a 800 palavras, isso não custa nada relevante. Se algum post crescer além de alguns kilobytes de prosa, eu vou dividir a renderização e aceitar um refresh de rota na troca de idioma.
O que eu faria diferente em escala
Se isso fosse um produto com centenas de páginas e dezenas de autores, eu iria de next-intl ou next-i18next no primeiro dia. Não porque o padrão acima quebre, ele não quebra, mas porque coordenar traduções entre um time exige ferramentas que eu acabaria reinventando.
Para um engenheiro, uma audiência, e dois idiomas, o padrão de contexto tipado se paga.