|
Documentation
Book a DemoPlatform
PlatformMCPCLIAPIWorkflows
Guides
Changelog

Localization

  • Overview
  • Translation API
  • Web App Localization
  • Mobile App Localization
  • iOS with String Catalogs
  • Android with strings.xml
  • Emails Localization
  • Static Content (e.g. .md, .json)
  • Next.js with Markdoc
  • Rails with i18n

Workflows

  • Engine Setup with MCP
  • Jira Triage
  • CI/CD

Next.js App Router Localization with Markdoc

The Lingo.dev CLI translates Markdoc files and JSON UI-string catalogs through a configured localization engine. Markdoc is a Markdown-based authoring format with typed, React-backed custom tags – a good fit for Next.js App Router sites that mix long-form content with interactive components.

This guide walks through localizing a Next.js App Router site end-to-end: configuring the CLI, organizing per-locale content, rendering Markdoc in dynamic routes, and automating translations with the Lingo.dev GitHub App.

Demo repository

Clone or fork lingodotdev/markdoc-nextjs-localization-example to follow along. The repository contains a working Next.js App Router app with Markdoc content, a Lingo.dev CLI configuration, and a CI workflow.

How Next.js + Markdoc Localization Works#

Most Next.js App Router sites split localized content into two layers:

LayerWhat lives thereExample file
Long-form contentMarketing pages, docs, blog postssrc/content/en/pages/home.md
UI stringsNavbar labels, CTAs, button statessrc/content/en/ui.json

Routes live under src/app/[lang]/ and read the matching locale's files at request time. A middleware picks a default locale from the browser's Accept-Language header and redirects bare paths like / to /en (or the best match).

The CLI parses Markdoc files with frontmatter and custom tags intact, and handles the UI-string catalog as JSON. Both translate the delta through your localization engine and write per-locale files alongside the source.

Prerequisites#

1

Create a localization engine

Every CLI run sends content through a localization engine – the configuration that determines which LLM model, glossary, brand voice, and rules apply. Create one in the Lingo.dev dashboard and generate an API key for CI.

2

Verify Node.js

The CLI requires Node.js 22 or higher:

bash
node -v
3

Set up your Next.js project

Your project needs the App Router (src/app/) and a per-locale content directory. The demo repo uses one directory per locale under src/content/ (for example src/content/en/) with two sub-folders (pages/ and blog/) plus a ui.json file. See Next.js internationalization for the routing basics.

Organize Content#

Split content by role. Long-form pages and posts are authored in Markdoc; short UI strings live in JSON so components can load them directly.

text
src/content/
  en/                  # Source locale
    pages/home.md      # Long-form Markdoc
    blog/hello.md
    ui.json            # UI strings (navbar, CTAs, button states)
  es/                  # Target locales – generated by Lingo.dev
  fr/
  de/

Markdoc files support frontmatter for per-page metadata (title, description, date, author) and custom tags that render as React components. A minimal page looks like:

markdown
---
title: Author once in Markdoc, ship in every language.
description: An example Next.js App Router app that localizes Markdoc with Lingo.
---

{% inline-callout type="info" %}
This page is authored in Markdoc and translated by Lingo.dev.
{% /inline-callout %}

## Built from three pieces

Markdoc custom tags render as React components – even interactive ones.

Configure the CLI#

Install the CLI and sign in:

bash
npm install -g @lingo.dev/cli
lingo login

Then scaffold the config and link it to your engine:

bash
lingo init
lingo link

lingo init creates .lingo/config.json with your source and target locales plus the file patterns to translate; lingo link adds your orgId and engineId. Commit .lingo/config.json so every teammate and CI run shares the same configuration.

For this project the config declares two file patterns – one for Markdoc content, one for the UI-string catalog:

json
{
  "orgId": "org_...",
  "engineId": "eng_...",
  "sourceLocale": "en",
  "targetLocales": ["es", "fr", "de"],
  "files": [
    { "pattern": "src/content/en/pages/*.md" },
    { "pattern": "src/content/en/blog/*.md" },
    { "pattern": "src/content/en/ui.json" }
  ]
}

The locale segment in each path is substituted per target locale: src/content/en/pages/home.md becomes src/content/es/pages/home.md, and src/content/en/ui.json becomes src/content/de/ui.json. The source path must contain the locale code. Formats are auto-detected from the file extension, so Markdoc (.md) and JSON (.json) files need no explicit type. See Configuration and Formats for the details.

