Overview

Pages are blocks. In the Commmerce Editor every page — home, product detail, collection, cart, checkout, custom landing pages — is stored as a block_type: "page" block in the database. The page selector shown in the editor toolbar (PageSelector.tsx) is a single React component that loads all page blocks for the active app version or store-theme version and lets you switch between them, promote a version to live, search by name or version, and create new pages via CreatePageBlock.

The same component is used in both projects: the Dev Portal (R.env.project === 'dev-portal') where developers build apps and themes, and the Store Editor (R.env.project === 'store-theme-editor') where merchants customize their installed theme. Behaviour adapts at runtime — Dev Portal always shows a nested page → version hierarchy, while the Store Editor shows either a nested or a flat list depending on whether the API returned versions data for the current theme (driven by the hasVersionData + useNestedMenu derived flags).

How it works

The selector lives in common/components/commmerce-editor/leftsidebar/PageSelector.tsx. It reads URL params via useParams to figure out the context (app_id + app_version_code for Dev Portal, or store_theme_id + store_theme_version_code for the Store Editor) and, on Store Editor, also reads storeThemeVersionCode from the optional ThemeEditorContext so the enriched version-aware API still works when the URL doesn't carry the version code.

The selector then fetches pages through one of three flows: the Dev Portal theme-pages fetch, the Store Editor versioned-pages fetch when a known theme version code is available, or the draft Store Editor page-list fetch when the theme is still a draft. Each row is normalised into an IPageOption with label / value / slug / latest_block_version_code / active_block_version_code / versions[].

