cf.completefrontend
My progress
SYSTEM DESIGN · #143

Design a documentation site like MDN

A documentation site with thousands of reference and guide pages in several versions and languages, written as Markdown in git, built into static HTML, and read mostly by developers who arrive from a search engine. The hard parts are building and deploying 10,000+ pages fast, navigation and links that never break, versions and translations side by side, search, and live code examples that do not slow the page down.

The question

Design the front end of a documentation site like MDN Web Docs. It has over 14,000 English pages of reference and guides, translations into several languages, and docs for more than one product version. Readers search, browse a sidebar, and run code examples inside the page. Writers change pages through pull requests.

Focus on how the content becomes fast pages, how readers find their way, how versions and languages fit together, search, and live examples. Assume the content already exists in a git repository.

Real-life examples: MDN Web Docs, React.dev, Stripe docs, Docusaurus, Nextra, VitePress, Read the Docs

Requirements: many pages, many readers, few writers

A documentation site is a library. Thousands of pages change slowly, a few hundred writers edit them, and millions of developers read them. Most readers land on one page from a search engine, find the line they need, copy a snippet, and leave. Some stay and browse the sidebar to learn a whole topic.

That shape decides almost everything. Pages are read far more often than they change, and every reader of a page sees the same text. So the pages can be built ahead of time into plain HTML files and served from a content delivery network (CDN): a set of servers around the world that keep copies of files close to readers.

Before drawing boxes, ask the questions whose answers move the design. The table lists them, the answer this guide assumes, and what changes if the interviewer answers differently.

Scope questions for a documentation site and how each answer moves the design.
QuestionAnswer we assumeWhat it changes
How many pages?Over 14,000 English pages, plus translations: roughly 50,000 pages in total across locales (estimate).A full rebuild of every page on every change becomes slow. We need incremental builds and build caches.
Where does content live?Markdown files with front matter in a public git repository. Changes arrive as pull requests.Docs as code: review, previews, and checks run in continuous integration (CI), not in a web editor.
Are there product versions?Yes for product docs (v1, v2, v3). MDN itself documents one evolving web platform and has no versions.Versioned URLs, a version switcher, canonical URLs, and banners on old versions.
How many languages?English plus about 9 translated locales. Translations lag behind English.Fallback to English, outdated-translation notices, hreflang tags, and one search index per locale.
Do readers run code?Yes: small HTML, CSS, and JavaScript examples run inside the page.Sandboxed iframes and a lazy-loaded editor, so the reading page stays light.
Do readers log in?Mostly no. A small optional account adds settings, but the docs are public.Every page is identical for every reader, which makes it cacheable at the edge.
How fresh must a change be?A merged fix should be live within about 10 minutes.The build and deploy pipeline has a time budget, which rules out naive full rebuilds.

Functional requirements

  • Read a page: title, summary, body with headings, code blocks, tables, notes, compatibility tables, and a last-updated date.
  • Navigate: a sidebar tree for the section, breadcrumbs, an 'on this page' table of contents, and previous and next links.
  • Search all pages of the current version and language, with results that jump to the right section.
  • Switch version and language while staying on the same topic when it exists.
  • Run and edit small code examples inside the page.
  • Give feedback: 'Was this page helpful?', a link to edit the page on GitHub, and a link to report a problem.
  • Writers: propose a change as a pull request, see a preview of the built site, and get told about broken links before merging.

Non-functional requirements

  • Fast first view on a mid-range phone: Largest Contentful Paint (LCP) of 2.5 seconds or less at the 75th percentile, the Core Web Vitals good threshold.
  • Little JavaScript: the page reads fine with JavaScript off; scripts only add search, examples, and small widgets.
  • Links never break: moved pages redirect, and headings keep their anchors.
  • Search friendly: one canonical URL per topic, correct hreflang links, sitemaps, and structured data.
  • Build and deploy a merged change in under 10 minutes; a pull request preview in a similar time.
  • Accessible: skip link, ordered headings, keyboard-friendly sidebar and search, readable code colours in light and dark themes.

Out of scope

Several parts are their own design problems, and this guide uses them without repeating them. The translation workflow is in Design an internationalization system, a web-based editor for pages in Design a markdown editor with live preview, and the full playground in Design an online code playground like CodePen. The search box widget is in Design an autocomplete, and the shared components and theme of the site are in Design a design system for many teams.

What makes a documentation site hard

A docs page looks simple: text, headings, code. The difficulty is in the numbers and in time. Five properties drive every later chapter.

  1. Many pages, built from shared parts. One change to a shared layout, sidebar, macro, or data file can touch thousands of pages, and the build must know which ones.
  2. Content is code. Pages change through reviewed pull requests that must pass checks, such as no broken links, before they merge.
  3. URLs are a public contract. Answers, blog posts, and code comments link to pages and headings for a decade, so moves and renames must never break them.
  4. Several copies of each topic. One topic can exist in three versions and ten languages, some missing or outdated, and both readers and search engines must find the right copy.
  5. Mostly static, with a few live parts. Search, the theme toggle, and runnable examples need JavaScript. The rest is fixed HTML, so a full single-page app would waste the reader's phone.

The wireframe below names the parts of a reference page, and each later chapter zooms into one of its regions. Only the search box, the example panel, and the theme toggle need JavaScript to work. The sidebar, breadcrumbs, and table of contents are plain links that script only upgrades, for example by highlighting the current section.

