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:
| Layer | What lives there | Example file |
|---|---|---|
| Long-form content | Marketing pages, docs, blog posts | src/content/en/pages/home.md |
| UI strings | Navbar labels, CTAs, button states | src/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#
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.
Verify Node.js
The CLI requires Node.js 22 or higher:
node -vSet 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.
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:
---
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:
npm install -g @lingo.dev/cli
lingo loginThen scaffold the config and link it to your engine:
lingo init
lingo linklingo 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:
{
"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:
// 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:
// 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:
// 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:
lingo push --backfill-missingOn later runs, just push the delta:
lingo pushlingo 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:
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:
lingo checkAdd this as a separate CI step before your Next.js build:
- name: Verify translations
run: lingo check
env:
LINGO_API_KEY: ${{ secrets.LINGO_API_KEY }}
- name: Build
run: pnpm build