cf.completefrontend
My progress
SYSTEM DESIGN · #122

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.

QuestionAnswer we assumeWhat 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.

Wireframe of the builder at builder.example.com/sites/acme/home. A top bar across the window holds the breakpoint switcher, undo, the save state, and Publish. On the left, an insert panel for dragging or clicking in elements, and below it the layers tree, which is virtualized. In the middle, the canvas: an iframe showing the real page at 991 px, with the selected Hero section outlined by the overlay layer drawn on top. On the right, the style panel with the class, layout, and type controls.builder.example.com/sites/acme/homeTop bar: breakpoints, undo, save state, PublishInsert paneldrag or clickLayers treevirtualizedCanvas iframethe real page at 991 pxHero: selectedoverlay layer on topStyle panelclass, layout, type
Five parts, one page. Every panel reads and writes the same store, and the selection box is drawn by the editor over the iframe, not inside the page.

Five things make this harder than it looks, and each one gets its own chapter.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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?

HTML string

Keep the page's markup as text and parse it on each edit.

Pros

  • Output is trivial: it is already HTML.
  • Imports from other sites are easy.

Cons

  • Every edit is a parse and print of the whole page.
  • No place for per-breakpoint styles, components, or CMS bindings.
  • Undo and saving work on the whole text, so they are large and coarse.

Nested tree of objects

A root node object whose children array holds child node objects, all the way down.

Pros

  • Looks like the DOM, so rendering is a simple recursive walk.
  • Easy to read in a debugger.

Cons

  • Finding a node by id means walking the tree: O(n) on 5,000 nodes.
  • An immutable update copies every ancestor on the path, and moving a node means removing and inserting deep inside two branches.

Normalized map by id

A flat nodes record keyed by id. Each node holds parentId and an ordered children array of ids. One rootId per page.

Pros

  • Read or update any node in O(1).
  • A move edits two children arrays and one parentId.
  • Each edit touches a few small objects, so subscriptions, undo entries, and save patches stay tiny.

Cons

  • Rendering must look up children by id.
  • Invariants (no cycles, parent and children agree) must be kept by the update functions, not by the shape.

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.

ts
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> };
The node record. Styles live in classes, not on the node.
The page as data, two views. On the left, the tree as the user sees it: a root node with two children, nav, an instance of the component c_nav, and hero, a section. Hero has two children: h1, a text node, and btn, a link. A note says nav expands from componentId c_nav with an override on cta.text. On the right, what is stored: a flat map of nodes by id. root lists children nav and hero. nav has parent root and component c_nav. hero has parent root and children h1 and btn. h1 has parent hero and text. btn has parent hero and an href. Lookup by id is O(1), and a move edits three small records.The tree people seerootnavinstanceherosectionh1textbtnlinknav: componentId c_navoverrides: cta.textStored: nodes by idroot: children nav, heronav: parent root, c_navhero: parent root, h1, btnh1: parent hero, textbtn: parent hero, hreflookup by id: O(1)a move edits 3 small records
The tree is what you draw; the map is what you store. Every record knows its parent and its ordered child ids, so finding, restyling, or moving one node never walks the page.

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?

In the editor's DOM

Render page nodes as components inside the editor's React tree, maybe inside a shadow root to scope styles.

Pros

  • Simplest wiring: one document, one store, direct event handlers.

Cons

  • Styles leak both ways (a shadow root stops selectors, but inherited properties like font-family still cross it).
  • Media queries and vw follow the editor window, so breakpoints can't be previewed truly.

Same-origin iframe

The editor creates an iframe on its own origin, writes a small renderer into it, and reaches into iframe.contentDocument directly.

Pros

  • True CSS, viewport, and media queries.
  • The editor can measure nodes and attach listeners synchronously, which keeps overlays and drag fast.

Cons

  • Any script inside runs with the editor's origin, so user custom code must never run in it unsandboxed.

Cross-origin iframe with postMessage

