Tutorial-like documentation of Playground App
Playground – Project Tutorial
A beginner-friendly walkthrough of the entire Playground project: how it's structured, what every piece of code does, and why it was built that way. Each section links to official documentation or learning resources so you can explore concepts further.
Table of Contents
- What is this project?
- Tech stack at a glance
- Project structure
- How Next.js routing works
- The root layout — wrapping every page
- Providers — global context for the whole app
- Shared components
- How app metadata is loaded —
lib/apps.ts - MDX — writing content like code
- Internationalisation (i18n) — English & German
- Mini-app: Tic-Tac-Toe
- Mini-app: Memory Game (deep dive)
- Mini-app: Digital Handshake
- Mini-app: Calisthenics Progression
- Multiplayer — real-time with Supabase
- Build & deploy
1. What is this project?
This is a personal playground — a Next.js website that collects small apps, games, and experiments in one place. Think of it like a toy chest: each drawer holds a separate project (Memory Game, Tic-Tac-Toe, …), but they all share the same shelf (the Next.js site around them).
The goal is to learn by building: each mini-app explores a different concept (React state, CSS animations, real-time databases…), and the surrounding site teaches how to organise a real-world Next.js project.
2. Tech stack at a glance
| Technology | What it does in this project | Learn more |
|---|---|---|
| Next.js 16 | The framework — handles routing, page rendering, and the static export | nextjs.org/docs |
| React 19 | The UI library — components, hooks, state | react.dev |
| TypeScript | Adds types to JavaScript so mistakes are caught before the code runs | typescriptlang.org/docs |
| Tailwind CSS v4 | Utility-first CSS — write styles as class names directly in JSX | tailwindcss.com/docs |
| MDX | Markdown files that can contain React components — used for app description pages | mdxjs.com |
| Supabase | Backend-as-a-service used for the real-time multiplayer database | supabase.com/docs |
| next-themes | Dark/light mode toggle without flashing | github.com/pacocoursey/next-themes |
| Headless UI | Accessible, unstyled UI components (the mobile menu Popover) | headlessui.com |
3. Project structure
playground/
├── src/
│ ├── app/ ← Next.js "App Router" pages
│ │ ├── layout.tsx ← Root layout (wraps every page)
│ │ ├── page.tsx ← Home page (/)
│ │ ├── providers.tsx ← Context providers
│ │ ├── apps/
│ │ │ ├── memory-game/ ← Description page + play page
│ │ │ ├── tic-tac-toe/
│ │ │ ├── digital-handshake/
│ │ │ └── calisthenics-progression/
│ │ └── journal/ ← Blog/notes section
│ ├── components/ ← Reusable React components
│ │ ├── Layout.tsx ← Header + main content + Footer wrapper
│ │ ├── Header.tsx ← Top navigation bar
│ │ ├── Footer.tsx
│ │ ├── Container.tsx ← Centred, max-width wrapper
│ │ ├── Card.tsx ← App listing card with sub-components
│ │ ├── AppList.tsx ← Filterable grid of app cards
│ │ ├── games/
│ │ │ ├── MemoryGame.tsx
│ │ │ └── TicTacToe.tsx
│ │ └── multiplayer/ ← Real-time multiplayer components
│ ├── lib/
│ │ ├── apps.ts ← Reads all app metadata from MDX files
│ │ ├── multiplayer.ts ← Supabase room management
│ │ └── supabase.ts ← Supabase client
│ ├── i18n/
│ │ ├── index.tsx ← Language context + useTranslation hook
│ │ └── translations/
│ │ ├── en.ts ← English strings
│ │ └── de.ts ← German strings
│ ├── styles/
│ │ └── tailwind.css ← Global CSS entry point
│ └── types/ ← Shared TypeScript types
├── public/
│ └── images/ ← SVG card images for Memory Game
├── next.config.mjs ← Next.js configuration
├── package.json
└── tsconfig.json
Analogy: Think of src/app/ as the address book for your website. Each
folder inside it is a street address — visit /apps/memory-game/ and Next.js
automatically serves the page.tsx file inside that folder. You never have to
configure routes manually.
4. How Next.js routing works
Next.js uses file-system routing: the folder structure is the URL
structure. Every folder that contains a page.tsx becomes a route.
src/app/apps/memory-game/page.tsx → /apps/memory-game/
src/app/apps/memory-game/play/page.tsx → /apps/memory-game/play/
layout.tsx files are special: they wrap all pages inside their folder (and
all subfolders). The root src/app/layout.tsx wraps every page on the site.
📖 Next.js App Router — RoutingFundamentals
5. The root layout — wrapping every page
File: src/app/layout.tsx
export default function RootLayout({ children }) {
return (
<html lang="en" className="h-full antialiased" suppressHydrationWarning>
<body className="flex h-full bg-zinc-50 dark:bg-black">
<Providers>
<div className="flex w-full">
<Layout>{children}</Layout>
</div>
</Providers>
</body>
</html>
)
}
What each line does:
<html suppressHydrationWarning>— Next.js renders HTML on the server first, then React "takes over" on the client (this is called hydration). ThesuppressHydrationWarningprop tells React to ignore tiny differences between the server-rendered and client-rendered HTML — needed becausenext-themesadds a class to<html>client-side to set the colour theme, which the server doesn't know about yet.className="h-full antialiased"—h-fullmakes the html element fill the viewport height;antialiasedmakes text edges smooth.<Providers>— Wraps everything in React Context providers (theme, language). More on this below.{children}— This is where the actual page content goes. Think of it as a placeholder that Next.js fills in with whatever page the user is visiting.metadata— The exportedmetadataobject sets the browser tab title and description. Any page can override it with its ownmetadataexport.
📖 Next.jsLayouts 📖 React hydrationexplained
6. Providers — global context for the whole app
File: src/app/providers.tsx
Providers are React components that make data available to all their children,
no matter how deeply nested, without manually passing props down. Think of it
like a radio broadcast: the provider is the transmitter, and any component that
tunes in (with useContext) can receive the signal.
This file sets up three providers:
AppContext — remembers the previous page
export const AppContext = createContext<{ previousPathname?: string }>({})
This context stores the URL of the page the user was on before the current
one. The back-button behaviour in AppLayout uses it to decide whether to show
a "← Back" link.
let pathname = usePathname()
let previousPathname = usePrevious(pathname)
usePrevious is a custom hook that uses useRef to remember the value from the
last render. On every navigation, pathname is the new URL; ref.current
(the previous value) becomes the old URL.
ThemeProvider — dark/light mode
<ThemeProvider attribute="class" disableTransitionOnChange>
next-themes wraps the app and adds a dark class to <html> when dark mode
is active. Tailwind then uses dark: prefixed classes to apply dark styles.
disableTransitionOnChange prevents an ugly flash of colour when the theme
changes on first load.
LanguageProvider — EN/DE translations
Covered in depth in section 10.
ThemeWatcher — syncs to OS preference
function ThemeWatcher() {
let { resolvedTheme, setTheme } = useTheme()
useEffect(() => {
let media = window.matchMedia('(prefers-color-scheme: dark)')
function onMediaChange() {
let systemTheme = media.matches ? 'dark' : 'light'
if (resolvedTheme === systemTheme) {
setTheme('system')
}
}
onMediaChange()
media.addEventListener('change', onMediaChange)
return () => media.removeEventListener('change', onMediaChange)
}, [resolvedTheme, setTheme])
return null
}
If the user changes their OS theme while the app is open (e.g. from dark to
light), this component detects the change via window.matchMedia and switches
the theme back to 'system' so it keeps following the OS. It renders nothing
(return null) — it only runs side-effects.
📖 React Context 📖 useRef and usePrevious pattern
Layout
File: src/components/Layout.tsx
export function Layout({ children }) {
return (
<>
{/* Fixed white panel behind the content */}
<div className="fixed inset-0 flex justify-center sm:px-8">
<div className="flex w-full max-w-7xl lg:px-8">
<div className="w-full bg-white ring-1 ring-zinc-100 dark:bg-zinc-900 dark:ring-zinc-300/20" />
</div>
</div>
{/* Scrollable content on top */}
<div className="relative flex w-full flex-col pt-20">
<Header />
<main className="flex-auto">{children}</main>
<Footer />
</div>
</>
)
}
Two layers are stacked on top of each other:
- A
fixedwhite/dark panel that stays in place as you scroll — it creates the visual "card" effect in the centre of the page. - The actual scrollable content: Header → main area → Footer.
pt-20 (padding-top: 5rem) pushes the content down to make room for the fixed
header.
Header
File: src/components/Header.tsx
The header is position: fixed so it stays at the top as you scroll. It
contains:
- Avatar — a React logo that links back to the home page.
- Desktop navigation — a pill-shaped nav bar with Home and Journal links,
only visible on
md(768px+) screens. - Mobile navigation — a "Menu" button that opens a
<Popover>overlay (from Headless UI) on small screens. Usesmd:hiddento hide itself on desktop. - Language toggle — switches between EN and DE. Uses
mountedstate to prevent a hydration mismatch: on the server there's no localStorage, so the component renders as if the locale is unknown, then updates client-side after mounting. - Theme toggle — switches dark/light. Same
mountedpattern for the same reason.
The mounted pattern explained:
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true) // only runs in the browser, after first render
}, [])
return <button aria-label={mounted ? `Switch to ${otherTheme}` : 'Toggle theme'}>
The server renders HTML without knowing the current theme or language. If the
button's aria-label on the server says "Switch to light" but the browser has
dark mode, React will notice the mismatch and log a warning. By using mounted,
the button renders a neutral label on the server and updates itself once it's
safely in the browser.
File: src/components/Footer.tsx
Simple footer with a copyright line and the useTranslation hook for the "Built
with" text. new Date().getFullYear() automatically shows the current year.
Container
File: src/components/Container.tsx
A reusable centred-content wrapper. It's built in layers — an outer component adds horizontal padding; an inner one caps the width and centres it:
ContainerOuter → max-width: 7xl (80rem), horizontal padding
ContainerInner → max-width: 5xl (64rem), more padding
Your content
Having it as a reusable component means you only change the max-width in one place if you ever redesign the layout.
Card
File: src/components/Card.tsx
The Card component is used on the home page to display each app. It uses a
pattern called compound components — a group of related sub-components that
live on the same object:
<Card>
<Card.Title href="/apps/memory-game">Memory Game</Card.Title>
<Card.Description>A card-matching game…</Card.Description>
<Card.Cta>Read more</Card.Cta>
<Card.Eyebrow>14 March 2025</Card.Eyebrow>
</Card>
The hover effect explained:
// Inside Card.Link:
<div className="absolute -inset-x-4 -inset-y-6 z-0 scale-95 bg-zinc-50
opacity-0 transition group-hover:scale-100 group-hover:opacity-100
sm:-inset-x-6 sm:rounded-2xl dark:bg-zinc-800/50" />
This is an invisible div that expands to fill the card area on hover. The
group class on the parent tells Tailwind "when the parent is hovered, apply
group-hover: styles to children". The div goes from scale-95 opacity-0 →
scale-100 opacity-100 on hover, creating a smooth "pop" background effect.
8. How app metadata is loaded — lib/apps.ts
File: src/lib/apps.ts
This is a server-side utility (it runs at build time, not in the browser).
It scans the src/app/apps/ directory, finds every folder that has a
page.en.mdx file, dynamically imports that file, and extracts the exported
app object from it.
export async function getAllApps() {
let appsDir = path.join(process.cwd(), 'src/app/apps')
let entries = fs.readdirSync(appsDir, { withFileTypes: true })
let appFilenames = entries
.filter(e => e.isDirectory() && fs.existsSync(path.join(appsDir, e.name, 'page.en.mdx')))
.map(e => `${e.name}/page.en.mdx`)
let apps = await Promise.all(appFilenames.map(importApp))
return apps.sort((a, z) => +new Date(z.date) - +new Date(a.date))
}
Why this design? The metadata for each app (title, description, tech tags, date) lives in the same file as the app's written content. This means when you add a new app, you only create one file and the home page automatically includes it — no manual registration required.
Analogy: It's like a book that auto-generates its own table of contents by scanning the chapter headings, rather than you having to write the TOC by hand.
📖 Node.js fs module 📖 Dynamic imports inJavaScript
9. MDX — writing content like code
MDX is Markdown with superpowers: you can embed React components inside it.
Each app has a page.en.mdx file that serves two purposes at once:
export const app = { ← JavaScript export (metadata)
title: 'Memory Game',
description: '...',
tech: ['React', 'TypeScript', 'useState', 'useEffect'],
date: '2025-03-14',
category: 'games',
}
A memory card game where you... ← Regular Markdown prose
## What I learned ← Markdown heading
- Managing game state with `useState`
The app export is what lib/apps.ts collects. The Markdown prose is rendered
as the body of the app's description page.
Configuration (next.config.mjs):
const withMDX = nextMDX({
extension: /\.mdx?$/,
options: {
remarkPlugins: ['remark-gfm'], // enables tables, strikethrough etc.
rehypePlugins: ['@mapbox/rehype-prism'], // syntax highlighting in code blocks
},
})
MDX files go through a pipeline: they're parsed as Markdown, converted to HTML
by remark, and then turned into React components by MDX. The plugins add extra
features to each stage.
📖 MDX documentation 📖 remark-gfm (GitHub FlavoredMarkdown)
10. Internationalisation (i18n) — English & German
File: src/i18n/index.tsx
The site supports English and German. All visible text is stored as key-value pairs in translation files, and components look up text by key rather than hardcoding it.
Translation files (src/i18n/translations/en.ts, de.ts):
// en.ts
'memory.moves': 'Moves: {{count}}',
'memory.youWon': 'You won in {{moves}} moves!',
// de.ts
'memory.moves': 'Züge: {{count}}',
'memory.youWon': 'Du hast in {{moves}} Zügen gewonnen!',
The LanguageProvider component:
export function LanguageProvider({ children }) {
const [locale, setLocaleState] = useState<Locale>('en')
useEffect(() => {
setLocaleState(getInitialLocale()) // reads localStorage or browser language
}, [])
const t = useCallback((key, params) => {
let text = translations[locale][key] ?? translations['en'][key] ?? key
if (params) {
Object.entries(params).forEach(([k, v]) => {
text = text.replace(new RegExp(`\\{\\{${k}\\}\\}`, 'g'), String(v))
})
}
return text
}, [locale])
return <LanguageContext.Provider value={{ locale, setLocale, t }}>
{children}
</LanguageContext.Provider>
}
getInitialLocale()checkslocalStoragefor a saved preference, then falls back to the browser's language setting (navigator.language).- The
t()function takes a key and optional parameters. It finds the string in the current locale's translations, then replaces{{placeholders}}with real values. For example:t('memory.youWon', { moves: 14 })→"You won in 14 moves!". useCallbackmemoises thetfunction so it doesn't get re-created on every render (only whenlocalechanges), which is an important performance detail.
Using translations in components:
let { t } = useTranslation()
<div>{t('memory.moves', { count: moves })}</div>
📖 React Context anduseContext 📖 useCallback
11. Mini-app: Tic-Tac-Toe
File: src/components/games/TicTacToe.tsx
This was the first project built — following the official Reacttutorial. It's a great warm-up because it introduces several fundamental React patterns.
Component structure
The game is split into three components:
TicTacToe (top-level)
└── Board (the 3×3 grid + status text)
└── Square × 9 (individual clickable cells)
Each component only knows about its own responsibility. Square just renders a
button. Board renders the grid and calculates the winner. TicTacToe manages
history and time travel.
State: immutable history
const [history, setHistory] = useState<(string | null)[][]>([Array(9).fill(null)])
const [currentMove, setCurrentMove] = useState(0)
history is an array of board states. Each element is an array of 9 cells
(null | 'X' | 'O'). When a move is made, a new array is created — the old
one is never mutated:
function handlePlay(nextSquares) {
// Discard any "future" moves if the player jumped back in time
const nextHistory = [...history.slice(0, currentMove + 1), nextSquares]
setHistory(nextHistory)
setCurrentMove(nextHistory.length - 1)
}
Why immutability? If you directly modify the existing array, React might not
notice the change and won't re-render. More importantly, keeping all past states
makes the time-travel feature trivial: jumping to move 3 just means setting
currentMove = 3 and reading history[3].
Analogy: It's like making a photocopy of a document before editing it, rather than erasing and rewriting. You always have all the old versions.
Winner detection
function calculateWinner(squares) {
const lines = [
[0, 1, 2], [3, 4, 5], [6, 7, 8], // rows
[0, 3, 6], [1, 4, 7], [2, 5, 8], // columns
[0, 4, 8], [2, 4, 6], // diagonals
]
for (const [a, b, c] of lines) {
if (squares[a] && squares[a] === squares[b] && squares[a] === squares[c]) {
return squares[a]
}
}
return null
}
All 8 possible winning combinations are hardcoded. For each combination, if all
three squares have the same non-null value, that value ('X' or 'O') is
returned as the winner.
📖 Official React Tic-Tac-Toetutorial
12. Mini-app: Memory Game (deep dive)
File: src/components/games/MemoryGame.tsx
This is the most complex solo game in the project. It introduces CSS 3D animations, asynchronous logic with cleanup, and hydration-safe initialisation.
The Card data type
interface Card {
id: number // unique across all 12 cards
imageId: number // shared between the two matching cards (0–5)
imageSrc: string // path to the SVG image
isFlipped: boolean
isMatched: boolean
}
id vs imageId: Two cards can have the same imageId (they're a pair)
but must have different ids (so React can tell them apart). For example, the
two heart cards have imageId: 1 and id: 2 and id: 3.
Creating and shuffling the deck
const CARD_IMAGES = [
'/images/memory-game/star.svg',
'/images/memory-game/heart.svg',
// … 6 total
]
function createDeck(): Card[] {
return CARD_IMAGES.flatMap((src, imageId) => [
{ id: imageId * 2, imageId, imageSrc: src, isFlipped: false, isMatched: false },
{ id: imageId * 2 + 1, imageId, imageSrc: src, isFlipped: false, isMatched: false },
])
}
Array.flatMap maps over the 6 images and for each one returns two card
objects (the pair), then flattens the array of pairs into a single flat array of
12 cards.
function shuffleDeck(cards: Card[]): Card[] {
const shuffled = [...cards] // copy so we don't mutate the original
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
}
return shuffled
}
This is the Fisher-Yates shuffle — the standard algorithm for a fair random shuffle. It works backwards through the array, swapping each element with a random element before it (including itself). Every possible order of 12 cards is equally likely.
Analogy: Imagine 12 cards face-down in a row. You start from the right, pick a random card from the remaining unsorted cards on the left, and swap it into position. Repeat until done.
📖 Fisher-Yates shuffleexplained
State
const [cards, setCards] = useState<Card[]>([])
const [selectedCards, setSelectedCards] = useState<number[]>([])
const [moves, setMoves] = useState(0)
const [isChecking, setIsChecking] = useState(false)
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
cards— the full array of 12 card objects.selectedCards— an array of 0, 1, or 2 indices (positions in thecardsarray) of currently flipped-but-not-yet-evaluated cards.moves— number of pairs the player has attempted.isChecking—trueduring the 1-second delay while a mismatch is shown. Blocks new clicks.timeoutRef— stores the ID of the activesetTimeoutso it can be cancelled if needed (e.g. when starting a new game mid-delay).
Why useRef for the timeout instead of useState? useRef stores a value
that persists between renders but doesn't cause a re-render when it changes.
The timeout ID is purely operational — the UI doesn't need to update when it
changes.
Hydration-safe initialisation
const [cards, setCards] = useState<Card[]>([]) // starts EMPTY
useEffect(() => {
setCards(shuffleDeck(createDeck())) // shuffled only in the browser
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current) // cleanup
}
}, [])
The deck is initialised empty and only filled inside useEffect. Here's why:
Next.js renders the page HTML on the server before the browser runs any
JavaScript. Math.random() would produce a different shuffle on the server than
in the browser, causing a hydration mismatch error — React would see that
what was rendered on the server doesn't match what it's trying to render in the
browser.
By starting with [] (same on server and browser) and only shuffling inside
useEffect (which runs only in the browser), both sides agree on the initial
state.
The return () => { clearTimeout() } is the cleanup function. It runs when
the component is removed from the page, preventing a crash from a timeout firing
after the component is gone.
📖 useEffect andcleanup 📖 Hydration errors inNext.js
Handling card clicks
function handleCardClick(index: number) {
if (isChecking) return // 1. blocked?
if (cards[index].isFlipped || cards[index].isMatched) return // 2. already active?
if (selectedCards.length >= 2) return // 3. two already selected?
// Flip the clicked card
const newCards = cards.map((card, i) =>
i === index ? { ...card, isFlipped: true } : card
)
const newSelected = [...selectedCards, index]
setCards(newCards)
setSelectedCards(newSelected)
if (newSelected.length === 2) {
setMoves(m => m + 1)
const [first, second] = newSelected
if (newCards[first].imageId === newCards[second].imageId) {
// ✅ Match!
setCards(newCards.map((card, i) =>
i === first || i === second ? { ...card, isMatched: true } : card
))
setSelectedCards([])
} else {
// ❌ No match — flip back after 1 second
setIsChecking(true)
timeoutRef.current = setTimeout(() => {
setCards(prev => prev.map((card, i) =>
i === first || i === second ? { ...card, isFlipped: false } : card
))
setSelectedCards([])
setIsChecking(false)
}, 1000)
}
}
}
Step by step:
-
Guard clauses at the top prevent invalid clicks. These early
returnstatements are called guard clauses — they exit the function immediately if a condition isn't met, keeping the main logic at the bottom unindented and easy to read. -
Immutable update:
cards.map(...)creates a new array with the clicked card'sisFlippedset totrue. The original array is never modified. -
Match check: Both cards have the same
imageId? → mark them asisMatched: true, clearselectedCards. -
Mismatch: Set
isCheckingto block new clicks, then usesetTimeoutto flip the cards back after 1000ms. Inside the timeout,setCards(prev => ...)uses the functional update form of setState —previs guaranteed to be the current state at the time the timeout fires, not the stale state captured when the timeout was created.
📖 Functional updates insetState
The card flip animation
Each card is a 3D structure made of three nested divs:
div.perspective ← Sets up the 3D camera
div.preserve-3d ← Rotates when flipped
div.back-face ← The "?" side
div.front-face ← The image side
{/* Outer wrapper — provides the 3D perspective */}
<div className="h-24 w-20 cursor-pointer [perspective:1000px] sm:h-32 sm:w-28">
{/* Middle layer — this is what actually rotates */}
<div className={clsx(
'relative h-full w-full transition-transform duration-500 [transform-style:preserve-3d]',
(card.isFlipped || card.isMatched) && '[transform:rotateY(180deg)]',
)}>
{/* Back face — visible when NOT flipped */}
<div className="absolute inset-0 ... [backface-visibility:hidden]">?</div>
{/* Front face — visible when flipped */}
<div className="absolute inset-0 ... [backface-visibility:hidden] [transform:rotateY(180deg)]">
<img src={card.imageSrc} />
</div>
</div>
</div>
How CSS 3D flipping works:
perspective: 1000px— sets how far away the "camera" is. The larger the value, the less dramatic the perspective distortion. Without this, the flip would look flat.transform-style: preserve-3d— tells the browser that children of this element should exist in 3D space (rather than being flattened onto a 2D plane).backface-visibility: hidden— hides a face when it's rotated more than 90° away from the viewer. Without this, you'd see both faces at once.- The front face starts pre-rotated by 180° (
rotateY(180deg)). When the middle layer rotates to 180°, the front and back cancel out — the front face appears straight-on, the back face is now pointing away. - The flip is triggered by adding
[transform:rotateY(180deg)]to the middle layer.transition-transform duration-500animates it over 500ms.
Analogy: Imagine a door with a poster on each side. The door starts closed —
you see side A. You rotate the door 180° and now you see side B. Each side has a
sign saying "only show yourself if you're facing the viewer"
(backface-visibility: hidden).
📖 CSS 3D transforms(MDN) 📖 CSS backface-visibility(MDN)
Placeholder cards during SSR
{cards.length === 0
? Array.from({ length: totalCards }, (_, i) => (
<div key={i} className="h-24 w-20 sm:h-32 sm:w-28">
<div className="... bg-violet-500">?</div>
</div>
))
: cards.map((card, index) => ( /* real cards */ ))
}
Since cards starts as [] on the server, this renders 12 face-down
placeholder cards — the correct number, in the correct size. This prevents a
layout shift when the real cards appear after hydration.
Win detection
const matchedPairs = cards.filter(c => c.isMatched).length / 2
const isGameWon = matchedPairs === CARD_IMAGES.length
{isGameWon && (
<div className="... text-green-700">
{t('memory.youWon', { moves })}
</div>
)}
These are derived values — calculated from state rather than stored in separate state variables. This is good practice: fewer state variables means fewer bugs from state getting out of sync.
13. Mini-app: Digital Handshake
File: src/app/apps/digital-handshake/page.en.mdx
This app is a personal portfolio website built as a separate project (with its own GitHub repo). The entry in this playground is just a description card pointing to the live site and the GitHub repository.
Key technologies used in that project:
- Next.js App Router with static export — same stack as this playground
- MDX content stored in a private git submodule — the portfolio content (work history, education) is kept in a private repository, while the site code is public. The submodule is checked out at build time.
- Tailwind CSS for styling
- next-themes for dark mode
The submodule pattern is a clever way to keep personal content private while open-sourcing the code. If someone clones the public repo, they get the site structure but not your personal data.
14. Mini-app: Calisthenics Progression
File: src/app/apps/calisthenics-progression/page.en.mdx
This is an older project (2019) built with a completely different stack — Python + Flask instead of JavaScript + React. It predates this playground and is listed here as a historical project card.
Key differences from the React apps:
| Feature | This playground (React) | Calisthenics app (Flask) |
|---|---|---|
| Language | TypeScript / JavaScript | Python |
| Rendering | Client-side + static export | Server-side (Flask renders HTML) |
| Styling | Tailwind CSS | Bootstrap |
| State management | React useState | Server state + HTML forms |
| Database | Supabase (multiplayer only) | SQLite or SQLAlchemy |
Seeing both in one portfolio is useful: it shows how the same problem (building a web UI) can be solved very differently, and why the JavaScript ecosystem has become dominant for interactive UIs.
15. Multiplayer — real-time with Supabase
Files: src/lib/multiplayer.ts, src/components/multiplayer/
Both Memory Game and Tic-Tac-Toe have multiplayer modes where two players can play in real time from different browsers. This is powered by Supabase — an open-source Firebase alternative that provides a PostgreSQL database with a real-time subscription feature.
How the room system works
Analogy: Think of it like an online game lobby. Player A creates a room and
gets a 6-character code (e.g. XK9P2M). Player B types that code to join. Once
both are in, the game starts and both players see every move as it happens.
Room lifecycle
Player A: createRoom() → inserts a row into the 'rooms' table
room.status = 'waiting', room.player_a = 'alice'
Player B: findRoomByCode() → reads the row by code
joinRoom() → updates room.player_b = 'bob', status = 'playing'
Both: subscribeToRoom() → listen for changes via Supabase Realtime
Room code generation
function generateRoomCode(): string {
const chars = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'
let code = ''
for (let i = 0; i < 6; i++) {
code += chars[Math.floor(Math.random() * chars.length)]
}
return code
}
The alphabet deliberately excludes visually confusing characters: I, O, 1,
0 (hard to tell apart from each other). This is a thoughtful UX detail — the
code is meant to be typed by a human.
Real-time subscriptions
export function subscribeToRoom(
roomId: string,
onUpdate: (room: Room) => void
): RealtimeChannel {
return supabase
.channel(`room:${roomId}`)
.on('postgres_changes', {
event: 'UPDATE',
schema: 'public',
table: 'rooms',
filter: `id=eq.${roomId}`,
}, payload => {
onUpdate(payload.new as Room)
})
.subscribe()
}
Supabase Realtime uses PostgreSQL's logical replication to detect row
changes and push them to connected clients via WebSockets. When Player A makes a
move (which updates the rooms table), Player B's subscription fires instantly
— no polling required.
Game state sync
The entire game state (all card positions, flipped states, scores) is stored as
a JSON column in the rooms table. When a player makes a move, they:
- Calculate the new game state locally.
- Call
updateGameState(roomId, newState)to write it to Supabase. - Both players'
subscribeToRoomcallbacks fire with the new state. - Both UIs re-render.
📖 Supabase Realtime documentation 📖 WebSocketsexplained
16. Build & deploy
Development
npm run dev
This runs three things in sequence (see package.json):
prepare-content— a script that prepares MDX content (copy content from submodule to app).export-projects— a script that exports project data, which is used in DigitalHandshake.next dev— starts the development server with hot reload.
Production build
npm run build
Because next.config.mjs sets output: 'export', Next.js generates a
completely static site (plain HTML, CSS, and JS files in an out/ directory).
No server is needed to host it — you can put it on any static file host (GitHub
Pages, Netlify, Cloudflare Pages, etc.).
trailingSlash: true ensures all URLs end with / (e.g. /apps/memory-game/),
which is required for static exports because each route is a directory with an
index.html inside.
Path aliases
In tsconfig.json, the @/ alias maps to src/:
"paths": { "@/*": ["./src/*"] }
This means instead of writing ../../components/Card you write
@/components/Card. Much easier to read and refactor.
📖 Next.js staticexports 📖 TypeScript path aliases
How to replicate this project from scratch
If you wanted to build this step by step, here's the order that makes most sense:
- Set up Next.js with TypeScript and Tailwind —
npx create-next-app@latest --typescript - Build the Layout component (Header, Footer, Container) — no logic, just HTML structure and Tailwind styling.
- Add the MDX system — configure
next.config.mjs, create one app folder with apage.en.mdxthat exports metadata, writelib/apps.tsto read it. - Build the home page — display app cards using
getAllApps(). - Build Tic-Tac-Toe — start here because it's the simplest stateful game. No timers, no animations.
- Add the i18n system —
LanguageProvider,useTranslation, EN + DE translation files. - Build Memory Game — builds on Tic-Tac-Toe concepts but adds CSS 3D animations and async timeout logic.
- Add dark mode —
next-themes,ThemeProvider,ThemeWatcher. - Add multiplayer — create a Supabase project, set up the
roomstable, buildGameLobbyand the real-time sync logic.
Generated March 2026 — covers commit state as of the documentation date.