Lingo.dev 的 CLI 可通过已配置的 localization engine,翻译 Markdoc 文件和 JSON UI 文案目录。Markdoc 是一种基于 Markdown 的内容编写格式,支持类型化、由 React 驱动的自定义标签,非常适合那些将长篇内容与交互式组件结合在一起的 Next.js App Router 站点。
本指南将带你完整走通 Next.js App Router 站点本地化的全流程:配置 CLI、按语言区域组织内容、在动态路由中渲染 Markdoc,以及借助 Lingo.dev GitHub App 实现翻译自动化。
示例仓库
克隆或 fork lingodotdev/markdoc-nextjs-localization-example,边看边做。这个仓库包含一个可直接运行的 Next.js App Router 应用、Markdoc 内容、Lingo.dev CLI 配置以及一套 CI 工作流。
Next.js + Markdoc 本地化如何运作#
大多数 Next.js App Router 站点都会把本地化内容拆成两层:
| 层级 | 内容类型 | 示例文件 |
|---|---|---|
| 长篇内容 | 营销页面、文档、博客文章 | src/content/en/pages/home.md |
| UI 文案 | 导航栏标签、CTA、按钮状态 | src/content/en/ui.json |
路由位于 src/app/[lang]/ 下,并在请求到来时读取对应语言区域的文件。中间件会根据浏览器的 Accept-Language 请求头选择默认语言区域,并将 / 这类不带前缀的路径重定向到 /en(或最佳匹配项)。
CLI 会解析带有 frontmatter 的 Markdoc 文件,并完整保留自定义标签;UI 文案目录则按 JSON 处理。两类内容都会通过你的本地化引擎翻译增量变更,并按语言区域将文件写回源文件旁。
准备工作#
创建 localization engine
确认 Node.js 版本
CLI 需要 Node.js 22 或更高版本:
node -v设置 Next.js 项目
你的项目需要启用 App Router(src/app/),并按语言区域拆分内容目录。演示仓库的做法是在 src/content/ 下为每个语言区域建立一个目录(例如 src/content/en/),其中包含两个子文件夹(pages/ 和 blog/)以及一个 ui.json 文件。路由基础可参考 Next.js internationalization。
组织内容#
按内容职责拆分最清晰:长篇页面和文章使用 Markdoc 编写,简短的 UI 文案则放在 JSON 中,方便组件直接加载。
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 文件支持通过 frontmatter 定义页面元数据(标题、描述、日期、作者),也支持可渲染为 React 组件的自定义标签。一个最小示例如下:
---
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.配置 CLI#
安装 CLI 并登录:
npm install -g @lingo.dev/cli
lingo login接着生成配置,并将其关联到你的引擎:
lingo init
lingo linklingo init 会创建 .lingo/config.json,写入源语言区域、目标语言区域以及待翻译的文件模式;lingo link 会添加你的 orgId 和 engineId。记得提交 .lingo/config.json,这样团队成员和 CI 每次运行都会使用同一套配置。
在这个项目中,配置声明了两个文件模式:一个用于 Markdoc 内容,另一个用于 UI 文案目录:
{
"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" }
]
}每条路径中的语言区域片段都会按目标语言区域替换:src/content/en/pages/home.md 会变成 src/content/es/pages/home.md,src/content/en/ui.json 会变成 src/content/de/ui.json。源路径中必须包含语言区域代码。格式会根据文件扩展名自动识别,因此 Markdoc(.md)和 JSON(.json)文件都无需显式指定类型。详情可参见 Configuration 和 Formats。
单文件目录
新版 CLI 要求每个语言区域对应一个文件,且路径中必须带有语言区域代码(如上所示)。如果你的 UI 文案仍保存在单个多语言区域 JSON 文件中,这种布局(也就是之前的 json-per-locale bucket)目前还不受新版 CLI 支持——请继续使用 legacy CLI,并关注更新日志以获取支持进展。推荐做法是拆分为每个语言区域一个文件。
在 App Router 中渲染 Markdoc#
典型的动态路由会加载文档并渲染转换后的内容树。示例仓库提供了一个简洁的辅助函数:
// 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 };
}App Router 页面本身只是一个轻量封装,用来将文档与特定语言区域的 UI 文案配对:
// 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>
);
}自定义 Markdoc 标签(callout、bento、blog-hero 等)在 markdoc.schema.ts 中声明,并绑定到 src/components/markdoc/ 下的 React 组件。完整 API 请参阅 Markdoc schema docs。
在中间件中检测语言区域#
Next.js 中间件会在路由渲染前检查请求。你可以利用它根据 Accept-Language 请求头,将不带前缀的路径重定向到最佳匹配的语言区域:
// 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|.*\\..*).*)", ],
};访客无需手动输入前缀,也能直接落到 /en、/es、/fr 或 /de。
本地翻译#
执行完 lingo login 后,运行一次 push。首次运行时,或新增目标语言区域后,需要先回填全部内容:
lingo push --backfill-missing后续运行时,只需推送增量:
lingo pushlingo push 会读取所有匹配文件模式的文件,借助已提交的 lock file(.lingo/lock.json)识别尚未翻译的条目,通过你的本地化引擎翻译增量内容,等待完成后,再将结果写入各个目标语言区域目录。Frontmatter 键、Markdoc 自定义标签和 JSON 结构都会原样保留——变化的只有可翻译文本。若要拉取在其他地方生成的翻译(例如 CI),请运行 lingo pull。
如果只想针对特定文件运行,可传入一个 glob:
lingo push "src/content/en/blog/*.md"在 CI 中实现自动化#
安装 Lingo.dev GitHub App 并将其连接到你的仓库。它会在服务端读取 .lingo/config.json 和已关联的 engineId,并在源内容发生变更时自动创建翻译 Pull Request——无需工作流文件、无需 runner、无需 API key 密钥,也不用来回处理 lock file。
部署前校验#
可将 lingo check 作为部署前的门禁,确保未翻译内容不会进入生产环境。只要还有条目待翻译,它就会以非零状态码退出:
lingo check可在 Next.js 构建前,将这一步单独加入 CI:
- name: Verify translations
run: lingo check
env:
LINGO_API_KEY: ${{ secrets.LINGO_API_KEY }}
- name: Build
run: pnpm build