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.
| Question | Answer we assume | What 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
hreflanglinks, 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.
- 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.
- Content is code. Pages change through reviewed pull requests that must pass checks, such as no broken links, before they merge.
- 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.
- 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.
- 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.
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?
Wikipedia pages are edited live in the browser by anyone, stored in a database, and rendered on request, then cached at the edge (see Design Wikipedia). A blogging platform stores posts in a database too, and adds per-reader feeds (see Design a blogging platform like Medium). A docs site changes through reviewed pull requests, so the whole site can be built ahead of time from git.
Docs also have versions, live code examples, and stricter link stability, because developers link to exact headings.
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?
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.
---
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}}
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.
- Read and validate: parse front matter, check it against the schema, and record the page in a manifest (slug, title, locale, parent).
- 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. - 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.
- Render the page: put the HTML into the layout for its
page-type, with the sidebar, breadcrumbs, table of contents, and metadata tags. - Check: every internal link points at a page that exists, and every
#anchorpoints 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.
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.
Link checking and broken anchors
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.
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;
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?
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:
| Changed input | Pages to rebuild | Typical count |
|---|---|---|
| One page's body text | That page only, because other pages read its title and summary, not its body. | 1 |
| One page's title or summary | That 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 API | Pages whose browser-compat key points at it. | 1 to 50 |
| The sidebar config for a section | Every page in that section, because each page's HTML includes the sidebar. | 100 to 2,000 |
| A macro's code | Every page that calls that macro. | Up to all pages |
| The page layout or global CSS | Every 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.
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));
}
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.
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.
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-revalidatefor the browser, with a longer shared-cache lifetime at the CDN (for examples-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?
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.
Lands on /fr/v3/guides/streaming. same path: found.
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-defaultfor 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
hreflangand the sitemap. Google advises againstnoindexfor 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.
<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" />
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.
Search across thousands of pages
Search is the second front door, after the search engine that brought the reader in. Readers type an API name ('scrollint') and expect that page first, or a task ('center a div') and expect the right section of the right guide. The search box widget, its keyboard behaviour, and the results list are covered in the guides Design an autocomplete and Design a search results page. The docs-specific question is where the index lives.
The naive approach builds one full-text index with Lunr or FlexSearch at build time and downloads it on first search. For over 14,000 pages of text that index is many megabytes per locale (estimate), downloaded by a reader who wanted one result on a phone. It also grows with every version you add.
Decision Where does the search index live?
What we pick: A chunked static index built per version and per locale, so a French v3 reader only ever loads French v3 chunks. It keeps the site free of servers and is indexed in the same CI run that builds the pages, so search is never behind the content. A public docs site that wants zero search work can apply for DocSearch instead; a company with many products and strict ranking needs may grow into its own server later.
Making docs search feel instant
- Two tiers. Ship a small title index (slug, title, and a short type label, about 15 bytes per page compressed, so roughly 200 kB for 14,000 pages: estimate) that answers API-name queries in under 10 ms, and query the full-text index in parallel for prose. Show title hits first.
- Load on intent. Import the search code when the reader focuses the box, presses
/, or presses Ctrl+K (Cmd+K on macOS), not on page load. Starting the import on hover or focus hides most of the delay. The guide Design a command palette covers the shortcut and modal patterns. - Results with sections. Each result shows the page title, the matched heading, and a snippet; the link goes to
page#heading-id, so stable anchors from the navigation chapter pay off again. - Filters that match docs. Type (guide, reference, tutorial), and a default scope of the current version and locale with a link to 'search all versions'.
- Log zero-result queries. They show which pages and synonyms are missing.
let pagefind: Promise<Pagefind> | null = null;
function loadSearch() {
// The bundle path includes locale and version, so each has its own index.
pagefind ??= import(/* @vite-ignore */ `/${locale}/${version}/pagefind/pagefind.js`);
return pagefind;
}
async function onQuery(q: string, filters: { type?: string }) {
const pf = await loadSearch();
const res = await pf.debouncedSearch(q, { filters }, 150);
if (res === null) return; // a newer keystroke replaced this search
const top = await Promise.all(res.results.slice(0, 8).map((r) => r.data()));
render(
top.flatMap((page) =>
page.sub_results.slice(0, 2).map((s) => ({ title: page.meta.title, section: s.title, url: s.url, excerpt: s.excerpt })),
),
);
}
Check your understanding · Exercise 6
Why build one static search index per version and per locale instead of one index for everything? 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.
{{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]
```
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?
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.
- 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.
| Part | Runs where | Responsibility |
|---|---|---|
| Content repos | GitHub | Markdown, front matter, sidebars, redirects, shared data. The single source of truth. |
| Build pipeline | CI workers | Validate, expand macros, render pages, write manifests, run link checks, build search indexes. Incremental with a shared cache. |
| Build cache | Object storage | Output per page and per pass, keyed by a hash of its inputs; shared by main and pull request builds. |
| CDN and edge rules | CDN | Serve HTML and hashed assets, apply redirects, pick the locale on the bare root URL only. |
| Page shell | Browser, static HTML | Header, sidebar, breadcrumbs, table of contents, article, footer. Works with JavaScript off. |
| Search island | Browser, loaded on intent | Shortcut, modal, title index, chunked full-text index. |
| Example island | Browser, loaded on click | Editor, sandboxed runner iframe on another origin, console output. |
| Small enhancers | Browser, at idle | Scroll spy, copy buttons, theme toggle, version and locale switcher, feedback widget. |
| Feedback API | Small serverless function | Stores '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.
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?
Measure first. A docs page is a 30 to 80 kB compressed HTML file served from a nearby CDN edge (estimate); with the CDN warm it often arrives within one or two hundred milliseconds. Prefetching the next page on hover with link rel="prefetch" or the Speculation Rules API gets most of the 'instant' feel with no router.
A single-page app adds a framework runtime and hydration of the whole article on every first visit, which is the visit most readers make. The cost lands on the common case to speed up the rare one. MDN's write-up of its own frontend rebuild names the same problem: its React app was only a wrapper around static content and could not reach into it.
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.
| Data | Lives in | Read by |
|---|---|---|
| Page front matter and body | Markdown in git | Build only |
| Rendered page | HTML file on the CDN | Browser, search engines, link checker |
| Version manifest (paths per version and locale) | One JSON file per version, hashed name | Switcher, at click time |
| Redirect map | Compiled CDN rules, plus a JSON copy per version | CDN for page requests, switcher for renamed pages |
| Search index | Chunked files per version and locale | Search island, on intent |
| Theme, code language tab, sidebar collapse state | localStorage | Small enhancers |
| Current version and locale | The URL path | Everything; the URL is the state (see the lesson URL as state) |
| Helpful votes | Feedback API database | Docs team dashboards |
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 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.
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 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: autowith 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.orgTechArticle, a subtype ofArticle, with its headline and modification date; Google documents onlyArticle,NewsArticle, andBlogPostingfor 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
mainlandmark. Without it a keyboard user tabs through hundreds of links on every page. - One
h1, thenh2andh3in 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
prethat scrolls sideways getstabindex="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
headso 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:
| Minutes | What to do | What to say out loud |
|---|---|---|
| 0 to 5 | Ask 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 12 | Draw 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 22 | Incremental 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 32 | Navigation 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 40 | Search 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 45 | Performance 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?
All 9,000 pages have a new cache key, so their content pass reruns. At 20 ms per page across 16 workers, that is about 11 seconds of rendering plus upload (estimate). The build then purges exactly those 9,000 HTML URLs.
To avoid a burst of misses at the origin storage, purge in batches or rely on stale-while-revalidate so the CDN serves the old copy while it fetches the new one.
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?
Keep the HTML identical for everyone, with a placeholder such as YOUR_API_KEY in samples. A small island checks for a session, fetches the reader's test keys from an authenticated API, and replaces the placeholders in the DOM. Stripe's docs, for example, show signed-in readers their own test keys in code samples.
Never put keys in the cached HTML, and show only test-mode keys in docs.
Think first
Your hosted search provider has an outage. What does the reader see?
With a static chunked index, nothing changes: search is files on the same CDN as the pages. With a hosted provider, the search island should time out after about 2 seconds and offer a fallback: the local title index for API names, and a link to a web search scoped to the site. The pages themselves keep working because search is an island, not part of the page.
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.
References
- mdn/content repository MDN's Markdown pages, front matter, macros, and the per-locale redirects file.
- mdn/translated-content repository MDN's translations, each with an
l10n.sourceCommitfield in its front matter. - MDN blog: a deep dive into MDN's new frontend How MDN's pages are built from Markdown to JSON to HTML, the
InteractiveExamplemacro, and why it moved to web components. - rari, MDN's build tool The Rust build tool that turns MDN's Markdown into HTML and JSON.
- Docusaurus: Versioning Versioned folders,
versions.json, and the warning about build time. - Docusaurus: i18n introduction Translated files per locale and one built site per locale.
- Pagefind A chunked static search index and its bandwidth numbers for large sites.
- Algolia DocSearch: who can apply Eligibility for the free hosted docs search program.
- Google Search Central: localized versions of your pages How
hreflangandx-defaultwork. - Google Search Central: consolidate duplicate URLs Canonical URLs, and why not to use
noindexto pick one. - Google Search Central: Breadcrumb structured data
BreadcrumbListmarkup and where Google shows it. - MDN: Intersection Observer API The API behind the table of contents scroll spy.
- web.dev: Web Vitals The thresholds used in the performance budget.
Concepts behind it
- Static generation and incremental regenerationRender pages to HTML once, ahead of time, and serve the same files from a CDN to everyone; incremental regeneration then refreshes single pages after launch, so you get static speed without rebuilding the whole site for every edit.
- CDN cachingKeeping shared copies of your responses on servers near every user, so most requests never travel to your origin: the biggest lever for global speed and for surviving traffic spikes.
- HTTP cachingThe browser's built-in cache, steered by headers on each response, which lets a repeat visit skip the network entirely or ask only "has this changed?": the cheapest speed-up on the web.
- Islands and partial hydrationShip a page as static HTML and send JavaScript only for the few parts that need it, each waking up on its own trigger, so content pages stay fast on slow phones instead of paying to hydrate the whole app.
- Locales, routing, and SEOHow a multilingual site picks a language, gives every language its own URL, tells search engines about the copies with hreflang, renders and caches each locale on the server, keeps translation bytes small, and copes with German text that runs 30% longer.
- Client-side routingHow single-page apps change screens without reloading: matching URLs to nested layouts, loading each route's code and data in parallel, guarding private pages, and restoring scroll and focus.
- Core Web VitalsThree numbers, measured on real users, that say whether a page loads fast (LCP), reacts fast (INP), and stays still (CLS): what each one measures, what makes it bad, and how to fix it.
- Service worker cachingUsing a service worker as a programmable cache between your page and the network, so you choose per request whether to answer from a stored copy, the network, or both: the basis of instant loads and offline apps.
Try next
- Design WikipediaMedium
- Design a blogging platform like MediumMedium
- Design an online code playground like CodePenHard
- Design an embeddable code editorHard
- Design a search results pageMedium
- Design an autocompleteMedium
- Design an internationalization systemMedium
- Design a markdown editor with live previewMedium
- Design a design system for many teamsHard
- Design breadcrumbsEasy
- Design a file-explorer tree viewMedium
- Design a command paletteMedium