Single-file catalogs

The new CLI expects one file per locale, with the locale code in the path (as above). If your UI strings live in a single multi-locale JSON file, that layout (the former json-per-locale bucket) is not supported by the new CLI yet – keep it on the legacy CLI and follow the changelog for support. Splitting into one file per locale is the recommended path.

Render Markdoc in the App Router#

A typical dynamic route loads a document and renders the transformed tree. The demo repo exposes a small helper:

ts
// src/lib/markdoc.ts
export async function loadDoc(
  locale: Locale,
  collection: "pages" | "blog",
  slug: string,
) {
  const raw = await fs.readFile(
    path.join(process.cwd(), "src/content", locale, collection, `${slug}.md`),
    "utf8",
  );
  const ast = Markdoc.parse(raw);
  const frontmatter = ast.attributes.frontmatter
    ? parseFrontmatter(ast.attributes.frontmatter)
    : {};
  const content = Markdoc.transform(ast, { ...schema, variables: { frontmatter } });
  return { frontmatter, content };
}

The App Router page is a thin wrapper that pairs the doc with locale-specific UI strings:

tsx
// src/app/[lang]/page.tsx
export default async function Home({ params }: PageProps<"/[lang]">) {
  const { lang } = await params;
  const doc = await loadDoc(lang, "pages", "home");
  const { home } = await getMessages(lang);

  return (
    <main>
      <h1>{doc.frontmatter.title}</h1>
      {renderMarkdoc(doc.content)}
    </main>
  );
}

Custom Markdoc tags (callout, bento, blog-hero, etc.) are declared in markdoc.schema.ts and wired to React components under src/components/markdoc/. See the Markdoc schema docs for the full API.

Detect Locale in Middleware#

Next.js middleware inspects the request before a route renders. Use it to redirect bare paths to the best-matching locale based on the Accept-Language header:

ts
// src/middleware.ts
export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const hasLocale = locales.some(
    (locale) => pathname === `/${locale}` || pathname.startsWith(`/${locale}/`),
  );
  if (hasLocale) return;

  const locale = pickLocale(request); // parses Accept-Language
  const url = request.nextUrl.clone();
  url.pathname = `/${locale}${pathname === "/" ? "" : pathname}`;
  return NextResponse.redirect(url);
}

export const config = {
  matcher: ["/((?!_next|api|.*\\..*).*)", ],
};

Visitors land on /en, /es, /fr, or /de without ever typing the prefix.

Translate Locally#

After lingo login, run a push. On the first run – or after adding a new target locale – backfill everything:

bash
lingo push --backfill-missing

On later runs, just push the delta:

bash
lingo push

lingo push reads every file matching your patterns, identifies untranslated entries using the lock file (.lingo/lock.json, committed), translates the delta through your localization engine, waits for completion, and writes results into each target locale's directory. Frontmatter keys, Markdoc custom tags, and JSON shapes are preserved – only translatable text changes. To fetch translations produced elsewhere (for example by CI), run lingo pull.

To scope a run to specific files, pass a glob:

bash
lingo push "src/content/en/blog/*.md"

Automate in CI#

Install the Lingo.dev GitHub App and point it at your repository. It reads .lingo/config.json and the linked engineId server-side and opens a translation pull request whenever source content changes – no workflow file, no runner, no API-key secret, and no lock-file juggling.

Verify Before Deploy#

Use lingo check as a deployment gate to ensure no untranslated content ships to production. It exits with a non-zero status if any entries still need translation:

bash
lingo check

Add this as a separate CI step before your Next.js build:

yaml
- name: Verify translations
  run: lingo check
  env:
    LINGO_API_KEY: ${{ secrets.LINGO_API_KEY }}
- name: Build
  run: pnpm build

Next Steps#

Static Content Localization
Markdown, MDX, JSON, YAML, and more file formats
Web App Localization
UI string patterns across common web frameworks
CI/CD Workflows
GitHub App and self-hosted runner patterns
Glossaries
Lock brand names and technical terms from translation

Was this page helpful?

Max PrilutskiyMax Prilutskiy·Updated 12 days ago·6 min read