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

I3 · Part 2 · How it runs

Server, client, and the line between them

Every page is rendered on the server once, at build time. The work is in deciding what crosses into the browser and making sure the first client render matches what the server wrote.

24 min read1 diagram6 sections
06On this page
  1. The rule
  2. What a chapter page is made of
  3. next/dynamic, and where it is not used
  4. Making the first client render match the server
  5. The lint rules that enforce it
  6. Two scripts in the root layout

The rule

A component is a server component unless it needs something only a browser has: state, an effect, an event handler, localStorage, or a measurement. Adding "use client" has a cost, because everything the file imports is bundled and shipped. Across app/, components/ and lib/, 158 files carry the directive. About a third belong to the three big interactive surfaces: the playground, the whiteboard and the mock interview.

What a chapter page is made of

TopicChapterPage branches on chapter.ready. An outline chapter, one that has not been written yet, renders TopicFrame with TopicOutlineChapter (components/topic/TopicOutlineChapter.tsx), a client component built from the same shared rail, header pager and bottom pager as the written path below, minus the search box, TocCard and the Listen button, since there is no body to search, link into or narrate. Its article is a roadmap card built from the chapter's syllabus section, and its aside carries a "Meanwhile" link to a related written topic and its interview round, from lib/topicRelated.ts.

A written chapter — every chapter in JavaScript, React, DSA and System Design, 156 of them — takes the path below instead, built from the same shared parts (ChapterRail, TocCard, ChapterEnd, ChapterPager, ChaptersSheet) as this very page.

app/layout.tsx · SERVER theme-init script runs before first paint TopicsNavProvider server-computed data handed to client context RouteFade · CLIENT TopicChapterPage · SERVER TopicFrame · client: wraps PageFrame TopicReader · client: rail search, keys, active section chapter HTML handed down as a prop ChapterRail / TocCard ChapterEnd / ChapterPager After mount enhance code, tables run demo scripts wire the narrator
Green is rendered on the server, and yellow is a client component. A server component can sit inside a client one when it is passed in as children or a prop, which is how the chapter body ends up as HTML while the reader around it is interactive.

TopicChapterPage in components/reader/topicPages.tsx is a server component. For a written chapter it computes the chapter's HTML with withHeadingIds, its diagram and exercise counts, and a flat list of the topic's chapters and levels, then hands all of it as props to TopicReader (components/topic/TopicReader.tsx) — a client component, the same role TopicOutlineChapter plays for an outline chapter, but interactive from the start because it owns the rail's search box, the active-section ring and the keyboard shortcuts. It renders TopicFrame, which wraps the client PageFrame, for its header, puts ChapterHeaderPager in its actions slot, and assembles ChapterRail, TocCard, ChapterEnd and ChapterPager around the chapter body — the same shared parts the architecture and Git readers use; a later story moves the architecture reader's own inlined header pager onto this one too. The chapter body itself still reaches the browser as HTML, written in by the server component above and handed down as a prop, not fetched or re-rendered by the client.

Topic covers branch the same way TopicChapterPage does, in TopicCoverPage, but on topicStats(topicId).written > 0 rather than a single chapter's ready flag. The three topics with a cover and something written — JavaScript, React and System Design — get TopicCover (components/topic/TopicCover.tsx), a server-computed list of cards and parts handed to a client component that renders TopicFrame and reads its own done/next/budget state through useCoverProgress, which wraps useReadingPlan. DSA has no cover of its own: its home is /level/dsa, rendered by TopicHome (components/topic/TopicHome.tsx), a thin adapter: it reads the reading plan, the check states and the placement, builds plain props and hands them to the generic topic shell, TopicShell (components/topic/shell/). The page leads with the reader's level. A first-time reader sees a friendly invitation whose one primary button is Check my level, with the size of the check taken from the placement bank, two quiet links (start from the beginning, pick a level myself) and a small three-stop preview. A placed reader sees a level card with a progress ring and an up-next card with one Continue button. Both get a journey map below: three stage cards on a dashed trail, the current one open and the others closed with a peek at their next stop, then a row of four tiles (practice, review, interview and notes) and a closed Plan & level panel that holds the plan, the level picker and the time budget. The bare /dsa is a permanent redirect to it, and topicHome() in lib/topics.ts gives every back link and breadcrumb the right address. Every other topic's cover is a roadmap: TopicCoverPage renders TopicOutline (components/topic/TopicOutline.tsx) in the same TopicFrame, listing the planned levels and sections.