Wireframe of a docs reference page titled Element: scrollIntoView() method. A header with the logo, a search box opened with slash, and version, language, and theme pickers. A left sidebar tree for Element with Properties, Methods, the current page scrollIntoView, and Events. The main column with breadcrumbs Web APIs / Element, the page title, text, an interactive example panel with an editor and Run button that stays static code until clicked, and a footer with Helpful and Edit on GitHub links and the last updated date. A right column On this page with Syntax, Parameters highlighted by the scroll spy, Examples, and Compat.Element: scrollIntoView() methoddocs.exampleSearch ( / )v3 EN themeElementPropertiesMethodsscroll()scrollIntoViewEventssidebar treeWeb APIs / ElementscrollIntoView()Try it: editor + Runstatic code until clickconsole: [2, 8]Helpful? Edit on GitHublast updated from gitOn this pageSyntaxParametersExamplesCompatscroll spy
Only the search box, the example panel, and a few small enhancers need JavaScript. The sidebar, breadcrumbs, and table of contents are plain links generated at build time.

That split is the basis for islands: static HTML everywhere, with small interactive parts that each load and start their own script. The lesson Islands and partial hydration covers the general idea, and the guides Design breadcrumbs and Design a file-explorer tree view cover those two widgets in depth.

Check your understanding · Exercise 1

Which answers from the interviewer would change this design the most? Select all that apply.

Think first

How is a docs site different from Wikipedia or a blogging platform, which also serve millions of mostly static pages?

Content as code: from Markdown in git to HTML

Start with where the words live, because the build, the review flow, and the link checks all follow from it. The naive answer is a database behind an admin screen, like a blog's content management system (CMS), which works for 200 pages. At 14,000 pages with hundreds of outside contributors, it lacks three things: a review step before a change goes live, a history you can diff and revert per line, and a check that a change does not break links on other pages.

Decision Where should documentation content live?

Database behind a CMS

Writers edit pages in a web editor. Pages are rows in a database, rendered on request or published to static files.

Pros

  • Friendly for non-technical writers.
  • Instant publishing, no build step.

Cons

  • Review and previews must be built by hand.
  • Hard to change 300 pages in one atomic step, for example renaming an API.
  • Outside contributors need accounts.

Plain Markdown in git

One folder per page with an index.md file. Front matter at the top holds metadata. Changes arrive as pull requests and are built in CI.

Pros

  • Review, history, blame, and revert come free from git.
  • Anyone with a GitHub account can contribute.
  • One pull request can change many pages and their redirects together.

Cons

  • Markdown alone cannot express compatibility tables or live examples, so you need an extension syntax.
  • Writers must learn git, or use the GitHub web editor.

MDX in git

Markdown files that can import and render React components inline, compiled to JavaScript modules at build time.

Pros

  • Any interactive component can live inside a page.
  • Type-checked imports catch some errors at build.

Cons

  • Each page is code: a typo in JSX breaks the build, and translators must not touch the JSX.
  • Easy to ship heavy client JavaScript by accident.
  • Tied to one framework.

What we pick: Plain Markdown in git, extended with a small, fixed set of macros: named placeholders the build expands, such as a compatibility table or a cross-reference link. MDN does this: its mdn/content repository holds over 14,000 English pages, each an index.md with front matter, and macros written as {{Compat}} or {{domxref("Element")}}. React.dev and many Docusaurus sites use MDX, which is fine for a few hundred pages written by one team; at MDN's size and contributor count a fixed macro set is safer because writers cannot break the build with arbitrary code.

Front matter: the page's metadata

Front matter is a YAML block at the top of the file. It holds what the build needs but the reader does not see as prose: the title, the URL slug, the page type (which picks a layout), and keys into shared data. Validate it with a schema in CI, so a missing page-type fails the pull request instead of rendering a broken page.

text
---
title: "Element: scrollIntoView() method"
slug: Web/API/Element/scrollIntoView
page-type: web-api-instance-method
browser-compat: api.Element.scrollIntoView
---

{{APIRef("DOM")}}

The **`scrollIntoView()`** method of the {{domxref("Element")}}
interface scrolls the element's ancestors so it becomes visible.

## Syntax

```js
scrollIntoView(options)
```

## Browser compatibility

{{Compat}}
A reference page in the style of mdn/content: front matter, then Markdown with macros.

The build pipeline, step by step

The build turns each file into a page in a few separate passes, so each is easy to test and the cache can store each pass's output.

  1. Read and validate: parse front matter, check it against the schema, and record the page in a manifest (slug, title, locale, parent).
  2. Expand macros: replace {{domxref("Element")}} with a link to the Element page, and {{Compat}} with a table built from shared compatibility data. This is where pages start to depend on other pages and on data files.
  3. Markdown to HTML: parse to a syntax tree, add heading ids, highlight code at build time (so no highlighter ships to the browser), and mark live examples.
  4. Render the page: put the HTML into the layout for its page-type, with the sidebar, breadcrumbs, table of contents, and metadata tags.
  5. Check: every internal link points at a page that exists, and every #anchor points at a heading id on that page.

MDN splits these passes across two tools. A build tool turns Markdown into HTML plus JSON metadata for each page, and the frontend assembles full pages from that JSON. Today the build tool is rari, written in Rust, which replaced the build in the older Node.js project yari; the frontend was rebuilt as server-rendered Lit web components. The split is the useful idea: content transforms and page layout change at different speeds and can be cached separately.

The build pipeline. index.md with front matter goes to validate, which checks the schema, then to macros, which expands Compat and cross-reference links and reads shared compat data, then to Markdown to HTML, which adds heading ids and highlights code, then to layout, which adds the sidebar and table of contents and reads the sidebar config. The rendered pages go to checks for links and anchors. A broken link fails the pull request; a clean build is uploaded to storage and the CDN.index.mdfront mattervalidateschemamacrosCompat, xrefto HTMLids, highlightlayoutsidebar, TOCcompat datashared JSONsidebarsper sectioncheckslinks, anchorsfail the PRlist broken linksbrokenuploadstorage, CDN
Each pass reads different inputs, which is what makes caching per pass possible later. Macros like Compat pull in shared data, and the layout pulls in the sidebar config: those two arrows are where one change fans out to many pages.

