|
Knowledgebase
EnterprisePlatform
PlatformAPIReact (MCP)CLIIntegrationsReact (Lingo Compiler)
Alpha
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

Workflows

  • Engine Setup with MCP
  • CI/CD

Next.js App Router Localization with Markdoc

Max PrilutskiyMax Prilutskiy·Updated 1 day ago·5 min read

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 GitHub Actions.

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 GitHub Actions 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/[locale]/pages/home.md
UI stringsNavbar labels, CTAs, button statessrc/content/[locale]/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's markdoc bucket parses Markdoc files with frontmatter and custom tags intact, and the json bucket handles the UI-string catalog. 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 instructions apply. Create one in the Lingo.dev dashboard and generate an API key.

2

Verify Node.js

The CLI requires Node.js 18 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 src/content/[locale]/ 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.
---

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

## Built from three pieces

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

Configure the CLI#

Create an i18n.json file in your project root. Declare two buckets – one for Markdoc content, one for the UI-string catalog:

json
{
  "$schema": "https://lingo.dev/schema/i18n.json",
  "version": "1.15",
  "locale": {
    "source": "en",
    "targets": ["es", "fr", "de"]
  },
  "buckets": {
    "markdoc": {
      "include": [
        "src/content/[locale]/pages/*.md",
        "src/content/[locale]/blog/*.md"
      ]
    },
    "json": {
      "include": ["src/content/[locale]/ui.json"]
    }
  }
}

The [locale] placeholder resolves to each configured locale code. With source: "en", the CLI reads from src/content/en/ and writes translated files into src/content/es/, src/content/fr/, and src/content/de/.

Single-file catalogs

If your UI strings live in a single multi-locale JSON file instead of one file per locale, use the json-per-locale bucket type. See Static Content Localization for the full list of bucket types.

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#

Set your API key and run the CLI:

bash
export LINGO_API_KEY="your-api-key"
npx lingo.dev@latest run

The CLI reads every file matching your bucket patterns, identifies untranslated entries using the lockfile, translates the delta through your localization engine, and writes results into each target locale's directory. Frontmatter keys, Markdoc custom tags, and JSON shapes are preserved – only translatable text changes.

To target a specific locale during development:

bash
npx lingo.dev@latest run --target-locale es

Automate with GitHub Actions#

Add a workflow file at .github/workflows/translate.yml to translate on every push:

Translations commit directly to main – zero friction, ideal for small teams:

yaml
name: Translate
on:
  push:
    branches: [main]
permissions:
  contents: write
jobs:
  translate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Lingo.dev
        uses: lingodotdev/lingo.dev@main
        with:
          api-key: ${{ secrets.LINGODOTDEV_API_KEY }}

Store your API key as LINGODOTDEV_API_KEY in Settings > Secrets and variables > Actions in your GitHub repository.

Verify Before Deploy#

Use the --frozen flag as a deployment gate to ensure no untranslated content ships to production. The CLI exits with a non-zero status if any entries need translation:

bash
npx lingo.dev@latest run --frozen

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

yaml
- name: Verify translations
  run: npx lingo.dev@latest run --frozen
- name: Build
  run: pnpm build

Next Steps#

Static Content Localization
Markdown, MDX, JSON, YAML, and more bucket types
Web App Localization
UI string patterns across common web frameworks
CI/CD Workflows
GitHub Actions, GitLab CI, Bitbucket Pipelines patterns
Glossaries
Lock brand names and technical terms from translation

Was this page helpful?