Lingo.dev CLI는 설정된 로컬라이제이션 엔진을 통해 Markdoc 파일과 JSON UI 문자열 카탈로그를 번역합니다. Markdoc은 타입이 지정된 React 기반 커스텀 태그를 지원하는 Markdown 기반 저작 포맷으로, 장문 콘텐츠와 인터랙티브 컴포넌트를 함께 사용하는 Next.js App Router 사이트에 특히 잘 어울립니다.
이 가이드는 Next.js App Router 사이트를 처음부터 끝까지 로컬라이즈하는 과정을 안내합니다. CLI 설정, 로캘별 콘텐츠 구성, 동적 라우트에서의 Markdoc 렌더링, GitHub Actions를 활용한 번역 자동화까지 모두 다룹니다.
데모 리포지토리
함께 따라 해보려면 lingodotdev/markdoc-nextjs-localization-example를 클론하거나 포크하세요. 이 리포지토리에는 Markdoc 콘텐츠가 포함된 동작하는 Next.js App Router 앱, Lingo.dev CLI 설정, GitHub Actions 워크플로가 담겨 있습니다.
Next.js + Markdoc 로컬라이제이션 작동 방식#
대부분의 Next.js App Router 사이트는 로컬라이즈된 콘텐츠를 두 레이어로 나눠 관리합니다:
| 레이어 | 포함되는 내용 | 예시 파일 |
|---|---|---|
| 장문 콘텐츠 | 마케팅 페이지, 문서, 블로그 게시물 | src/content/[locale]/pages/home.md |
| UI 문자열 | 내비게이션 바 레이블, CTA, 버튼 상태 | src/content/[locale]/ui.json |
라우트는 src/app/[lang]/ 아래에 위치하며, 요청 시점에 해당 로캘의 파일을 읽어옵니다. 미들웨어는 브라우저의 Accept-Language 헤더를 기준으로 기본 로캘을 선택하고, / 같은 기본 경로를 /en(또는 가장 적합한 로캘)로 리디렉션합니다.
CLI의 markdoc 버킷은 frontmatter와 커스텀 태그를 그대로 유지한 채 Markdoc 파일을 파싱하고, json 버킷은 UI 문자열 카탈로그를 처리합니다. 두 버킷 모두 변경된 부분만 로컬라이제이션 엔진으로 번역한 뒤, 소스 파일 옆에 로캘별 파일로 저장합니다.
사전 준비#
로컬라이제이션 엔진 만들기
CLI를 실행할 때마다 모든 콘텐츠는 로컬라이제이션 엔진을 거칩니다. 이 설정은 어떤 LLM 모델, glossary, 브랜드 보이스, instructions를 적용할지 결정합니다. Lingo.dev dashboard에서 엔진을 생성하고 API key를 발급하세요.
Node.js 확인
CLI를 사용하려면 Node.js 18 이상이 필요합니다:
node -vNext.js 프로젝트 설정
프로젝트에는 App Router(src/app/)와 로캘별 콘텐츠 디렉터리가 필요합니다. 데모 리포지토리에서는 src/content/[locale]/ 아래에 두 개의 하위 폴더(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 파일은 페이지별 메타데이터(title, description, date, author)를 위한 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 설정#
프로젝트 루트에 i18n.json 파일을 만드세요. Markdoc 콘텐츠용 하나, UI 문자열 카탈로그용 하나까지 총 두 개의 버킷을 선언합니다:
{
"$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"]
}
}
}[locale] 플레이스홀더는 설정된 각 로캘 코드로 해석됩니다. source: "en"를 사용하면 CLI는 src/content/en/에서 읽고, 번역된 파일을 src/content/es/, src/content/fr/, src/content/de/에 저장합니다.
단일 파일 카탈로그
UI 문자열이 로캘별 개별 파일이 아니라 하나의 다중 로캘 JSON 파일에 들어 있다면 json-per-locale 버킷 유형을 사용하세요. 전체 버킷 유형 목록은 Static Content Localization에서 확인할 수 있습니다.
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로 이동하게 됩니다.
로컬에서 번역하기#
API 키를 설정한 뒤 CLI를 실행하세요:
export LINGO_API_KEY="your-api-key"
npx lingo.dev@latest runCLI는 버킷 패턴과 일치하는 모든 파일을 읽고, lockfile을 사용해 아직 번역되지 않은 항목을 식별한 다음, 변경분만 로컬라이제이션 엔진으로 번역해 각 대상 로캘 디렉터리에 결과를 저장합니다. frontmatter 키, Markdoc 커스텀 태그, JSON 구조는 그대로 유지되며 번역 가능한 텍스트만 바뀝니다.
개발 중 특정 로캘만 대상으로 하려면 다음과 같이 실행하세요:
npx lingo.dev@latest run --target-locale esGitHub Actions로 자동화하기#
푸시할 때마다 번역이 실행되도록 .github/workflows/translate.yml에 워크플로 파일을 추가하세요:
번역 결과를 main에 직접 커밋하는 방식입니다. 마찰이 거의 없어 소규모 팀에 적합합니다:
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 }}GitHub 리포지토리의 Settings > Secrets and variables > Actions에 API 키를 LINGODOTDEV_API_KEY으로 저장하세요.
배포 전 검증#
번역되지 않은 콘텐츠가 프로덕션에 배포되지 않도록 --frozen 플래그를 배포 게이트로 사용하세요. 번역이 필요한 항목이 하나라도 있으면 CLI는 0이 아닌 상태 코드로 종료합니다:
npx lingo.dev@latest run --frozen이 단계는 Next.js 빌드 전에 별도의 CI 단계로 추가하세요:
- name: Verify translations
run: npx lingo.dev@latest run --frozen
- name: Build
run: pnpm build