Review with a preview per pull request

A writer who fixes a table wants to see it rendered before merging, and so does the reviewer. So CI builds every pull request and deploys it to its own URL, such as pr-41823.preview.docs.example. Build only the pages the pull request touches plus their dependents (the next chapter shows how), so a preview takes a minute or two. A CI bot then comments the preview links and any problems the checks found on the pull request; MDN's content repository runs a 'PR review companion' workflow for this.

Sequence diagram of a pull request. The writer opens a pull request on GitHub. GitHub asks CI to run the build. CI uploads 14 changed pages to the preview host, which returns a preview URL. CI reports 2 broken anchors back to GitHub, which shows the writer a red check with the links. The writer pushes a fix. GitHub asks CI to rebuild, which rebuilds 1 page and reports a green check with the preview links.WriterGitHubCI buildPreviewopen PRrun buildupload 14 pagespreview URL2 broken anchorsred check, linkspush fixrebuild 1 pagegreen, preview
The preview builds only the changed pages and their dependents, so it is ready in a minute or two. The failure path is the common one: a broken anchor turns the check red before any reader can hit it.

Broken links are among the most common docs bugs. A link checker runs on the built HTML, not the Markdown, because macros create links too. It loads every page's set of ids once, then checks every href for two kinds of failure: the target page does not exist (and has no redirect), or the page exists but the #fragment matches no id on it.

The second kind is easy to miss. Someone renames the heading 'Parameters' to 'Arguments', its id changes, and 2,000 links to #parameters silently land at the top of the page.

js
const ids = new Map(); // url -> Set of ids on that page
for (const page of builtPages) ids.set(page.url, collectIds(page.html));

const errors = [];
for (const page of builtPages) {
  for (const href of collectHrefs(page.html)) {
    const url = new URL(href, page.url);
    if (url.origin !== SITE_ORIGIN) continue;
    const target = redirects.get(url.pathname) ?? url.pathname;
    if (!ids.has(target)) {
      errors.push({ page: page.url, href, kind: "missing-page" });
    } else if (url.hash && !ids.get(target).has(decodeURIComponent(url.hash.slice(1)))) {
      errors.push({ page: page.url, href, kind: "missing-anchor" });
    }
  }
}
if (errors.length) process.exitCode = 1;
The core of a link checker: collect ids per page, then check every internal link and anchor.

Check your understanding · Exercise 2

A writer renames the heading 'Return value' to 'Returns' on a popular page. Which safeguards catch or soften the break? Select all that apply.

Static generation at scale: building 50,000 pages fast

Every page is the same for every reader and changes only when a pull request merges. That is the textbook case for static site generation (SSG): render every page to an HTML file at build time and serve the files from a CDN. The lesson Static generation and ISR covers the idea; here the problem is size.

Do the arithmetic. Over 14,000 English pages plus translations is roughly 50,000 pages (estimate). If one page takes 20 ms to parse, expand macros, highlight code, and render, a single-threaded full build takes 50,000 x 20 ms = 1,000 seconds, about 17 minutes, before upload.

Our budget is 10 minutes from merge to live, and most merges change one page. Rebuilding 50,000 pages to publish a typo fix is the naive approach, and it fails the budget.

Decision How do we turn 50,000 Markdown files into pages within the time budget?

Full rebuild with a framework SSG

Docusaurus, Next.js static export, or VitePress renders every page on every build.

Pros

  • Zero custom tooling; a working site on day one.
  • Every build is from scratch, so it is always consistent.

Cons

  • Build time grows with page count times versions times locales.
  • Docusaurus warns that versioning increases build time; at tens of thousands of pages, builds of many minutes and high memory use are common (estimate).

Render on request, cache at the edge

No build of pages. A server renders a page on first request from the Markdown; the CDN caches it; a merge purges changed URLs. This is the incremental static regeneration (ISR) family.

Pros

  • Deploy is instant; nothing to prebuild.
  • Scales to any page count.

Cons

  • Needs a running server and a cold-cache path that must stay fast.
  • Errors surface when a reader hits the page, not in CI.
  • Link checks and search indexing need the full site anyway.

Incremental build with a dependency graph

The build records what each page depends on. On a change it rebuilds only the changed pages and their dependents, reusing cached output for the rest, spread across all CPU cores.

Pros

  • A one-page change rebuilds a handful of pages in seconds.
  • Everything is still checked in CI before going live.
  • Output is plain files: any CDN, no servers.

Cons

  • You must track dependencies correctly; a missed edge serves stale pages.
  • Changes to shared parts (layout, sidebar config) still rebuild many pages.

What we pick: Incremental static build with a dependency graph and a content-addressed cache, run in parallel workers. A framework SSG is the right start below a few thousand pages; MDN's size is why it runs its own pipeline. Render-on-request stays useful for pull request previews of huge sites.

Which pages does a change touch?

During a build, record every input each page reads. The kinds of edges, from most to least common:

The cost of a change depends on what it touches, not on how big the diff is.
Changed inputPages to rebuildTypical count
One page's body textThat page only, because other pages read its title and summary, not its body.1
One page's title or summaryThat page, plus pages that show its title or summary (cross-reference links, child listings on the parent page).1 to 30
A shared data file, such as compatibility data for one APIPages whose browser-compat key points at it.1 to 50
The sidebar config for a sectionEvery page in that section, because each page's HTML includes the sidebar.100 to 2,000
A macro's codeEvery page that calls that macro.Up to all pages
The page layout or global CSSEvery page, but only the last render pass; the Markdown-to-HTML output is reused from cache.All pages, fast pass