The chapter body is written into the page with dangerouslySetInnerHTML and carries suppressHydrationWarning. The attribute is there because, after mount, TopicReader changes that DOM directly. enhanceCodeBlocks, enhanceTables, enhanceTryBlocks, activateScripts and setupNarration in components/reader/enhancements.ts and narration.ts add copy buttons, wrap tables, run each chapter's demo scripts and attach the narrator; TopicReader adds one more, markNoSmooth, which tags every .try, .demo, canvas, range input and draggable element with data-no-smooth so dragging one does not fight the page's smooth-scroll underneath it. React never re-renders that HTML, so those changes are safe.

A chapter can also mark a place for something interactive with a block-level <div data-play="binary-search"></div>. For such a chapter, splitIslands in lib/chapterIslands.ts cuts the body at each placeholder into HTML segments and islands, and TopicChapterPage hands TopicReader those segments instead of one string. The reader still renders the same #chapters container, with the HTML segments written in as before and, in each island's place, the element that TopicChapterPage rendered on the server and passed down in islands. The island carries data-island, and the enhancers, narration and scroll regions skip anything inside it. A chapter without a placeholder takes the single-string path unchanged.

The island is PlayIsland, an async server component. It asks the registry in lib/play/registry.ts, which holds one dynamic import() per play id, for that id's tracer, a pure function in lib/play/ that records a whole run as a list of frames. It runs the tracer once on the default input and hands the frames, the code lines and a title to Player, the one client component in components/play/, as props. The tracer is imported only on the server, so none of its code reaches the browser, and the first frame is in the HTML before any JavaScript runs. The player only indexes into the frames, so back, scrub and reset cost nothing. An id the registry does not know renders the island with a plain sentence instead of a player.

A chapter can also wrap a code block in a div whose data-code attribute names the block, with the JavaScript inside the pre, which is what the server renders and what shows by default. When the body contains one, TopicChapterPage passes codeBlocks and TopicReader shows a four-way language switch (CodeLanguageSwitch) in the chapter header. Choosing Python, Java or C++ dynamically imports that one language's file from content/dsa/code/<chapter-id>/, a map from block id to the HTML for the <code> element, and applyCodeLanguage rewrites only the marked blocks, so the page, the players and the checks are left alone. The choice is the groundwork:dsa:lang key. Every marked block must have a translation in all three languages, and each parses with the editor's grammars, which tests/dsa-code-languages.test.ts checks.

The questions behind a chapter check are data, kept apart from the chapter. content/quiz-types.ts holds only types: a Question that is a single, multi, order or predict, each tagged with its chapter, level, skill, pattern and the heading that teaches it, and a PatternRecord with a chapter's signals, template, time, space and interview styles. System Design has its own pool type beside it: a DesignQuestion is a single, multi or order question tagged with the same chapter, level and heading but with a skill from a short design vocabulary (concept, trade-off, diagnose, estimate, sequence) and no pattern, so none of the DSA pattern vocabulary leaks into it. The runner, the draw and the shared integrity helpers take either kind. lib/quiz.ts holds the fixed vocabularies of patterns, skills and interview styles and the pure helpers that report problems in a pool, so a client component may import it. Each chapter's questions sit in their own file under content/dsa/quiz/, and lib/quizPool.ts keeps, like the play registry, one dynamic import() per chapter id behind loadPool (every written chapter has a pool, forty-eight in all, from Complexity analysis to Interview strategy), which gives null for an id it does not know. Nothing else imports the pool files, so a chapter page can load its own questions without carrying any other chapter's. tests/dsa-quiz.test.ts runs the helpers over every pool the registry lists, and each helper has a bad fixture that proves it fails.

The check itself is plain logic and one store. lib/checkDraw.ts draws five questions with at least one trace and one recognise or complexity question, prefers questions the last draw did not hold so a retry differs whenever the pool allows, grades each kind exactly (a multi answer needs the exact set and an order answer the exact sequence) and passes at four of five. lib/quizRecord.ts holds the record transitions and checkStatus, which derives passed, not checked, read before checks or unread from the read mark and the record, so unmarking cannot leave a stale checked. lib/quizStore.ts keeps the records in groundwork:quiz, following the code-language store. A pass marks the chapter read through progress.setChapterDone, only when it is not already read, so a Check yourself pass on an old mark keeps that mark and its review clock. Marking a chapter unread from the reader, the cover or the path also clears its pass and not-checked flag but keeps its attempts, and Reset my progress clears the store. The runner stops the reader's plain-key shortcuts from reaching the page while focus is inside the check. A tick that links to the check also opens it: the end card, contents card and chapter card ticks call requestCheck in lib/checkOpen.ts from their click, which dispatches a groundwork:open-check event on window and leaves the native #check anchor scroll alone, and the runner, which also looks at the address on arrival for #check, starts a fresh check at once (from the start card or a finished one), draws the five questions, shows a banner above the first that says to pass 4 of 5 and offers Mark as read without the check, pulses the card for 1.4 seconds (a static outline under reduced motion) and moves focus to the question's legend. A tick pressed with a modifier key or a non-primary button is left to the browser, a check begun with the Start button gets the banner on the next tick without being redrawn, and a second click while the bannered check runs only repeats the pulse, and a chapter that is already read ignores the event and the address.

