DEV Community

Cover image for Building a Game Wiki: Heartopia.live Tech Stack
xiazai77
xiazai77

Posted on

Building a Game Wiki: Heartopia.live Tech Stack

I built a game wiki for Heartopia — a cozy life sim that blew up earlier this year — and wanted to share the tech decisions behind it. Not because the stack is groundbreaking, but because building a game wiki has constraints that don't apply to typical web projects, and the choices I made were driven by those constraints.

The site is Heartopia.live. It covers codes, recipes, characters, an interactive world map, collection tracking, and event guides across 8 languages. Here's what powers it and why.

Why Static Export

The single most important decision: Next.js with static export (output: 'export'). No server. No serverless functions. Just HTML, CSS, and JS files deployed to a CDN.

Why? Game wikis have a specific traffic pattern. When a new update drops or an event starts, traffic spikes hard — sometimes 10x normal volume — for 48 hours, then drops back down. A server-based setup means either paying for capacity you don't need 95% of the time, or scrambling to scale during spikes.

Static files on a CDN handle traffic spikes for free. Cloudflare Pages serves the whole site. Zero cold starts, zero scaling concerns, near-zero cost. The trade-off is no server-side logic, but for a wiki that's mostly read-heavy content, it's the right call.

// next.config.js
const nextConfig = {
  output: process.env.NODE_ENV === 'production' ? 'export' : undefined,
  trailingSlash: true,
  images: { unoptimized: true },
}
Enter fullscreen mode Exit fullscreen mode

The trailingSlash: true is for clean URLs on static hosting. images.unoptimized because Next.js image optimization requires a server — with static export you handle images yourself.

One nuance: I only enable output: 'export' in production. During development, keeping it dynamic means proper 404 handling and HMR work normally. Caught this the hard way after wondering why dev mode was broken.

Next.js 14 + React 18

Nothing fancy here. App Router, TypeScript, standard file-based routing. The [lang] dynamic segment handles i18n:

src/app/[lang]/codes/page.tsx
src/app/[lang]/recipes/page.tsx
src/app/[lang]/world-map/page.tsx
Enter fullscreen mode Exit fullscreen mode

Each page gets the language from the URL parameter and loads the corresponding translation file. No i18n library — just JSON files and a getDictionary() function. For a site with mostly static content, this is simpler than setting up next-intl or similar.

8 Languages Without an i18n Library

The site supports English, Chinese, Thai, Portuguese, Indonesian, Spanish, French, and German. I went back and forth on whether to use an i18n framework and decided against it.

The approach: one JSON file per language, loaded at build time. Each page calls getDictionary(lang) which returns the typed translation object. TypeScript catches missing keys at compile time.

// Simplified version
const dictionaries = {
  en: () => import('./locales/en.json'),
  zh: () => import('./locales/zh.json'),
  th: () => import('./locales/th.json'),
  // ...
}

export const getDictionary = async (lang: string) =>
  dictionaries[lang]()
Enter fullscreen mode Exit fullscreen mode

For translation itself, I use LLM translation directly — no scripts, no regex replacement. Read the source file, translate the full content, write the target file. It's more accurate than automated pipelines for game-specific terminology.

The rule I enforce: no hardcoded language checks anywhere in components. No lang === 'en' ? 'Save' : '保存'. Every UI string comes from the dictionary. This makes adding a new language a matter of adding one JSON file instead of touching every component.

Data Architecture

Game wiki data changes on a different cadence than the site code. New codes drop daily. Recipes and characters change with game updates. Event content is seasonal.

I keep game data in separate JSON/TS files in a data/ directory, not in a CMS or database. For a statically exported site, this means data changes require a rebuild, but the build takes under 30 seconds so it's fine. A GitHub push triggers a Cloudflare Pages build automatically.

src/lib/data.ts          // Data loading utilities
src/data/codes.json      // Gift codes (updated frequently)
src/data/recipes.json    // Cooking recipes
src/data/characters.json // NPC data
src/data/fish.json       // Fish collection
Enter fullscreen mode Exit fullscreen mode

The alternative was a headless CMS, but for a solo-maintained wiki where I'm the only editor, JSON files in a repo are faster to update than any CMS dashboard.

Interactive World Map

The world map page is the most complex component. Players use it to find fish spawns, bug locations, forageable items, and more. It needs to be filterable, zoomable, and fast.

I went with a custom implementation using CSS transforms for pan/zoom rather than pulling in Leaflet or MapLibre. The game map is a single large image (not tiles), so a full mapping library would be overkill. Custom markers are positioned with percentage-based coordinates relative to the map image.

The filter system lets users toggle categories on and off — show only fish, show only bugs, etc. State is managed with React useState, no external state library needed.

Collection Tracker

The tracker lets players check off items they've found across 7 categories (fish, bugs, birds, crops, flowers, forageables, wild animals). Progress is saved to localStorage.

Simple but effective. No auth required, no server storage, works offline. The main UX challenge was making 200+ checkboxes across 7 tabs not feel overwhelming — solved with search, category filters, and a progress bar per category.

Styling

Tailwind CSS. No component library. For a content-heavy wiki, custom styling is faster than fighting a component library's opinions about how things should look. Tailwind's utility classes make responsive design straightforward, and the built-in dark mode toggle was trivial to implement.

Deployment

Cloudflare Pages. Push to main → automatic build → deploy to global CDN. SSL, custom domain, and edge caching are all handled automatically. Cost: $0 on the free tier, which handles the traffic levels a game wiki gets.

The deploy pipeline:

git push origin main
→ Cloudflare Pages detects push
→ Runs `npm run build` (Next.js static export)
→ Deploys /out directory to CDN
→ Live in ~60 seconds
Enter fullscreen mode Exit fullscreen mode

SEO Considerations

Game wikis live and die by search traffic. A few things that matter:

Trailing slashes and consistent URLs. Pick one format and stick with it. trailingSlash: true in Next.js config ensures every URL ends with /.

Proper hreflang tags. With 8 languages, search engines need to know which version to show which users. Each page generates <link rel="alternate" hreflang="xx"> tags for all language variants.

Dynamic meta tags. Every page has unique title and description pulled from the translation files. No hardcoded English meta tags on non-English pages.

Sitemap generation. src/app/sitemap.ts generates a sitemap with all pages across all languages. This is critical for getting non-English pages indexed.

What I'd Do Differently

Start with fewer languages. I launched with 8 and maintaining translations across all of them for every content update is time-consuming. Starting with 2-3 and adding more based on actual traffic data would have been smarter.

Add search earlier. A wiki without search is frustrating once it passes ~20 pages. I added it later and it immediately became one of the most-used features.

Track data freshness. Codes expire, events end, game mechanics change. I don't have a great system for flagging stale content yet. Something like a lastVerified date per data entry would help.

Numbers

The whole thing runs on: Next.js 14, React 18, TypeScript, Tailwind CSS, Cloudflare Pages. No database, no CMS, no auth, no server. Build time under 30 seconds. Monthly hosting cost: $0.

For a game wiki that needs to be fast, handle traffic spikes, support multiple languages, and be easy to update solo — this stack does the job. Nothing clever, nothing trendy. Just the right tool for each specific constraint.

Top comments (0)