DEV Community

Cover image for Building a Swedish Sudoku Site with Next.js 15, MDX & a Pure-TS Puzzle Engine
Evy Lundell
Evy Lundell

Posted on

Building a Swedish Sudoku Site with Next.js 15, MDX & a Pure-TS Puzzle Engine

I recently launched sudokun.se — a free Swedish sudoku site with no sign-up required and no ads inside the puzzle. Here's a walkthrough of the interesting technical decisions behind it.

The stack at a glance

  • Next.js 15 App Router with React Server Components + selective client hydration
  • TypeScript in strict mode throughout
  • Tailwind CSS for styling
  • MDX for all content (guides, blog posts, landing pages)
  • schema-dts for typed JSON-LD schema builders
  • Vitest for the puzzle engine unit tests

The puzzle engine — pure TypeScript

The core of the site is a fully self-contained sudoku engine in lib/sudoku/. No external dependencies.

Deterministic puzzle generation via seeded PRNG

I wanted puzzles to be reproducible from a seed string (useful for daily puzzles and sharing). The engine uses a mulberry32 PRNG seeded with a FNV-1a hash of the seed string:

function hashSeed(seed: string): number {
  let h = 2166136261 >>> 0;
  for (let i = 0; i < seed.length; i++) {
    h ^= seed.charCodeAt(i);
    h = Math.imul(h, 16777619) >>> 0;
  }
  return h >>> 0;
}

function mulberry32(a: number): () => number {
  return function () {
    a |= 0;
    a = (a + 0x6d2b79f5) | 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}
Enter fullscreen mode Exit fullscreen mode

A solved board is generated using randomized backtracking with the seeded PRNG, then clues are removed down to the target count per difficulty level — while always verifying the puzzle has a unique solution.

Difficulty levels

Level Clues given
Barn 50–55
Lätt 38–45
Medel 28–35
Svår 22–27
Extrem 17–21

The solver is a pure constraint-propagation + backtracking solver. Unique-solution verification is done by running the solver and checking it finds exactly one result.


Selective client hydration

The game board is a 'use client' component, but the surrounding page shell — layout, header, footer, FAQ, quick-links — is fully server-rendered. This means:

  • First paint is fast — HTML arrives with all content already rendered
  • JS bundle only includes what the interactive board actually needs
  • The difficulty selector is the only stateful piece on the homepage

Switching difficulty remounts the board via React's key prop, which is simpler and more reliable than trying to imperatively reset game state:

<GameFullscreenWrapper key={difficulty} difficulty={difficulty} />
Enter fullscreen mode Exit fullscreen mode

MDX content pipeline

All guides, blog posts, and landing pages live as .mdx files under content/. Frontmatter is the source of truth for metadata and structured data.

---
title: "Hur spelar man sudoku?"
description: "Lär dig sudokuets grundregler på fem minuter."
publishedAt: "2025-03-01"
faq:
  - question: "Kan samma siffra finnas två gånger i en rad?"
    answer: "Nej, varje siffra 1–9 får bara förekomma en gång per rad, kolumn och 3×3-ruta."
howTo:
  steps:
    - text: "Identifiera tomma rutor"
    - text: "Använd eliminering för att hitta rätt siffra"
---
Enter fullscreen mode Exit fullscreen mode

The MDX renderer reads the frontmatter and automatically generates FAQPage, HowTo, and Article JSON-LD without any manual schema work on the content author's part.


Typed JSON-LD schema builders

Rather than writing raw JSON objects, I used schema-dts to get TypeScript type-checking on all structured data:

import type { FAQPage, WithContext } from 'schema-dts';

export function buildFAQ(
  items: { question: string; answer: string }[],
): WithContext<FAQPage> {
  return {
    '@context': 'https://schema.org',
    '@type': 'FAQPage',
    mainEntity: items.map((item) => ({
      '@type': 'Question',
      name: item.question,
      acceptedAnswer: { '@type': 'Answer', text: item.answer },
    })),
  };
}
Enter fullscreen mode Exit fullscreen mode

If I accidentally pass the wrong shape — TypeScript catches it at compile time. No more silent JSON-LD bugs that only surface in Google Search Console weeks later.


Security headers out of the box

Next.js makes it trivial to add security headers globally via next.config.mjs:

async headers() {
  return [
    {
      source: '/:path*',
      headers: [
        { key: 'X-Content-Type-Options',  value: 'nosniff' },
        { key: 'Referrer-Policy',         value: 'strict-origin-when-cross-origin' },
        { key: 'X-Frame-Options',         value: 'SAMEORIGIN' },
        { key: 'Permissions-Policy',      value: 'camera=(), microphone=(), geolocation=()' },
      ],
    },
  ];
}
Enter fullscreen mode Exit fullscreen mode

What I'd do differently

  1. Puzzle caching — currently every page load generates a fresh puzzle. A small in-memory cache of pre-generated puzzles per difficulty would eliminate the ~20ms generation cost on the first load.
  2. Daily puzzle with a stable URL — using new Date().toISOString().slice(0, 10) as the seed makes this trivially easy to add.
  3. Web Worker for generation — the engine is synchronous. On low-end devices, a 17-clue extreme puzzle can block the main thread for 30–80ms. Moving generation to a Web Worker would fix this.

Try it

The site is live at sudokun.se — free, no account, works on mobile and desktop.

If you're building something similar — puzzle sites, game sites, MDX-heavy content sites — happy to go deeper on any part of this in the comments.

Top comments (0)