I recently built a multilingual fan site for The Duskbloods, an upcoming FromSoftware game. The challenge: 9 languages (English, Japanese, Korean, Chinese, Spanish, French, German, Italian, Portuguese), static generation, and no middleware — all deployed on Cloudflare Workers.
Here's how I did it and what I learned.
The Architecture
The site uses Next.js 15 App Router with next-intl v4 for internationalization. The key constraint: I wanted to avoid middleware to keep Cloudflare Worker costs down.
src/
├── app/
│ ├── (root)/ # English at /
│ │ ├── gameplay/
│ │ ├── characters/
│ │ └── ...
│ └── [locale]/ # Other languages at /zh, /ja, /ko...
│ ├── gameplay/
│ ├── characters/
│ └── ...
├── messages/ # Translation files
│ ├── en.json
│ ├── ja.json
│ ├── zh.json
│ └── ...
└── components/ # Shared components
└── views/
Route Groups for Language Separation
Instead of using middleware to detect locale, I use route groups:
-
(root)— English content at the root path/ -
[locale]— Other languages at/zh,/ja,/ko, etc.
This means English gets clean URLs (/gameplay) while other languages get prefixed URLs (/zh/gameplay). Good for SEO — English is the default, and other languages have clear URL signals.
Why No Middleware?
Cloudflare Workers charge per request. Middleware runs on every request. For a static site with 9 languages, that's 9x the middleware invocations for every page load. By handling locale in the route, I skip middleware entirely.
// src/app/[locale]/layout.tsx
export async function generateStaticParams() {
return ['ja', 'zh', 'ko', 'es', 'fr', 'de', 'it', 'pt'].map(locale => ({ locale }));
}
This pre-generates all locale variants at build time. Zero runtime locale detection.
The Translation System
Message Files
Each locale has a JSON message file:
// src/messages/zh.json
{
"gameplay": {
"intro": {
"eyebrow": "玩法介绍",
"title": "游戏机制",
"lead": "深入了黄昏征讨的核心机制。"
},
"virtue": {
"title": "美德",
"types": [
{"title": "讨伐之美德", "desc": "黄昏之地会出现特别强力的敌人..."},
{"title": "灯之美德", "desc": "点亮分布在黄昏之地中..."}
]
}
}
}
Components Are Language-Agnostic
The same view component renders all languages — only the message file changes:
// src/components/views/GameplayView.tsx
export default function GameplayView() {
const t = useTranslations('gameplay');
return (
<section>
<h2>{t('intro.title')}</h2>
<p>{t('intro.lead')}</p>
</section>
);
}
Handling Structured Content
For arrays and objects, I use t.raw():
const types = t.raw('virtue.types') as VirtueType[];
return (
<div>
{types.map((v, i) => (
<div key={i}>
<h3>{v.title}</h3>
<p>{v.desc}</p>
</div>
))}
</div>
);
Optional Sections with Safe Loading
Some content exists in some languages but not others. I created a safeLoad helper:
function safeLoad<T>(key: string, check: (r: unknown) => boolean): T | null {
try {
const raw = t.raw(key) as unknown;
return check(raw) ? (raw as T) : null;
} catch { return null; }
}
const virtue = safeLoad<VirtueData>('virtue', (r) =>
!!r && typeof r === 'object' && 'types' in r && Array.isArray((r as any).types));
// In JSX:
{virtue && <section>...</section>}
This way, sections gracefully degrade when translations are missing.
Content Sourcing
The game has an official gameplay guide in 9 languages. I fetched each version and extracted structured content:
import requests, re, json
guides = {
"en": "https://.../lang=en",
"ja": "https://.../lang=ja",
"ko": "https://.../lang=ko",
# ...
}
for locale, url in guides.items():
html = requests.get(url).text
# Extract h3 headings and their descriptions
items = extract_h3_with_desc(html)
# Write to message file
update_message_file(locale, items)
This gave me official translations for all 9 languages — no machine translation needed.
External Images
The game's official CDN hosts screenshots and gameplay images. I reference them directly:
const GGBASE = 'https://media.fromsoftware.jp/theduskbloods/campaign/resources/networktest/images/gameplayguide';
const virtueImages = [
`${GGBASE}/virtue/pc/img_01.jpg`,
`${GGBASE}/virtue/pc/img_02.jpg`,
// ...
];
No need to host images myself. The CDN is fast and reliable.
Deployment on Cloudflare Workers
I use OpenNext to build for Cloudflare:
npx opennextjs build
npx wrangler deploy
The build outputs static HTML for all 9 language variants. Cloudflare serves them globally with zero cold starts.
SEO Considerations
Sitemap
// src/app/sitemap.ts
export default function sitemap() {
const pages = ['', '/gameplay', '/characters', '/gallery', '/network-test'];
const locales = ['zh', 'ja', 'ko', 'es', 'fr', 'de', 'it', 'pt'];
return [
...pages.map(path => ({ url: `https://duskbloods.net${path}`, priority: 0.8 })),
...locales.flatMap(locale =>
pages.map(path => ({
url: `https://duskbloods.net/${locale}${path}`,
priority: 0.7,
}))
),
];
}
Structured Data
Each page includes JSON-LD breadcrumbs:
const breadcrumbLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://duskbloods.net' },
{ '@type': 'ListItem', position: 2, name: 'Gameplay', item: 'https://duskbloods.net/gameplay' },
],
};
What I Learned
Route groups > middleware for static multilingual sites. Simpler, cheaper, better for SEO.
t.raw()is your friend. Don't try to translate arrays witht()— uset.raw()and type the result.Safe loading prevents crashes. Missing translations shouldn't break the build. Use try/catch and null checks.
Official translations beat machine translation. If the content exists in multiple languages from the source, use it.
Cloudflare Workers + OpenNext works well for static multilingual sites. No cold starts, global CDN, reasonable cost.
The site is live at duskbloods.net with all 9 languages fully translated. The code is a standard Next.js 15 project — if you're building something similar, the patterns above should work for you.
Questions or suggestions? Drop a comment below.
Top comments (0)