How this is builtGroundworkHow this is built
10 / 26 written chapters

I4 · Part 2 · How it runs

Where a reader's state lives

Thirty-six localStorage keys, two sessionStorage keys, a query string and a URL hash. There are no accounts, no cookies and no database.

9 min read1 diagram7 sections
07On this page
  1. Everything is in the browser
  2. The core keys: lib/storage.ts
  3. The other twenty-six
  4. The progress model
  5. How components follow it
  6. State in the URL
  7. Why nothing is on a server

Everything is in the browser

This site keeps nothing about its readers on a server. No file under app/ uses "use server". Nothing reads or writes a cookie. The three functions under /api keep no data between requests, apart from a rate-limit counter in memory. So everything the site remembers about you has to live in one of four places your browser already has.

Components client islands lib/hooks.ts useSyncExternalStore lib/storage.ts progress, activity, code Feature stores playground, runs, mock, boards lib/navTrail.ts where you came from Problem filters ProblemsView Share links #share= and #board= localStorage 36 keys or prefixes one origin, one device sessionStorage 2 keys, one tab The URL query: replaceState hash: never sent to any server
Every store is a small module with a get, a set and, where a component needs to follow changes, a subscribe. Components never touch localStorage directly while rendering.

The core keys: lib/storage.ts

KEYS has ten entries, all prefixed jsnotes:, from the site's first name. Every read and write goes through store. It JSON-encodes values, wraps each call in try, returns the fallback on the server or when parsing fails, and makes set() return false when the browser refuses a write, for example because storage is full or disabled.

KeyHolds
jsnotes:progress{ chapters, exercises }: a mark per chapter read and a flag per exercise solved
jsnotes:activityA count per local calendar day, "2026-09-25": 3
jsnotes:code:{id}, jsnotes:code:{id}:{lang}Unsaved editor text per exercise; JavaScript has no suffix
jsnotes:levelThe last level you read, so topic links go straight to it (a topic with chapter checks, such as DSA, keeps its level in its placement instead)
jsnotes:theme, jsnotes:fontOne of nine themes and eight fonts, read before paint by the theme-init script
jsnotes:narrationVoice, rate and pitch
jsnotes:sound-enabledWhether solving an exercise plays a sound

The other twenty-six

As features were added, each kept its own keys next to its own code. Newer ones use a groundwork: prefix. Most still go through store. The whiteboard has its own read and write in lib/whiteboard/storage.ts, because a board with pasted images can fill the quota, and a failed save has to be reported to the reader rather than silently ignored.

