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;
};
}
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} />
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"
---
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 },
})),
};
}
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=()' },
],
},
];
}
What I'd do differently
- 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.
-
Daily puzzle with a stable URL — using
new Date().toISOString().slice(0, 10)as the seed makes this trivially easy to add. - 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)