Design a no-code website builder
A visual editor where people who don't write code build responsive marketing sites by dragging blocks onto a live page, styling them per screen size, and publishing to a real domain. The hard parts are keeping the page as a tree of data, editing a real page inside an iframe with overlays drawn on top, dropping blocks into flex and grid layouts, a class and breakpoint styling model, and compiling the result into fast static HTML and CSS.
The question
Design the web client for a no-code website builder like Webflow or Framer. A marketer opens a page, drags sections, text, images, and buttons onto it, changes fonts, colors, and spacing in a style panel, and checks how the page looks on desktop, tablet, and phone. When they are happy, they press Publish and the site goes live on their own domain.
Walk me through how you represent a page, how the editor shows it and lets people select and drag things on it, how styles and breakpoints work, how saving and undo work, and what you ship to visitors when the site is published.
Real-life examples: Webflow, Framer, Wix Studio, Squarespace, Builder.io, Plasmic
Requirements: scoping a website builder
A website builder has two products in one. The first is the editor: a heavy, interactive app that a designer keeps open for hours. The second is the published site: a set of plain web pages that strangers load once on a phone over a slow network. The two have opposite needs, and most of the design is about keeping them apart while making sure the page you see in the editor is the page visitors get.
So the scope questions cover both sides: who builds and how much CSS they know, how big a page gets, how live the canvas must be, and what Publish ships and how fast it must load. Each answer moves a part of the design, so ask them before drawing boxes.
| Question | Answer we assume | What it changes |
|---|---|---|
| Who builds the sites? | Designers and marketers. Some know CSS words like padding and flex; most never write code. | The style panel speaks CSS concepts with friendly controls. No code editor in the main flow. |
| How big is a page? | Typically 200 to 1,500 elements; a long landing page can reach 5,000 (our estimate). | The tree must update one node in constant time, and the layers panel must be virtualized. |
| Must the canvas match production? | Yes. What you see must be the real HTML and CSS a visitor gets, at any width. | The canvas renders real DOM with real CSS, in an iframe, not a drawing. |
| Which screen sizes? | A base desktop size plus tablet and phone, with optional wider sizes. | A breakpoint model where styles cascade between sizes. |
| Reusable parts? | Yes: a navbar or footer built once and used on every page, with small per-use changes. | Components with instances and overrides in the data model. |
| Dynamic content? | Yes: a blog or job list from a content collection, one template page per collection. | Bindings from node props to CMS fields, and template pages. |
| Do people edit together? | Teams of 2 to 10. Usually one person per page at a time. | Page-level locking first; live multiplayer is a follow-up. |
| What does Publish produce? | Static HTML, CSS, and a little JS per page on a CDN, on the customer's domain with TLS. | A compiler from tree to files, an atomic deploy, and instant rollback. |
Functional requirements
- Add elements from an insert panel (section, container, text, image, button, link, form, embed) by drag or by click.
- Select, hover, move, duplicate, and delete elements on a live canvas and in a layers tree.
- Style an element through a style panel: layout (flex, grid), spacing, size, typography, color, borders, effects.
- Edit styles per breakpoint and preview the page at any width.
- Create components, place instances, and override text or images per instance.
- Bind elements to CMS fields and design one template page for a whole collection.
- Undo and redo, autosave, and restore an older version.
- Publish to a staging URL and to a custom domain; roll back a bad publish.
Non-functional requirements
- Editing feels instant: a style change shows on the canvas in the next frame, under about 16 ms of work, and a drag keeps 60 frames per second on a page of 5,000 elements.
- The editor loads in a few seconds on a laptop. It is a desktop app; phones only need to view, not edit.
- Nothing is lost: an edit is saved to the server within a couple of seconds, and a crash loses at most that much.
- Published pages are fast: they should pass Core Web Vitals (LCP at most 2.5 s, INP at most 200 ms, CLS at most 0.1, at the 75th percentile of visits) with no work from the author.
- The editor's own CSS and JS never touch the user's page, and the user's custom code never touches the editor or the account.
- Both the editor and the output are accessible: the editor works by keyboard, and the output uses real headings, landmarks, and alt text.
Out of scope: the server-side hosting stack (we treat the CDN as a service we call), e-commerce checkout, the CMS editing screens themselves, and billing. Mention them and move on.
Also keep away from what other guides cover in full: the field palette of a form builder, the canvas renderer of a design tool like Figma, the preview sandbox of a code playground, and translating a site into several languages (the form-builder, figma, codepen, and i18n-system guides). We reuse their answers and point to them where they fit.
Check your understanding · Exercise 1
Which answers to scope questions change the design of a website builder the most? Select all that apply.
What makes a website builder hard
Look at the editor first. It has five parts: a top bar with the breakpoint switcher, undo, and Publish; an insert panel and a layers tree on the left; the canvas in the middle; and the style panel on the right. Every part reads and writes the same page, so a change in one shows in all the others in the same frame.
Five things make this harder than it looks, and each one gets its own chapter.
- The page is data, not HTML. People never touch markup. Every click edits a tree of nodes, and the tree must be fast to change, easy to undo, small to save, and able to describe reusable components and CMS bindings.
- The canvas is a real page you can't fully control. It shows the user's own CSS, fonts, and layout. The editor must draw selection boxes and drop lines on top of it, and keep them lined up as the page scrolls, reflows, and zooms.
- Drag and drop goes into a live layout. A block dropped into a row of flex items, a grid, or an empty section must land where the pointer says, which depends on the layout direction of whatever is under the pointer.
- Styling is CSS with training wheels. People need classes they can reuse and values that change per screen size, and they need to see where each value comes from. That is the CSS cascade, made visible.
- The output is a separate product. What visitors load must be small static files, not the editor. The publish step is a compiler, and a deploy that can't leave a site half updated.
Notice what is not on the list: drawing shapes on a canvas element, sorting a vertical list, and running untrusted JavaScript live as you type. Those are the cores of the figma, drag-drop-list, and codepen guides. A website builder uses the browser's own layout engine as its renderer, which is both the easy part (you get real CSS for free) and the hard part (you don't control it).
The page as data: a tree of nodes
Start with the naive idea: store the page as an HTML string, the way a person would write it. Selecting an element then means parsing the string, finding the element, changing an attribute, and printing the string again. Undo means keeping old strings.
Two features break it at once. A style for the tablet size has no place in one tag's style attribute, and a navbar shared by 20 pages becomes 20 copies that drift apart. HTML is the output format, not the editing format.
So the page is data: a tree of nodes. Each node has an id, a type (section, text, image, link), props (the text, the image source, the link target, the HTML tag), a list of style classes, and its children. The editor renders the tree to DOM; Publish compiles the same tree to HTML. This is config-driven UI, the same idea the form-builder guide uses for fields, applied to a whole page; the notion guide stores its blocks the same way.
Decision How should the editor store the node tree in memory?
What we pick: Normalized map by id. Almost every editor action names one node (select it, restyle it, move it), so O(1) lookup by id matters more than easy recursive rendering. It also makes each edit a small patch of a few node records, which is what undo, autosave, and collaboration want to send. We keep the invariants in one module of pure functions (insertNode, moveNode, removeNode) that every action goes through.
type NodeId = string;
type PageNode = {
id: NodeId;
type: "section" | "container" | "text" | "image" | "link" | "embed" | "instance" | "collection-list";
tag: string; // "section", "h2", "a", "img"...
parentId: NodeId | null;
children: NodeId[]; // order matters
props: Record<string, PropValue>; // text, src, alt, href...
classes: string[]; // ["Button", "Large"]: base + combo
hidden?: Partial<Record<BreakpointId, boolean>>;
componentId?: string; // set when type is "instance"
overrides?: Record<string, PropValue>; // "innerNodeId.prop" to value
};
type PropValue = string | number | boolean | { bind: string }; // bind to a CMS field
type Page = { id: string; rootId: NodeId; nodes: Record<NodeId, PageNode> };
Components, instances, and overrides
A navbar used on every page must be built once. Webflow calls this a component (the original is the main component; before 2023 it was called a Symbol), and Framer also calls it a component.
A common data model: a component definition is its own small tree, stored once per site. Where the navbar appears, the page holds one node of type instance with a componentId. The instance has no children of its own.
Per-use changes are overrides: a map from a path inside the definition (ctaButton.text) to a value (Start free trial). At render time the editor expands the instance: it walks the definition's tree and, for each prop, uses the override if there is one and the definition's value if not. Edit the definition and all 40 instances change, except the props someone overrode.
An instance may override only the props that the definition exposes as named properties (Webflow calls them component properties: a title, a button text, an image). A short named list is easier to understand than overriding any inner node, and safer: a changed definition can't silently orphan an override that points at a deleted inner node.
Two edge cases are worth naming. A component must not contain an instance of itself, directly or through another component, so inserting an instance checks the graph of component references for a cycle. And people sometimes want to break the link and edit freely; that is detach, which copies the expanded tree into the page as normal nodes with fresh ids.
CMS collections and template pages
A blog has 300 posts but one design. The builder stores a collection: a set of items with typed fields like name, slug, cover image, and rich text body (Webflow uses the same word). A prop can hold a binding instead of a value: { bind: "cover" } on an image's src.
A template page (Webflow calls it a Collection page) is designed once with bindings and is published once per item, at a URL built from the item's slug. A collection-list node repeats its child subtree for each item that matches a filter and sort, like the three latest posts on the home page.
In the editor, bindings resolve against one sample item so the designer sees real text and real images while designing. The template's tree stays one tree; only the values change per item. That keeps the editor fast and makes publishing 300 pages a loop over data, not 300 trees.
Check your understanding · Exercise 2
A navbar component is used on 40 pages. On the pricing page, its button text is overridden to Contact sales. The designer then changes the button color and text in the definition. What happens with the model above? Select all that apply.
The canvas: a real page in an iframe, with overlays on top
The naive canvas renders the user's nodes as React components right inside the editor's own DOM. It works for a demo and fails on the first real page.
The user's CSS says button { border-radius: 0 } and the editor's own buttons lose their corners. The editor's reset stylesheet changes the user's heading margins, so the canvas no longer matches production. And a 100vh hero or a @media (max-width: 767px) rule reacts to the editor window, not to the 390 px phone the designer picked.
The fix is to give the page its own document. An iframe has its own DOM, its own stylesheets, and its own viewport. Set the iframe to 390 px wide and the page's media queries, vw units, and container sizes behave as they would on a 390 px wide phone. That is the whole trick behind true-to-production editing.
Decision Where should the editor render the user's page?
What we pick: A same-origin iframe for the canvas, with no user scripts running in it. We own the node types, so the renderer inside is ours and trusted, and synchronous measurement keeps overlays exact. User custom code embeds are shown as placeholders or run in their own nested sandboxed iframe on another origin. A product that edits the customer's own code components (Builder.io, Plasmic) has no choice: their app is a different origin, so they pay the postMessage cost. The codepen guide covers that bridge in depth.
Mapping iframe boxes to editor overlays
Selection boxes, hover outlines, spacing handles, and drop lines are not drawn inside the page. If they were, they would change the page's DOM, could be hit by the page's CSS (div { display: none } on a wild selector), and would show up in the measurements. They live in an overlay layer: an absolutely positioned layer in the editor document, stacked exactly over the iframe, with pointer-events: none except on handles.
To place a box, measure the node inside the iframe with getBoundingClientRect(). It returns a rectangle relative to the viewport of the element's own document, so inside an iframe it is relative to the iframe's viewport and already reflects the page's scroll. To move it into editor coordinates, scale by the canvas zoom and add the iframe's own position in the editor.
Zoom is a transform: scale() on the iframe, with transform-origin: 0 0. A transform scales only the painted result, so the page keeps its layout width, its media queries still see 991 px, and rects measured inside stay in unscaled page pixels. The CSS zoom property is the wrong tool here: it changes layout sizes, and browsers have differed on how it applies to an iframe's contents.
function overlayRect(el: Element, frame: HTMLIFrameElement, zoom: number, layer: HTMLElement) {
const r = el.getBoundingClientRect(); // relative to the iframe's viewport
const f = frame.getBoundingClientRect(); // iframe in the editor viewport
const l = layer.getBoundingClientRect(); // overlay layer in the editor viewport
return {
x: f.left - l.left + r.left * zoom,
y: f.top - l.top + r.top * zoom,
width: r.width * zoom,
height: r.height * zoom,
};
}
// Zoom is transform: scale() on the iframe (origin 0 0), so f is already the scaled box,
// while r is in unscaled page pixels: hence the multiply. Assumes the iframe has no border.
Boxes go stale whenever anything moves. Each of these events only marks the overlays dirty:
- The page scrolls: a
scrolllistener on the iframe's window. - The iframe or a selected node changes size: a
ResizeObserver. It reports size changes only, not moves. - The DOM changes: a
MutationObserveron the page body, which fires after our own renders. - An image or web font finishes loading and reflows the page without any DOM change: a capturing
loadlistener on the iframe's document, and theloadingdoneevent ondocument.fonts. - Our own style edits: a CSSOM rule write changes no DOM, so no observer sees it, and the editor marks the overlays dirty itself. The same goes for zoom.
A single requestAnimationFrame callback then measures the few nodes that have overlays (selected, hovered, drop target) and moves them. Reading all rects first and writing all styles after keeps it to one layout per frame, the read-then-write rule from the reflow-repaint-composite lesson.
requestAnimationFrame re-measures it.Custom code and embeds
Users paste third-party embeds (a booking widget, a video, analytics) and custom scripts. In a same-origin canvas these would run with the editor's origin and could read the editor's storage or call our API as the user. So the canvas never runs them directly.
An embed node renders a nested iframe with sandbox="allow-scripts" (and without allow-same-origin), served from a separate domain; the sandbox gives it an opaque origin that can't touch the editor. Head and footer scripts don't run in the canvas at all; they run on the published site and in a preview tab on the site's own domain. The editor app also sends Content-Security-Policy: frame-ancestors 'self' so no other site can frame the editor, as the clickjacking-and-iframes lesson explains.
Check your understanding · Exercise 3
A selection box drifts 120 px away from its element after the designer scrolls the canvas. Which fixes are correct? Pick one.
Drag and drop onto a live layout
The drag-drop-list guide sorts items in one list along one axis. A website builder drops into any of thousands of containers, each with its own layout: a vertical stack, a horizontal flex row, a three-column grid, an empty section. The same pointer position can mean "before this card" in a row and "inside this section" in a stack. So a drop has two parts: find the node under the pointer, then work out the insertion point from that node's layout.
Finding the node under the pointer
During a drag the pointer crosses from the editor document into the iframe, and events over the iframe go to the iframe's document. setPointerCapture is meant to keep them on one element, but browsers don't all honor capture across frame boundaries, so the editor also lays a transparent shield over the iframe while dragging, and every pointermove lands in the editor. Each move converts editor coordinates into page coordinates (subtract the iframe's offset, divide by zoom: the reverse of the overlay mapping) and asks the page which element is there.
Decision How do we find which node is under the pointer during a drag?
What we pick: Browser hit testing, throttled to one call per animation frame. It is always right about what is visually on top, and on a same-origin canvas it is a synchronous call. To fix the tiny-span problem we walk up from the hit element to the nearest node that can accept the dragged type, and Alt-drag lets power users target the deepest node.
Before, after, or inside
Once we have a target node, read its parent's computed style with getComputedStyle to find the main axis. A flex parent with flex-direction: row runs siblings left to right, so compare the pointer's x with the target's horizontal middle: left half means before, right half means after. A flex column and plain block layout stack vertically, so compare y. A grid with more than one column track fills each row left to right, so compare x as well.
If the target itself can hold children, a central band means inside: the middle half of the box along the main axis, from 25% to 75%, a value we chose because it feels right in practice. The outer quarters still mean before or after, whether the container is empty or not.
type Drop = { parentId: NodeId; index: number; where: "before" | "after" | "inside" };
function dropPosition(target: PageNode, rect: DOMRect, x: number, y: number, win: Window): Drop {
const parentEl = win.document.querySelector(`[data-node-id="${target.parentId}"]`)!;
const cs = win.getComputedStyle(parentEl);
const row = cs.display.includes("flex")
? cs.flexDirection.startsWith("row")
: cs.display.includes("grid") && cs.gridTemplateColumns.split(" ").length > 1; // block: vertical
const t = row ? (x - rect.left) / rect.width : (y - rect.top) / rect.height; // 0..1 along the main axis
if (canHaveChildren(target) && t > 0.25 && t < 0.75) {
return { parentId: target.id, index: target.children.length, where: "inside" };
}
const index = indexInParent(target) + (t >= 0.5 ? 1 : 0);
return { parentId: target.parentId!, index, where: t >= 0.5 ? "after" : "before" };
}
Two refinements matter. Right-to-left pages flip the row axis, so check direction too, and row-reverse flips it again. The drop indicator is a 2 px line drawn in the overlay layer along the edge where the node will land, or an outline around the parent for inside. It moves on each frame but changes nothing in the tree until the drop.
What can contain what
Some drops make invalid HTML or broken pages: a link inside a link, a section inside a paragraph, a form inside a form, a child dropped into an image, a component dropped into its own definition. One table of nesting rules, checked by the same function on drag and on paste, answers "can this parent accept this child?" When the answer is no, the walk-up continues to the next ancestor that can. If none can, the indicator turns into a red not-allowed outline and the drop does nothing.
Near the canvas edge the page should scroll. When the pointer is within 40 px of the iframe's top or bottom edge, a frame loop scrolls the iframe window by a speed that grows as the pointer gets closer, then runs hit testing again, because the content under a still pointer has changed. Escape cancels the drag and removes the indicator.
The keyboard alternative
Dragging must not be the only way to move things. The layers tree (built like the tree-view guide's tree) is the keyboard surface: arrows move focus, and shortcuts move the selected node up or down among its siblings, out to its parent's level, or into the previous sibling. Cut and paste moves a node anywhere. Every keyboard move goes through the same moveNode function and the same nesting rules as a drag, and announces the result in a live region, such as "Moved Card into Grid, position 3 of 4".
Check your understanding · Exercise 4
A designer drags a Button over the rightmost fifth of a Card. The Card sits in a container with display: flex; flex-direction: row, and Cards can hold children. Where does it land? Pick one.
Styling: classes, combo classes, and breakpoints
The naive style panel writes what you set onto the selected node, like an inline style attribute. It feels natural for one element and falls apart for a site. The designer styles 24 buttons one by one; the brand color changes; they now change 24 buttons. Inline styles also can't express hover states or screen sizes, and they make the published HTML heavy because every element repeats its full styles.
Decision Where should a value set in the style panel be stored?
What we pick: Named classes with combo classes, which is the model Webflow uses. It is the closest to how CSS really works, which matters because the output is CSS, and it gives designers reuse without code. We add guard rails: the panel shows how many elements on the site use the class before you edit it ("Button: 38 elements on 12 pages"), offers to create a combo class instead, and finds unused classes for cleanup. For values that should change site-wide, like brand colors and spacing steps, classes point at variables.
How styles are stored
A style rule is keyed by three things: which class selector, which breakpoint, and which state (none, hover, focus, pressed). Its value is a small map of CSS properties. Storing rules this way, not as CSS text, lets the panel answer "where does this value come from?" with a lookup, and lets the compiler sort and merge rules at publish time.
type StyleRule = {
selector: string[]; // ["Button"] or ["Button", "Large"] for a combo
breakpoint: BreakpointId; // "base" | "1280" | "tablet" | "mobile-l" | "mobile-p"...
state: "none" | "hover" | "focus-visible" | "active";
values: Record<string, StyleValue>; // "padding-top": ...
};
type StyleValue =
| { kind: "length"; value: number; unit: "px" | "rem" | "em" | "%" | "vw" | "vh" }
| { kind: "var"; name: string } // a design token, like --color-brand
| { kind: "keyword"; value: string } // "flex", "center", "auto"
| { kind: "raw"; value: string }; // escape hatch, validated with CSS.supports()
type Site = { classes: Record<string, { name: string; usedBy: number }>; rules: StyleRule[] };
Typed values make the panel friendly. A length shows a number field with a unit menu, and switching units can convert the value (16 px to 1 rem). A variable shows a swatch and its name. The raw escape hatch lets an expert type clamp(2rem, 5vw, 4rem), and the panel checks it with CSS.supports("font-size", value) before accepting it, so invalid CSS never reaches the output.
Variables come from the site's design tokens, compiled to custom properties on :root; the design-system-rollout guide explains token tiers. That is also how a dark theme or a rebrand becomes one edit, and the dark-mode-theming guide covers the theme switch itself.
The fast path from panel to canvas
Dragging a padding value from 16 to 64 fires about 50 input events in a second. Regenerating the whole site stylesheet and swapping it in the iframe each time would restyle every element on the page. Instead the canvas keeps one CSSStyleRule object per stored rule in a constructed stylesheet, built with the iframe window's own CSSStyleSheet constructor, because a document can only adopt sheets constructed for it. An edit calls rule.style.setProperty("padding-top", "64px") on exactly that one rule, and the browser restyles only the elements that match its selector.
While the pointer is down, these writes are a preview: they go to the CSSOM at most once per frame and not to the store. On pointer up we commit one change to the store, so undo gets one entry and autosave one patch, not 50. A typed value commits at once. Either way the editor marks the overlays dirty, and they re-measure in the next requestAnimationFrame.
Check your understanding · Exercise 5
A designer selects one of 38 buttons that share the class Button and sets its background to red. They only wanted this one button red. What should the editor do? Select all that apply.
Breakpoints and responsive editing
A designer sets a 64 px heading on desktop, sees it wrap into five lines on a phone, and sets 36 px in the phone view. Two questions follow: does 36 px also apply to tablet, and when they later change desktop to 72 px, does the phone heading change? The answers are the breakpoint model, and the panel must show them, not hide them.
Decision Which direction should styles cascade between screen sizes?
What we pick: Desktop first from a base breakpoint, with optional larger breakpoints that cascade up: Webflow's model, because builders design the desktop comp first. Base styles carry no media query; Tablet is max-width: 991px, Mobile landscape max-width: 767px, Mobile portrait max-width: 479px, and the larger sizes use min-width: 1280px, 1440px, and 1920px. The output size difference against mobile first is a few KB of CSS per site (our estimate), which is small next to images.
The panel shows where each value comes from with a colored indicator next to the label (Webflow uses blue and orange labels): set here at this breakpoint, inherited from a larger breakpoint, inherited from the base class of a combo, or the browser default. Clicking an inherited value jumps to where it was set. Resetting a value deletes the override, and the inherited one shows again. That one feature answers both of the opening questions before the designer has to ask them.
Previewing a breakpoint is just setting the iframe's width: 991, 767, 390. Because the canvas is a real document, its media queries switch exactly as on a device, and any width in between can be tried by dragging the canvas edge. Hiding an element on one size is a style like any other (display: none at that breakpoint), so the element still ships in the HTML. The layers tree shows it dimmed so people can still find and select it.
Check your understanding · Exercise 6
A heading's font size is 64 px at Desktop base and 36 px at Mobile landscape. Nothing is set at Tablet or Mobile portrait. What does each size show? Select all that apply.
Undo, autosave, versions, and teammates
Because every change goes through a few pure functions on the normalized tree, every change can be described as a small operation: insert node, remove node, move node, set prop, set style value, add class to node. That one decision pays for three features at once: undo keeps a stack of operations with their inverses, autosave sends operations instead of whole pages, and collaboration, if we add it, merges operations.
type Op =
| { op: "insert"; node: PageNode; parentId: NodeId; index: number }
| { op: "remove"; nodeId: NodeId; snapshot: PageNode[]; parentId: NodeId; index: number }
| { op: "move"; nodeId: NodeId; from: { parentId: NodeId; index: number }; to: { parentId: NodeId; index: number } }
| { op: "setProp"; nodeId: NodeId; key: string; before: PropValue | undefined; after: PropValue | undefined }
| { op: "setStyle"; ruleKey: string; prop: string; before: StyleValue | undefined; after: StyleValue | undefined };
type Transaction = { id: string; label: string; ops: Op[] }; // one undo step, e.g. "Drop Button"
One user action can produce several operations (paste a section: dozens of inserts), so they are grouped into a transaction, which is one undo step with a label for the history panel. The undo-redo guide covers the command stack, coalescing, and selection restore in depth; the part special to a builder is that undo must also restore the breakpoint and selection the user was on, or undoing a phone-only change while looking at desktop appears to do nothing.
Autosave and versions
Each committed transaction goes into an outbox. About one second after the last edit, or at once when the page is hidden, the outbox is flushed as one PATCH with the operations and the version the client last saw. The server applies them in order, bumps the version, and replies.
Operations are also written to IndexedDB before sending, so a crash or a closed laptop loses nothing: on next load, unsent operations are replayed. The save indicator in the top bar follows a small state machine.
PATCH.Versions are coarser. The server keeps a full snapshot of each page every few minutes of active editing and on every publish, plus the operation log between them. Restoring an old version doesn't rewind history; it writes the old snapshot as a new version, so restoring is itself undoable.
Two people, one page
Decision What happens when two teammates open the same page?
What we pick: Page lock with presence for the first version, with the lock scoped to a page and shared items (classes, components, variables) saved through version checks: a PATCH built on an old version gets a 409 and the client re-applies its operations on top of the fresh state. Because edits are already operations, moving to live multiplayer later is a sync layer, not a rewrite. The figma and google-docs guides, and the collaboration-basics and crdts lessons, cover that path.
Check your understanding · Exercise 7
A designer on the phone breakpoint drops a button, switches to desktop, then presses undo. What should happen? Select all that apply.
Publishing: compiling the tree into a fast site
The shortcut for publishing is tempting: the editor already knows how to render the tree, so host a small app that downloads the page JSON and renders it in the visitor's browser. That ships the renderer, a UI framework, and the JSON to every visitor.
A landing page that could be 30 KB of HTML becomes a few hundred KB of JavaScript (our estimate) that must download, parse, and run before the hero text appears. LCP gets worse, search crawlers see an empty shell until the script runs, and a slow phone pays for code that only makes sense in an editor.
Decision What do we serve to visitors of a published site?
What we pick: Compile to static files at publish. Marketing sites change a few times a week and are read millions of times, so moving all work to publish time is the right trade. CMS edits republish only the pages that use the changed item (its template page and pages with lists that include it), which is the incremental idea from the ssg-and-isr lesson. Server rendering stays an option for features that are truly per request, such as a logged-in members area.
What the compiler does
- HTML: walk the tree, expand component instances with their overrides, resolve CMS bindings, and print each node with its tag, classes, and attributes. Text is escaped; only embed nodes print raw markup.
- CSS: sort rules by breakpoint (base, then larger breakpoints, then smaller ones, so later rules win in the right order), group each breakpoint under one media query, drop classes no page uses, and write variables on
:root. One site file with a content hash in its name, cached for a year. - JS: only for features a page uses: the navbar menu, a slider, tabs, scroll-triggered animations. Each is a small module; a page with none ships none.
- Images: resize each upload to several widths, encode AVIF and WebP, and print
srcsetandsizespluswidthandheightso the browser reserves space (no layout shift). The first large image getsfetchpriority="high"; images further down getloading="lazy". - Fonts: self-host WOFF2 files on the same CDN, subset to the languages used,
font-display: swap, and preload the one font the hero text needs. - SEO: per page title, description, canonical URL, and Open Graph tags from the page settings; a
sitemap.xml, arobots.txt, and the site's redirect rules.
Interactions (fade in on scroll, hover lift, open menu) are stored as data too: trigger, target, and a list of property changes over time. The compiler turns simple ones into CSS transitions and @keyframes, and scroll triggers into a few lines that add a class when an IntersectionObserver sees the element. Everything is wrapped in a prefers-reduced-motion: no-preference media query, so people who asked for less motion get the final state without the movement.
Atomic deploys, rollback, and domains
A site of 60 pages is about a hundred files. If we overwrite files in place, a visitor arriving mid-upload can get new HTML that points at a CSS file that has not arrived yet. So every publish writes to a new, immutable folder named by a deploy id, and nothing live changes during the upload.
Only when every file is in place does the publish service flip one pointer, the domain's current deploy id, at the edge. Rollback flips the pointer back to the previous id, which takes seconds because those files never went away. Hosts like Netlify describe their own deploys the same way: atomic, immutable, with instant rollback.
Staging is the same pipeline with a different pointer: every site gets a free subdomain on our staging domain (Webflow's is yoursite.webflow.io), and Publish can target staging, production, or both. Custom domains need DNS pointed at our edge (a CNAME for www, A records for the apex) and a TLS certificate, which the platform requests automatically from an ACME certificate authority like Let's Encrypt once DNS checks pass. The domain screen polls that check and shows each step: DNS found, certificate issued, live.
The output budget is what makes this design worth it. A typical compiled landing page is 15 to 40 KB of compressed HTML, 10 to 30 KB of CSS, and 0 to 20 KB of JS (our estimates). With the hero image prioritized and sized, that leaves room for a good LCP on a mid-range phone, a near-zero CLS because every image has dimensions, and a good INP because little script competes for the main thread.
Real results still depend on what authors add, such as huge images and third-party embeds, so the platform should measure field data per page rather than promise scores. The core-web-vitals and image-optimization lessons explain the measurements.
Check your understanding · Exercise 8
A publish uploads 98 of 100 files and then the upload fails. What do visitors of the live site see? Select all that apply.
Architecture, data model, and API
Now put the pieces together. The editor is one app in the parent document, with one store that owns the site data. The canvas iframe is a view of that store, not a second source of truth: it receives the tree and the style rules and renders them, and it never edits them by itself. Every panel, including the canvas, sends user intent as a transaction to the store.
| Part | Lives in | Responsibility |
|---|---|---|
| Document store | Editor | Normalized nodes, classes, rules, components, variables. Applies transactions through the pure tree functions and notifies subscribers per node. |
| Editor UI state | Editor | Selection, hover, current breakpoint, zoom, open panels. Not saved with the site, not part of undo. |
| History | Editor | Undo and redo stacks of transactions with their inverses and the context to restore. |
| Sync client | Editor (maybe a worker) | Outbox of operations, IndexedDB journal, PATCH with base version, lock heartbeat, retry and conflict handling. |
| Canvas renderer | Iframe | Renders nodes to DOM with data-node-id, holds the constructed stylesheet, applies diffs. Trusted code only. |
| Overlay layer | Editor | Selection, hover, spacing handles, drop indicator. Measures the iframe through the mapping function. |
| Drag controller | Editor | Pointer capture, hit testing in the iframe, drop position, nesting rules, autoscroll. |
| Publish client | Editor | Starts publish jobs, shows progress, staging and production targets, rollback list. |
| Compiler | Server job | Tree to HTML, CSS, JS, image variants, sitemap; uploads a new deploy and flips the pointer. |
The boundaries follow the two hard rules from earlier. The iframe boundary exists for CSS isolation and a true viewport, so only the renderer sits inside it; everything interactive stays outside, where the editor's own styles and handlers live.
The compiler sits on the server because publishing must not depend on the author's laptop staying open, and because the same compiler runs for CMS-triggered republishes when nobody has the editor open. Sharing the node-to-HTML printing code between the canvas renderer and the compiler keeps the two from drifting: one module prints tags and attributes, and the canvas adds only data-node-id and editor hooks on top.
Follow one edit around the loop. The designer types 48 in the font size field. The style panel builds a transaction with one setStyle operation, and the store applies it, pushes it on the undo stack, and puts it in the outbox.
Subscribers wake up: the canvas bridge calls setProperty on one CSSOM rule, the panel shows the new value as set here, and the overlay marks the selection dirty. A second later the sync client sends the batch, and the server replies with the new version number. Nothing in this loop re-renders the whole page.
Check your understanding · Exercise 9
Which things belong in editor UI state, not in the saved site document? Select all that apply.
Data model and API
Each piece of data has one home, chosen by who else needs it and how long it lives. The page tree and site styles live in the document store, loaded once per page and kept in memory; they are also the save format. Site-wide items, such as classes, components, and variables, load with the site because every page uses them.
CMS items are server data fetched on demand and cached by query; a template needs only one sample item while editing. Selection, breakpoint, and zoom live in UI state, and the open page id lives in the URL so a link or reload brings you back to it.
type SiteDoc = {
siteId: string;
version: number; // bumps on every accepted PATCH
breakpoints: { id: BreakpointId; query: string | null }[]; // base has null
classes: Record<string, { name: string; usedBy: number }>;
rules: StyleRule[];
variables: Record<string, { value: string; group: "color" | "space" | "font" }>;
components: Record<string, { name: string; rootId: NodeId; nodes: Record<NodeId, PageNode>; properties: string[] }>;
};
type PageDoc = Page & {
version: number;
settings: { slug: string; title: string; description: string; ogImage?: string };
template?: { collectionId: string }; // set on CMS template pages
lock?: { userId: string; expiresAt: string };
};
The API is plain REST with JSON plus one WebSocket for presence and lock changes. Saves send operations, not documents, so a font size change is a few hundred bytes. Every write carries the version it was built on, so the server can refuse stale writes instead of losing work.
GET /api/sites/s_42 -> SiteDoc
GET /api/sites/s_42/pages/p_home -> PageDoc
PATCH /api/sites/s_42/pages/p_home
{ "baseVersion": 311, "transactions": [ { "id": "t_9f", "ops": [ ... ] } ] }
200 { "version": 312 }
409 { "error": "stale", "version": 314, "ops": [ ... ] } // ops since 311
423 { "error": "locked", "by": "u_7", "expiresAt": "..." }
POST /api/sites/s_42/pages/p_home/lock -> 200 { "expiresAt": "..." } (heartbeat)
GET /api/sites/s_42/cms/c_posts/items?limit=1 -> sample item for a template
POST /api/sites/s_42/publish { "targets": ["staging", "production"] } -> 202 { "jobId": "j_5" }
GET /api/publish-jobs/j_5 -> { "state": "uploading", "done": 61, "total": 100 }
POST /api/sites/s_42/rollback { "deployId": "d_120" } -> 200
WS /api/sites/s_42/presence { "type": "lock", "pageId": "p_home", "by": "u_7" }
Errors share one shape, { "error": code, ...details }, and each code has a planned reaction. A 409 means a shared item changed: the client rebases its pending operations on the returned ones, and if a pending operation now targets a deleted node it is dropped and the user sees a short notice. A 423 means someone else holds the lock: the canvas turns read-only and the top bar shows who. A network error keeps operations in the outbox and IndexedDB and retries with backoff while the indicator shows offline.
Check your understanding · Exercise 10
Why does each PATCH carry baseVersion? Select all that apply.
Editor performance, accessibility, and extensions
Keeping a 5,000-node page fast
The editor's budget is one frame, about 16 ms, for anything that follows the pointer or a keystroke. Four habits keep it there on large pages; the runtime-performance lesson covers how to profile them.
- Per-node subscriptions. Each rendered node in the canvas subscribes to its own record in the store and is memoized, so changing node 4,211 re-renders node 4,211, not the page. With the normalized map, "did my node change?" is one reference comparison.
- Styles through the CSSOM, not through React. Style edits never re-render nodes. They write one rule, and the browser restyles what matches.
- A virtualized layers tree. A 5,000-row tree renders only the 40 or so rows in view, plus a few above and below. Expanding a section computes the visible flat list from the tree once, not per row.
- One measurement pass per frame. Overlays, hit tests, and autoscroll all read layout inside one
requestAnimationFramecallback, before any write. No measurement insidepointermovehandlers directly.
Load time matters less than for the published site but still counts. The editor code is split by panel (the CMS screens, the interactions editor, and the publish dialog load on first open), the site document is fetched in parallel with the code, and the canvas renders the first screen of the page before the rest of the tree is attached.
Accessible output by default
The builder is responsible for millions of pages written by people who have never heard of a landmark, so the best place to fix accessibility is the defaults and the warnings.
Every element exposes a tag choice in its settings (a section can be section, header, footer, nav, main, article, or aside; a text block can be h1 to h6 or p), and the insert panel's presets use the right tags. Images ask for alt text on upload, with a decorative checkbox that sets an empty alt. Buttons that only hold an icon ask for a label.
An audit panel runs checks on the tree, not on the DOM, so it is cheap to run after each change. It flags skipped heading levels (an h2 followed by an h4), more than one h1, missing alt text, links with no text, a div with a click interaction that should be a button, and text below a 4.5:1 contrast ratio (3:1 for large text) against its background, resolved through the cascade.
Each finding links to the node, which selects it on the canvas. Publish shows the count and lets the author continue; blocking would only teach people to add bad alt text to get past it.
An editor you can use without a mouse
A canvas full of absolutely placed overlays is not keyboard friendly by itself. The layers tree from the keyboard section is the keyboard model of the canvas: a real tree view with role="tree", arrow keys, and type-ahead. Selection is shared, so moving focus in the tree selects on the canvas and scrolls the iframe to the node.
The style panel uses real labelled inputs; a number field supports arrow keys (Shift plus arrow for steps of 10). Moves and drops announce in a polite live region, and focus returns to the tree after dialogs close (the focus-management and keyboard-navigation lessons cover the patterns).
Custom code and apps
Sooner or later people need something the builder doesn't have, and three levels serve them. Embeds run in sandboxed frames in the canvas and print raw into the output, as the custom code section described. Code components let developers register their own components with typed props that show up as panel controls; the follow-up in the interview chapter walks through what that changes.
An app API lets third parties read and change the site through the same operations the editor uses. Apps run in a sandboxed iframe panel and talk to the editor through postMessage with scoped permissions, so all three levels stay out of the editor's origin.
Think first
A customer pastes a script tag into an embed, and the published site is on their domain. Whose problem is XSS on that site, and what must the builder still protect?
On the published site the script is the customer's own content on the customer's own domain. It can do anything a script there can do, which is the point of an embed. That is their choice, like adding a script tag by hand.
What the builder must protect is everything else: the editor origin, other customers' sites, and our sessions. So embeds never run on the editor origin, published sites live on domains that share no cookies with the editor, and any text field that is not an embed is escaped by the compiler so a heading can never become a script.
Check your understanding · Exercise 11
Dragging the padding slider on a 4,000-node page drops to 15 frames per second. Which changes help? Select all that apply.
Running it in the interview
This problem is wide, and an hour is short. Lead with the two-product split, because it frames everything. Then draw the editor wireframe and the iframe with the overlay layer on top. Those two drawings carry most of the conversation, and every later decision points back at them.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 5 | Scope: who builds, page size, breakpoints, components, CMS, teams, what Publish produces. | The non-functional list: canvas true to production, edits in one frame, fast output. |
| 5 to 12 | The page as data: normalized nodes, classes, components with overrides. | The node record and a small tree; why not an HTML string. |
| 12 to 24 | The canvas: same-origin iframe, overlay layer, coordinate mapping, observers. | The wireframe, the mapping formula, the select sequence. |
| 24 to 32 | Drag and drop: hit testing, before, after, or inside from the parent's layout, nesting rules, keyboard moves. | The drop sequence with the not-allowed path. |
| 32 to 42 | Styling and breakpoints: classes and combo classes, desktop-first cascade, where a value comes from. | The cascade diagram and the generated media queries. |
| 42 to 52 | Saving and publishing: operations, outbox, versions, compiler, atomic deploy. | The save state machine and the publish pipeline. |
| 52 to 60 | Whatever the interviewer picks: performance, accessibility, multiplayer, extensions. | Per-node subscriptions, the CSSOM fast path, the audit panel. |
Say the trade-offs as sentences, not as boxes. "I render the page in an iframe because media queries must react to the preview width, not to my window." "Styles go through one CSSOM rule while dragging, and commit once on pointer up, so undo gets one step." "Visitors never download the editor; Publish compiles to HTML and CSS, and a deploy is a pointer flip." Each one names a cost and why you accept it, which is what interviewers grade.
Common mistakes to avoid: drawing the page on a canvas element like a design tool, storing inline styles per element, re-rendering the whole page on every style keystroke, and serving the page JSON plus a renderer to visitors. If you catch yourself heading toward one of them, say why you are turning away. Showing the rejected path is part of a strong answer.
Think first
Follow-up: "Our biggest customers want to drag their own React components onto pages. What changes?"
The canvas now runs the customer's app, which is their code on their origin, so it becomes a cross-origin iframe. The customer adds a small SDK that registers each component with a typed prop schema (text, number, image, enum, children slot); the editor builds panel controls from the schema. Builder.io and Plasmic document setups along these lines.
Every measurement and selection now crosses postMessage, so the SDK sends node rects in batches after each render and on scroll, and the editor accepts a frame of lag in the overlays. The page tree stores component names and props, never code, and the customer's own build renders it, so publishing becomes their deploy, not ours.
Think first
Follow-up: "A customer's published home page has a 4.5 s LCP on phones. What do you check, and what can the platform fix for everyone?"
Find the LCP element first. Usually it is a hero image that is huge, lazy-loaded by mistake, or a CSS background the browser discovers late. Or it is hero text waiting on a web font, or a third-party embed script blocking the main thread.
Platform fixes help every site at once: never lazy-load the first large image, give it fetchpriority="high" and a right-sized srcset, turn background images in the first section into real img elements, preload the hero font, and warn in the editor when an embed adds a render-blocking script. Field data from visitors, grouped by page, lets the dashboard point at the worst pages.
Think first
Follow-up: "A collection grows to 20,000 items and every edit republishes for minutes. How do you fix it?"
Track dependencies at compile time: for each output page, record which items and which shared parts (classes, components, variables) it used. An item edit then rebuilds only its own template page and the list pages that show it, and a class edit rebuilds only the CSS file, because the HTML still points at the same class names.
List pages that show many items get pagination at a fixed page size, so one new item changes a handful of pages instead of all of them. The ssg-and-isr lesson covers the same trade for regenerating on demand.
Check your understanding · Exercise 12
You have 10 minutes left and haven't covered publishing. Which points are most worth saying? Select all that apply.
Summary
- A website builder is two products: a heavy editor for builders and small static pages for visitors. Keep them apart, and never ship the editor runtime to visitors.
- The page is data: a normalized map of nodes by id with ordered child ids, classes, and props. Components are one stored tree plus instances with overrides; CMS templates are one tree with bindings.
- The canvas is the real page in a same-origin iframe, so CSS, media queries, and
vwunits are true to production. Overlays live in the editor document and map iframe rects by zoom plus the iframe offset, re-measured once per frame when something marked them dirty. - Drag and drop uses the browser's own hit testing, walks up to a node that may accept the item, and picks before, after, or inside along the parent's main axis. The layers tree gives the same moves by keyboard.
- Styles live in named classes with combo classes, keyed by breakpoint and state, and cascade desktop first. The panel shows where every value comes from. Edits during a drag write one CSSOM rule per frame and commit once.
- Every change is an operation inside a transaction: undo applies inverses, autosave sends operations with a base version, and a 409 means rebase. Page locks come first; multiplayer is a sync layer on top later.
- Publish compiles each page to HTML, a hashed site CSS file, and per-feature JS, with
srcsetimages and self-hosted fonts. Each deploy is an immutable folder, and going live or rolling back is one pointer flip. - User code never runs on the editor origin: embeds go in sandboxed frames on another domain, and the compiler escapes every text field.
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: Element.getBoundingClientRect() Rects are relative to the element's own viewport, the basis of the overlay mapping.
- MDN: Document.elementsFromPoint() Browser hit testing for drag targets inside the iframe.
- MDN: ResizeObserver Knowing when the iframe or a selected node changes size.
- MDN: MutationObserver Knowing when the page's DOM changes so overlays re-measure.
- MDN: the iframe element Sandbox tokens for embeds and why
allow-scriptswithallow-same-originis unsafe on your own origin. - MDN: Document.adoptedStyleSheets Constructed stylesheets, and why the canvas sheet must be built in the iframe's own document.
- MDN: Element.setPointerCapture() Pointer capture for drags; pair it with a shield over the iframe.
- Webflow Help Center: Breakpoints overview How styles cascade from the desktop base down to smaller and up to larger breakpoints.
- Webflow Help Center: Classes Base classes and combo classes in a visual style panel.
- Webflow Help Center: Components overview Main components, instances, and per-instance properties.
- Netlify: atomic and immutable deploys The deploy-folder and pointer-flip model behind safe publishing and instant rollback.
- web.dev: Web Vitals The LCP, INP, and CLS thresholds the published pages should pass.
Concepts behind it
- Normalizing stateStoring each entity once, in a table keyed by its id, and having lists and relationships point at ids instead of holding copies, so one change updates every view that shows it.
- Config-driven UIDescribing screens as JSON that names known components, rendered through an allowlisted registry, so layouts change without an app release: safe only if configs are validated, never executed, and older clients get fallbacks.
- Clickjacking and iframesIframes power embeds, payment fields, and widgets, and also let an attacker hide your page under a fake one to steal a click: control both directions with framing headers, the sandbox attribute, and checked postMessage.
- 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.
- 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.
- Layout, paint, and compositingWhat the browser redoes when something on the page changes after the first render, why moving a box with
transformis cheap while changing itswidthis not, and how to keep scrolling and animation inside a 16 ms frame. - Semantic HTML firstUsing the HTML element that already means what you need, like
button,a,nav, andh2, so keyboards, screen readers, and voice control understand the page with no extra code. - Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
Try next
- Design a drag-and-drop form builderHard
- Design a collaborative design tool like FigmaHard
- Design an online code playground like CodePenHard
- Design undo and redoHard
- Design a drag-and-drop reorderable listMedium
- Design NotionHard
- Design a collaborative document editor like Google DocsHard
- Design a design system for many teamsHard
- Design a theme switcher with dark modeEasy
- Design an internationalization systemMedium
- Design a file-explorer tree viewMedium
- Design an admin CMSMedium