Most tutorials stop at translating buttons and labels. But what about the actual content - blog posts, product names, user bios fetched from your API? Thats where most multilingual setups break down.
In this post, I'll show you how to build a fully multilingual Next.js app - both the static UI and the dynamic data - using two tools that work really well together: Lingo.dev Compiler (an official Lingo.dev product) and Mango.dev (an open-source library I built on top of the Lingo.dev SDK).
1. The Problem
If you've ever tried to make a React app multilingual, you've probably reached for i18next. It works - but it comes with a lot of ceremony: translation key files, manual key management, and wrapping every string in a translation function. And even after all that, you still haven't solved the bigger problem.
Here's the gap:
Static UI Dynamic Server Data
────────────────────── ──────────────────────────────
"Submit" button Blog post titles from your DB
"Welcome back" label Product descriptions from API
Nav menu items User-generated content
Error messages Search results
✅ i18next handles this ❌ i18next can't help here
For dynamic data - posts fetched from a database, products from an API - you'd have to either:
- Store every piece of content in every language in your database (painful to maintain)
- Manually call a translation API for each field (no type safety, lots of boilerplate)
- Send untranslated data to the frontend and translate there
None of these feel right. So I started looking for a better way.
2. The Solution - Two-Layer Architecture
The fix is to treat static UI and dynamic data as two separate concerns, each with the right tool:
Layer 1 - Lingo.dev Compiler (static UI) Layer 2 - Mango.dev (dynamic data)
──────────────────────────────────────── ──────────────────────────────
buttons, labels, nav menus + API responses, blog posts
zero code changes needed + user-generated content
translated at build time + translated on the server
no JSON files, no translation keys + type-safe, exclude paths
Together = fully multilingual app ✅
Lingo.dev Compiler is a free, build-time translation system. You write your JSX exactly as you normally would - no translation keys, no wrapping strings in functions - and the compiler automatically detects text, generates AI translations, and injects them at build time. Zero runtime overhead.
Mango.dev is an open-source library I built for the Lingo.dev Hackathon. Its a thin, type-safe layer over the Lingo.dev SDK. You pass it any JavaScript object, tell it which fields to skip, and it returns the same object with every string field replaced by a multilingual map:
// input
{ id: 1, title: "Hello World", body: "Welcome to my blog." }
// output after mango.translate()
{
id: 1, // excluded - number
title: { en: "Hello World", hi: "हेलो वर्ल्ड", fr: "Bonjour le Monde" },
body: { en: "Welcome to my blog.", hi: "मेरे ब्लॉग में आपका स्वागत है।", fr: "..." }
}
The backend does the translation. The frontend just reads field[lang].
JSONPlaceholder / your API
│
▼
Next.js API Route
│
▼
mango.translate(data, { exclude: ["id", "userId"] })
│
Lingo.dev SDK (translation, retries)
│
▼
{ title: { en: "...", hi: "...", fr: ".." } }
│
▼
Frontend: fetch -> useMango() -> t() -> display
💡 Pro Tip: The API key never leaves the server.
MangoProvideron the frontend only receiveslangsanddefaultLang- no key, no risk.
3. What You'll Build
A multilingual blog app using Next.js where:
- Posts are fetched from JSONPlaceholder (mimicking a real server/database)
- Translated into English, Hindi, and French via Mango.dev on the server
- Static UI (nav, labels, buttons) translated automatically via Lingo.dev Compiler - no code changes needed
- Displayed with a language switcher on the frontend
Screenshots
📸 The screenshots below are from the live demo app. The demo uses Tailwind CSS and shadcn/ui for styling - but that's outside the scope of this post. This guide only covers the Lingo.dev Compiler + Mango.dev integration. You can style your app however you like.
4. Tech Stack
@lingo.dev/compiler -> static UI translations (build-time, zero code changes)
@mango.dev/core -> server-side dynamic data translation
@mango.dev/react -> MangoProvider, useMango(), t()
Next.js -> framework (API routes + React frontend)
5. Step-by-Step Implementation
Step 1 - Create a new Next.js app
If you're starting fresh, create a new Next.js project:
npx create-next-app@latest my-blog-app
cd my-blog-app
Choose TypeScript, App Router, and Tailwind CSS (optional) when prompted.
Step 2 - Install dependencies
# Static UI translations (build-time)
npm install @lingo.dev/compiler
# Dynamic data translations (server-side)
npm install @mango.dev/core @mango.dev/react
Step 3 - Setup Lingo.dev Compiler (static UI)
Authenticate with Lingo.dev - this saves your API key locally in one step:
npx lingo.dev@latest login
Windows users: If that doesn't work, run
npm install lingo.dev@latestfirst, then usenpx lingo login.
This opens a browser login and automatically writes LINGODOTDEV_API_KEY to your environment. If the browser auth is blocked, just add it manually to .env.local:
# .env.local
LINGODOTDEV_API_KEY=your_api_key_here
Find your key in Lingo.dev project settings. The free Hobby tier is enough.
Make your next.config.ts async and wrap it with withLingo:
// next.config.ts
import type { NextConfig } from "next"
import { withLingo } from "@lingo.dev/compiler/next"
const nextConfig: NextConfig = {}
export default async function (): Promise<NextConfig> {
return await withLingo(nextConfig, {
sourceRoot: "./app",
sourceLocale: "en",
targetLocales: ["hi", "fr"],
models: "lingo.dev",
dev: {
usePseudotranslator: true, // fake translations in dev - no API costs
},
})
}
Thats all the config you need. No translation keys, no JSON files, no wrapping strings. Just write your JSX normally and the compiler picks it up:
export function Nav() {
return (
<nav>
<a href="/">Home</a> {/* automatically translated at build time */}
<a href="/about">About</a> {/* same - zero code changes needed */}
</nav>
)
}
💡 Pro Tip: Use
usePseudotranslator: truein development - it generates fake translations instantly so you can see what gets picked up without any API costs. Turn it off only when you want to check real translation quality.
Step 4 - Setup providers (Mango.dev + Lingo.dev Compiler)
Define a shared LANGS constant - used on both the backend and frontend:
// lib/constants.ts
export const LANGS = ["en", "hi", "fr"] as const
export type Lang = typeof LANGS[number] // "en" | "hi" | "fr"
Initialize Mango on the server only:
// lib/mango.ts
import { Mango } from "@mango.dev/core"
import { LANGS } from "./constants"
// API key stays here - never sent to the client ✅
export const mg = new Mango({
api_key: process.env.LINGODOTDEV_API_KEY!,
langs: [...LANGS],
sourceLang: "en",
})
Wrap your app with LingoProvider and MangoProvider on the frontend:
// app/layout.tsx
import { LingoProvider } from "@lingo.dev/compiler/react"
import { MangoProvider } from "@mango.dev/react"
import { LANGS } from "@/lib/constants"
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<LingoProvider>
<MangoProvider langs={[...LANGS]} defaultLang="en">
<html>
<body>{children}</body>
</html>
</MangoProvider>
</LingoProvider>
)
}
MangoProvider only needs langs - no API key, completely safe for the client.
Step 5 - API Route (translate data server-side)
Define your data shape and the translated type:
// lib/data.ts
import type { Translated } from "@mango.dev/core"
import type { Lang } from "./constants"
export interface BlogPost {
id: number // exclude - not human text
userId: number // exclude - not human text
title: string // translate ✅
body: string // translate ✅
}
// What the translated blog post looks like
export type TranslatedBlogPost = Translated<BlogPost, "id" | "userId", Lang>
// -> { id: number, userId: number, title: Record<Lang, string>, body: Record<Lang, string> }
Now translate in the API route:
// app/api/posts/route.ts
import { NextResponse } from "next/server"
import { mg } from "@/lib/mango"
import type { BlogPost, TranslatedBlogPost } from "@/lib/data"
export async function GET() {
try {
// Fetch from JSONPlaceholder - mimics your real database/API
const res = await fetch("https://jsonplaceholder.typicode.com/posts?_limit=5")
const posts = await res.json() as BlogPost[]
// Translate all posts in one call - Mango.dev handles the entire array
const { posts: translatedPosts } = await mg.translate(
{ posts },
{
exclude: ["posts[].id", "posts[].userId"], // skip non-translatable fields
fast: true, // prioritize speed
}
)
return NextResponse.json({ data: translatedPosts as TranslatedBlogPost[], status: "ok" })
} catch (error) {
return NextResponse.json(
{ data: [], status: "error", message: String(error) },
{ status: 500 }
)
}
}
💡 Pro Tip: Wrap the array in an object
{ posts }before passing tomg.translate(). This gives you proper TypeScript autocomplete onexcludepaths - passing a raw array exposes array prototype methods instead.
Step 6 - Frontend (display + language switching)
Fetch the translated posts and render them using useMango():
// components/PostList.tsx
"use client"
import { useMango } from "@mango.dev/react"
import type { TranslatedBlogPost } from "@/lib/data"
export function BlogPostList({ posts }: { posts: TranslatedBlogPost[] }) {
const { t } = useMango() // t() reads field[currentLang]
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
<h2>{t(post.title)}</h2> {/* { en: "...", hi: "...", fr: "..." } -> string */}
<p>{t(post.body)}</p>
<span>#{post.id}</span> {/* plain number - excluded, no t() needed */}
<span>user: {post.userId}</span> {/* same - excluded */}
</li>
))}
</ul>
)
}
Add the language switcher - it syncs both Lingo.dev Compiler and Mango.dev together:
// components/LangSwitcher.tsx
"use client"
import { useMango } from "@mango.dev/react"
import { useLingoContext } from "@lingo.dev/compiler/react"
export function LangSwitcher() {
const { lang, setLang, langs } = useMango()
const { setLocale } = useLingoContext()
function handleSwitch(l: string) {
setLang(l) // switches Mango.dev dynamic content
setLocale(l) // switches Lingo.dev Compiler static UI
}
return (
<div>
{langs.map((l) => (
<button key={l} onClick={() => handleSwitch(l)} disabled={l === lang}>
{l}
</button>
))}
</div>
)
}
When you switch language both the static UI and dynamic content update at the same time. No extra API calls, just an instant re-render.
Step 7 - See it in action
Run your dev server:
npm run dev
Open http://localhost:3000. You'll see the blog in English by default - posts fetched and translated server-side by Mango.dev.
English - baseline:
Use the language switcher to switch languages. Both the static UI (labels, nav) and the dynamic post content switch instantly:
Hindi - static UI + post content both translated:
French - same behaviour:
6. TypeScript Superpowers
This is where Mango.dev really shines compared to doing it manually.
Autocomplete on exclude paths
exclude accepts Paths<T> - every valid dot-notation path extracted from your type. TypeScript autocompletes and catches typos at compile time:
await mg.translate({ posts }, {
exclude: ["posts[].id", "posts[].userId"] // ✅ autocompleted
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// TypeScript error if path doesn't exist
})
Translated<T> return type
The return type of mg.translate() is automatically inferred. No manual type casting needed:
// TypeScript knows exactly what this is:
// { id: number, title: Record<"en"|"hi"|"fr", string>, body: Record<...> }
const { posts: translatedPosts } = await mg.translate({ posts }, { exclude: [...] })
Use it to type your component props - gives you full autocomplete on t() calls:
type TranslatedBlogPost = Translated<BlogPost, "id" | "userId", Lang>
function PostCard({ post }: { post: TranslatedBlogPost }) {
const { t } = useMango()
return <h1>{t(post.title)}</h1> // ✅ TypeScript knows post.title is a multilingual map
}
Type-safe language switching
setLang() only accepts languages defined in your provider - TypeScript prevents invalid switches:
setLang("hi") // ✅
setLang("de") // ❌ TypeScript error - "de" not in LANGS
7. Key Takeaways
- Lingo.dev Compiler + Mango.dev = complete multilingual solution - one for static UI at build time, one for dynamic data at runtime. Zero overlap.
- Lingo.dev Compiler needs zero code changes - no translation keys, no JSON files, no key management. Your JSX stays clean.
-
API key stays on the server -
MangoProvidernever needs it. Your key is safe. - One call translates entire data structures - nested objects, arrays, mixed shapes. Mango.dev handles it all.
-
Type safety end-to-end - from
excludeautocomplete toTranslated<T>return types tosetLang()validation. Catch mistakes at compile time, not runtime. - Language switching is instant - no extra API calls after the initial translation. Static UI switches via Lingo.dev Compiler context, dynamic content via Mango.dev context.
Mango.dev was built for the Lingo.dev Hackathon as an open-source DX layer over the Lingo.dev SDK, making type-safe multilingual data translation feel natural in TypeScript. Lingo.dev Compiler handles the rest - your static UI - with zero code changes.
References
- Lingo.dev - Translation platform powering both tools
- Lingo.dev Compiler Docs - Official docs for build-time static UI translation
- Lingo.dev Compiler Quick Start - Get up and running in 5 minutes
- Mango.dev on GitHub - Source code, packages, and demo app
- Mango.dev Live Demo - See it in action
Thanks for reading! If you build something with this stack or have questions, feel free to reach out - I'd love to see what you make.
🐦 X (Twitter): @manjhss
#nextjs #react #typescript #i18n #multilingual #opensource #fullstack #lingodotdev #mangodotdev




Top comments (0)