FRONTEND2026-04-09📖 2 min read

Building a Full-Featured Browser Mahjong Game with Next.js: A Complete Tech Stack Walkthrough

Building a Full-Featured Browser Mahjong Game with Next.js: A Complete Tech Stack Walkthrough

A walkthrough of the tech stack behind "Janten," a browser mahjong game built with Next.js 16 and TypeScript — from separating game logic into an npm package, to pure-function-based state management, to the design decisions for mobile support, with real code examples.

髙木 晃宏

代表 / エンジニア

👨‍💼

Why Build a Browser Game with Next.js?

At aduce, we develop and operate Janten (janten.net) — a full-featured 4-player mahjong game that runs in the browser. Game development usually conjures images of Unity or Unreal Engine, but for Janten we deliberately chose Next.js.

The reasoning is simple. Mahjong is a turn-based game — real-time 60fps rendering isn't needed. Meanwhile, it has a lot of Web application demands: SEO-friendly blog articles, an API that returns hand-tile images, and so on. Next.js lets us manage the game UI, content pages, and API endpoints in a single integrated project.

Janten's Tech Stack at a Glance

CategoryTechnologyVersion
FrameworkNext.js16.2
UIReact19.2
LanguageTypeScript5.9
Game logicmahjong-core (our own package)1.0
Build (core)tsup8.x
TestingVitest4.1
ContentMDX + remark-gfm + rehype-slug-

The frontend is built on React 19 Client Components, and the game logic is fully separated as a local npm package.

Separating Game Logic into an npm Package

The single most important design decision in Janten is carving out the mahjong rules processing into an independent package called mahjong-core.

// package.json { "dependencies": { "mahjong-core": "file:./mahjong-core" } }

mahjong-core is built with tsup, producing a typed package with both CJS and ESM output. That means the same logic is usable from both the Next.js frontend and API Routes. Game rules don't get polluted by UI concerns, and tests run fast as pure logic tests.

Pure-Function-Based Game State Management

Game state transitions are designed as pure functions. For example, the pon operation (taking another player's discard) looks like this:

export function executePon( player: number, tile: Tile, discarder: number, hands: Tile[][], melds: Meld[][], discards: Tile[][], ): PonResult { const nH = hands.map(h => [...h]); const nM = melds.map(m => [...m]); const nD = discards.map(d => [...d]); nD[discarder].pop(); // ... take two matching tiles from the hand and add them to the meld nM[player].push({ type: "pon", tiles: ponT, closed: false }); return { hands: nH, melds: nM, discards: nD, ippatsu: nIp }; }

Input arrays are copied via spread syntax, returning new state without mutating the original. The big upsides of this design: no side effects means you can write tests from any state, and the UI-side custom hook useMahjongGame just calls these pure functions and sets the result into useState.

Mobile Support: Responsive Design via TileSizeContext

Mahjong game UIs are a tough genre for mobile. You have to fit 136 tiles plus four players' hands and discard piles into a limited screen. In Janten, a React Context called TileSizeContext centrally manages tile dimensions that adapt to screen size.

const mob = vw < 500; const nar = vw < 680; const landscape = vh <= 500 && vw > vh; const lsScale = landscape ? Math.min(1.2, Math.max(0.7, vh / 430)) : 1; const T_H = mob ? 30 : nar ? 38 : 46; const TS_W = mob ? 16 : nar ? 20 : 24; const TS_H = mob ? 22 : nar ? 27 : 32;

In addition to the usual responsive tiers (mobile / narrow / desktop), we handle smartphone landscape mode too. lsScale calculates a scale coefficient based on the iPhone 14 Pro Max's landscape height (430px) and injects it into each component as a CSS variable. The layout problems that are hard to solve with media queries alone are addressed through Context-delivered dynamic values.

Implementing an SVG Hand-Tile Image API

Janten has a hand-tile image generation API implemented via a Next.js API Route. Pass a tile notation as a URL parameter, and it returns an SVG image.

GET /api/mahjong/tehai?tiles=123m456p789s11z&shanten=true&waits=true
export async function GET(request: NextRequest) { const tilesParam = searchParams.get("tiles"); const tiles = parseTileNotation(tilesParam); // shanten count and waits also calculated via mahjong-core const shantenNum = calcShanten(allHandTiles as Tile[]); const waits = getWaits(tiles as Tile[]); const svg = renderSvg({ tiles, tsumo, doraKeys, shantenNum, waits, ... }); return new NextResponse(svg, { headers: { "Content-Type": "image/svg+xml" }, }); }

Having separated the game logic into mahjong-core means the API Route can directly call shanten-count and waits-detection logic. This image API is used for embedding in blog articles and sharing on social media — a feature that plays directly to Next.js's strength of having game and content in the same codebase.

Summary

Building a browser game with Next.js is a highly rational choice from the angle of integrating turn-based gaming with web content. Here's what we've learned through Janten:

  • Separate game logic into a standalone package, usable from both the frontend and the API
  • Design state transitions as pure functions, securing testability and maintainability
  • Centrally manage tile dimensions via React Context, adapting to mobile and landscape orientation
  • Sharing game logic with API Routes lets you implement value-added features like image generation cheaply

At aduce we offer end-to-end IT development support — from technology selection through design and implementation. For consultation on Web applications or game development, please feel free to reach out via Contact.