DEV Community

Nicholas Ma
Nicholas Ma

Posted on

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

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/
Enter fullscreen mode Exit fullscreen mode

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 }));
}
Enter fullscreen mode Exit fullscreen mode

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": "点亮分布在黄昏之地中..."}
      ]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
);
Enter fullscreen mode Exit fullscreen mode

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>}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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`,
  // ...
];
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
      }))
    ),
  ];
}
Enter fullscreen mode Exit fullscreen mode

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' },
  ],
};
Enter fullscreen mode Exit fullscreen mode

What I Learned

  1. Route groups > middleware for static multilingual sites. Simpler, cheaper, better for SEO.

  2. t.raw() is your friend. Don't try to translate arrays with t() — use t.raw() and type the result.

  3. Safe loading prevents crashes. Missing translations shouldn't break the build. Use try/catch and null checks.

  4. Official translations beat machine translation. If the content exists in multiple languages from the source, use it.

  5. 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)