Next.js Blog Automation: Integrating DeepL for i18n
Documenting the full engineering practice and lessons learned while building an automated i18n pipeline for a personal Next.js blog using DeepL API, Contentlayer, and GitHub Actions.
- 13 min read
- 2026.10.16
By adopting isolated document compilation, incremental SHA-256 hash caching, and AST tag protection mechanisms, we built an automated translation pipeline for UI strings and article bodies within DeepL's free tier.
Next.js Blog Automation: Integrating DeepL for i18n
Why this matters
For a personal engineering blog centered on systems engineering, agent tooling, and developer workflows, keeping content confined exclusively to a Chinese audience is a missed opportunity—many in-depth debugging walkthroughs and architectural retrospectives provide valuable references for developers and open-source communities worldwide. However, when tackling internationalization (i18n), most personal sites face an awkward dilemma:
- Manual Bilingual Maintenance: Drafting a Chinese post and subsequently hand-writing an English version. As post count grows and past posts undergo iterative revisions, the two sets of MDX files inevitably drift apart, culminating in unmanageable maintenance debt.
- Client-side Translation Widgets: Relying on client-side JavaScript widgets such as Google Translate. Not only do these introduce jarring cumulative layout shifts (CLS), but more crucially, search engine crawlers cannot index statically rendered translated content. They fail to produce distinct English routes, proper
hreflangtags, or sitemaps, offering zero utility for technical SEO or Generative Engine Optimization (GEO).
This article documents the complete engineering journey of integrating the DeepL Free API into this site to establish a fully automated bilingual pipeline: covering UI string extraction, MDX AST syntax isolation and protection, Contentlayer isolated document architecture, SHA-256 incremental hash caching, and automated PR delivery through GitHub Actions. All implemented well within DeepL’s monthly 500,000-character free tier, delivering a zero-overhead publishing workflow.
Core Architecture Design
Before writing translation scripts, the primary architectural question is: In what format should the translated English text reside in the codebase?
1. Architectural Isolation: Protecting the Knowledge Graph and Canonical Routes
This blog relies on Contentlayer to build its static content model. Articles are not merely isolated standalone pages; they actively participate in site-wide Knowledge Graph computation, territory categorization, and bidirectional cross-referencing with project case studies.
Treating English articles naively as standard Blog documents (for instance, dropping files into data/blog/en/) would introduce destructive side effects:
- Doubling the
allBlogscount, which in turn doubles the nodes and edges in the graph, distorting its topological structure; - Corrupting topic article counts and overall territory statistics;
- Risking namespace collisions and URL pollution across existing Chinese routes.
Consequently, we established an independent document type, BlogTranslation, in contentlayer.config.ts:
// contentlayer.config.ts
export const BlogTranslation = defineDocumentType(() => ({
name: 'BlogTranslation',
filePathPattern: 'blog-i18n/**/*.mdx',
contentType: 'mdx',
fields: {
title: { type: 'string', required: true },
date: { type: 'date', required: true },
tags: { type: 'list', of: { type: 'string' }, default: [] },
summary: { type: 'string' },
tldr: { type: 'string' },
sourceHash: { type: 'string' },
translatedAt: { type: 'string' },
},
computedFields: {
slug: {
type: 'string',
resolve: (doc) => doc._raw.flattenedPath.split('/').slice(2).join('/'),
},
locale: {
type: 'string',
resolve: (doc) => doc._raw.flattenedPath.split('/')[1] || 'en',
},
},
}));
English translations reside strictly under data/blog-i18n/en/*.mdx, generating an independent allBlogTranslations collection:
allBlogsRemains Pristine: Contains only the canonical Chinese manuscripts, ensuring the knowledge graph, tag relation models, and routing hierarchy stay completely clean;- On-Demand Hydration: When accessing
/en/blog/[slug], the page fetches the English title, summary, and MDX body fromallBlogTranslations; if a translation is missing, it falls back gracefully to Chinese with a helpful banner.
2. Dual Automated Pipelines: Differentiating UI Strings and Article Content
Translation requirements fall into two categories with vastly different data characteristics and translation strategies:
| Dimension | UI Strings (messages/*.json) | Blog Content (data/blog/*.mdx) |
|---|---|---|
| Data Shape | Structured nested JSON key-value pairs | Rich text MDX with Frontmatter and JSX |
| Variable Protection | ICU syntax placeholders (e.g., {count, plural, ...}) | Inline code, code blocks, LaTeX formulas, HTML wrappers |
| Incremental Strategy | Key diffing (translating only missing keys in English JSON) | SHA-256 hash comparison (zero API calls if unchanged) |
| Execution Script | scripts/translate-messages.mjs | scripts/translate-blog.mjs |
UI String Protection
UI strings frequently contain interpolation tokens for next-intl. Before querying DeepL, the script replaces {param} tokens with special un-translatable markers, which are accurately restored after translation, ensuring variable identifiers remain intact.
SHA-256 Content Hash Caching
In the frontmatter of data/blog-i18n/en/*.mdx, the script persists the sourceHash of the Chinese original. On every run:
const currentHash = crypto.createHash('sha256').update(chineseBody).digest('hex');
if (existingTranslation?.sourceHash === currentHash) {
// Hash match indicates original manuscript has not changed; skip
continue;
}
This guarantees that only newly drafted articles or modified paragraphs incur DeepL calls. Unmodified posts consume zero API quota during recurring maintenance or routine CI runs.
Technical Retrospective: Resolving MDX AST Syntax Breakage
Feeding lengthy Markdown/MDX into a machine translation API often fails not because of awkward phrasing, but because syntax degradation causes compilation to crash. Across our 25-post migration, two main challenges emerged:
Issue 1: YAML Frontmatter Indentation Corruption
In many articles, summary or tldr values span multiple lines using folded scalar syntax (>-). Simple regex-based single-line replacement frequently strips or misaligns indentations, causing the YAML parser to throw:
YAMLException: All mapping items must start at the same column
Resolution: Eliminate regex string manipulation entirely in favor of object-level serialization and parsing with gray-matter:
const parsed = matter(rawContent);
const translatedTitle = await translateText(parsed.data.title);
const translatedSummary = await translateText(parsed.data.summary);
const translatedTldr = await translateText(parsed.data.tldr);
const output = matter.stringify(translatedBody, {
...parsed.data,
title: translatedTitle,
summary: translatedSummary,
tldr: translatedTldr,
sourceHash: currentHash,
translatedAt: new Date().toISOString(),
});
This eliminates indentation discrepancies and escaping errors at the source.
Issue 2: Breaking JSX Container Tags and Paragraph Boundaries
MDX parsers (acorn / remark) enforce rigorous boundaries between plain text paragraphs and JSX elements. DeepL exhibited two disruptive behaviors when processing custom components or HTML elements:
- Collapsing Repeated Closing Tags: Adjacent closing tags like
</div></div></div>were occasionally treated as redundant whitespace and collapsed into fewer tags; - Splitting Opening and Closing Tags across Paragraphs: For example, table header cells:
were sometimes returned as:<th className="px-4 py-2">Scenario</th>
With opening tags isolated on newlines and closing tags attached to text, acorn would throw:<th className="px-4 py-2">Scenario</th>Expected closing tag before end of paragraph.
Resolution: A dual-layer isolation and compaction strategy:
- Pre-translation Block Tag Isolation (
separateBlockTags): Before dispatching text to DeepL, standalone HTML/JSX tag lines have blank lines inserted around them, ensuring they are treated as standalone non-translatable blocks and preserved verbatim; - Post-translation Compact Sanitization: Patterns such as
<tag>\n text</tag>are collapsed back into single-line representations, cleaning up stray newlines inside containers:restored = restored.replace( /<([a-z][a-z0-9]*)\s*([^>]*)>\s*\n\s*([^<\n]+)\s*<\/\1>/gi, '<$1 $2>$3</$1>', ); - CI Validation Guardrail: In GitHub Actions, an explicit
npx contentlayer2 buildcheck runs before committing PR changes. Any malformed MDX output is caught and stopped immediately within the runner.
Full-Stack UI and Listing Localization
A robust i18n implementation should not stop at article bodies. If visiting /en displays a Chinese article archive timeline or Chinese category tags, the experience remains broken.
We resolved this by implementing unified localization helpers:
1. Unified Post Extraction with getLocalizedPosts(locale)
export function getLocalizedPosts(locale?: string): CoreContent<Blog>[] {
const sorted = allCoreContent(sortPosts(allBlogs));
if (!locale || locale === 'zh') return sorted;
const translationMap = new Map(allBlogTranslations.map((t) => [t.slug, t]));
return sorted.map((post) => {
const trans = translationMap.get(post.slug);
if (!trans) return post;
return {
...post,
title: trans.title || post.title,
summary: trans.summary || post.summary,
tldr: trans.tldr || post.tldr,
};
});
}
The homepage Writing Section, /blog archive, knowledge territory nodes, and topic tags all feed from this function to render matching titles and summaries.
2. Automatic Tag Localization and Typography
The TraceList component incorporates useTranslations('tags') to translate tag keys:
const tTags = useTranslations('tags');
const formatTag = (tag: string) => {
const tagSlug = slug(tag);
if (tTags.has(tagSlug)) return tTags(tagSlug);
if (!tag.includes('.') && tTags.has(tag)) return tTags(tag);
return tag;
};
Paired with the site's typography utility type-meta (text-transform: uppercase), tags render with clean, editorial uppercase styling:
运维→OPERATIONS独立开发→INDIE DEVELOPMENT打印机→PRINTER
Performance and Quota Overhead
Running this automated pipeline across all 25 existing deep-dive articles:
- Character Consumption: 25 in-depth technical posts consumed approximately 182,000 characters—well below the DeepL Free limit of 500,000 characters per month;
- Duration: Translating a full-length post took 5–8 seconds;
- Ongoing Cost: Each new 3,000–5,000 character post uses only ~4,000 characters, meaning the monthly quota comfortably accommodates 80–100 new articles;
- Zero Mental Friction: Authors simply draft Chinese MDX in
data/blog/and commit tomain. An automated GitHub Actions run detects changes, formats the translations, validates the build, and opens a ready-to-merge PR for Vercel deployment.
Key Takeaways
Building this automated workflow reinforces a core principle: content internationalization is fundamentally an engineering problem of data flow modeling and AST error recovery.
- Single Source of Truth (SSOT): Always treat the original manuscript as the primary source of truth, and translations as read-only generated artifacts.
- Clear Boundaries between AST and Natural Language: Machine translation engines understand prose, not code syntax. Strip tags, formulas, and code before sending text over the wire.
- Keep Validation in CI: Automation demands rigid verification. Typechecks, unit tests, and pre-build compilation in CI are essential to ensuring production stability.