The end card asks once. EndPopup in components/check/ watches the end card with an IntersectionObserver and opens EndPopupDialog, a focus-trapped portal dialog on the shared FocusTrap, only after the end card has stayed in view for 800 milliseconds, so a fast scroll past it or a jump that lands elsewhere stays quiet and nothing depends on motion. The rule is a pure function in lib/endPopup.ts: not before hydration, only for a chapter with a check that is not read, never after a dismissal, never when the address is #check or the page was reached there, never while a check question or another dialog is on screen. Yes saves the dismissal, asks the runner to open the check and sets the #check address; Not yet, Escape and a click outside save it too, and focus returns to where it was. The dismissal is the popupDismissed flag on the chapter's record in groundwork:quiz, so it survives a pass, an unmark and a reload, and Reset my progress clears it.

The state of each chapter shows in text on the chapter rail and the phone Chapters sheet, the cover's entries and chips, and the path's steps and progress card. lib/chapterState.ts turns a chapter's read mark, its groundwork:quiz record, whether it has a check, and the placement's tested-out list into one of four words: checked (a pass, and only while the read mark exists), not checked (marked read anyway), read before checks (read with no pass and no anyway flag, which covers old marks and a miss) and tested out (credited by the placement and not yet read). A chapter with no check written says nothing, a stale pass on an unread chapter says nothing, and ids in the placement that the topic does not have are ignored. lib/useChapterStates.ts reads the quiz store and the placement store with useSyncExternalStore, so all of it is empty until the page has hydrated, and gives topics without completion: "quiz" an empty map, so Git, this book and the JavaScript path render as before.

Finding a reader's level is plain logic first, with the flow built on top of it. lib/placement.ts is a pure reducer: it takes the chapters in curriculum order and the placement-tagged questions as arguments, keeps a state that survives a JSON round trip, and never draws at random, so the same answers always give the same state. It asks six beginner questions, then four more beginner ones, or six intermediate ones and then six advanced ones, so no path passes 18 questions. A stage's score is its correct answers over the answers given. A score of 0.8 or more raises the reader to the next stage and one below 0.45 lowers them, the same two thresholds the mock interview uses, and a stage ends early after three misses or "not sure" answers in a row. A "not sure" answer counts as a miss. A chapter is tested out when it has two correct answers and none of its direct prerequisites was missed or answered "not sure". The result lists the chapters still allotted (everything at or above the reader's level, plus lower ones they missed, less those tested out), the chapters tested out, and the chapters to revise earlier, which are lower ones that are neither allotted nor tested out and stay open. Its stage sizes live in the module rather than in content/dsa/placement.ts, because client code cannot import content/dsa. lib/placementRecord.ts holds the stored shape, its sanitiser and the builders for the three ways a reader can be placed, and lib/placementStore.ts keeps one record in groundwork:dsa:placement, following the check's store, never touching jsnotes:progress or jsnotes:level. loadPlacementBank in lib/quizPool.ts gathers the placement-tagged questions from every registered pool, keeping the pools that load if one fails.

The flow is /dsa/placement, a static page that is noindex and has no timer. The server renders the site header and a skeleton, so the page is never blank while the script loads, and PlacementFlow in components/placement/ takes over in the browser: it loads the bank and shows one of three screens in a column as wide as the --topic-w token allows (1040 pixels): an intro card with what to expect, the honest size line from the bank and one Begin button; the questions; or the plan. The question screen has a progress bar in the colour of the stage being asked and asks one question at a time with the check's own ChoiceList and OrderList, which draw each answer as a lettered card (A to D or 1 to 4 pick one and Enter continues; lib/choiceDisplay.ts maps the keys), with a Next button that stays disabled and says why until an answer is picked, a "Not sure yet" button of the same height (sticky at the bottom of the screen on phones), and "Previous question" and "Start over" as quiet links. It shows no score, and saves the record in groundwork:dsa:placement when the last question is answered. Each answer is also kept in a draft, groundwork:dsa:placement-draft, so a reload resumes the run; lib/placementDraft.ts drops a draft that does not fit the bank. The length and time it quotes come from the bank itself (lib/placementBank.ts): it says how many chapters have placement questions, that a thin bank gives a first estimate and the highest level it can reach, and the plan page leaves out its Revise earlier sentence when that list is empty. Only ?retake=1 starts a retake. Its plan screen leads with the level in a ring in the colour of that stage and one Continue button, with Retake and Reset as quiet actions below it, then lists the chapters to read, the ones tested out and the ones under Revise earlier, none of them locked. Reset asks again before it clears only the placement key and its draft. lib/placementPlan.ts turns a record and the chapter list into that view and ignores ids the topic no longer has. A reader with no placement and no read mark sees an invitation on /level/dsa whose primary button is Check my level (the flow), with two quiet links: start from the beginning (a record that allots everything) or pick a level myself (a record built from the level, written only for a first-time reader); if the bank is empty the page shows those and the flow as three equal doors instead. A placed reader sees a level card and one Continue button, with the plan inside the closed Plan & level panel, and a reader with read marks sees a gentle prompt to check their level, which runs the same flow and never touches a mark. nextChapter in lib/completion.ts takes the plan, and readingPlan in lib/placementPlan.ts builds it from the same split the plan page lists, so Continue, Start, Up next, the header button and the reading-time budget on the cover and the plan page all use the first unread chapter that is allotted and skip the chapters tested out and the ones under Revise earlier (the path's own next step still falls back to the first open chapter of that path), and a topic that checks no longer writes jsnotes:level from its level cards or its path.