The page renders on a separate origin (or is the customer's own app, the setup Builder.io and Plasmic document for code components). Editor and page talk only through postMessage.

Pros

  • Strong isolation: page code can't read the editor's cookies, storage, or DOM.
  • Works when the components are the customer's own code.

Cons

  • Every measurement is an async round trip, so overlays and drop indicators lag a frame or more.
  • Two protocols to version and keep in step.

Draw the page yourself

Lay out and paint boxes on a canvas element with your own layout engine, as a design tool does.

Pros

  • Total control of rendering, zoom, and performance.

Cons

  • You must re-implement CSS layout, text shaping, and fonts, and it will never match a browser exactly. The output would not be what visitors see.

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.

ts
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.
One function maps a node's box from page space to overlay space.
ZoomPage scroll
Node
Dragging
Canvas lab at zoom 100% with the page scrolled 0 px. Card 2 has page box 144, 186, 112 by 118; in the iframe's viewport its rect is 144, 186; in the editor its overlay is at 168, 250, 112 by 118. Drop: after Card 2, in Cards row, index 2.iframe: 400 x 280 page px, at 24, 64HeroHeadingButtonCard 1Card 2Card 3node: Card 2page: 144, 186rect: 144, 186size: 112 x 118x 1, plus frameoverlay: 168, 250size: 112 x 118dropafter Card 2parent: Cards rowindex: 2axis: x, parent is a row
Dragging a button at 85% along Card 2, measured on its parent's main axis. Result: after Card 2, in Cards row, index 2. The middle half of a container means inside; the edges mean before or after.

Boxes go stale whenever anything moves. Each of these events only marks the overlays dirty:

  • The page scrolls: a scroll listener 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 MutationObserver on 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 load listener on the iframe's document, and the loadingdone event on document.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.

Selecting an element, as a sequence between the designer, the canvas iframe, the editor store, and the overlay layer. The designer clicks the heading. The canvas finds the closest element with a data-node-id and tells the store to select h1. The store tells the overlay the selection changed. The overlay asks the canvas to measure h1, and gets back its rect: 40, 64, 320 by 48 in page pixels. The overlay draws the selection box at the iframe offset plus the rect times zoom. Later the page moves because of a scroll, a resize, or a DOM change; the overlay re-measures in the next animation frame.DesignerCanvasStoreOverlayclick on headingselect h1selection changedmeasure h1rect 40,64,320x48box drawnpage movedre-measure in rAF
Selection lives in the store, measuring lives in the overlay. When the page scrolls or reflows, observers only mark the box dirty, and one 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?

Browser hit testing

Call frameDoc.elementsFromPoint(x, y) and take the first element with a data-node-id attribute that our renderer adds.

Pros

  • Exact: respects z-index, transforms, overflow clipping, and position fixed.
  • No index to build or keep fresh.

Cons

  • Costs a hit test per move (cheap, but throttle to one per frame).
  • Returns the topmost element, which may be a tiny inline span when the user meant its parent.

Own rect index

At drag start, measure every node once and put the rects in a spatial index; query it on each move.

Pros

  • Fast queries, and works with a cross-origin canvas after one bulk transfer.

Cons

  • Stale as soon as the page reflows, for example when a placeholder opens space or an image loads.
  • Must reproduce stacking and clipping by hand.

Drop zone elements

Insert visible drop targets between all children while dragging.

Pros

  • Simple, very clear targets for beginners.

Cons

  • Inserting thousands of zones reflows the whole page, so the layout you are dropping into is not the real one.

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.

ts
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" };
}
The drop position from the pointer, the target's rect, and its parent's layout.

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.

Dragging a Button onto the page, as a sequence between the designer, the drag controller, the canvas, and the store. Pointer down on Button in the insert panel. Pointer moves. The drag controller hit tests the canvas, which returns the Card under the pointer and its rect. The controller shows a drop line after the Card. The designer moves over a Link; a link may not contain a button, so the controller shows a red not-allowed outline. The designer moves back and releases over the Card, and the controller sends one insert transaction, after the Card, to the store.DesignerDrag ctrlCanvasStoredown on Buttonpointermovehit testCard, its rectline after Cardmove over a Linkred: not allowedpointerup on Cardcommit insert
The tree changes only once, on pointer up. Everything before it, including the refused target, is overlay drawing in the editor; the store never sees a half-finished drag.

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?

Per-element styles

Each node carries its own style object, per breakpoint, compiled to a unique class per node.