AreaKeys
Readerjsnotes:zoom, jsnotes:reading-budget, jsnotes:last-seen-level, groundwork:resume (the last chapter you opened, so the site menu can offer to continue there)
Editorjsnotes:editor-settings, jsnotes:editor-height, jsnotes:playground-live, jsnotes:lang:{exerciseId}
Playgroundgroundwork:playground:project (every open file), groundwork:playground:runs (the last 15 runs), groundwork:playground:stdin
Whiteboardgroundwork:boards (the index), groundwork:board:{id} (one per board), groundwork:boards:last, groundwork:boards:prefs
Tourgroundwork:tour ({ v: 1, seen: true, at }, written when the first-visit tour is skipped or finished, so it never offers itself again; a missing or malformed value counts as not seen)
DSA chapter codegroundwork:dsa:lang (versioned: { v: 1, language }, one of JavaScript, Python, Java or C++, with its own store, snapshot cache and cross-tab storage listener; read only by a chapter that has marked code blocks)
Chapter checkgroundwork:quiz (versioned: { v: 1, chapters }, one record per chapter with its attempts, best score, last attempt time, pass time, whether it was marked read without the check, the ids missed in the latest attempt, and whether the end-of-chapter popup was dismissed; it has its own store, snapshot cache and cross-tab storage listener, and a chapter's read mark stays in jsnotes:progress)
DSA placementgroundwork:dsa:placement (versioned: { v: 1, record }, one record with how the reader was placed, their level, a score for each stage, the chapters tested out and the chapters allotted, when it was taken and how many seconds it took; absent means not placed yet, and a stale version or junk reads as absent; it has its own store, snapshot cache and cross-tab storage listener, an in-memory copy when a write fails, and it never writes jsnotes:progress or jsnotes:level; /dsa/placement saves it when a placement ends, the doors save a start-from-the-beginning or picked-level record, a run in progress is kept in the sibling key groundwork:dsa:placement-draft, which a finished run or Reset removes, and Reset on the plan screen clears only these two keys)
Mock interviewgroundwork:mock:config, groundwork:mock:current (a loop in progress), groundwork:mock:history (last 50), groundwork:mock:retry (up to 60 questions to try again)

sessionStorage holds two more, groundwork:nav:here and groundwork:nav:from. NavTrail in the root layout records every pathname and title, so the playground's back button can say where you came from. That only makes sense for the tab you are in. Three other strings that look like keys are not keys: groundwork:problems-url is an event name, groundwork-board tags clipboard and export JSON, and groundwork-preview is a postMessage type.

The progress model

A chapter mark is { at, reviews }. The literal true is still accepted from older saves and treated as { at: 0, reviews: 0 }. Review is spaced repetition with fixed gaps: a chapter is due 3, 7, 21, 60 and then 180 days after the last time it was read or reviewed. After the fifth review, dueAt() returns null and the chapter is never due again. An exercise is simply true once solved.

The first time a chapter is marked or an exercise solved, a call to recordActivity() adds one to today's count. lib/gamification.ts derives everything else and stores none of it. The streak is the run of consecutive days with activity. XP is 10 per exercise, 5 per chapter and 3 per review. The level is floor(sqrt(xp / 25)) + 1. Keeping only the raw facts means a change to the formula applies to everyone's history at once, with no migration.

Whether a tick marks a chapter read straight away is a topic setting, Topic.completion, which is "read" unless set. lib/completion.ts has no runtime content imports and answers it for every place that ticks a chapter and every place that picks the next one. tickHref returns null for a "read" topic, and for a chapter that is already read in any topic, so the usual button toggles the mark. For an unread chapter in a "quiz" topic it returns a link to the chapter's #check, so a read chapter can always be unmarked. nextChapter is what Continue, Up next, the path's next step and the reading budget ask, and it returns the first unread chapter. A server page passes completion down as a string, because a function cannot cross into a client component. So far only the DSA topic is "quiz", and the link lands on that chapter's check.

How components follow it

progress and activity share a single listener set. Every write calls emitProgressChange(), and every component that used useProgressValue re-reads its snapshot. The reading plan, the review badge and the streak all update the moment you mark a chapter, and nothing polls. The mock store and run history keep their own listener sets and cached snapshots. The rendering chapter explains why snapshots must be primitives or cached objects.

What this does not do is follow other tabs. Nothing listens for the browser's storage event, so a chapter marked in one tab shows up in another only after it navigates or reloads. Adding that would be a single addEventListener in progress.subscribe.

State in the URL

The /problems filters are the search box, topic, levels, status, sort and view. They live in the query string, so a filtered list can be bookmarked or shared. ProblemsView reads window.location.search with useSyncExternalStore. It subscribes to popstate and to a custom groundwork:problems-url event, and its server snapshot is the empty string. Changes are written with history.replaceState, leaving out any value that equals its default, and then the event is fired. Typing in the search box therefore does not add a history entry per keystroke.

It avoids useSearchParams() on purpose. On a prerendered page that hook renders everything below the nearest Suspense boundary in the browser only. That is why /practice ships 27 KB of HTML. With the external-store approach, /problems prerenders the full unfiltered list, 276 KB of HTML, and applies your filters straight after hydration. /practice?id=, the bare /path and the old /level?topic= links do use useSearchParams inside Suspense. Their HTML has nothing worth prerendering anyway — the bare /path only ever reads a topic and a level from the query string to redirect on. The old /soon?topic= links never reach a client at all: next.config.ts redirects them to the topic's own cover or level page, both prerendered, or to the home page. A written topic's old /path?topic=&level= link is redirected by the same file straight to /path/<topic>/<level>, a prerendered page that reads neither useSearchParams nor any other request input.

Share links live in the hash, which browsers never send to a server. lib/shareLink.ts packs the playground's files as [name, lang, code] rows. It compresses them with CompressionStream("deflate-raw"), encodes them as base64url and appends the result as #share=. On the way in it accepts at most 20 files, 60-character names and 200,000 characters of code each. The whiteboard's #board= works the same way. It leaves out pasted images, and on reading it keeps at most 2,000 elements. Both open what they receive as new tabs or a new board next to your own work, never replacing it, and both clear the hash afterwards. HashRedirect handles a third kind of hash: an old /notes#closures link is forwarded to /notes/closures.

Why nothing is on a server

Because nothing is stored about anyone, there is nothing to sign in to, nothing to leak and no database to run. That is also why every page can be a static file: no page differs by reader until the browser has it. The cost is just as concrete. Clearing site data erases months of progress. A second device starts from zero. And a full localStorage quota can only be reported, not solved.

Where sync would plug in

Progress is the state worth syncing, and all of it goes through progress and activity in lib/storage.ts. An account-backed version would replace those two objects' reads and writes and keep the same subscribe(). No component would change. The other thirty-one keys are preferences and drafts that can stay per-device.