Cache each pass separately, keyed by a hash of everything that pass read. The content pass reads the page's own file, the data files its macros use, the title and summary of each page it links to, and the build tool's version. The render pass reads the content pass's output plus the layout and the sidebar. If a key is in the cache, reuse the old output; content addressing makes this safe, because any change to any input changes the key, so a stale entry can never match.

ts
type Inputs = { own: string; deps: string[] }; // file paths, or ids of other pages' title and summary

function keyOf(pass: string, inputs: Inputs): string {
  const parts = [pass, TOOL_VERSION, hashOf(inputs.own), ...[...inputs.deps].sort().map(hashOf)];
  return sha256(parts.join("\n"));
}

async function cached(key: string, run: () => Promise<string>): Promise<string> {
  const hit = await cache.get(key);
  if (hit !== undefined) return hit;
  const out = await run();
  await cache.put(key, out);
  return out;
}

async function buildPage(page: Page): Promise<string> {
  const body = await cached(keyOf("content", page.contentInputs), () => renderContent(page));
  const layoutInputs = { own: sha256(body), deps: [page.layoutFile, page.sidebarFile] };
  return cached(keyOf("render", layoutInputs), () => renderLayout(page, body));
}
A content-addressed build cache with one key per pass, so a sidebar edit reuses the expensive content pass.

An alternative is to keep the sidebar out of the page, as a separate cached HTML fragment or JSON file per section, so a sidebar edit rebuilds one file instead of 2,000. The cost is one extra request and a sidebar that appears a moment later, or an include at the edge. Many sites accept the re-render instead, because the content pass is a cache hit and the render pass takes seconds.

Try it. Pick a site size, a change, and whether the cache is warm, and see what rebuilds, how long it takes, and what the CDN must drop.

Pages
Change
CacheWorkers

Rebuild 1 pages fully and re-render 0; 13,999 come from cache. About 30 s to deploy, within the 10 minute budget. Purge 1 HTML URLs.

Build lab. 14000 pages, change: body, cache warm, 16 workers. Each of the 50 cells stands for 280 pages. Full rebuild: 1 pages. Render pass only: 0 pages. From cache: 13999. Build and upload about 30 s. CDN purge: 1 HTML URLs; hashed assets stay cached.rebuildrendercached30 swithin budgetOnly the page itself: others read its title, not its body.CDN: purge 1 HTML URLs; hashed CSS and JS stay cached.Work: 0.0 s on 16 workers, upload 0.0 s, setup 30 s.
Each cell is 2% of the site. The cost of a change depends on what it touches, not on the size of the diff: a one-line sidebar edit can re-render more pages than a rewritten article. Times are estimates (18 ms content pass, 2 ms render pass per page, 30 s setup).

Deploying to a CDN without broken pages

The output is two kinds of files with opposite caching needs. The lessons HTTP caching and CDN caching explain the headers; here is how they apply.

  • Hashed assets (CSS, JavaScript, fonts, images) get a content hash in the file name, such as main.3f9a1c.css. The name changes when the content does, so they can be cached forever: Cache-Control: public, max-age=31536000, immutable.
  • HTML pages keep stable URLs, so browsers must recheck them: Cache-Control: public, max-age=0, must-revalidate for the browser, with a longer shared-cache lifetime at the CDN (for example s-maxage=86400) and an explicit purge of the changed URLs on deploy.
  • Upload order matters. Upload new hashed assets first, then the HTML that references them, and keep old assets for a few days for readers who still have an old page open.
  • Purge only what changed. The build knows which output files changed, so purge exactly those URLs. Purging the whole CDN after every merge sends a wave of cache misses to the origin storage.

Check your understanding · Exercise 3

A writer edits the sidebar config for the CSS section (about 1,200 pages). With a warm cache and the sidebar baked into each page, what happens? Select all that apply.

Versions and languages: many copies of one topic

Product docs (not MDN, which documents one evolving platform) must keep v1, v2, and v3 readable side by side, because customers run old versions for years. With ten locales, one topic can have 30 copies. The questions are how to build them, where the switcher sends a reader when a copy is missing, and which copy search engines should rank.

Decision How do we build and serve several versions?

Separate build per version

Each version is built from its own git branch or tag and deployed to its own path or subdomain, such as /v2/ or v2.docs.example. Read the Docs works this way.

Pros

  • An old version is frozen: its build never changes unless you patch its branch.
  • Build time does not grow with the number of versions.

Cons

  • Fixes to the shared theme or a security issue must be backported to every branch.
  • The switcher needs a manifest of which pages exist in each version, built separately.

One build with versioned folders

All versions live on one branch: the current docs in docs/ and frozen copies in versioned_docs/version-1.0.0/. Docusaurus works this way: a CLI command copies the folder and its sidebar and adds the name to versions.json.

Pros

  • One theme, one deploy, and the build knows every version's pages, so the switcher and canonical tags are easy.
  • A fix to an old version is an ordinary edit.

Cons

  • Every build renders every version: build time multiplies. Docusaurus itself warns that versioning increases build time and complexity.
  • Contributors must decide which copies to edit.

What we pick: One build with versioned folders while there are three or four versions, because the switcher, canonical tags, and search scoping all need knowledge of every version. The incremental build from the static generation chapter removes most of the build time cost, since frozen versions almost never change and their cache keys stay warm. Past five or six versions, move the oldest to separate frozen builds and drop them from search.

Where the switcher lands