A topic whose completion is "quiz" gets a second island, CheckIsland, with id="check", between the practice strip and the end card of each written chapter. It is an async server component: topicPages.tsx builds it, because TopicReader is a client component and cannot render one, and passes it in as a check node the same way as the play islands. CheckIsland calls loadCheckQuestions in lib/checkPool.ts, which asks the DSA registry and then the System Design one, and hands only that chapter's questions to the client CheckRunner, which draws nothing until the reader presses Start, so the cached server HTML holds only the start card. A chapter with no pool renders no check card at all: the end card carries one line saying there is no check yet, and its Mark as read button and the rail's are the only ways to mark it. In that topic, while a chapter with a check is unread, the end card's and the contents card's Mark as read are links to it, and so are the ticks on the cover cards and the path steps, which point at the chapter's page. The server pages pass the ids of the chapters that have a pool to the reader, the cover and the path as a plain list, because lib/completion.ts returns no link for a chapter with no check, which keeps its ordinary read toggle. A chapter that is already read keeps its toggle button. The state chapter explains the policy behind that.

System Design keeps completion as "read", because "quiz" would send its level page through the DSA placement flow, which saves to the single key groundwork:dsa:placement. Its chapters still carry a pool: lib/designPool.ts keeps one dynamic import() per chapter under content/system-design/quiz/, and topicPages.tsx renders CheckIsland in a practice mode for a topic that does not require checks. In practice mode CheckRunner draws the same five questions and shows the same explanations and Read again links, but it never marks the chapter read, never writes to groundwork:quiz and ignores the #check address and the tick events. tests/system-design-quiz.test.ts runs the integrity helpers over every System Design pool, recomputes each numeric answer from the files in tests/design-estimates/, and fails when a chapter has neither practice nor a pool.

The root layout works the same way. It computes the topic list, with each topic's written count, and the guides' chapter lists on the server, then hands them to a small client provider in lib/topicNav.tsx. The site drawer reads them from context and never imports the topic data module itself.

next/dynamic, and where it is not used

ImportWhereWhy
Board, with ssr: falseapp/whiteboard/WhiteboardShell.tsxIts useState initialisers read localStorage, and on a first visit even write a board. None of that can run on a server, so the server renders only the "Setting up the board" line. The board's chunk is 86 KB, or 28 KB gzipped, and is not part of the first load.
Room and Scorecardapp/mock/MockApp.tsxOnly needed once a loop starts. app/mock/preload.ts exports the same import(), so the lobby warms it with requestIdleCallback when the pointer enters, or focus reaches, a start button.
PracticeWorkspaceapp/mock/Room.tsxCoding rounds need the editor, but talking rounds do not.
FocusScopeShortcutHelpOnly needed once the shortcut help opens.

