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 with t() — use t.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.

Building a 9-Language Fan Site with Next.js 15 and next-intl (No Middleware)
Nicholas Ma

