Design a blogging platform like Medium
A long-form reading and publishing platform where most visitors arrive from search or social links to read one article, a few become members, and a smaller group writes. The hard parts are serving the article page from the edge while still showing each reader their own claps, highlights, and paywall meter, keeping a 3,000-word page fast and readable, metering free reads in a way that can't be bypassed but still lets search engines index the text, anchoring highlights to text that authors later edit, and a writing flow with autosave, previews, scheduling, and email delivery.
The question
Design the web front end of a blogging platform like Medium. Writers publish long articles with images and embeds. Readers find them through search, social links, and a home feed. Readers can clap, highlight passages, and leave responses. Some articles are for paying members only, and non-members get a few free ones a month.
Focus on the article page: how it reaches the reader fast, how it stays readable, how the paywall works, and how highlights and claps work. Then cover the writing and publishing flow at a high level.
Real-life examples: Medium, Substack, Hashnode, dev.to, Ghost
Requirements: a reading product with a writing tool attached
A blogging platform looks like one product, but it serves three groups with very different needs. Readers arrive on one article from Google, X, LinkedIn, or a newsletter, read it, and leave; most are not logged in. Members pay a monthly fee, read many articles, clap, highlight, and follow writers. Writers draft, edit, and publish.
Readers outnumber writers by orders of magnitude, so the article page is the product. The editor matters, but it serves a small, logged-in, patient audience.
Before drawing boxes, ask the questions whose answers change the design. The table lists them, the answer this guide assumes, and what moves if the interviewer says something else.
| Question | Answer we assume | What it changes |
|---|---|---|
| Where do readers come from? | Most article views come from outside: search, social links, and email. Many are first-time, anonymous visitors on phones. | The first view must be fast with a cold cache and no login. Search engine optimization (SEO) and link previews are core features, not extras. |
| How many articles, how many reads? | Tens of millions of published articles. A popular one gets hundreds of thousands of reads in a day; most get a handful (estimate). | A long tail means we can't keep every page warm in a cache. Popular pages must survive spikes without hitting the origin, our own application servers. |
| Is there a paywall? | Yes. Some stories are member-only. Non-members get a small number of free member-only reads per month. | The server must decide per request how much text to send, which fights with edge caching: keeping one shared copy of each page on servers near readers. |
| What can readers do on a page? | Clap, highlight, add a private note, respond, follow the writer, save to a reading list, share. | Each of these is per-viewer data. It can't live in the cached HTML. |
| What does the editor need? | Rich text with images, code blocks, and embeds; autosave; preview links; scheduling; publishing to a publication or newsletter. | We reuse a rich text editor and focus on the publishing lifecycle around it. |
| Which devices? | Phones are the majority of reads; desktop is the majority of writing (estimate). | The reading layout is designed mobile first. The editor can assume a keyboard. |
Functional requirements
- Read an article: title, subtitle, author, publish date, reading time, hero image, body with images, code, quotes, and embeds.
- Clap for an article, up to a per-reader limit, and see the total.
- Highlight a passage, optionally add a private note, and see passages that many readers highlighted.
- Hit a paywall on member-only stories after the free reads run out, with a clear path to subscribe or sign in.
- Follow writers and publications, save articles to a reading list, and read saved articles offline.
- Write: draft with autosave, preview through a private link, schedule or publish, send to email subscribers, edit after publishing.
- Share: links that render rich previews on social apps, and links that jump to a quoted passage.
Non-functional requirements
- Fast first view on a mid-range phone over 4G: Largest Contentful Paint (LCP) of 2.5 seconds or less at the 75th percentile, which is the Core Web Vitals good threshold.
- No layout shift while reading: Cumulative Layout Shift (CLS) of 0.1 or less, even with images and embeds loading late.
- Responsive interactions: Interaction to Next Paint (INP) of 200 ms or less for clap, highlight, and menus.
- Search friendly: the article text is in the server HTML, with correct structured data (a machine-readable description of the article), canonical URLs (the one official address of each page), and sitemaps.
- Edits show up fast: a published edit reaches readers within seconds to a minute, not hours.
- The paywall holds: a non-member can't read a locked story by turning off JavaScript or editing CSS.
- Accessible: proper landmarks and headings, keyboard access to every action, alt text on images, reduced motion respected.
Out of scope, and where it's covered
Several nearby problems already have their own guides. Mention them in the interview, then move on. The editor's document model, contenteditable, the toolbar, and paste handling are in the rich-text-editor guide, and Markdown with live preview is the markdown-editor guide.
Threaded responses are the comments-thread guide, and the ranked home feed is the news-feed guide. Image upload is the file-uploader guide, and mentions in the editor are the mention-input guide. Payment checkout, recommendation ranking, and writer payouts are backend problems and we leave them out.
Check your understanding · Exercise 1
Which answers to scope questions would most change the design of the article page, select all that apply? Select all that apply.
What makes this hard
A blog page looks like the easiest thing on the web: some text and a few pictures. Four properties of this product turn it into a real design problem, and every later chapter answers one of them.
- Same page, different readers. The article text is identical for everyone, which makes it ideal for a shared cache. But the page also shows whether you clapped, what you highlighted, whether you follow the writer, and how many free reads you have left. One page, two kinds of data with opposite caching needs.
- Long pages on phones. A typical story is 1,500 to 4,000 words with 5 to 15 images and a couple of embeds (estimate). Each image or embed that loads without reserved space shoves the text down while someone is reading. Each third-party embed can pull in hundreds of KB of script.
- Text that is both the product and the paywall. Search engines must see the full text to rank it. Non-members must not get the full text for free. Doing both without breaking Google's rules on cloaking is subtle.
- Text that changes under readers' marks. Readers highlight sentences. Writers fix typos, add paragraphs, and delete sections after publishing. A highlight saved as "characters 1,204 to 1,288" points at the wrong words after the first edit.
A quick estimate shows why the reader path dominates. Say a popular story gets 300,000 reads in its first day, mostly in the first few hours. That's a peak around 20 to 50 requests per second for one URL (estimate).
If each read needs a server render plus five database queries for claps, follows, and highlights, one story keeps a whole server busy. If the HTML comes from an edge cache, the origin sees a few requests per minute for that page, and only the small per-reader calls reach it.
Think first
Before reading on: if you could only cache one thing for an anonymous reader, what would it be, and what would you have to remove from it first?
Cache the full article HTML: title, byline, body, images, and the public clap count as of the last render.
Remove everything that depends on the viewer: "you clapped 12 times", the follow button state, the reader's highlights, the meter count, and the signed-in avatar. Those become small separate requests after the page shows.
The paywall is the exception: it decides how much of the body is in the HTML. The paywall chapter handles it with cached variants.
Getting the article to the reader: static at the edge, personal on the side
Start with the naive version. The article route is server-rendered on every request: look up the post, look up the viewer's clap count, follow state, highlights, and meter, then render one HTML page. Then a story gets shared by a big account. Every one of the 30 requests per second runs the full render and five queries, the origin's p95 response time (the time 95 percent of requests beat) climbs from 150 ms to two seconds, and LCP for every reader goes with it.
The fix is to notice that the expensive part, the article, is the same for everyone, and the personal part is small. So split them along that line and cache the shared part as close to readers as possible, on a CDN (content delivery network) whose servers sit near them. The ssg-and-isr and cdn-caching lessons explain the general tools, and the wikipedia guide applies them to another read-heavy article site; here's how they apply to this page.
Decision How should the article HTML reach an anonymous reader?
What we pick: Pick the static, edge-cached page revalidated on publish, with personal data fetched on the side. A blog is read thousands of times per edit, and its freshness is driven by an event (publish, edit, unpublish), not by a clock. The cost is a second small request for per-viewer state, which we make cheap and invisible.
How the cache stays fresh
There are two ways to keep a cached article fresh. Time-based revalidation says "this page is good for N seconds", for example Cache-Control: public, s-maxage=60, stale-while-revalidate=86400. The CDN serves the cached copy, and after 60 seconds the next request triggers a refresh in the background while still getting the old copy.
On-demand revalidation says "this page is good until I tell you otherwise": the page is cached with a very long lifetime, and the publish handler invalidates it. We use on-demand as the main path and a long time-based lifetime as a safety net, in case an invalidation message is lost.
In Next.js this maps to cache tags. The article's data fetch is tagged with post:8f3a2c and author:41. When the writer publishes an edit, the server action calls revalidateTag for the post tag.
Next.js marks that cached data stale: the next request still gets the old page while a fresh render runs, then later readers get the new one. Next.js recommends revalidateTag(tag, "max") for exactly this kind of content, where a few seconds of staleness is fine. When the writer must see the edit immediately on their own redirect, updateTag in a server action expires the entry so the writer's next request waits for the fresh version.
// app/[username]/[slug]/page.tsx
import { cacheTag } from "next/cache";
async function getPost(id: string) {
"use cache";
const post = await db.posts.findPublished(id);
cacheTag(`post:${id}`, `author:${post.authorId}`);
return post;
}
// app/editor/actions.ts
"use server";
import { revalidateTag } from "next/cache";
export async function publish(postId: string) {
const post = await db.posts.publish(postId);
// The article page, the author's profile page, and every
// publication or tag page that lists this post share the tag.
revalidateTag(`post:${postId}`, "max");
revalidateTag(`author:${post.authorId}`, "max");
await cdn.purgeByTag([`post:${postId}`]); // if a CDN sits in front
}
Why tags and not paths? One article appears on many pages: its own URL, the author's profile, publication and tag pages, the RSS feed. With paths, the publish handler must know every URL that shows the post. With tags, each page declares what it shows and the handler invalidates one name.
If a separate CDN sits in front, use its tag purge (Fastly surrogate keys, Cloudflare cache tags) so the edge copy is dropped too. Fastly also offers a soft purge, which marks the tagged objects stale instead of deleting them, the same trade as the max profile.
The personal layer
After the cached HTML shows, one client request fetches everything about this viewer and this post: GET /api/posts/8f3a2c/viewer. It returns my clap count, whether I follow the author, my highlights and notes, whether the post is in my reading list, and the meter state. It's Cache-Control: private, no-store, it's tiny (1 to 3 KB), and it doesn't block reading.
The UI is designed so that its arrival never moves layout. The clap button already has its final size and only changes its number and color, and highlights paint as background marks on text that's already there.
Signed-out readers skip the call: no session cookie, nothing personal to fetch. The stack-overflow guide uses the same split for question pages.
Check your understanding · Exercise 2
A writer fixes a typo in a published story that is listed on their profile page and two tag pages. Which statements about invalidation are correct? Select all that apply.
A long page that loads fast, holds still, and reads well
An edge-cached page fixes time to first byte. It doesn't fix what happens after: a 400 KB hero image, two web fonts, twelve inline images, a YouTube video, a GitHub Gist, and an embedded post. Each one can hurt a Core Web Vital. The core-web-vitals, image-optimization, fonts, and third-party-scripts lessons go deep on each tool; this chapter is about the choices specific to a long article.
LCP: the hero image or the title
On most stories the LCP element is the hero image; on stories without one, it's the title or the first paragraph. For the image case, three things matter. The image URL must be in the HTML, not discovered by JavaScript. It gets fetchpriority="high" and no loading="lazy", so the browser fetches it ahead of other images.
And it's served in a size that fits the screen: a srcset with widths like 400, 800, 1200, and 1600 in AVIF or WebP, with sizes set to the article column width. A 1600-pixel JPEG at 380 KB becomes an 800-pixel AVIF of around 60 KB on a phone (estimate). On a slow 4G link, that saving alone can move LCP from poor to good.
Fonts: the serif body text
Long-form sites set the body in a serif and the UI in a sans: with bold and italic, often five or six font files. If the LCP text waits for a font, LCP waits too. Self-host WOFF2 files, preload only the regular body weight, and use font-display: swap with a fallback whose metrics are adjusted (size-adjust, ascent-override, descent-override) to match the web font. Then the swap from Georgia changes letter shapes but barely moves line breaks, so CLS stays near zero.
CLS: reserve space for everything
The editor knows every image's size at upload time, so the stored document keeps width and height for each image block, and the renderer writes them as attributes. Browsers turn those into an aspect-ratio, so the box is the right height before a single byte of the image arrives. Embeds get the same treatment: the server stores the provider's aspect ratio (16:9 for YouTube) or a measured height for a Gist, and the placeholder box uses it. Images below the first screen get loading="lazy" and decoding="async".
Decision How should third-party embeds like YouTube, Gist, and social posts load?
What we pick: Use facades for video and social embeds, and render code Gists as static highlighted code from the stored source with a link to the original. Fall back to lazy iframes for embed types we don't support yet. Chrome's performance guidance recommends facades for exactly these embeds (Lighthouse flagged them with an audit until version 13), and on a reading page almost nobody plays every video.
For INP, the risk on a long page is hydration: if the whole 4,000-word article is a React tree, hydrating it (running the components again in the browser to attach event handlers to the server HTML) costs real main thread time on a cheap phone. Keep the body as static server HTML and hydrate only the islands, the small interactive parts: clap bar, follow button, highlight toolbar, responses drawer. The islands-and-partial-hydration lesson shows how.
Check your understanding · Exercise 3
A story has a hero image, eleven inline images, and three YouTube embeds. Its LCP is 3.8 s and CLS is 0.24 on phones. Which changes would help? Select all that apply.
Typography: the page people stay on
A reading platform succeeds only if people finish stories, so typography is a main feature here. Interviewers rarely ask about it, which is why naming the numbers stands out.
- Measure (line length): 45 to 75 characters per line is the classic range, with about 66 often called ideal. Shorter lines break the reading rhythm; longer ones make the eye lose its place on the return sweep. WCAG 1.4.8 (level AAA) caps text blocks at 80 characters. Set the column in
chrather than pixels, so it tracks the font size. Onechis the width of the digit 0, which is wider than the average letter, so a column of Nchholds about 1.2 to 1.4 times N characters of real text:max-width: 55chgives roughly 65 to 75 in common serifs. Check the count with the actual font. - Size: about 18 px body on phones and 20 to 21 px on desktop for a serif. Body text below 16 px on a phone forces zooming.
- Line height: around 1.5 to 1.6 for body text, tighter (1.2) for headings. WCAG 1.4.8 asks for at least 1.5 within paragraphs.
- Paragraph spacing: a clear gap between paragraphs, larger before headings than after, so a heading visually belongs to the text below it.
- Dark mode: follow
prefers-color-schemeby default and let readers override it with a stored choice. Use off-white on dark gray rather than pure white on pure black, which causes halation (a glow that blurs the letters) for many readers. Don't invert images. - Zoom: everything in
remandch, so a reader who sets a larger default font size gets a larger, still well-measured column.
.article-body {
max-width: 55ch; /* about 65 to 75 characters in a serif */
margin-inline: auto;
padding-inline: 1.25rem;
font-family: "Body Serif", "Body Serif Fallback", Georgia, serif;
font-size: clamp(1.125rem, 1rem + 0.5vw, 1.3125rem);
line-height: 1.6;
}
.article-body p + p { margin-block-start: 1.5em; }
.article-body h2 { margin-block: 2.2em 0.6em; line-height: 1.2; }
.article-body pre { max-width: 100%; overflow-x: auto; }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--text: #e6e6e6;
--bg: #121212;
}
}
Reading time
Medium's help center describes its estimate as word count divided by about 265 words per minute, plus an adjustment for images. Medium has described that adjustment as 12 seconds for the first image, one second less for each next image, and 3 seconds for every image from the tenth on. For Chinese, Japanese, and Korean, count characters, not spaces: Medium uses about 500 characters per minute.
Compute it once at publish time on the server, store it on the post, and render it into the cached HTML. Don't compute it in the browser: the number must match on the feed card, the article page, the email, and the link preview, and the paywall preview doesn't have the full text anyway.
function readingMinutes(wordCount: number, imageCount: number) {
let imageSeconds = 0;
for (let i = 0; i < imageCount; i++) {
imageSeconds += Math.max(12 - i, 3);
}
const seconds = (wordCount / 265) * 60 + imageSeconds;
return Math.max(1, Math.round(seconds / 60));
}
// 1,500 words + 5 images: 340 s + 50 s = 390 s, shown as "6 min read"
The progress indicator
A thin bar at the top that fills as you read helps readers see how far they are, and it's a classic place to waste main thread time. A scroll listener that sets a width on every event causes layout work on every frame.
Better: drive it with a CSS scroll-driven animation where supported, which the browser can run off the main thread. Use a named view timeline on the article element rather than scroll() on the page, so the responses section and footer don't count. Elsewhere, fall back to a passive listener that updates a transform: scaleX() once per animation frame. The bar is decorative, so hide it from assistive technology with aria-hidden.
Check your understanding · Exercise 4
Which typography choices make a long article easier to read, select all that apply? Select all that apply.
The paywall and the meter
Member-only stories show a preview to non-members: the title, the opening paragraphs, and a box asking them to sign in or subscribe. On Medium, writers in the Medium Partner Program choose which stories go behind the paywall, and they earn a share of membership fees based on how members engage with those stories. Medium also gives non-members a small number of free member-only stories each month. The exact number has changed over the years and isn't clearly documented, so treat it as a config value, FREE_READS_PER_MONTH, not a constant.
Google calls this pattern flexible sampling and describes two kinds: metering, a monthly quota of free articles, and lead-in, showing the opening of a story before the paywall. Medium uses both. This chapter answers three questions: where the meter is counted, how the locked text is kept from non-members, and how search engines still index the whole story.
The naive build is common: send the full article, count reads in localStorage, and when the count runs out, cover the text with a gradient and a modal. Then someone opens a private window and the meter is full again. Someone else turns off JavaScript and reads everything. Reader modes and "remove paywall" extensions do this automatically for millions of people.
Decision Where should the free-read meter be counted?
What we pick: Count on the server per account for signed-in readers. For anonymous readers, pick per business rule: either preview only, which pushes readers to create an account, or a soft cookie meter as a sign-up funnel. Either way, the decision about what text to send is made on the server, never in client code.
Decision How should the locked part of the story be kept from non-members?
What we pick: Use two cached variants chosen at the edge. Server-side truncation is the only lock that holds, and choosing between two cached copies keeps the edge-cache economics from the delivery chapter. Never add Vary: Cookie: every reader has a different cookie, so it turns one shared cache entry into one per reader. Compute the variant at the edge and make it part of the cache key.
Try both lock methods below. Pick a reader, open stories until the free reads run out, and watch which paragraphs are actually in the HTML, and which tricks would reveal them.
Pick a reader, then press Open story to count a view.
The decision at the edge
Members carry a short-lived signed token (for example a JSON Web Token, or JWT, valid for 15 minutes, refreshed by the app) that says member: true. The edge checks the signature with a public key: no network call. Free signed-in readers need one call to a meter service in a fast key-value store near the edge: "unlock story X for user U this month if allowed". It's idempotent: if X is already in U's unlocked set, it returns yes without spending a read, so re-opening a story never costs twice.
async function chooseVariant(req: Request, post: PostMeta) {
if (!post.memberOnly) return "full";
const claim = await verifyMemberToken(req); // signature only, no I/O
if (claim?.member) return "full";
if (await isVerifiedGooglebot(req)) return "full"; // DNS check, cached
if (claim?.userId) {
const r = await meter.unlock(claim.userId, post.id, monthKey());
return r.allowed ? "full" : "preview";
}
return "preview";
}
// Cache key: URL + variant. Response to the browser: private, no-store,
// because the choice depended on who asked.
Letting search engines index the full story
If Googlebot gets the preview, the story ranks on three paragraphs. If Googlebot gets the full text and readers get a preview, that can look like cloaking: showing crawlers different content than users, which Google's spam policies forbid. Google's answer is structured data for paywalled content.
The page marks itself isAccessibleForFree: false and lists the locked sections with hasPart, each a WebPageElement with isAccessibleForFree: false and a cssSelector that points at a class wrapping the locked text. Google's guidelines say to use only class selectors and not to nest the sections. This tells Google the difference is a paywall, not deception.
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "What I learned shipping a design system",
"image": ["https://cdn.example.com/p/8f3a2c/hero-1200.jpg"],
"datePublished": "2026-03-02T08:00:00Z",
"dateModified": "2026-03-04T10:12:00Z",
"author": { "@type": "Person", "name": "Ana Ruiz", "url": "https://example.com/@ana" },
"isAccessibleForFree": false,
"hasPart": {
"@type": "WebPageElement",
"isAccessibleForFree": false,
"cssSelector": ".paywall"
}
}
The crawler check must be real. Anyone can send a User-Agent header that says Googlebot, so a user-agent check alone is a free bypass.
Google documents two ways to verify. One is a reverse DNS lookup on the IP, then a forward lookup on the returned host name that must give back the same IP; for Googlebot the host name ends in googlebot.com. The other is matching the IP against Google's published IP ranges for its common crawlers.
Accept only the Googlebot answer for the full variant. Google's list also has googleusercontent.com hosts, but those are fetches that sites running on Google Cloud make for their own users, so anyone could route a request through one. Do the check at the edge and cache the result per IP for a few hours.
unlock is idempotent: opening X again this month returns ok without spending a read.One more door through the paywall is deliberate. Medium lets writers share a friend link that opens a member-only story for anyone. Build it as a signed token in the URL: an HMAC (a keyed hash that only our servers can compute) of the post id and a key version. The edge treats a valid token as entitlement for that one story, rel=canonical points at the clean URL so the token is never indexed, and bumping the key version revokes every friend link at once.
Check your understanding · Exercise 5
Which paywall designs let a free reader with no reads left get the full text of a member-only story, select all that apply? Select all that apply.
Highlights and notes: anchoring marks to text that changes
A reader selects a sentence, a small toolbar appears, they tap Highlight, and the sentence turns green. Next week they come back and it's still green. Then the writer fixes a typo in the first paragraph, adds a new section, and rewrites the highlighted sentence slightly.
Which words should be green now? This is the core problem of highlights: an anchor is a description of a piece of text that must find that text again, in a document that may have changed.
The W3C Web Annotation Data Model gives us vocabulary for this. A TextPositionSelector describes a span by character offsets: start is the first character, and end is the position just after the last one. A TextQuoteSelector describes it by content: the exact text, plus a few characters of prefix before it and suffix after it.
The model allows several selectors for one target, so a client can try one and fall back to another. Annotation tools like Hypothesis store both, with 32 characters of prefix and suffix, for exactly this reason.
Decision How should a highlight be stored so it survives edits to the article?
What we pick: Store all of it: block id, block-local offsets, exact quote, prefix, and suffix. Positions are fast and precise when nothing changed; quotes with context recover after edits; block ids keep the search small. The rich-text-editor guide gives every block a stable id, which is what makes this cheap.
Try each anchor format below. Apply a writer's edit and see which anchors re-attach, which land on the wrong words, and which give up.
Stored when the reader highlighted cache it at the edge: position 69 to 89; quote; and block b2, offsets 36 to 56, prefix product, so , suffix ..
type Anchor = { blockId: string; start: number; end: number;
exact: string; prefix: string; suffix: string };
function resolve(a: Anchor, doc: Doc): Span | "orphaned" {
const block = doc.blockText(a.blockId);
if (block?.slice(a.start, a.end) === a.exact)
return { blockId: a.blockId, start: a.start, end: a.end };
const hits = [
...findAll(block ?? "", a.exact).map((i) => at(a.blockId, i)),
...doc.findEverywhere(a.exact),
];
if (hits.length) return best(hits, a); // score prefix, suffix, distance
const fuzzy = doc.fuzzyFind(a.exact, { maxErrors: Math.ceil(a.exact.length * 0.25) });
return fuzzy ? fuzzy : "orphaned";
}
Where should re-anchoring run? Resolving on the client at render time repeats work on every view. Better: when the writer publishes an edit, a background job re-anchors every highlight on that post, stores the new offsets, and flags orphans.
The client then only verifies cheaply (does the slice match the quote?). Orphans aren't deleted; they stay in the reader's highlight list with the original quote and a note that the passage changed.
Painting highlights without touching the article DOM
The old way to show a highlight is to wrap the text in a mark element. That mutates the server-rendered HTML, which can confuse hydration, splits text nodes, and gets messy when highlights overlap.
The CSS Custom Highlight API paints ranges without changing the DOM. Build a Range for each anchor, add them to a Highlight object, register it with CSS.highlights.set("mine", highlight), and style it with ::highlight(mine). It's supported in Chromium 105+, Safari 17.2+, and Firefox 140+; keep the mark approach as a fallback for older browsers.
Popular highlights ("Top highlight") use a second registry name with a lighter style. They come from a public, short-cached endpoint with the aggregated top passages per post.
The selection toolbar
Listen for selectionchange and wait until the selection settles (on pointerup or keyup, or about 150 ms after the last change on touch). If it's collapsed or outside the article body, hide the toolbar; otherwise place it from getRangeAt(0).getBoundingClientRect(): above the selection on desktop, below it on phones, where the system copy menu sits above.
Keyboard readers select with Shift and the arrow keys, so a shortcut (for example Alt plus H) moves focus into the toolbar, a polite live region (an element whose updates screen readers announce) announces it, and Escape closes it and returns focus.
Sharing a quote: text fragments
Share from the toolbar copies a link that scrolls to and marks the passage, using a text fragment: #:~:text= followed by an optional prefix, the start text, an optional end text, and an optional suffix, each percent-encoded. For example, #:~:text=the%20article%20page-,is%20the%20product marks "is the product" where it follows "the article page". The browser does the scrolling and marking; our code does nothing.
Chromium 80+, Safari 16.1+, and Firefox 131+ support it; other browsers ignore the directive and just open the page. The browser strips the directive from the URL that scripts see, so it never collides with our own routing. It's built from the same quote, prefix, and suffix we already store, so one anchor format serves both.
Check your understanding · Exercise 6
A reader highlighted "caching is the product" in paragraph 7. The writer then inserts two new paragraphs at the top and changes the highlighted sentence to "caching is the real product". What happens with each anchor? Select all that apply.
Claps: many taps, one honest number
Medium's clap is not a like. A reader can clap up to 50 times per story, and holding the button claps repeatedly. The public total is the sum of every reader's claps.
A happy reader may tap 30 times in five seconds, and each tap must feel instant: the number ticks up, the icon pops, and a small "+30" bubble floats. The reddit guide covers optimistic voting in general; claps add two twists, rapid repeated input and a per-user cap.
The naive build sends POST /claps with +1 on every tap. Thirty taps make thirty requests, which is wasteful but not the real problem.
The real problem is retries: if request 17 times out and the client retries it, but the first attempt actually reached the server, the reader now has 31 claps. Increments are not idempotent. And if requests arrive out of order, a server enforcing the cap of 50 may reject the wrong one.
Decision How should rapid clap taps reach the server?
What we pick: Send the reader's absolute total, debounced. It turns a stream of non-idempotent events into one idempotent fact ("my claps on this post are 12"), which makes retries and duplicate tabs safe. The debounce-throttle lesson covers the timing; here the important part is what the request means, not how often it's sent.
const MAX = 50;
let mine = initialMine; // from the viewer payload
let confirmed = initialMine;
let seq = 0;
const flush = debounce(async () => {
const sent = mine;
const mySeq = ++seq;
try {
// keepalive lets the request finish if the tab closes mid-flight
const res = await api.put(`/posts/${id}/claps`, { count: sent, seq: mySeq }, { keepalive: true });
if (mySeq === seq) { confirmed = res.count; render(res.count, res.total); }
} catch {
if (mySeq === seq) { mine = confirmed; render(confirmed); toast("Claps not saved"); }
}
}, 800);
function onTap() {
if (mine >= MAX) return pulseLimit();
mine += 1; render(mine); flush();
}
addEventListener("pagehide", () => flush.flush()); // send before leaving
A few details make it solid. Flush pending claps on pagehide with fetch and keepalive: true, so the request survives the tab closing. Show the total as the cached public total plus the reader's change since load.
At 50, the button stops counting and gives a gentle cue, not an error. Holding the button repeats about 8 times a second and stops on pointerup, pointercancel, or pointerleave. Under prefers-reduced-motion, skip the bounce and floating bubble.
Check your understanding · Exercise 7
A reader taps clap 12 times, the debounced PUT with count 12 times out, and the client retries it. Then the reader taps 3 more times. What does the server end up with? Select all that apply.
Writing and publishing: the lifecycle around the editor
The editor itself (the document model, contenteditable, the toolbar, paste) is the rich-text-editor guide. Here we design what surrounds it: saving, previewing, scheduling, publishing to readers and inboxes, and editing afterwards. Writers are few compared to readers, but losing a writer's draft is the worst bug this product can have.
Autosave and versions
Save the draft about 1.5 seconds after the writer stops typing, and at least every 10 seconds during long bursts. Each save is a PUT of the whole document (a 4,000-word story is roughly 30 to 60 KB of JSON) with the baseVersion it was built on; the server answers with the new version.
Also keep a copy in IndexedDB, the browser's built-in database, on every change, so a dead network or a closed laptop loses nothing; on reopen, offer to restore it if it's newer than the server's. The status line says exactly one of "Saving", "Saved", or "Offline, saved on this device". The server also snapshots the draft every few minutes of editing and at every publish, so writers can restore earlier versions.
Decision What happens when the same draft is open in two tabs, or on a laptop and a phone?
What we pick: Use the version check. Drafts are almost always single-author, so conflicts are rare and the important property is that nothing is ever silently lost. A same-browser BroadcastChannel makes the common two-tabs case nearly conflict-free; the 409 path handles the cross-device case honestly.
Preview links for unpublished drafts
Writers share drafts with editors and friends before publishing. The link must work without an account, must not be guessable, and must not be indexed.
Make it /p/8f3a2c?share= plus a random 128-bit token stored on the draft, or an HMAC of the post id and a token version. The page is rendered on demand, never cached publicly, and sends X-Robots-Tag: noindex. Revoking is a button that bumps the token version, and expiry is optional, for example 30 days.
Scheduling, publications, and email
Scheduling stores publishAt in UTC and shows it in the writer's time zone with the zone name ("Tue 9:00 AM, India Standard Time"). A server job publishes it, then runs the same steps as a manual publish: revalidate tags, update the sitemap's lastmod, notify followers. The client never decides when to publish, because the writer's laptop may be closed. Submitting to a publication adds a review state: the editors of the publication approve before it goes live.
Newsletter delivery, Substack's core feature, renders the same document a second time as email HTML. Mail clients run no JavaScript and support limited CSS, so the email renderer inlines styles, keeps a single narrow column (about 600 pixels), turns embeds into linked thumbnails, and uses absolute image URLs. Paid posts send free subscribers the preview plus a "subscribe to read" button, like the web paywall. A test email to the writer catches most rendering surprises.
Slugs, redirects, and canonical URLs
Writers change titles after publishing, and old links are already on X and in newsletters. So the URL holds both a readable slug and the stable id: /@ana/what-i-learned-shipping-a-design-system-8f3a2c. The router reads only the trailing id. If the slug part doesn't match the current one, respond with 301 to the current URL, so old links keep working and search engines move their ranking to the new URL.
Writers who first published on their own blog can set a canonical URL, written as link rel="canonical" pointing at the original. Search engines then credit the original and don't treat the copy as duplicate content.
Images are uploaded through the flow in the file-uploader guide, and cropping a hero to the card and share aspect ratios is the image-cropper guide. At upload, the server records width and height (needed for CLS) and generates widths like 400, 800, 1200, and 1600 in AVIF and WebP, behind an image CDN URL. The editor asks for alt text right in the image block, with a visible "Add alt text" prompt, and the publish dialog lists images still missing it.
Check your understanding · Exercise 8
Which publishing behaviors protect writers and readers, select all that apply? Select all that apply.
SEO and sharing: being found and looking good in a link
Most readers arrive from somewhere else, so how the story looks to Google, to Slack's link unfurler, and to an RSS reader matters as much as how it looks on the page. All of it is generated on the server at publish time and lives in the cached HTML.
- Article structured data. A JSON-LD block (JSON in a script tag, the format Google prefers for structured data) of type
BlogPostingwithheadline,image(several aspect ratios are welcome),datePublished,dateModified, andauthorwithnameandurl. Member-only stories add the paywall properties from the paywall chapter. - Link previews. Open Graph tags (
og:title,og:description,og:image,og:typeset toarticle) andtwitter:cardset tosummary_large_image. Most platforms crop around a 1.91:1 image, so generate 1200 by 630. - Generated share images. Not every story has a hero, and a bare hero lacks the title. At publish, render one from a template (title, author, avatar, publication logo) as a PNG; in Next.js,
ImageResponsefromnext/ogdoes this. Put the post version in its URL, so an edit gets a new image and no cache serves the old title. - Canonical. Every page declares its own clean canonical URL (no tracking or share parameters), or the original's URL for cross-posted stories.
- Sitemaps. One sitemap file holds at most 50,000 URLs or 50 MB uncompressed, so tens of millions of stories need a sitemap index pointing at many files, split by month of publication, each with
lastmod. Old months rarely change and can be cached for a long time. - RSS. Feeds per author, per publication, and per tag, with the latest 20 or so items. Member-only stories include only the preview in the feed, matching the paywall.
The one SEO mistake that's easy to make with the design so far: sending the hero image, title, and body text only after JavaScript runs. Everything search engines and unfurlers need is in the first HTML response here, because the article is server-rendered and cached. The per-viewer layer, which crawlers don't need, is the only part loaded by the client.
Check your understanding · Exercise 9
A writer changes a story's title. What should change automatically? Select all that apply.
Architecture, data model, and API
Put the pieces from the previous chapters together. There are two front ends sharing one design system: the reader (article pages, profiles, publications, the home feed) and the writer (editor, stats, settings). They have opposite profiles.
The reader is mostly static HTML with a few interactive islands, optimized for first view. The writer is a client-heavy app behind login, optimized for not losing work. Splitting them by route keeps the editor's large bundle (often several hundred KB with its document model and plugins) away from readers.
| Piece | Runs where | Responsibility |
|---|---|---|
| Article renderer | Server, at publish or first request | Turns the stored document into HTML, both preview and full variants, with structured data and share tags. Tagged by post and author. |
| Edge middleware | CDN edge | Picks the variant (preview or full) from the membership token, the meter, a friend link, or a verified crawler. Redirects stale slugs. |
| Viewer client | Browser, small island | Fetches the viewer payload once; owns my claps, follow state, reading list, and highlight data. |
| Clap bar | Browser island | Optimistic count, debounced absolute total, flush on page hide. |
| Highlight layer | Browser island | Selection toolbar, anchor creation and resolution, painting with the Custom Highlight API, text fragment share links. |
| Responses drawer | Browser, loaded on open | The comments-thread design, loaded only when opened. |
| Editor app | Browser, writer routes | Rich text editor, autosave with versions, IndexedDB draft copy, image upload, publish dialog. |
| Publish service | Server | Validates, snapshots a version, revalidates tags, purges the CDN, queues email and follower notifications, updates the sitemap. |
| Service worker | Browser | Caches saved stories for offline reading and the app shell for the writer. |
Each boundary has a reason. The renderer is separate from the edge decision because rendering is expensive and rare, while choosing a variant is cheap and runs on every request. The viewer client is one fetch for all per-viewer state rather than one per widget, because five widgets making five calls is five round trips on a phone.
The responses drawer is lazy because most readers never open it. And the publish service owns invalidation, because it's the only place that knows when content changed.
Check your understanding · Exercise 10
Why is the per-viewer state fetched as one payload after the page shows, rather than rendered into the HTML, select all that apply? Select all that apply.
Data model and API
The client-side data falls into three groups with different homes. Public content (the post, its author, the public clap total, top highlights) is baked into cached HTML or fetched from public, cacheable endpoints. Viewer state (my claps, my highlights, follow, saved, meter) lives in a small server-state cache in the browser, keyed by post id, fetched once per page and updated optimistically. Drafts live in the editor's store, mirrored to IndexedDB, and saved to the server with versions.
data-block-id.type Post = {
id: string; slug: string; authorId: string; publicationId?: string;
title: string; subtitle?: string; heroImage?: Img;
blocks: Block[]; // paragraphs, headings, images, code, embeds
readingMinutes: number; memberOnly: boolean; previewBlockCount: number;
publishedAt: string; updatedAt: string; version: number;
canonicalUrl?: string; clapTotal: number; responseCount: number;
};
type Block = { id: string; kind: "p" | "h2" | "img" | "code" | "embed" | "quote"; text?: string;
img?: Img; embed?: { provider: string; thumb: Img; aspect: number; url: string } };
type Img = { src: string; width: number; height: number; alt: string };
type ViewerState = {
myClaps: number; following: boolean; saved: boolean;
highlights: Highlight[];
meter?: { remaining: number; resetsAt: string; unlocked: boolean };
};
type Highlight = { id: string; anchor: Anchor; note?: string;
status: "ok" | "orphaned" };
type Draft = { postId: string; version: number; doc: Block[]; savedAt: string };
The API is REST, because almost everything is a document read or a small write, and REST responses cache cleanly at the CDN. Writes return the new state, so the client can reconcile its optimistic view with the server's.
GET /@ana/what-i-learned-8f3a2c HTML, cached at the edge (variant)
GET /api/posts/8f3a2c/viewer 200 ViewerState, private, no-store
GET /api/posts/8f3a2c/top-highlights 200 [{ blockId, start, end, count }], s-maxage=300
PUT /api/posts/8f3a2c/claps { count: 12, seq: 4 } -> { count: 12, total: 1840 }
POST /api/posts/8f3a2c/highlights { anchor, note? } -> 201 Highlight
DELETE /api/highlights/h_91 204
PUT /api/me/follows/41 204 (idempotent; DELETE to unfollow)
PUT /api/me/saved/8f3a2c 204
PUT /api/drafts/8f3a2c { baseVersion: 17, doc } -> { version: 18 }
409 { code: "version_conflict", current }
POST /api/drafts/8f3a2c/publish { publishAt?, publicationId?, email: true }
POST /api/drafts/8f3a2c/preview-link -> { url, expiresAt }
Errors: { code: "meter_exhausted" | "rate_limited" | "version_conflict", message }
Notice the verbs. Follow, save, and clap are PUT of a state, not POST of an action, so they're idempotent and safe to retry. Lists such as an author's stories and the home feed use cursor pagination as in the news-feed guide.
Check your understanding · Exercise 11
Which API choices make optimistic UI safe to retry on a flaky phone connection? Select all that apply.
Accessibility, offline reading, and the rest of the page
Accessibility on a reading page
A reading page is the easiest kind of page to make accessible, and the easiest to quietly break. The semantic-html and screen-readers lessons cover the basics; these are the parts specific to articles.
- Landmarks and structure. The story is an
articleinsidemain, with the title as the onlyh1. The site header is aheaderwithnav, the responses a separatesectionwith its own heading. Screen reader users jump by landmark and by heading, so this is their table of contents. - Heading order from the editor. The editor offers two heading levels that map to
h2andh3, neverh1, so writers can't break the outline. The renderer never skips a level. - Alt text. Prompt for it on every image block in the editor, and list missing ones in the publish dialog. Decorative images get an explicit empty
alt. Generated captions can suggest a draft, but the writer confirms. - Interactive islands are real controls. The clap button is a
buttonwhose accessible name includes the count, and changes are announced through a polite live region, throttled so 30 taps don't produce 30 announcements. - The highlight toolbar is reachable from the keyboard, traps nothing, and returns focus on Escape, as described in the highlights chapter.
- Motion. The clap bounce, floating bubbles, and smooth scrolling to a text fragment respect
prefers-reduced-motion. - Paywall box. The preview's end is announced as a heading ("This story is for members"), not just a visual fade, so screen reader users know why the text stopped.
Saved stories, offline
Saving a story to the reading list is a PUT; reading it on a plane is a job for a service worker, the script that sits between the page and the network (see the service-worker-caching lesson). On save, the page messages the service worker, which fetches the story's HTML (the variant this reader is entitled to) and phone-sized images into a dedicated cache such as saved-v1.
Navigations to a saved story try the network first with a 3-second timeout, then fall back to the saved copy. Cap the cache (say, the 50 most recent saves), drop entries on unsave, and clear member-only copies on sign-out.
Following, the home feed, and responses
Following a writer is an optimistic PUT with the button flipping immediately. The home feed (followed writers, publications, and recommendations) is a ranked, cursor-paginated list; its design is the news-feed guide. Responses are threaded comments with their own article-like editor and their own claps; their design is the comments-thread guide. On the article page, the responses drawer loads only when opened, and its count is part of the cached HTML, refreshed when the post is revalidated.
Check your understanding · Exercise 12
Which of these are correct for the reading page? Select all that apply.
Running it in the interview
Forty-five minutes is not enough to cover everything in this guide, so choose on purpose. The reader page is where the interesting trade-offs are; the editor can easily take the whole interview, so name it, point to the rich-text-editor problem, and move on unless asked.
| Minutes | What to do | What to draw |
|---|---|---|
| 0 to 5 | Scope: who reads, where from, paywall or not, which reader actions. State that reading dominates. | Nothing yet, or a list of requirements. |
| 5 to 15 | Delivery: static at the edge, revalidate on publish with tags, per-viewer payload on the side. | The data flow: browser, CDN, origin, viewer endpoint. |
| 15 to 25 | Pick the deep dive the interviewer leans toward: paywall variants and metering, or highlight anchoring. | The edge decision, or an anchor with its fallback steps. |
| 25 to 33 | Claps (absolute total, debounced) and Core Web Vitals on a long page. | A small sequence diagram for the clap flow. |
| 33 to 40 | Publishing: autosave with versions, conflicts, previews, slugs and redirects. | The post lifecycle states. |
| 40 to 45 | Accessibility and offline in one minute each, then questions. | Nothing new. |
What to say out loud: name the split between shared and per-viewer data in the first ten minutes; state that the paywall is enforced by what the server sends, not by what the client hides; and say why claps send a total, not increments. Those three sentences show the interviewer you understand caching, security, and idempotency, which is most of what this question tests.
Think first
Follow-up: a story with 2 million cached views gets edited 40 times in an hour by a nervous writer. What happens to your cache?
Each publish of an edit invalidates the post tag, and with the stale-while-revalidate profile the next request triggers one re-render while readers keep getting the previous version. So the origin does about 40 renders, not 2 million.
Autosaves of a published story's pending edits don't touch the live page at all. Only pressing "Save and publish" does, so typing never thrashes the cache.
If edits come faster than renders, coalesce: the publish service debounces invalidations per post for a few seconds.
Think first
Follow-up: a member cancels. Their membership token is valid for 15 more minutes. Is that a problem?
It's a deliberate trade: a short-lived signed token lets the edge decide without a network call. The cost is up to 15 minutes of extra access after cancellation, which is fine for a reading product.
For stricter needs, shorten the lifetime or keep a small revocation list at the edge, checked only for member tokens.
Think first
Follow-up: how would you show that 1,200 readers highlighted the same sentence, when everyone highlights slightly different spans?
Aggregate on the server per block: count, for each character position, how many highlights cover it, then find the longest run above a threshold and snap it to sentence boundaries.
Recompute in a background job, store the top one or two passages per post, and serve them from a public endpoint with a short cache. Re-run it when the post is edited, using the same re-anchoring as personal highlights.
Check your understanding · Exercise 13
Which moves make a strong interview answer for this problem, select all that apply? Select all that apply.
Summary
- The article page is the product. Serve it as static HTML from the edge, revalidated by tag when the writer publishes, and fetch everything per-viewer in one small private payload.
- Keep a long page fast and still: hero image in the HTML with high priority, metric-matched fallback fonts, stored width and height for every image and embed, and click-to-load facades for third-party embeds.
- Readability is measurable: 45 to 75 characters per line, line height near 1.6, body near 20 px on desktop, reading time computed once on the server.
- A paywall is what the server sends, not what the client hides. Cache a preview and a full variant, choose at the edge from a signed token and a server-side meter, verify crawlers by DNS or IP, and mark locked sections with paywall structured data.
- Anchor highlights with a block id, block-local offsets, and the quote with prefix and suffix, and re-anchor in steps from exact to fuzzy to orphaned. Paint them with the Custom Highlight API and share them with text fragments.
- Claps send the reader's absolute total, debounced, with a sequence number. Idempotent writes make optimistic UI safe to retry.
- Writers must never lose work: autosave with versions and an IndexedDB copy, a 409 on conflicts, signed preview links, server-side scheduling, and slug changes that redirect.
Check your answer
Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.
References
- About claps (Medium Help Center) Up to 50 claps per reader per story, and how claps feed ranking.
- Read time (Medium Help Center) About 265 words per minute, 500 characters per minute for Chinese, Japanese, and Korean, plus an image adjustment.
- Flexible sampling general guidance (Google Search Central) Metering and lead-in, and why paywalled content needs structured data.
- Structured data for subscription and paywalled content (Google Search Central) The isAccessibleForFree and hasPart markup that separates a paywall from cloaking.
- Article structured data (Google Search Central) BlogPosting and Article properties Google reads.
- Verify requests from Google crawlers and fetchers (Google) Reverse and forward DNS, published IP ranges, and which host names belong to which crawler type.
- Web Annotation Data Model (W3C) TextQuoteSelector, TextPositionSelector, and combining selectors.
- Fuzzy anchoring (Hypothesis) How an annotation tool stores position and quote selectors with 32-character context and re-anchors after edits.
- Text fragments (MDN) The #:~:text= syntax for linking to a passage.
- CSS Custom Highlight API (MDN) Painting ranges without changing the DOM.
- revalidateTag (Next.js docs) On-demand revalidation by cache tag and the stale-while-revalidate profile.
- Working with surrogate keys (Fastly) Tagging cached responses and purging every object with one key.
- Lazy load third-party resources with facades (Chrome for Developers) Click-to-load previews for video and social embeds.
- Optimize Cumulative Layout Shift (web.dev) Image dimensions, reserved space for embeds, and font swaps.
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.
- Cache invalidationHow every copy of a piece of data, from the server cache to the CDN to the open tab, learns that it is out of date: by time, by event, or by a new name, in the right order and without a stale read sneaking back in.
- 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.
- 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.
- Web fontsLoading custom fonts without invisible text or a jumping page: choosing
font-display, preloading the right files, subsetting them, and matching the fallback font's size. - Optimistic updatesShowing the result of a user's action at once, before the server confirms it, and quietly undoing it if the server says no: the trick that makes likes, checkboxes, and chat messages feel instant on any network.
- Debounce and throttleTwo small timing tools that turn a flood of events (keystrokes, scrolls, resizes) into a few calls you can afford: debounce waits for a pause, throttle runs at a steady rate.
- 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.