Building Bilingual Next.js Sites with next-intl
Practical EN/VI i18n for marketing and portfolio sites — routing, messages, MDX content, and what to localize vs leave alone.
Nguyễn Đình Giang
· · 4 min read
Frontend developer in Ha Noi. I write about the Next.js, WordPress, and performance work I actually ship.
Shipping one language is easy. Shipping English and Vietnamese without duplicating your whole app is where most marketing sites get messy.
I use next-intl on my portfolio and the same mental model on client work when content needs both locales: shared UI structure, localized copy, and locale-aware metadata.
Routing that doesn’t fight SEO
I prefer localePrefix: "as-needed" — English at /, Vietnamese at /vi/.... That keeps the default locale clean for sharing while still giving crawlers clear language URLs and hreflang.
// i18n/routing.ts (simplified)
export const routing = {
locales: ["en", "vi"],
defaultLocale: "en",
localePrefix: "as-needed",
}Every public page should set locale-specific title, description, and Open Graph tags. A Vietnamese visitor sharing a link should see Vietnamese previews.
Split “chrome” copy from “content”
I keep two layers:
- UI strings — nav, buttons, empty states →
messages/en.json+messages/vi.json - Long-form content — blog MDX, project descriptions, case studies → separate files or locale overrides
const t = useTranslations("Nav")
return <Link href="/projects">{t("projects")}</Link>Don’t dump entire blog posts into JSON. JSON is for short, reusable strings. MDX is for articles.
What I actually translate
| Translate | Often leave as-is |
|---|---|
| Headlines, CTAs, form labels | Brand names, product names |
| Meta titles/descriptions | Code samples |
| Case study narrative | Tech stack labels (Next.js, GSAP) |
| Dates / “Present” wording | Logo alt text if brand-locked |
Vietnamese isn’t English with different words — sentence length changes layout. Always re-check wrapping on buttons and hero lines after translation.
Gotchas I’ve hit
- Hard-coded English in components bypasses the message files — search for string literals before launch
- Images with burned-in text need locale variants or redesign
- Dates and phone formatting differ; don’t assume
en-USeverywhere - Client components that read messages still need the right provider tree
Canonical and hreflang have to agree
hreflang does not save a page that tells Google the English URL is the real one. On this portfolio every /vi/... URL has to canonicalise to itself, while hreflang still advertises the en / vi / x-default pair. Google prefers the canonical when the two signals conflict, so a Vietnamese page whose canonical is /projects gets treated as a duplicate of the English page.
The metadata helper has to take the locale actually being served, not a hard-coded default:
const locale = await getLocale()
const localizedPath = locale === "en" ? path : `/${locale}${path}`
return {
alternates: {
canonical: localizedPath,
languages: {
en: absolute(path, "en"),
vi: absolute(path, "vi"),
"x-default": absolute(path, "en"),
},
},
openGraph: {
url: absolute(path, locale),
locale: locale === "vi" ? "vi_VN" : "en_US",
},
}Re-exporting generateMetadata from the English file into the /vi route is safe only when that function calls getLocale(). Otherwise the Vietnamese page inherits the English canonical.
Localize the title, not just the body
lang="vi" plus a translated body and an English <title> is a mixed page. Crawlers and Zalo/Facebook previews read the title first. Nav, forms, and meta strings live in messages/vi.json. Long posts and case studies live in MDX or a copy object, not in that JSON file.
Before I call a route done:
- Vietnamese title and description on
/vi/... - Self-referencing canonical
og:urlis the/viURL andog:localeisvi_VN- No hard-coded English sentence left in the component (search literals)
- Sitemap lists both the
/viURL and the English URL as their own<loc>, and only after the canonical is fixed
Images with burned-in text still need a second asset or no text. Dates and phone numbers format for the locale instead of assuming en-US.
English stays at / and Vietnamese at /vi on this site. The same split shows up in client work, for example the multilingual tour platform. Routing reference: next-intl routing.
The review pass I actually run
I do not treat “the JSON file exists” as done. After the strings land I open / and /vi on the same template and check four things in the rendered HTML, not in the editor. The pass is the same one I use before a client handoff.
<html lang>matches the body.lang="vi"with an English title is a fail even when the paragraphs are Vietnamese.- The canonical href matches the address bar. On
/vi/blogthat href contains/vi/blog. On/blogit does not. hreflangentries are reciprocal: the English page points at/vi/..., and the Vietnamese page points back.- Buttons and the hero still fit. Vietnamese runs longer, so a label that was one line in English wraps onto the nav or the mobile menu. I fix the wrap in layout, not by shortening the translation into nonsense.
Missing keys are the other failure. next-intl throws or falls back depending on config. I keep the key sets identical between en.json and vi.json and I diff the key paths before shipping, so a new CTA cannot go out in English on the Vietnamese site by accident. Brand names, code, and stack labels stay as-is. Everything a person reads in the chrome gets a Vietnamese line.
Final thought
i18n is a product decision, not just a library install. Decide early what must be bilingual, keep messages small, and treat Vietnamese QA as part of design review — not a last-day dump into Google Translate.
Need this kind of feature on your site? See hire details →
Get new posts by email
One email when I publish something new. No spam, unsubscribe anytime.