The reader is on /v3/guides/streaming and picks v1. The naive switcher sends them to the v1 home page, and they must find the topic again. A good switcher tries, in order: the same path in v1; a mapping from the version's redirects file (the page was renamed between versions); the closest parent that exists, such as /v1/guides/; and only then the version's home page. The build writes one small manifest per version listing its paths, so the decision runs in the browser with no server.

Locales add one step before the parent: when the topic exists in English but not in the reader's language, show the English content at the French URL, with a notice in French. Docusaurus behaves the same way, since a page with no translated file is built from the original. A reader who wanted French and gets the right English page is better served than one who gets a French section index.

From v3 page
Version
Language

Lands on /fr/v3/guides/streaming. same path: found.

Switcher lab. From the v3 page guides/streaming, switching to v3 in fr. Steps: same path: found. Lands on /fr/v3/guides/streaming. Canonical: /fr/v3/guides/streaming. hreflang: en, fr, ja. enfrjav1v2v3nononoyesyesnoyesyesyes1. same path: foundURL: /fr/v3/guides/streamingcanonical: /fr/v3/guides/streaminghreflang: en, fr, ja, x-default=enno banner: latest version, current translation
The grid shows which copies of the landed topic exist: rows are versions, columns are languages. The switcher tries the same path, then the redirect map, then English content, then the closest parent, and the head tags follow from where it lands.

Translations that lag behind English

English changes daily; volunteers translate weeks or years later. A translated page that silently disagrees with English is worse than no translation, because the code in it may be wrong. So record which English commit a translation was made from in its front matter, as MDN's translated-content repository does with an l10n.sourceCommit field.

At build time, compare that commit with the last commit that changed the English source. If English has moved on, render a banner: 'This page was translated from English and may be out of date. View the English page.' Sort a dashboard of outdated pages by traffic, so translators fix the most read ones first.

Canonical URLs and hreflang

  • Translations are not duplicates. Each locale's page has a canonical URL pointing at itself, never at English. Pointing a French page's canonical at English tells search engines to drop the French page.
  • hreflang links tell search engines which pages are the same topic in other languages. Each page lists every locale's copy, including itself, and the links must point both ways: Google ignores a pair that does not. Add x-default for the page to show when no locale matches. The lesson i18n performance and SEO covers the details.
  • Fallback pages (English content served under a French URL) are duplicates of the English page. Point their canonical where the English page's canonical points, and leave them out of hreflang and the sitemap. Google advises against noindex for this, because a canonical does the job.
  • Old versions usually point their canonical at the same topic in the latest version when the content is nearly the same, so ranking goes to the current docs; Read the Docs, for example, builds canonical URLs from the project's default version. When a topic exists only in v1, it keeps a self canonical. A canonical is a strong hint, not a rule, and only canonical URLs belong in hreflang.
  • Banners on old versions: 'You are reading v1 docs. The latest is v3', with a link to the same page in v3 when it exists.
html
<link rel="canonical" href="https://docs.example/fr/v3/guides/streaming" />
<link rel="alternate" hreflang="en" href="https://docs.example/en/v3/guides/streaming" />
<link rel="alternate" hreflang="fr" href="https://docs.example/fr/v3/guides/streaming" />
<link rel="alternate" hreflang="ja" href="https://docs.example/ja/v3/guides/streaming" />
<link rel="alternate" hreflang="x-default" href="https://docs.example/en/v3/guides/streaming" />
Head tags for the French v3 page of a topic that exists in English, French, and Japanese.

Check your understanding · Exercise 5

A reader on /fr/v3/api/client switches to v1, where the page was called /v1/api/connection and was never translated into French. Where should they land, and with which tags? Select all that apply.

Live code examples without a heavy page

A reference page is better when the reader can change x * 2 to x * 3 and see the output. MDN does this in two ways. Many reference pages open with an interactive example: the writer puts an {{InteractiveExample}} macro in the Markdown, followed by code blocks marked interactive-example, and the page renders them as an editor with a console or a live output. Longer live samples render their HTML, CSS, and JavaScript in an iframe, and a Play button opens the code in the separate MDN Playground.

text
{{InteractiveExample("JavaScript Demo: Array.prototype.map()")}}

```js interactive-example
const array = [1, 4, 9, 16];
const mapped = array.map((x) => x * 2);
console.log(mapped);
// Expected output: Array [2, 8, 18, 32]
```
An interactive example as it is written in mdn/content: plain Markdown that the build turns into a live editor.

Two problems follow. Weight: a real code editor with syntax highlighting and autocomplete is hundreds of kilobytes of JavaScript, and loading it on thousands of pages for the few readers who click Run would wreck the performance budget. Safety: the code the reader types runs on our origin unless we isolate it, so a pasted snippet could read cookies or rewrite the page.

Decision How do we run examples inside a docs page?

Editor and runner on page load

Every page with an example loads the editor bundle and creates the iframe immediately.

Pros

  • Run is instant.

Cons

  • Every reader pays hundreds of KB of JavaScript and an extra document per example, for a feature most do not use.
  • Hurts Interaction to Next Paint (INP) and LCP on phones.

Static code first, upgrade on intent

Build time renders the example as highlighted static code plus its expected output. A Run or Edit button loads the editor and creates the sandboxed iframe only when clicked.

Pros

  • Zero cost for readers who only read.
  • Readable with JavaScript off; search engines see the code.

Cons

  • The first Run waits for a download, about 300 to 800 ms on 4G (estimate).
  • Two renderings of the same example to keep in sync.

Link out to a playground

Show static code and an 'Open in playground' link to a separate app such as CodePen or the MDN Playground.

Pros

  • Nothing runs on the docs page at all.

Cons

  • The reader leaves the page and loses the reading context.