Pros

  • Zero concepts to learn: you style what you clicked.
  • No surprise changes to other elements.

Cons

  • No reuse: a site-wide change means editing every element.
  • CSS grows with the number of elements, not the number of designs.

Named classes, with combo classes

Values go into a named class (Button). The node lists its classes. A combo class (Button plus Large) holds only the differences, compiled to .button.large.

Pros

  • Change the class once, every element using it updates.
  • Maps directly to real CSS, which the output needs anyway.
  • CSS size grows with the number of designs, not elements.

Cons

  • Users must learn that editing a class changes other elements.
  • Without guard rails, people make Button 2, Button 2 copy, and the class list rots.

Atomic utilities

Each value becomes a single-purpose class like p-16 or bg-blue-600, Tailwind style, and nodes list many of them.

Pros

  • Smallest possible CSS: each value appears once in the stylesheet.
  • No naming.

Cons

  • Long class lists are unreadable for non-developers.
  • A brand change still means editing every node, unless the utilities point at variables.

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.

ts
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[] };
One rule per selector, breakpoint, and state. Values can point at variables.

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?

Desktop first

Base styles are for desktop. Smaller sizes override them with max-width media queries, and each override also flows down to every smaller size.

Pros

  • Matches how most marketing designers work: the desktop comp comes first.
  • Webflow works this way, with optional larger sizes (1280, 1440, 1920) that cascade up with min-width.

Cons

  • Phones download and parse desktop rules and then override them.
  • Easy to forget phone overrides until late.

Mobile first

Base styles are for the smallest screen. Larger sizes add min-width overrides that flow up.

Pros

  • Matches modern CSS practice; the simple layout is the default.
  • Phone pages carry fewer overrides.

Cons

  • Fights the designer's habit of designing the big screen first.
  • Desktop comps need many overrides.

Independent layouts per size

Each breakpoint has its own full set of values; nothing cascades.

Pros

  • No surprises: changing one size never changes another.

Cons

  • Every change is made three or four times.
  • Data and CSS triple in size.

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 desktop-first breakpoint cascade with two example values. Desktop base has no media query and sets the title font size to 64 px and the photo display to block. 1280 and up uses min-width 1280px and inherits both from base. Tablet, max-width 991px, inherits 64 px and block from base. Mobile landscape, max-width 767px, sets 36 px and sets display none. Mobile portrait, max-width 479px, inherits 36 px and none from Mobile landscape.title font-sizephoto display1280 and upmin-width: 1280pxDesktop baseno media queryTabletmax-width: 991pxMobile landscapemax-width: 767pxMobile portraitmax-width: 479px64px from base64px set64px from base36px set36px from MLblock from baseblock setblock from basenone setnone from ML
Values set at the base flow up to larger sizes and down to smaller ones. An override on a smaller size flows only down, and one on a larger size only up. Each cell says where its value comes from, which is what the style panel shows next to every property.

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.

Editing at
Breakpoint lab, editing at Mobile landscape. 1280 and up: font-size 64px (base), display block (base). Desktop base: font-size 64px set, display block set. Tablet: font-size 64px (base), display block (base). Mobile landscape: font-size 36px set, display none set. Mobile portrait: font-size 36px (ML), display none (ML). Generated CSS: .title { font-size: 64px } .photo { display: block } @media (max-width: 767px) { .title { font-size: 36px } .photo { display: none } }title font-sizephoto display1280 and up64px (base)block (base)Desktop base64px setblock setTablet64px (base)block (base)Mobile landscape36px setnone setMobile portrait36px (ML)none (ML)600 px matches: base, tablet, MLlast matching rule wins: 36pxgenerated CSS.title { font-size: 64px }.photo { display: block }@media (max-width: 767px) {.title { font-size: 36px }.photo { display: none }}
At Mobile landscape, font-size 36px is set here, so it shows as an override and flows down to smaller sizes that have no value of their own.

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.

ts
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"
Operations carry what they need to be inverted.

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.