Selecting a page (or a specific version inside a page) issues a full window.location.href navigation via the handleSelect(targetBlockId, targetVersionCode) handler. The Commmerce Editor relies on a fresh route to bootstrap the editor scope — once you land on the new URL the editor reads block_id and block_version_code from the path and hydrates the page via setBlockData in store/slices/createBlockSlice.ts, which seeds children (the page's sections), assigns activeChildId to the first section, propagates block_type / block_version_slug down to dialog roots, resets history / historyIndex / pendingSnapshot, and clears the hasUnsavedChanges flag.

Each section inside the opened page is managed by store/slices/createSectionSlice.ts. The slice owns the top-level children: BaseNode[] array on the editor store; section CRUD (addSection, duplicateSection, deleteSection, reorderSections, moveSectionUp, moveSectionDown, replaceSection) is mutated through it, and every mutation is bookended by saveToHistory() so the delta undo/redo system can track it. Cross-section element drags are handled separately by store/slices/createMovementSlice.ts (moveElementToSection, moveElementsToSection), which adapts the moved element's styles to the target container, applies position: absolute with zoom-adjusted left/top for position-relative or grid-cell targets, and runs descendant-loop guards.

Make live happens in place — clicking the inline button invokes the activate-block-version flow (Dev Portal) or activate-store-block-version flow (Store Editor), refetches the page list, and updates the star icon without leaving the dropdown. The "activating" spinner is keyed by activatingKey = ${blockId}:${versionCode} so only the targeted row shows the ... placeholder while the call is in flight.

Key features

  • Live search — A search input at the top of the dropdown filters pages by label and (in nested mode) by version name / version code in real time.
  • Nested versioning — For pages with multiple versions, each page expands into a submenu listing every block_version_code alongside a coloured Ant Design status tag (Draft / Published / Live) and the version name (e.g. v3 · 1.2.0).
  • Single-version collapse — When a page has only one version, the submenu is skipped and clicking the page name navigates straight in — the dropdown fabricates a synthetic versionRows entry with status draft if the API didn't return one.
  • Implicit-live fallback — When a page has exactly one version and none is explicitly active, the renderer displays the single version as Live because the backend resolver falls back to it.
  • Make live in place — Non-active versions show an inline Make live button that activates the version without leaving the dropdown. The spinner is row-scoped via the composite activatingKey.
  • Active highlighting — The currently live version gets a yellow filled Star icon; the version you have open is rendered in font-semibold.
  • Create page — A + button in the search row opens the CreatePageBlock dialog to create a new page block with optional is_home routing.
  • Inline page settings — A 3-dot button next to the selector opens the BlockSettingsDialog, the same dialog documented on the Page Settings page. Hidden via hidePageSettings when the caller wants to render it elsewhere on the toolbar.
  • Two variantsvariant="unified" (default) renders a single pill plus the 3-dot icon; variant="separate" renders three separate bordered buttons used by the dark code-mode toolbar.
  • Auto-hide — Returns null when the URL has no block_id, so the selector silently disappears off non-editor routes.

Anatomy of a page block

The selector normalises every API row into an IPageOption (declared in PageSelector.tsx line 24). The store slice (createBlockSlice.ts) tracks the same fields plus extras for the currently opened page (block_version_id, block_version_slug, block_image, block_version_settings). The Dev Portal also surfaces a versions[] array of IBlockVersionEntry entries per page.

FieldTypeDefaultDescription & use case
block_idnumberStable database ID for the page block, used as IPageOption.value. Use case: the route param :block_id uses this — combined with :block_version_code it uniquely scopes the editor to one page version.
block_namestringDisplay label shown in the selector pill, the dropdown rows, and the editor header. Use case: name pages by their merchant-facing purpose ("Home", "Product Detail", "Cart Drawer") so they're easy to find in the dropdown's search.
block_slugstring""URL slug for the page. Validated at creation against ^[a-z0-9-:]+(/[a-z0-9-:]+)*$ (lowercase, hyphens, colons, slashes only). Use case: author the public URL of the page — checkout/success for nested paths, reset-password:token for parametrised routes.
block_type"page"Only page-type blocks appear in the selector. Other types (component, dialog) live in the Components panel and Dialogs section of the left sidebar respectively. Use case: the selector filters to block_type === "page" so reusable components don't pollute the page list.
latest_block_version_codenumberThe most recent draft version code returned by the API. Used as the default target when no explicit version is selected (flat-mode leaf clicks, or fabricated single-version entries). Use case: "open the latest draft" without naming a specific version — typical when opening a page from a flat Store Editor list.
active_block_version_codenumber | nullnullThe currently live version code, if any. Drives the yellow star icon. Use case: see at a glance which version is being served to end users — useful before promoting a draft.
versions[]IBlockVersionEntry[]undefinedVersion history. Each entry has block_version_code (number), block_version_name (string, often a semver), and block_version_status (draft / published / active). Use case: drives the nested submenu — when present, the dropdown expands into one row per version with its own status tag and Make-live button.
is_homebooleanfalseSet at creation via CreatePageBlock. Marks the page as the theme's home route — when true, the Store Editor creates a route with an empty slug (mounted at /) instead of the page's own slug. Use case: mark exactly one page as the storefront's landing page.

Anatomy of a versions[] entry (IBlockVersionEntry in PageSelector.tsx):

FieldTypeDefaultDescription & use case
block_version_codenumberNumeric version code, monotonically increasing per page. Shown in the dropdown as v{code}. Use case: uniquely identifies a snapshot of the page's content for routing, history, and Make-live.
block_version_namestringHuman-readable version label, often a semver like 1.2.0. Shown after the version code as v3 · 1.2.0. Use case: communicate intent (semver, release name, hotfix tag) to other devs / merchants browsing the version history.
block_version_status"draft" | "published" | "active""draft"Lifecycle state of this version. Drives the coloured status tag and whether the Make-live button is shown (only for non-active rows). Use case: tracks which versions are editable (draft), frozen for review (published), or live in production (active).

Version statuses

Every version of a page sits in one of three states. The dropdown renders a coloured Ant Design Tag via the StatusTag helper at the bottom of PageSelector.tsx (lines 525-529).

FieldTypeDefaultDescription & use case
draftgrey "Draft"initialEditable working copy. Saves overwrite the draft until it's published. Use case: in-progress changes that haven't been promoted yet — every new page version starts here.
publishedblue "Published"Frozen snapshot. Cannot be edited, but can be promoted to live via Make live. Use case: hand-off point for review or staged rollout — publish a candidate, share the preview URL, then activate when approved.
activegreen "Live"The version currently being served to end users. Marked with a yellow filled star. Only one version per page can be active at a time. Use case: "what's live right now" — useful as a baseline before editing another draft.

Dev Portal vs Store Editor menu structure

PageSelector renders one of three menu shapes depending on the project and what the data-fetch returned. The derived flag useNestedMenu = isDevPortal || hasVersionData drives the shape, and hasVersionData is true iff at least one page in the loaded list has a non-empty versions[] array.

FieldTypeDefaultDescription & use case
Dev Portal (nested)project === 'dev-portal'always nestedAlways shows page → version submenu. Search matches both page label and version name/code. Use case: developers iterate on multiple versions of the same page (drafts, A/B variants, hotfix branches) — the nested view shows every version inline.
Store Editor — unbundled (nested)project === 'store-theme-editor' && hasVersionDatanested when versions returnedThe store theme has been unbundled and getStoreThemePages returned versions[] data. Use case: merchants on an unbundled theme see the same versioned view as devs — they can switch between published / draft versions of their customised pages.
Store Editor — draft (flat)project === 'store-theme-editor' && !hasVersionDataflat listPre-unbundle: the dropdown drops to a flat list of pages, each a single leaf that navigates straight to (block_id, latest_block_version_code). The + create / Make live features still work but operate on the implicit latest version. Use case: a freshly-installed theme before the merchant has versioned it — only the latest snapshot exists.

handleSelect also branches by project for the navigation URL (lines 170-185): Dev Portal goes to {routeUrl.devApps}/{app_type}/{app_id}/{app_version_code}/{block_id}/{version_code}; Store Editor with a known theme version goes to {routeUrl.home}{app_type}/{store_code}/{store_theme_id}/{themeVersionCode}/{block_id}/{version_code}; Store Editor without a theme version falls back to a 6-segment URL.

Section management (after the page opens)

Once setBlockData hydrates the page, all section-level mutations route through createSectionSlice.ts. Every action calls saveToHistory() before and after so the editor's delta undo/redo can step over it as a single user-level operation, and each also flips hasUnsavedChanges to true to enable the Save indicator.

FieldTypeDefaultDescription & use case
addSection(index?)actionappendInserts a new default section after index, or appends to the end when no index is given. Re-numbers display_order on every row after the insertion. Use case: start a new layout band on a page — header strip, marketing row, footer band.
duplicateSection(sectionId)actionappend cloneDeep-clones the section with fresh unique IDs / classes (updateUniqueIdsAndClasses(node, isRoot=true)) and appends to the end. Use case: reuse a band's layout as a starting point for the next one without rebuilding it from scratch.
deleteSection(sectionId)actionRemoves the section, re-numbers display_order on remaining children, and re-points activeChildId to the first remaining section (or null if the page is now empty). Use case: remove an obsolete promo band or campaign section that's no longer needed.
reorderSections(from, to)actionSplices the section out of from and into to, then re-numbers display_order on every row. Use case: shuffle the visual order of bands on a page (drag-and-drop in the Layers panel funnels through this).
moveSectionUp / moveSectionDownactionThin wrappers over reorderSections that shift a single section by one position; no-ops if the section is already at the boundary. Use case: nudge a band one slot up or down with the inline arrow buttons in the Layers panel.
replaceSection(sectionId, newSection)actionSwaps a section with an incoming reusable block. Sanitises root styles: forces position: relative, width: 100%, default height: 37.5rem if missing, and strips top/left/right/bottom from both base and responsive styles so the dropped block sits in document flow. Use case: drop a reusable block (template) onto an existing section to replace its content while keeping the section frame intact.
moveElementToSection (movement slice)actionCross-section element drag. Guards: no moving a section root, no moving an element into itself, no moving into one's own descendants. Adapts styles to the target (forces width: 100% for non-media in empty containers, sets position: absolute with zoom-adjusted left/top when the target is position-relative or a grid cell). Use case: reorganise content across bands by dragging an element from one section's layer tree into another.
moveElementsToSection (movement slice)actionBatched multi-select variant of moveElementToSection. Saves history once for the entire batch, applies media-element styling (maxWidth: 100%, objectFit: contain), and only applies the drop position to the first element. Use case: drag multiple selected elements between sections in one undoable operation.

Workflows

1. Switching between pages

  1. Click the page-name pill in the editor toolbar — PageSelector's Dropdown opens.
  2. Type a few characters into the search box. In nested mode the filter also matches against version names and codes.
  3. Click a page row. Single-version pages navigate immediately; multi-version pages expand a submenu.
  4. Click the desired version inside the submenu. The browser does a full reload via window.location.href to /.../:block_id/:block_version_code. The editor rehydrates via setBlockData on landing.

2. Promoting a draft to live

  1. Open the page selector and expand the page's submenu.
  2. Find a Draft or Published version.
  3. Click the inline Make live button on that row. The button is suppressed for the row that's already active.
  4. handleMakeLive invokes the activate flow for the current project (Dev Portal or Store Editor), refetches the page list, and moves the star to the new active version. No navigation occurs.
  5. If the call fails the selector surfaces the error toast and leaves the previous active version in place.

3. Creating a new page

  1. Click the + icon next to the search box (or, in variant="separate", the standalone + button in the toolbar).
  2. The CreatePageBlock dialog opens. Enter a Page Name — the slug is auto-generated by lowercasing the name, replacing non-alphanumerics with hyphens, and trimming leading / trailing hyphens.
  3. Override the auto-generated slug if needed. It must match ^[a-z0-9-:]+(/[a-z0-9-:]+)*$ — lowercase, hyphens, colons (for params like reset-password:token), and slashes (for nested paths like checkout/success).
  4. Optionally toggle Home Page if this should be the theme's / route — the Store Editor's createPageRoute call then uses an empty slug.
  5. Submit. The dialog runs the project-appropriate create-block flow — Dev Portal or Store Editor — supplying the new block's metadata (block_type: "page", block_use_case: "internal", is_home, block_name, block_slug, block_version_slug, an empty block_data.sections, etc.).
  6. Store Editor also registers a page route right after creation so the URL is reachable. Errors in route creation are logged but don't block navigation.
  7. The browser navigates to the freshly created page via window.location.href.

4. Managing sections inside the opened page

  1. Once a page is open, its top-level children array (managed by createSectionSlice.ts) holds its sections.
  2. Use the canvas controls or the Layers panel to call addSection, duplicateSection, deleteSection, moveSectionUp, or moveSectionDown — each mutation snapshots before and after via saveToHistory, so a single undo/redo step covers it.
  3. Drop a reusable block onto an existing section to invoke replaceSection, which sanitises the incoming root styles (forces position: relative, width: 100%, default height 37.5rem) so the block fits the section frame.
  4. Drag elements between sections to trigger moveElementToSection (createMovementSlice.ts) — guards prevent moving a section root, moving an element into itself, or moving it into one of its own descendants. Media elements (img / video / iframe / canvas / svg) keep their predefined dimensions; non-media elements get width: 100% when dropped into an empty container.
  5. Multi-select drags route through moveElementsToSection, which saves history once for the whole batch.

5. Store Editor on a freshly-installed (flat) theme

  1. A merchant installs a theme but hasn't unbundled / customised it. themeCtx.storeThemeVersionCode is unset, so the selector skips the versioned page fetch and uses the draft Store Editor page-list fetch instead.
  2. The dropdown renders a flat list — each row is a leaf that navigates straight to (block_id, latest_block_version_code) when clicked.
  3. Make-live, Page Settings, and Create page still work, but operate on the implicit latest version since no version submenu is shown.
  4. Once the merchant unbundles the theme, subsequent loads return versions[] data, hasVersionData flips to true, and the dropdown auto-upgrades to the nested view on next render.

6. Searching by version code or name

  1. Open the dropdown and type into the search box.
  2. In nested mode, the filter is OR-style: a page row stays visible if its label matches OR if any of its versions has a matching name or code. Matching versions inside a non-matching page label are still shown via the matchingVersions filter.
  3. For example, typing 1.2 surfaces any version with 1.2 in its block_version_name, regardless of which page it belongs to.

Examples

Shape of a single IPageOption after normalisation (fetchPages in PageSelector.tsx lines 90-143):

{
  "label": "Product Detail",
  "value": 142,
  "slug": "product/:handle",
  "latest_block_version_code": 5,
  "active_block_version_code": 4,
  "versions": [
    { "block_version_code": 5, "block_version_name": "1.3.0", "block_version_status": "draft" },
    { "block_version_code": 4, "block_version_name": "1.2.0", "block_version_status": "active" },
    { "block_version_code": 3, "block_version_name": "1.1.0", "block_version_status": "published" }
  ]
}

Tips & gotchas

  • The selector hides itself (return null) when the URL has no block_id. If you don't see it, the route you're on isn't an editor route.
  • Switching pages does a hard reload via window.location.href — any unsaved changes are lost. Save first (Ctrl+S / Cmd+S) before switching.
  • Status tags reflect what's in the database at fetch time. Use the Make live button (which calls fetchPages() on success) to refresh the list without leaving the page.
  • The Store Editor falls back to a flat (non-nested) list when the theme is still a draft and hasn't been unbundled yet — there are no per-page versions in that state.
  • The + create button in the dropdown is the only entry point to CreatePageBlock (and on variant="separate", a duplicate standalone button); the 3-dot icon next to the pill opens settings for the already-open page, not for the page you're hovering in the dropdown.
  • Slugs accept colon-prefixed segments (e.g. reset-password:token) for route parameters and forward slashes for nested paths (e.g. checkout/success). Underscores and uppercase letters are rejected by the regex.
  • The "Make live" spinner is keyed by ${blockId}:${versionCode}, so two concurrent activations on different rows won't collide visually.
  • The renderVersionLabel button uses both onMouseDown + onClick with stopPropagation / preventDefault to keep the Ant Design Dropdown from treating the click as an item-select (otherwise the menu would close and navigate).
  • The dropdown popup is rendered via popupRender with onClick={(e) => e.stopPropagation()} so clicks inside the search box don't bubble up and close the menu prematurely.
  • The + button in variant="separate" appears twice — once inside the dropdown's search row, and once as its own toolbar button — both open the same CreatePageBlock dialog.
  • When a single-version page has no explicit active status, the dropdown still labels it Live (the implicit-active fallback in the menu builder), matching what the backend resolver does.
  • Navigating to a different page resets the editor's history stack (history: [], historyIndex: -1) via setBlockData — undo/redo doesn't survive page switches by design.