What we pick: Static code first, upgraded on intent, plus a link out to the full playground for bigger experiments. The iframe runs on a separate origin, such as live.docs-examples.net, with a sandbox attribute, so the reader's code cannot touch the docs page's cookies or DOM. The guide Design an online code playground like CodePen covers the sandbox, separate origin, and message passing in depth, and Design an embeddable code editor covers the editor.

The upgrade has more states than it looks, and each one needs a visible label: the download can fail, the code can throw, and an infinite loop can freeze the iframe. The state machine below covers them.

State machine of a live example. It starts as static code with its expected output. Click Run moves it to loading, which imports the editor. When loaded it is ready, with the editor shown. If loading fails it moves to load failed, which shows a retry that goes back to static. From ready, Run moves to running in a sandboxed iframe; when done it returns to ready with the output shown. If the iframe does not reply within the timeout it moves to hung, and Reset replaces the iframe and returns to ready.staticcode + outputloadingimport editorreadyeditor shownload failedshow retryhungReset buttonrunningsandbox iframeclick RunloadederrorretryRundonetimeoutResetReaders who never click Run stay in static: no editor, no iframe.The output panel keeps its size in every state, so nothing shifts.
Two failure states get their own labels: the editor download can fail, and the reader's code can loop forever. Both lead back to a working state without reloading the page.
  • Start the import on hover or focus of the Run button, which removes most of the wait on desktop.
  • Reserve the space. The output panel has a fixed minimum height in the static HTML, so running the example does not shift the text below it (Cumulative Layout Shift).
  • One editor bundle per page, shared by all examples on it; each example gets its own iframe only when run.
  • Guard against loops. The parent cannot interrupt a busy iframe, but a separate-origin iframe can run in its own process, and the parent can show 'Not responding: Reset' after a timeout and replace the iframe.
  • Test the examples in CI. Run each JavaScript example in a headless browser and compare the console output with the 'Expected output' comment, so examples never rot.

Check your understanding · Exercise 7

Which choices keep live examples both safe and cheap? Select all that apply.

Architecture and data model: a build system, a CDN, and a few islands

Almost all the work happens before a reader arrives: in CI, the content becomes files on a CDN. In the browser, a static page starts only a few small islands, each with its own script. There is no app shell and no client-side router; a link click is a normal page load, which is fast because the next page is a small cached HTML file. The lesson Routing explains when a multi-page site beats a single-page app, and a docs site is the clearest case.

Architecture. In CI, the content repo feeds the incremental build, which reads and writes a build cache keyed by hash and passes pages to checks for links and search indexing. Checks upload changed files to storage at the CDN. Storage feeds the edge, which applies 301 redirects and caching. A small feedback API sits beside them. The edge sends HTML to the page in the browser, and index chunks to the search island. The browser also has the example island, loaded on Run, and enhancers such as the scroll spy and copy buttons, which post votes to the feedback API.CI, per mergecontent repomd, sidebarsbuildincrementalcheckslinks, searchcacheby hashCDN and APIedge301s, cachestorageHTML, assetsfeedbackvotes APIupload changedBrowserpage HTMLsidebar, TOCsearchon / or Ctrl+Kexampleon Runenhancersspy, copyHTMLchunksPOST voteworks with JS offrunner iframe on another origin
Almost all the work happens in CI. The CDN only stores files and applies redirects, and the browser runs a static page with islands that load their own code when the reader asks for them.
Each part and why it sits where it does.
PartRuns whereResponsibility
Content reposGitHubMarkdown, front matter, sidebars, redirects, shared data. The single source of truth.
Build pipelineCI workersValidate, expand macros, render pages, write manifests, run link checks, build search indexes. Incremental with a shared cache.
Build cacheObject storageOutput per page and per pass, keyed by a hash of its inputs; shared by main and pull request builds.
CDN and edge rulesCDNServe HTML and hashed assets, apply redirects, pick the locale on the bare root URL only.
Page shellBrowser, static HTMLHeader, sidebar, breadcrumbs, table of contents, article, footer. Works with JavaScript off.
Search islandBrowser, loaded on intentShortcut, modal, title index, chunked full-text index.
Example islandBrowser, loaded on clickEditor, sandboxed runner iframe on another origin, console output.
Small enhancersBrowser, at idleScroll spy, copy buttons, theme toggle, version and locale switcher, feedback widget.
Feedback APISmall serverless functionStores 'Was this helpful?' votes and comments; the only write path.

Why these boundaries? The build sits in CI because that is where review happens: a page that fails a check never reaches the CDN. The CDN does only simple, cacheable work (redirects, headers), not rendering, so there is no server to scale on a traffic spike.

Each island loads its own code, so the reader who only reads downloads only the small enhancers, a few kilobytes. The feedback API is separate because it is the only thing that writes, and it must never be able to slow down a page view.

How data moves

Follow one merged pull request to one reader. The commit triggers the build, which writes changed HTML, manifests, and search chunks to storage and purges the changed URLs. A reader's request misses the CDN once and is then served from the edge; the browser renders the HTML at once and fetches the search bundle only if the reader starts a search. The only data going back up is feedback votes and analytics beacons.

Data flow. A merge in git sends a diff of 3 files to the CI build. The build writes changed files to storage. Storage triggers a purge of 14 URLs at the CDN edge. The browser sends GET page to the edge and receives HTML of about 45 kilobytes. The browser posts a vote to the feedback API. On slash, the browser fetches the search library and 2 chunks, about 60 kilobytes; on Run, it fetches the editor bundle and opens the runner iframe.git mergereviewed PRCI buildincrementalstoragefilesdiff: 3 fileschanged filesCDN edgecache, 301spurge 14 URLsbrowserstatic pageGET pageHTML, 45 kBfeedbackvotesPOST voteon / : GET library + 2 chunks, about 60 kB (estimate)on Run : GET editor bundle, then the runner iframe
Data flows one way: from git to the edge to the reader. The only writes are feedback votes, and they go to their own small API so they can never slow a page view.