The save state machine. Saved, with everything sent, goes to unsaved on an edit. Unsaved starts a 1 second timer and goes to saving when edits pause or the page is hidden. Saving sends a PATCH with the operations and the base version. A 200 reply with the new version goes back to saved. A network error goes to offline, which keeps the operations in IndexedDB and retries with backoff. A 409 goes to rebasing, which applies the returned operations and re-sends. A 423 goes to read-only, because someone else holds the lock. Each failure state leads back: offline returns to saving when the network is back, rebasing returns to saving with the re-sent operations, and read-only reloads the page when the lock is free.savedall sentunsaved1 s timersavingPATCH senteditidle or hide200: version + 1offlineretry, backoffrebasing409: apply theirsread-only423: lockedonline: back to savingre-send: back to savinglock freed: reloadnetwork409423
Every failure keeps the operations: offline retries them, rebasing re-sends them on top of the newer version, and read-only waits for the lock. Edits in any state just join the outbox and are sent with the next 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?

Last save wins

Both edit; each autosave overwrites the page.

Pros

  • Nothing to build.

Cons

  • One person's work silently disappears. Never acceptable for a paid tool.

Page lock with presence

The first editor holds a lock on the page, renewed by a heartbeat every 30 seconds. Others see who is editing and get a read-only canvas until it is released or expires.

Pros

  • Simple and safe; matches how marketing teams split work by page.
  • No merge logic in the style system or the tree.

Cons

  • Two people can't polish the same page at once.
  • A stale lock from a closed laptop blocks others until it expires.

Live multiplayer

Broadcast operations through a WebSocket; merge with a CRDT or server-ordered operations; show live cursors and selections.

Pros

  • Real co-editing, like Figma.

Cons

  • Every operation type needs merge rules, including moves that could create cycles and class edits that affect other pages.
  • Weeks of work and a sync service to run.

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?

Editor runtime in the browser

A client-side app fetches the page tree and renders it, like the canvas does.

Pros

  • One renderer for editor and output; publish is just saving JSON.

Cons

  • Large JS on every page, slow LCP and INP on phones, weak SEO without extra work.

Server render on each request

An edge or origin function renders HTML from the tree on every request, with a cache in front.

Pros

  • Fresh content without a publish step; good for per-visitor personalization.

Cons

  • A render service to scale for millions of visitors; a cache miss is slow; the tree format becomes a runtime dependency forever.

Compile to static files at publish

A compiler turns each page tree into HTML, one site CSS file, and only the JS that the used features need. Files go to a CDN.

Pros

  • The fastest possible page: plain HTML and CSS from the edge, no work per request.
  • Hosting is cheap and almost never goes down.

Cons

  • A publish step, so changes are not live until published.
  • CMS edits need a rebuild of the affected pages.

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.

The publish pipeline. The site document, with the trees and style rules, goes into the compiler, a server job. The compiler writes HTML per page, one hashed site CSS file, per-feature JS such as nav.js and tabs.js, images in AVIF and WebP, self-hosted WOFF2 fonts, and the sitemap and robots files. All of it goes into a new folder, deploy d_121. Then the edge pointer flips so current is d_121, and visitors get the new files. A note says no editor runtime is shipped and the hashed CSS is cached for a year.Site doctrees, rulesCompilerserver jobHTML per pagesite.3f9a.cssnav.js, tabs.jsimg: AVIF, WebPfonts: WOFF2sitemap, robotsd_121new folderEdgecurrent = d_121flipvisitorsno editor runtime shippedCSS hashed, cached 1 year
Nothing visitors load is the editor: the compiler turns data into plain files, and only after the whole folder is uploaded does one pointer change.

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 srcset and sizes plus width and height so the browser reserves space (no layout shift). The first large image gets fetchpriority="high"; images further down get loading="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, a robots.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.

Publish and rollback as a sequence between the editor, the publish API, the compiler job, and the CDN edge. The editor posts publish and gets 202 with a job id. The API starts a build of deploy d_121. The compiler uploads 100 files into the new folder, while the live site still serves d_120. The editor polls progress. When every file is in place, the compiler sets current to d_121 at the edge, and the API tells the editor the site is live on d_121. Later the author rolls back to d_120, and the API sets current back to d_120 in seconds.EditorPublish APICompilerCDN edgePOST publish202 jobIdbuild d_121upload 100 filespoll progresscurrent = d_121live: d_121rollback to d_120current = d_120
Upload and go-live are separate steps. Until the pointer flips, visitors see the old deploy, so a failed upload changes nothing, and a rollback is the same flip in reverse.

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.