The playground editor is not loaded this way, even though that would be the obvious place for it. PracticeWorkspace imports CodeEditor statically and renders it only when useMounted() is true. The prerendered HTML contains EditorSkeleton instead. That is why /practice and every problem page load 1,182 KB of first-load JavaScript, against 681 KB for a chapter page. What is split off is finer-grained: the 18 language modes in lib/codeLanguages.ts, Vim mode, the minimap, the TypeScript compiler and sql.js are all behind import() and load when first needed.

Making the first client render match the server

A prerendered page was built by a server that has never seen your browser. If the first client render reads localStorage and draws something different, React throws a hydration error. It has happened here at least twice. The mock lobby read its saved loop settings in a useState initialiser, which caused React error #418 for anyone who had used it before. The playground read its saved Live toggle and stdin during render. Both now go through the hooks in lib/hooks.ts, which are all built on useSyncExternalStore. During hydration it uses the server snapshot, and it switches to the client snapshot straight after.

HookServer and hydrationAfterUsed for
useMounted()falsetrueGating whole subtrees and effects, such as the editor and the reader's DOM enhancements
useClientValue(get, fallback)fallbackget(), read onceZoom, reading budget, editor height, whether the browser has IntersectionObserver
useProgressValue(get, fallback)fallbackget(), re-read on every progress writeDone counts, due reviews, streaks, solved ticks
useLastLevel(), useOSColorScheme()null, "light"Saved level; live media queryTopic links, the theme picker

useSyncExternalStore compares snapshots with Object.is, so a getter that builds a new object on every call would re-render forever. The code gets around this in two ways. Some getters return a primitive. ProgressView subscribes to JSON.stringify(computeStats()) and parses it in a useMemo, and useReadingPlan subscribes to a string of ones and zeros. The others cache the parsed value against the raw string: runsSnapshot() in lib/runHistory.ts, savedConfig() in the lobby, and mockSnapshot(), which is dropped on every write.

Settings the reader can change use a second pattern. useReadingPlan reads the saved reading budget with useClientValue, keeps an override in useState that starts as null, and uses the override once the reader clicks. There is no effect that copies storage into state.

The lint rules that enforce it

eslint.config.mjs uses eslint-config-next 16.3.0, which turns on eslint-plugin-react-hooks 7.1.1's recommended set: 16 rules. Two are the familiar ones, rules-of-hooks and exhaustive-deps. The other 14 come from the React Compiler's analysis. They include set-state-in-effect, set-state-in-render, refs, purity, immutability, globals and static-components, and all but two are errors. The compiler itself is not enabled: there is no reactCompiler option in next.config.ts and no compiler plugin installed. The code is held to the compiler's rules without being compiled by it.

These rules shaped the patterns above. set-state-in-effect is why useMounted is not the usual "set a flag in useEffect" hook. The rule also explains a workaround: the level-up celebration on /progress calls setState inside a setTimeout(…, 0) in its effect. Seven lines in the codebase switch a rule off: three for exhaustive-deps, two for refs in CodeEditor, and two for prefer-const.

Two scripts in the root layout

theme-init. app/layout.tsx renders <html data-theme="light"> because the server cannot know your theme. Before the page paints, a blocking inline <script> in the <head> runs the code in lib/themeInitScript.ts. It reads jsnotes:theme and jsnotes:font, falls back to prefers-color-scheme, and sets data-theme and data-font on the root element. There are nine themes and eight fonts. <html> has suppressHydrationWarning because those attributes will legitimately differ from the server's. The script sits in <body>, as the Script docs ask, and Next moves it into the head.

RouteFade. This client component wraps every page. It stores the first pathname in useState, and while you are still on that path it renders its children bare. That way the hydrated tree is exactly the server's, and nothing animates on first load. After a client navigation it wraps the page in <div key={pathname} className="route-fade">, which fades it in over 0.28 seconds. The key remounts the page on every navigation, which throws away its state. That is fine here, because a new route is a new page anyway. The providers, ErrorReporter, NavTrail and the analytics components sit outside it and stay mounted.

The rule for the next feature

Read browser state through one of the hooks in lib/hooks.ts, never in render or in a useState initialiser, unless the whole component is behind ssr: false. A browser test used to seed storage before loading the mock lobby and fail on error #418; it was cut with the browser suite, so the rule is kept now by reading the console for hydration errors when a page that reads storage changes.