Think first

A teammate proposes rebuilding the site as a single-page app with client-side routing, so moving between pages 'feels instant'. What do you answer?

Data model: the files are the interfaces

A docs site has almost no runtime API. Its real interfaces are files: the front matter writers fill in, the manifests the build writes for the browser, and the search index. Getting these shapes right matters more than any endpoint, because thousands of files and every client script depend on them. Most data is fixed at build time and baked into HTML or small JSON files on the CDN; very little is per reader.

Where each piece of docs data lives.
DataLives inRead by
Page front matter and bodyMarkdown in gitBuild only
Rendered pageHTML file on the CDNBrowser, search engines, link checker
Version manifest (paths per version and locale)One JSON file per version, hashed nameSwitcher, at click time
Redirect mapCompiled CDN rules, plus a JSON copy per versionCDN for page requests, switcher for renamed pages
Search indexChunked files per version and localeSearch island, on intent
Theme, code language tab, sidebar collapse statelocalStorageSmall enhancers
Current version and localeThe URL pathEverything; the URL is the state (see the lesson URL as state)
Helpful votesFeedback API databaseDocs team dashboards
ts
type PageType = "guide" | "web-api-interface" | "web-api-instance-method" | "css-property";

type PageMeta = {
  slug: string;                 // "Web/API/Element/scrollIntoView"
  locale: string;               // "en-US"
  version: string;              // "v3", or "current" for unversioned docs
  title: string;
  pageType: PageType;
  browserCompat?: string;       // key into shared compatibility data
  sidebar?: string;             // which sidebar config to use
  headings: { id: string; text: string; level: 2 | 3 }[];
  lastModified: string;         // ISO date of the last git commit touching the file
  sourceCommit?: string;        // translations: English commit it was made from
  outdated?: boolean;           // computed: English changed since sourceCommit
};

// /v3/manifest.3c1e9a.json: which paths exist, per locale, for the switcher.
type VersionManifest = {
  version: string;
  locales: Record<string, string[]>;   // "fr" -> list of slugs that exist in French
  renamed: Record<string, string>;     // old slug -> new slug within this version
};
The build's view of a page, and the manifest it writes for the switcher.
Data model. PageMeta holds slug, locale, version, page type, headings, dates, and the source commit; it is rendered into page HTML on the CDN with a short cache. VersionManifest holds the slugs per locale and renamed pages; it becomes a hashed manifest JSON on the CDN, cached as immutable. Redirects hold from and to pairs and collapsed chains; they become 301 rules at the CDN. On the reader side, localStorage keeps the theme and code tab, the URL path holds locale and version, and the feedback database stores votes.PageMetaslug, localeversion, pageTypeheadings, datesVersionManifestslugs per localerenamed pagesRedirectsfrom, to pairschains collapsedpage HTMLCDN, short cachemanifest.jsonhashed, immutable301 rulesat the CDNlocalStoragetheme, code tabURL pathlocale, versionfeedback DBvotesper reader
Everything in the top row is decided at build time and becomes a static file. Only the bottom row changes per reader, and only one of those parts, the votes, needs a server.

The few runtime interfaces

The browser makes only three kinds of requests after the page: manifest and search files (static, cacheable forever because their names are hashed), the example runner iframe (static, on its own origin), and feedback. Feedback is the only API that needs a server, and it is write-only from the page's point of view.

http
POST /api/feedback HTTP/1.1
Content-Type: application/json

{ "page": "/en-US/docs/Web/API/Element/scrollIntoView",
  "version": "current", "helpful": false,
  "reason": "code-sample-wrong", "comment": "The options example throws in Safari",
  "clientId": "a91f..." }

HTTP/1.1 202 Accepted

HTTP/1.1 429 Too Many Requests
Retry-After: 60
The feedback endpoint: small, idempotent per reader and page, and never on the critical path.

The page sends the vote with fetch(url, { method: "POST", keepalive: true }), so it survives the reader navigating away, and shows 'Thanks' at once without waiting for the reply. The clientId is a random id in localStorage, so a reader who clicks twice changes their vote instead of adding another. A free-text comment is sent only if the reader types one, and the server strips anything that looks like an email or API key before storing it.

Check your understanding · Exercise 8

Why does the version manifest file have a content hash in its name? Select all that apply.

Performance, SEO, accessibility, and feedback

The design already avoids the big costs: no server rendering per request and no framework runtime for reading. This chapter covers what is left, with the docs-specific details.

Performance budget

Set a budget per page type and fail CI when a pull request breaks it. A reasonable budget for a reference page (estimates): HTML 30 to 80 kB compressed, CSS under 40 kB, JavaScript under 30 kB before any island loads, and no web font on the critical path for body text. Code is already highlighted at build time; the other levers are below.

  • Fonts. Use the system font stack for prose or one self-hosted variable font with font-display: swap; a monospace font for code can load later because code blocks have a fallback that fits.
  • Compatibility tables and big diagrams below the fold get content-visibility: auto with a size hint, so the browser skips their layout until the reader scrolls near.
  • Measure in the field. Collect Core Web Vitals from real readers, split by page type and locale, so a regression after a macro change shows up on the page type it hit (see the lesson Core Web Vitals).

