DEV Community

Cover image for How to Make Any React App Multilingual - Static UI + Dynamic Data
Surendra Manjhi
Surendra Manjhi

Posted on

How to Make Any React App Multilingual - Static UI + Dynamic Data

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
Enter fullscreen mode Exit fullscreen mode

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 ✅
Enter fullscreen mode Exit fullscreen mode

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: "..." }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

💡 Pro Tip: The API key never leaves the server. MangoProvider on the frontend only receives langs and defaultLang - 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.

App in English

Language switcher dropdown

Same app switched to Hindi

Same app switched to French


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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Windows users: If that doesn't work, run npm install lingo.dev@latest first, then use npx 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
Enter fullscreen mode Exit fullscreen mode

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
    },
  })
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

💡 Pro Tip: Use usePseudotranslator: true in 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"
Enter fullscreen mode Exit fullscreen mode

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",
})
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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> }
Enter fullscreen mode Exit fullscreen mode

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 }
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

💡 Pro Tip: Wrap the array in an object { posts } before passing to mg.translate(). This gives you proper TypeScript autocomplete on exclude paths - 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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:3000. You'll see the blog in English by default - posts fetched and translated server-side by Mango.dev.

English - baseline:

English posts rendered

Use the language switcher to switch languages. Both the static UI (labels, nav) and the dynamic post content switch instantly:

Language switcher highlighting active language

Hindi - static UI + post content both translated:

Switched to Hindi

French - same behaviour:

Switched to French


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
})
Enter fullscreen mode Exit fullscreen mode

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: [...] })
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 - MangoProvider never 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 exclude autocomplete to Translated<T> return types to setLang() 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


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)