Architecture of the builder. The editor document holds the top bar, insert panel, layers tree, style panel, drag controller, and overlay layer; under them, UI state with selection and zoom, and the document store with nodes and rules; and at the bottom, history with transactions, the publish client, and the sync client with its outbox and IndexedDB. Panels and the drag controller send transactions to the store. The store feeds history and the sync client. The store pushes nodes and rules into the canvas iframe, a same-origin frame with the canvas renderer, which adds data-node-id, and a stylesheet of CSSOM rules. The overlay layer measures the renderer. On the server side, the site API with REST and a WebSocket talks to the sync client, and the compiler, which deploys to the CDN, is started by the publish client.Editor documentTop barInsert panelLayers treeStyle panelDrag ctrlOverlayUI stateselection, zoomDocument storenodes, rulesHistorytransactionsPublishjobs, rollbackSync clientoutbox, IDBCanvas iframeRendererdata-node-idStylesheetCSSOM rulesServerSite APIREST + WSCompilerdeploys to CDN
One store, many views. The iframe holds only the trusted renderer and its stylesheet; everything interactive stays in the editor document, and only the sync and publish clients talk to the server.
PartLives inResponsibility
Document storeEditorNormalized nodes, classes, rules, components, variables. Applies transactions through the pure tree functions and notifies subscribers per node.
Editor UI stateEditorSelection, hover, current breakpoint, zoom, open panels. Not saved with the site, not part of undo.
HistoryEditorUndo and redo stacks of transactions with their inverses and the context to restore.
Sync clientEditor (maybe a worker)Outbox of operations, IndexedDB journal, PATCH with base version, lock heartbeat, retry and conflict handling.
Canvas rendererIframeRenders nodes to DOM with data-node-id, holds the constructed stylesheet, applies diffs. Trusted code only.
Overlay layerEditorSelection, hover, spacing handles, drop indicator. Measures the iframe through the mapping function.
Drag controllerEditorPointer capture, hit testing in the iframe, drop position, nesting rules, autoscroll.
Publish clientEditorStarts publish jobs, shows progress, staging and production targets, rollback list.
CompilerServer jobTree 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.

Data flow. On load, the site API sends SiteDoc and PageDoc to the document store. On an edit, the style panel sends a transaction to the store. The store sends operations to the outbox, which is also written to IndexedDB. About 1 second after the last edit, the outbox hands a batch to the sync client, which sends a PATCH to the site API. The API replies with version 312, or a 409 with the operations the client missed. The store also sends a node diff or one CSS rule change down to the canvas iframe. On publish, the site API starts the compiler, which writes HTML, CSS, and JS to the CDN edge, and visitors get pages from the CDN.load docsStylepanelStorenormalizedOutboxIndexedDBSyncclientSite APItxnops1 sPATCHCanvasiframenode diff, CSS rulereply: version 312or 409 with opspublishCompilerserver jobCDN edgedeploy d_121HTML, CSS, JSvisitors
Writes flow right as small operations, never as whole pages. The canvas gets a diff or a single CSSOM rule, and visitors only ever touch the CDN, never the API or the editor.

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.

ts
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 };
};
What the editor loads for one page.

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.

http
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" }
The calls the editor makes.

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 requestAnimationFrame callback, before any write. No measurement inside pointermove handlers 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?

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.

MinutesWhat to doWhat to draw or say
0 to 5Scope: 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 12The page as data: normalized nodes, classes, components with overrides.The node record and a small tree; why not an HTML string.
12 to 24The canvas: same-origin iframe, overlay layer, coordinate mapping, observers.The wireframe, the mapping formula, the select sequence.
24 to 32Drag 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 42Styling and breakpoints: classes and combo classes, desktop-first cascade, where a value comes from.The cascade diagram and the generated media queries.
42 to 52Saving and publishing: operations, outbox, versions, compiler, atomic deploy.The save state machine and the publish pipeline.
52 to 60Whatever 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?"

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?"

Think first

Follow-up: "A collection grows to 20,000 items and every edit republishes for minutes. How do you fix it?"

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 vw units 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 srcset images 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.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next