SEO and sharing

  • Titles that match searches. 'Element: scrollIntoView() method' beats 'scrollIntoView'. Put the API name first and the section after.
  • Structured data. Add JSON-LD with a BreadcrumbList, which Google can show as the path in desktop results (the guide Design breadcrumbs covers the markup). Describe the page as a schema.org TechArticle, a subtype of Article, with its headline and modification date; Google documents only Article, NewsArticle, and BlogPosting for its article features, so treat the subtype as extra meaning, not a promised rich result.
  • Open Graph images per page, generated at build time from one template, so a shared link shows 'scrollIntoView() method, Web APIs' instead of a generic logo. Regenerate one only when its title changes.
  • Sitemaps per version and locale, joined by a sitemap index. A single sitemap file may list at most 50,000 URLs, so 50,000 pages need splitting anyway. Leave out fallback pages and old versions whose canonical points elsewhere.
  • Last modified dates from git in the sitemap, matching the date shown on the page.

Accessibility

  • A skip link as the first focusable element jumps past the header and a 900-item sidebar to the main landmark. Without it a keyboard user tabs through hundreds of links on every page.
  • One h1, then h2 and h3 in order. Screen reader users jump by heading; the table of contents is built from the same headings, so a lint rule on heading order fixes both. The lessons Semantic HTML and Keyboard navigation cover the basics.
  • Code blocks: a pre that scrolls sideways gets tabindex="0" and a label so keyboard users can scroll it. The copy button has a specific accessible name such as 'Copy JavaScript example' and announces 'Copied' through a polite live region.
  • Dark mode with readable code. Pick a code theme for each mode and check every token colour against its background for 4.5 to 1 contrast. Apply the saved theme with a tiny blocking script in the head so the page does not flash light first.

Offline docs, briefly

Developers read docs on planes and trains. A service worker can cache pages as they are visited, so the last 50 pages read work offline, and the search island can keep its loaded chunks. For HTML use network first with a cache fallback, so readers online always see the latest page; the lesson Service worker caching covers the strategies.

Full offline copies are a bigger feature. DevDocs, for example, lets readers pick documentation sets to download and stores them in the browser. For a site of this size, offer downloads per section ('Download CSS docs, about 15 MB': estimate) rather than for everything.

Feedback loops

  • 'Was this page helpful?' with a reason when the answer is no (wrong code, missing info, hard to follow). Rank pages by 'no' votes per thousand views to find the worst pages, not the most viewed ones.
  • 'Edit on GitHub' links to the source file of the current version and locale, a path the build already knows.
  • 'Report a problem' opens a prefilled issue with the page URL, version, locale, and browser.
  • Last updated from the last git commit that touched the file, with a link to its history.

Check your understanding · Exercise 9

Which changes improve accessibility for keyboard and screen reader users on a docs page? Select all that apply.

Running it in the interview

In 45 minutes you cannot cover every chapter. The interviewer wants to see that you spot the shape early (static, huge, versioned, linked from everywhere) and spend your time on the decisions that shape implies. A plan that works:

A 45 minute plan for the docs site question.
MinutesWhat to doWhat to say out loud
0 to 5Ask the scope questions: page count, versions, locales, live examples, personalization, freshness.'Every reader sees the same page and changes come through review, so I want to build ahead of time and serve from a CDN.'
5 to 12Draw the pipeline: git, CI build, cache, storage, CDN, browser with islands.'Almost all work happens before the reader arrives. The browser gets HTML plus a few islands.'
12 to 22Incremental build and deploy: dependency graph, cache keys, hashed assets, purging changed HTML.Do the arithmetic: 50,000 pages at 20 ms is 17 minutes; a typo fix should rebuild a handful.
22 to 32Navigation and versions: sidebar, scroll spy, stable anchors, redirects, switcher ladder, canonical and hreflang.'URLs are a public contract. Moving a page adds a redirect in the same pull request.'
32 to 40Search and examples: chunked index per version and locale, load on intent; static code upgraded on click into a sandboxed iframe.Name the bytes: title index, first chunk, editor bundle.
40 to 45Performance budget, accessibility, feedback; then trade-offs you would revisit.'If pages became personalized, I would keep the static page and fill the personal part on the client.'

Draw the pipeline first, not the page. Many candidates start with a component tree; the stronger move is to notice that the hard parts of this problem are in the build and in URL design. Then sketch the page wireframe quickly, naming the islands.

Follow-up questions to expect

Think first

A macro used on 9,000 pages has a bug fix. How long until it is live, and what happens to the CDN?

Think first

Product wants code samples to include the signed-in reader's real API key. How do you add that without losing static pages?

Think first

Your hosted search provider has an outage. What does the reader see?

Check your understanding · Exercise 10

Which statements would a strong candidate make in the first ten minutes? Select all that apply.

Summary

  • A docs site is the same for every reader and changes through reviewed pull requests, so build every page ahead of time and serve static files from a CDN.
  • Keep content as Markdown in git with front matter and a fixed set of macros; validate front matter, check internal links and anchors on the built HTML, and give every pull request a preview.
  • At 50,000 pages a full rebuild does not fit the deploy budget. Track what each page reads, key each pass's cache on a hash of its inputs, and rebuild only changed pages and their dependents.
  • Hashed assets are cached forever; HTML is short-cached at the browser and purged at the CDN for exactly the URLs that changed.
  • URLs and heading anchors are a public contract: stable slugs, explicit ids for renamed headings, and a redirects file with collapsed chains.
  • The version and locale switcher tries the same path, then the redirect map, then English content for a missing translation, then the closest parent. Fallback pages carry a notice, name an English canonical, and stay out of hreflang.
  • Search uses a chunked static index per version and locale, loaded when the reader shows intent; examples are static code upgraded on click into a sandboxed iframe on another origin.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next