`, etc. |
+|---|---|---|
+| Shape | Single value (a string for `Heading` / `Paragraph` / `Text`, a URL for `Image`, a JSON tree for `RichText`). The author sets it via the right panel: either as a **static value** typed inline, or as a **binding** to a CT field. | N props as declared in `registerComponent` schema. Each prop set via the right panel — static value or binding to a CT field. |
+| Visual styling | Plain HTML by default — ``, `
`, `
`. Styling comes from node `styles.default.responsiveStyles.default` set via the Design panel, OR app-side CSS targeting the rendered DOM. RTE embeds use the default serializer unless you register a custom one (see `register-json-rte`). | Whatever your React + CSS produces. Pixel-perfect; uses your design system. |
+| Layout authoring | Composable via Basic `Box` + node `responsiveStyles` (see pitfall row below). | Owned by the React component code. |
+| Authoring UX | Quick — drop, type a value or bind a field in the right panel, done. Good for editorial copy that lives ONLY in the composition (not modelled in the CMS). | Higher-fidelity — drop a fully-styled block; bind props to CT fields. Right-panel surface is exactly the prop schema you registered. |
+| Best for | Headlines, body copy, captions, CTA labels, RTE blocks — wherever literal copy can live in the composition itself OR map 1:1 to a CT field with no surrounding shell. | Marquee / styled / interactive sections — hero, carousel, 3D viewer, anything pixel-sensitive or with multi-field internal structure. |
+
+**These are two render paths.** The local standalone page (custom `Hero`) and the Studio composition (Basic `Heading` + `Paragraph`) will NOT look identical by default. Treat the gap as design work, not a bug.
+
+**Recommended pattern — hybrid.** Use both inside one Section:
+
+- **Custom registered components** for the styled / interactive shells — the hero with its background, the carousel with its animations.
+- **Basic field components** for editorial copy that doesn't need a custom shell — headline, intro paragraph, footnote — or for repeated content blocks where authors should be able to swap copy quickly via the right panel without engineering ever touching the codebase.
+- **Match the Basic-component styling to your brand** by setting node `responsiveStyles` via the Design panel (typography, spacing, color) — or write CSS that targets the rendered semantic DOM. For RTE, register a custom renderer (`register-json-rte`).
+
+Decision rule:
+
+- Is THIS region a single string / scalar value that maps 1:1 to a CT field (or is composition-local copy)? → Basic field component.
+- Is THIS region a multi-prop / styled / interactive piece? → Custom registered component.
+- Both, in the same Section, is normal and good.
+
+## Prerequisite — the canvas chain must be wired
+
+Section authoring requires the full canvas chain: a route mounting ``, the project's **Canvas URL** pointing at it, and — most often missing — a **non-empty per-locale Base URL on the environment the project targets**. Missing any link = blank or Playground canvas with no explanation. If you can't confirm all three, run [`setup-section-preview`](../setup-section-preview/SKILL.md) first.
+
+## Task
+
+1. **Open Studio → Sections tab → + New Section.** Confirm you are in section authoring mode (the palette shows the **Registered Components** category and **Smart Containers** category — both are section-mode signals).
+
+2. **Name the section** using the supplied `sectionName`. The display name is what template authors see in the Sections palette.
+
+3. **Connect A Schema.** The Schema panel offers structural shapes only — no scalar fields. Pick the kind matching `linkedSchemaKind`:
+ - `content-type` → pick the connected CT
+ - `global-field` → pick the Global Field UID
+ - `group` → pick the group field on the parent CT
+ - `modular-block` → pick the Modular Block field; the section sees the union of allowed block types
+ - `block` → pick a specific block type inside a Modular Block; the section sees just that block's fields
+ - `reference` → pick the Reference field; the section sees the union of `reference_to` CTs
+
+ If the user asks to "link to the title field" or similar, redirect: that's not how sections work. Either pick the parent group / object / CT, or use **Expose Section Prop** at the component level later. If `linkedSchemaUid` is blank, warn explicitly: the section is static-only, no auto-binding, every value typed by hand at template-drop time.
+
+ When the picked schema is a *multiple* variant (`isMultiple=y`), note it in the section's intent — this section is meant to be dropped *inside a Repeater* on a template. The Repeater iterates the list; each iteration places the section once.
+
+4. **Scan the linked CT for Slot candidates BEFORE binding fields directly.** Inspect the CT for any **Global Field**, **Modular Block**, **Group**, or **Reference** fields. For each one, decide:
+ - **Expose it as a Section Slot** (the preferred default) when the nested data is composable, shared across pages, or likely to have variants. Carve a Slot at that location via `use-section-slot` instead of binding the nested shape inline. Doing so keeps the nested shape replaceable with any compatible Section per template instance.
+ - **Bind it inline** only when the shape is genuinely one-off, small, and unlikely to be reused. This is the rarer case.
+
+ Default for Global Field / Modular Block / Group / Reference fields: **expose a Slot**. Inlining throws away the reusability of the nested structure. If unsure, expose a Slot — it can always be filled with a single fixed child Section.
+
+ See `understand-section-slots` § *When to expose one — the Global Field / Modular Block / Group / Reference heuristic* for the full rationale (Global Fields are the strongest Slot candidate of the four).
+
+5. **Drop registered components.** For each entry in `initialComponents`:
+ a. Open the palette and switch to the **Registered Components** category. Do not drop Studio default components — they bypass the project's brand system.
+ b. Drag the component onto the section canvas. Use the canvas drop indicators to confirm placement inside the intended container.
+ c. Select the dropped node and bind each prop via the Data Picker. The picker shows the Section's linked-schema fields by default. If the dropped node sits inside a Repeater, the picker switches root to the **iteration item's** fields automatically (labelled "Repeater Data") — bind from whichever root the picker is showing. Add a Condition Block around any Reference or Modular Block iteration.
+
+6. **Optional smart containers.**
+ - For a section whose linked schema IS the *multiple* variant, you usually drop a Repeater bound to `template.items[]` (or whatever the array path is) and place sub-components inside it.
+ - Carve a **Section Slot** (see `use-section-slot`) if you want template authors to drop arbitrary components into a named region.
+ - **Expose Section Props** (see `expose-section-props`) for value-level overrides — toggling a flag or swapping a label per template instance.
+
+7. **Save.** Studio surfaces the **Expose Props** modal on Save — toggle which component props template authors should be able to override per page. If you skip this step the section is locked: template authors can drop it but cannot change any value.
+
+## Inputs needed from the user
+
+In this order. Stop and ask if any is missing — DO NOT guess a `linkedSchemaUid` or invent component types.
+
+1. `sectionName` — display name (reject empty or generic "Section 1"; ask again).
+2. `linkedSchemaKind` — one of `content-type / global-field / group / modular-block / block / reference`. If the user says "field name X", redirect: ask for the enclosing structural shape.
+3. `linkedSchemaUid` — UID of the structural shape; allow skip with an explicit static-only warning.
+4. `isMultiple` — y/n; affects whether the section is intended to live inside a Repeater on the template.
+5. `initialComponents` — comma-separated list of registered component types; reject any type not currently registered in the project.
+
+## Acceptance
+
+This skill succeeds only when ALL of the following are true. If any fails, do not claim success — surface the failure and stop.
+
+- [ ] The new Section appears in the Studio Sections tab with the supplied `sectionName`.
+- [ ] If `linkedSchemaUid` was supplied, the section's Schema panel shows that UID under the correct structural kind (and **not** under any individual field).
+- [ ] If the user asked to link to a scalar field, the request was redirected to the enclosing shape (or to Expose Section Prop) — single-field linking was NOT attempted.
+- [ ] When `isMultiple=y`, the section's intent is documented for template authors: "drop inside a Repeater".
+- [ ] Every component listed in `initialComponents` exists on the section canvas and was dropped from the **Registered Components** palette category (not the Studio defaults).
+- [ ] Each bound prop on those components resolves under the right Data Picker root — the Section's linked-schema root for props outside any Repeater, and the **Repeater Data** root (iteration item) for props inside a Repeater. A Condition Block wraps any Reference or Modular Block iteration.
+- [ ] On Save, the **Expose Props** modal was acknowledged — either props were exposed or the locked state was a deliberate choice.
+- [ ] Dropping the saved section onto a template that has a field of the linked schema's shape auto-binds with no manual picker steps.
+- [ ] **Migration builds only** — if this Section is replacing an existing production Section on a live route, run `verify-visual-parity` against the production URL at all target viewports before declaring success. Structural checkpoints (component present, prop bound) are necessary but not sufficient; pixel drift ships silently otherwise. Skip this line for greenfield Sections that have no production counterpart to compare.
+
+## Common pitfalls
+
+| Pitfall | Why it bites | Fix |
+| --- | --- | --- |
+| Trying to link the section to a scalar field (`title`, `description`, `image`) | Studio's picker doesn't offer scalar fields — a section composes UI against a *shape*, not a value | Pick the parent Group / Global Field / CT / Block; expose individual props later via Expose Section Prop |
+| Linking to a multi-variant schema without dropping a Repeater | Section composes against the whole list as one shape; renders only the first item or fails to bind | When `isMultiple=y`, drop a Repeater bound to the array path and place components inside it |
+| Picking Modular Block instead of a specific Block type | Bindings are typed against the union of block shapes — most fields don't resolve | If the section is for one block type, pick `block` and select that specific block; if the section handles many, use Repeater + Condition Block per block type |
+| Skipping Connect A Schema | Section is static-only; won't auto-bind on templates; authors wire every value by hand | Pick a structural schema; only skip when you know the section is purely presentational |
+| Dropping Studio default components instead of Registered Components | No brand consistency; section ignores the project's component library | Switch palette category to **Registered Components** before dragging |
+| Skipping the Expose Props step on Save | Template authors get a locked section — no value overrides possible | Toggle the props that should be overridable in the Expose Props modal |
+| Assuming the Section is broken because the canvas shows defaults, not CMS values | Design Mode renders registration defaults; Preview Mode renders real bindings. See `use-repeater` for the two-mode model. | Toggle Preview Mode in Properties → Configuration. |
+| Binding inside a Repeater without a Condition Block on references / modular blocks | Iteration items are polymorphic; bindings silently fail | Wrap with Condition Block (single-CT references included) |
+| Picking from the Section's linked-schema root for a prop that sits inside a Repeater | Wrong scope — every iteration shows the same value (the parent's field), not the per-item value | Inside a Repeater, the Data Picker switches to the iteration item's fields automatically — bind from that root |
+| Naming the section "Section 1" / "New Section" | Authors cannot tell sections apart in the palette | Use an intent-revealing name like `Hero Strip`, `Card Grid`, `Testimonial Card` |
+| Dropping a **Rows / Box wrapper** before the first real component | Leaves a visible "Drop Here" placeholder zone in the canvas (and in every screenshot) that authors must clean up later. Rows is an e2e-test scaffolding habit, NOT a Studio authoring pattern. | Drop the first component directly onto the canvas root slot. Wrap in a container ONLY when you need explicit layout (e.g. an `hstack` to put two cards side-by-side). |
+| Adding a **Repeater for a single, non-list use case** | Repeater iterates a multi-valued field; a static page with one Hero + one Product Card needs zero iteration. Adding one introduces a phantom iteration scope and shifts the Data Picker so the parent Section's fields aren't reachable from inside. | Use Repeater ONLY when the linked field is genuinely a list (Modular Block list, multi-Group, multi-Reference, multi-entry pinned query). For a single hero or single card: drop the component directly. |
+| Path A when the card visual may be reused elsewhere | Locks the card into one parent; reuse contexts drift out of sync | Default to Path B — see § *Path A vs Path B*. |
+| Slot inside Repeater without a sized layout container | Child stretches full canvas width | Wrap in grid/flex/`max-width` Box. See `use-section-slot` § *Layout container*. |
+| Leaving empty "Drop Here" zones at the bottom of the canvas | Saved compositions render those zones as visible placeholders in screenshots and to authors browsing the section gallery. | Before Save, switch to Layers and delete any orphan empty Box / Slot rows. Acceptance: the Layers tree contains ONLY the components you intended; no orphan structural wrappers remain. |
+| **Assigning a CSS class to a Basic `box` and expecting `display: flex/grid` to apply** — the box renders but the children stack vertically (no flex) or single-column (no grid). | The Basic `Box` component is just `
` — Studio renders box layout from the node's **`styles.default.responsiveStyles.default`** (set via the Design panel on the node) and surfaces it through the renderer's style pipeline. An external CSS class on the app side has no node-level styles to attach to and only applies whatever rules its stylesheet defines — it does NOT make Studio's renderer emit `display: flex`. | Author Basic-box layout via node `responsiveStyles` in the Design panel: select the Box → Design tab → set `display`, `gap`, `alignItems`, `flexDirection`, etc. The renderer writes these into the DOM. Reach for a CSS class only for tokens/colors/typography already wired through your design system — never for the load-bearing layout shape. (Custom registered components are different — they can apply layout via their own React/CSS.) |
+
+
+
+## LLM execution caveat — drag-drop works, but only with the right sequence
+
+Studio's canvas is a React-DnD iframe. Palette tiles listen on `mousedown` / `mousemove` / `mouseup` (NOT HTML5 native drag), and the drop COMMITS only when mousemove fires intermediate events between mousedown and mouseup. The high-level `dragTo()` helper fires HTML5 `dragstart`/`drop` which Studio does not honor — you must use `page.mouse.down()` / `page.mouse.move({steps})` / `page.mouse.up()` directly.
+
+**Stable selectors (verified by execution):**
+
+- Palette tile: `[data-builder-component="true"][data-node-type=""]` where `` is e.g. `doc-hero`, `doc-card`, `repeater`, `header`, `box`. (Section tiles use the section's composition UID as the type.)
+- Canvas iframe: `[data-testid="canvas-iframe"]`
+- Drop slot inside the iframe: `[data-composable-studio-slot="true"]` (the `="true"` filter is required; without it you can match elements that have the attribute but aren't active drop targets)
+- Layers row title (to select a node for deletion or inspection): `[data-testid="layer-editable-title-container"]`
+- Node IDs (to verify a drop committed): `[data-composable-studio-id]` inside the FrameLocator
+
+**The drop sequence — proven working pattern:**
+
+```ts
+const item = page.locator('[data-builder-component="true"][data-node-type="doc-hero"]');
+const frame = page.frameLocator('[data-testid="canvas-iframe"]');
+const slot = frame.locator('[data-composable-studio-slot="true"]').first();
+
+await item.hover(); // 1. position cursor over palette tile
+await page.mouse.down(); // 2. mousedown → posts PARENT_DRAG_START to iframe
+const sb = await slot.boundingBox();
+await page.mouse.move(sb.x + sb.width / 2, // 3. move cursor in STEPS — required for mousemove events to fire
+ sb.y + sb.height / 2,
+ { steps: 10 });
+await slot.hover(); // 4. final settle on the slot (FrameLocator handles cross-frame)
+await page.mouse.up(); // 5. mouseup → commits the drop
+```
+
+The `page.mouse.move({steps: 10})` between mousedown and mouseup is the critical detail. Without intermediate mousemove events, the iframe's drag-tracking code never registers the path and the drop is silently swallowed.
+
+**Anti-phantom guardrail.** Always verify a NEW `data-composable-studio-id` appeared inside the FrameLocator after each drop:
+
+```ts
+const idsBefore = await frame.locator('[data-composable-studio-id]')
+ .evaluateAll(els => els.map(e => e.getAttribute('data-composable-studio-id')));
+// ... drop sequence ...
+await page.waitForTimeout(800);
+const idsAfter = await frame.locator('[data-composable-studio-id]')
+ .evaluateAll(els => els.map(e => e.getAttribute('data-composable-studio-id')));
+const newIds = idsAfter.filter(id => !idsBefore.includes(id));
+if (newIds.length === 0) {
+ throw new Error('Drop did not commit; do not continue.');
+}
+```
+
+If `newIds.length === 0`: stop and surface the failure — do not fabricate completion.
+
+**Sibling drops after the root slot is consumed.** Once a component is dropped at the canvas root, `[data-composable-studio-slot="true"]` may return zero matches because the root slot is now occupied. To add siblings, hover the **edge** of an existing node — Studio reveals a drop indicator there. Alternatively wrap children in a container (`box`, `vstack`, `hstack`) and drop subsequent siblings into the container's slot.
+
+**Execution-path matrix:**
+
+| Path | Drag-drop status |
+|---|---|
+| Human in their own Studio browser | ✅ Native — this is how authors use Studio every day |
+| Playwright with direct `page.mouse.down/move/up` access | ✅ Use the proven sequence above |
+| Playwright `dragTo()` only | ❌ Fires HTML5 drag events Studio does not honor |
+| Synthetic `DragEvent` dispatched from page-context JS | ❌ Same reason |
+
+**What ALSO works programmatically (verified):**
+
+- Click a Layers row + press `Delete` → removes the node and persists
+- Click the Save button → persists the composition; the button greys out post-save
+- Switch right-panel tabs (Settings / Design / Data) via direct DOM clicks
+- Open Configuration / URL Pattern / Schema Picker modals via their action buttons
+- Read iframe canvas state via `frameLocator` (read-only operations)
+- Switch palette accordion sections (Basic / Media / Container / Smart Containers / Registered Components / HTML Elements) via direct DOM clicks
+
+## See also
+
+- `docs/32-sections/link-content-types-with-linked-schema.md` — how schema connection drives auto-binding
+- `docs/20-bring-your-own-components/register-components.md` — getting components into the Registered Components palette
+- `docs/34-smart-containers/create-repeatable-content-with-repeaters.md` — Repeater + multi-variant sections
+- `docs/34-smart-containers/condition-blocks.md` — required wrapping for Reference / Modular Block iteration
+- `use-section-slot` — carve a drop region into the section
+- `expose-section-props` — value-level overrides on Save
+- `build-connected-template` — drop the saved section onto a template with a matching field
+- `build-repeating-section` — the dedicated guide for the "parent Section with Repeater + Slot, child Section fills the Slot per iteration" pattern (Path B from this skill, walked end to end)
diff --git a/codex/byoc-end-to-end/SKILL.md b/codex/byoc-end-to-end/SKILL.md
new file mode 100644
index 0000000..53649a3
--- /dev/null
+++ b/codex/byoc-end-to-end/SKILL.md
@@ -0,0 +1,146 @@
+# byoc-end-to-end
+
+
+## When to use
+
+End-to-end orchestrator for the BYOC arc — chains register-component (lazy) → import-design-tokens → build-section → use-section-slot → build-connected-template → configure-csr-vs-ssr → setup-template-preview-routes → verify-setup.
+
+Use when an engineer wants to bring their own component library into a Studio-backed app end-to-end. Walks register → section → template → publish. Phrases — "BYOC", "use my components in Studio", "Studio with my design system". Do NOT use for one-off component registration (`register-component`) or once components already exist in Studio.
+
+# BYOC end-to-end — from registered components to a rendered Template
+
+BYOC ("Bring Your Own Components") chains the ~8 sub-skills that take registered React components → rendered Template. This orchestrator only names + orders them; it doesn't replace any sub-skill.
+
+## Prerequisite — concepts first
+
+If the user can't crisply define Section, linked schema, Template, and `` vs `` in one sentence each → run `start-here-zero-knowledge` first.
+
+## Task
+
+Print the plan below, customised for the user's `projectShape` and `renderMode`. Don't execute the sub-skills automatically — name them, and let the user invoke each one in turn.
+
+### Phase 0a — Architecture planning (run BEFORE Preflight)
+
+- **`plan-studio-architecture`** — requirements → printed architecture plan (Sections, Connected pages, content model, build order). Skip only if the user already has a written plan.
+
+Gate: printed plan with classified pages, content model, ordered Section + Template inventory, build order, sanity flags acknowledged.
+
+### Phase 0 — Preflight (run once)
+
+- **`analyze-project-fit`** — confirm React 18, single React instance, no peer override forcing 19. Catches the most common install foot-guns.
+- **`install-studio`** — install SDK packages, pin React 18, wire `ContentstackLivePreview.init`, set env Base URL pre-flight. Output: SDK installed + env Base URL for target locale is non-empty.
+- **`enable-visual-experience`** — confirm stack-level Live Preview is on (commonly missed; silently blocks the canvas).
+- **`setup-section-preview`** — create the `/canvas` route mounting ``. Set the Canvas URL in Studio Project Settings.
+
+Gate: open Studio, open any composition (even an empty one). Canvas iframe loads cleanly (no blank frame, no Component Loading Error). If not → `troubleshoot-canvas` before continuing.
+
+### Phase 1 — Register components (lazy by default)
+
+- **`register-component`** — for each component the user wants Studio to surface in the palette. Default to the LAZY shape: `component: () => import("./Foo").then(m => ({ default: m.Foo }))`. Eager only when the component is tiny and on a hot path where Suspense fallback is unacceptable.
+- **`register-breakpoints`** (optional) — if the site needs custom responsive breakpoints registered for Studio's responsive editor.
+- **`register-json-rte`** (optional) — only if the site uses JSON-RTE-bound props.
+- **`import-design-tokens`** — register the customer's design system. Do this BEFORE building any Section; tokens here drive visual fidelity of every later canvas preview.
+
+Detour: `component: Hero` where `Hero` is `function Hero() { ... }` (no `props` arg) → "Invalid hook call". Back to `register-component`, read the arity rule. The lazy shape avoids the trap.
+
+Gate: open the canvas. Switch the palette to **Registered Components**. Every component the user expects to see is present, with a non-blank preview (defaults populated).
+
+### Phase 2 — Build the first Section
+
+- **`build-section`** — pick a linked schema (Content Type / Global Field / Group / Modular Block / Block / Reference). The picker only offers structural shapes; no scalar fields. **Default to Global Field as the section anchor** unless a different shape fits better.
+- **Mid-step: scan for Slot candidates.** Inside `build-section` step 4, the user is told to inspect the linked CT for **Global Field / Modular Block / Group / Reference** fields. For each one, decide:
+ - Expose as a Slot (the preferred default) → run `use-section-slot` for that field.
+ - Bind inline (rare) → only when the shape is genuinely one-off.
+ - Global Field is the strongest Slot candidate of all four — build a separate Section against the Global Field's schema and expose it as a Slot in the parent.
+- **`expose-section-props`** — for any prop the user wants overridable per Section-drop (e.g. background colour, layout variant). Do this WHILE building the Section, not later.
+- **`wire-component-default-data`** (optional) — for components whose defaults aren't representable as static `defaultValue:` literals.
+
+Gate: the Section appears in the Studio Sections tab. Open it on the canvas → bindings render real data from a sample entry. If not → `troubleshoot-data-binding`.
+
+### Phase 3 — Repeat Phase 2 for every Section the site needs
+
+- Most pages need 3–6 Sections (Header, Hero, Feature Grid, Testimonial, CTA, Footer, etc.).
+- Build them ONE AT A TIME and verify each before moving on.
+- Cross-references: if a Section iterates a list (Modular Block, multi-Reference, multi-Group), use **`build-repeating-section`** — the dedicated guide for the parent-with-Slot + child-Section pattern, which composes `use-repeater` + `use-section-slot` + `use-condition-block` into one flow. Don't drop raw cards into a repeating Slot; build the card as its own Section and let the parent's Slot accept it.
+- Discoverability: run `discover-sections` periodically to see what's already been built — avoids accidentally rebuilding a Section that already exists.
+
+### Phase 4 — Choose Template flavor + assemble
+
+- **`build-connected-template`** — assemble the Sections from Phase 3 into the Template. Templates compose Sections; they don't model fields directly. If the user hasn't built any Sections yet, back to Phase 2.
+
+Gate: the Template renders on the canvas with the expected Section stack. Switching the preview entry updates the rendered content. If not → `troubleshoot-composition-resolution`.
+
+### Phase 5 — Visitor render path (CSR vs SSR vs RSC)
+
+- **`configure-csr-vs-ssr`** — the canonical decision skill. For `renderMode = unsure`, this skill picks based on the host framework (Next App Router → RSC default; Vite + React Router → CSR; Remix → SSR). Output: a clear recommendation and the per-route shape to use.
+- **`setup-template-preview-routes`** — create the visitor route(s) mounting ``. Either a catch-all `[[...slug]]` (recommended for most projects) or per-template routes (more control, more boilerplate). The route(s) here serve BOTH real visitors AND Studio's template-preview iframe — same code path.
+
+Gate: open `` + `` directly in a browser (not through Studio). The Template renders with real data. Open the same Template in Studio's Templates tab → the iframe shows the same render, with edit overlays.
+
+### Phase 6 — Verify + ship
+
+- **`verify-setup`** — 5-layer smoke test (Delivery SDK reachability, Live Preview channel, Visual Editor, Studio canvas, new-user trap smoke). ALL FIVE layers must pass before deploying.
+- **`troubleshoot-ssr-rendering`** (if needed) — for SSR-specific failures (RSC client-reference boundaries, hydration mismatches, lazy-not-loaded race when `fetchSpec` isn't awaited).
+- **`deploy-studio-site`** — production checklist (env Base URL points at the deployed origin, Canvas URL still `/canvas`, prod build serves the right CSP headers for the iframe).
+
+Gate: deployed site renders a real Template at a real URL. Studio's preview of the same Template (inside Studio) shows the same content.
+
+## Branch: brownfield (`projectShape = existing`)
+
+Insert `migrate-page-to-studio` between Phase 0 and Phase 1 — pick one small existing page (not the homepage). In Phase 5, mount the new visitor route side-by-side with the old one; redirect after verification.
+
+## Branch: greenfield (`projectShape = new`)
+
+Phase 0 includes project bootstrap. Recommended: Next.js App Router (RSC default, catch-all route) or Vite + React Router (CSR). React 18.3.1 exactly. Canvas-app and visitor-app are the SAME app — one `package.json`, two route trees.
+
+## Common detours, and the step to back to
+
+| Symptom | Detour | Back to |
+|---|---|---|
+| Component throws "Invalid hook call" the moment the canvas renders it | The component was registered eagerly with arity 0 (e.g. `function Header() {...}` with no `props` arg) | `register-component` — switch to the lazy shape, OR add a `props` arg |
+| Studio canvas blank, no console error | env Base URL empty, or Canvas URL set to a full URL instead of a path | `setup-section-preview` + `understand-canvas-url` |
+| Components don't show up in the palette | Registration file isn't imported at app startup | `register-component` — `import "./register-components"` from the app entry |
+| Sections panel is empty even after Phase 2 finished | Wrong project selected in Studio top-bar | switch project, OR re-run `provision-studio-project` if it was provisioned against a different stack |
+| "Internal components missing: page" on the visitor route | SDK is pre-PR-#851 build | `upgrade-studio-sdk` — bump to the patch that ships the visitor-path auto-register |
+| Template renders in Studio but not on the live route | The visitor route isn't mounting ``, OR is mounting `` by mistake | `understand-canvas-vs-component` + `setup-template-preview-routes` |
+| "registered as lazy but hasn't been loaded yet" | `fetchSpec` / `fetchCompositionData` wasn't awaited before `` mounted | `configure-csr-vs-ssr` — the SSR / RSC patterns show the right await placement |
+| `useData()` warning in dev console only | Benign — basic component hit the SSR pass before `DataCtxProvider` attached. Disappears post-hydration. | `troubleshoot-ssr-rendering` — confirm it's a warning, not an error |
+
+## Inputs needed from the user
+
+1. `projectShape` — `existing` or `new`. Used to insert the `migrate-page-to-studio` branch at the right point.
+2. `renderMode` — `csr` / `ssr` / `rsc` / `unsure`. Used by `configure-csr-vs-ssr` in Phase 5; `unsure` is acceptable — that skill picks based on the host framework.
+
+## Acceptance
+
+This orchestrator succeeds when:
+
+- [ ] The user sees the full BYOC path on one page, with every sub-skill named.
+- [ ] Each phase has an explicit gate (the signal that says "ready for the next phase").
+- [ ] The plan is customised to their `projectShape` (existing vs new) and `renderMode`.
+- [ ] Common detours are flagged WITH the step to back to — not just "something went wrong."
+- [ ] The plan ends at "deployed + verified," not at "Template renders."
+
+## Common pitfalls
+
+| Pitfall | Why it bites | Right move |
+|---|---|---|
+| Treating this orchestrator as a sub-skill replacement | The orchestrator doesn't author Sections or Templates — it points at the skills that do. Running it doesn't accomplish any phase. | Use this to see the whole path. Invoke the named sub-skill for each phase. |
+| Skipping the concept gate at the top | User registers components, builds a "Section" that's actually unwrapped components on a route, ships a one-off page, loses all reuse benefit | If the user can't define Section + Template + linked schema crisply, RUN `start-here-zero-knowledge` first. Five minutes of concepts saves weeks of rework. |
+| Running phases out of order | E.g. building a Section before importing design tokens — the Section's canvas preview looks wrong; the user thinks the Section is broken; rebuilds it; same problem because tokens are still missing | Tokens before Sections. Sections before Templates. Templates before visitor routes. Verify before deploy. The order is load-bearing. |
+| Building all Sections in one big batch before verifying any of them | If something is wrong with the SDK / registry / Live Preview channel, the user discovers it after 4 hours of building, then has to debug across all Sections at once | Build ONE Section, verify it on the canvas, move to the next. Catch breakage when the surface area is small. |
+| Forgetting Phase 6 ("verify + ship") | Template renders on the canvas → user assumes prod will be fine → CSP headers blocking the iframe in prod, env Base URL pointing at localhost in prod, etc. | `verify-setup` and `deploy-studio-site` are mandatory phases, not optional. The plan doesn't end at "renders on canvas." |
+
+## See also
+
+- `start-here-zero-knowledge` — the conceptual prerequisite (run BEFORE this skill if the user is new)
+- `plan-studio-architecture` — Phase 0a, the requirements→architecture planner that runs before this skill
+- `analyze-project-fit` — Phase 0 gate
+- `install-studio`, `setup-section-preview`, `setup-template-preview-routes` — wiring
+- `register-component`, `import-design-tokens`, `register-breakpoints`, `register-json-rte` — registration
+- `build-section`, `use-section-slot`, `expose-section-props`, `use-repeater`, `use-condition-block` — Section authoring
+- `build-repeating-section` — the dedicated end-to-end recipe for parent-with-Slot + child Section iteration (composes the four skills above)
+- `build-connected-template` — Template authoring
+- `configure-csr-vs-ssr`, `troubleshoot-ssr-rendering` — render modes
+- `verify-setup`, `deploy-studio-site` — ship
+- `migrate-page-to-studio` — brownfield branch
diff --git a/codex/choose-connected-vs-freeform/SKILL.md b/codex/choose-connected-vs-freeform/SKILL.md
new file mode 100644
index 0000000..4547cb4
--- /dev/null
+++ b/codex/choose-connected-vs-freeform/SKILL.md
@@ -0,0 +1,90 @@
+# choose-connected-vs-freeform
+
+
+## When to use
+
+Decide whether a new page is a Connected (content-type-bound) or Freeform (one-off) template. **Connected is the default; Freeform is the very last resort.** Hands off to the matching build skill.
+
+Use at the start of authoring a new page, before any template is created. Phrases — "Connected or Freeform?", "what kind of template", "should this be a landing page". Emits a recommendation (Connected default; Freeform only when justified by the Three-AND rule) and the next skill to run. Do NOT use to actually build the template.
+
+# Choose Connected vs Freeform Template
+
+## Context
+
+Studio offers two kinds of top-level compositions: **Connected templates** (bound 1:1 to a content type, so every entry of that CT renders through the same template at a derived URL) and **Freeform templates** (one-off pages assembled from any mix of sources — Pinned Queries, Additional Entry Data, Component Default Data — with no content-type binding).
+
+**Default is Connected.** Connected scales with content (one template renders unlimited entries), auto-binds `template.*` against the connected entry, supports `{{entry.*}}` URL variables, gives authors a content-first workflow (edit the entry → page updates), and benefits from CT-level governance (validation, locales, variants, publishing workflows). **Most pages should be Connected**, including pages that only ever have one instance — model a single-entry content type rather than reaching for Freeform.
+
+**Freeform is the very very very last resort.** Use it ONLY when **ALL THREE** are true:
+
+1. **Short-lived** — the page has an expected end date (campaign, splash, takeover, time-boxed promo). Not a "one-off that lives forever."
+2. **Owns ZERO content of its own** — no hero copy, no page-specific title, no subheading, no CTA text, no callouts, no closing message. **Literally no copy authored on the page.** Hero copy IS content; throwaway HTML is owned content in disguise. If you find yourself writing copy in JSX, you have a content field — model it as a single-entry CT → Connected.
+3. **Visible content is 100% assembled by pinning / pulling from existing entries** — pinned entries from existing CTs, pinned queries, references into existing collections. The page is a *vitrine*, not a *source*.
+
+If any one of those three is missing → **not Freeform.** Use Connected with the right CT shape (single-entry CT for one-off long-lived pages, multi-entry CT for repeating shapes including recurring campaigns, single-entry CT even for short-lived pages that own any copy).
+
+Examples that do NOT qualify for Freeform (despite tempting at first glance):
+- **"Black Friday 2026 landing"** — even if short-lived and references existing product entries, it has its own campaign hero copy, headlines, CTA text. Owned content → single-entry CT or generic `campaign` CT → Connected.
+- **"Conference splash"** — has its own conference hero, dates copy, sponsor framing. Owned content → Connected.
+- **"Marketing landing page"** — almost always has page-specific copy. Owned content → Connected.
+- **Homepage / About / Contact / Pricing** — long-lived AND own their content → single-entry CT → Connected.
+
+Genuine Freeform cases are rare and usually internal-tool-ish: a merchandiser dashboard pinning 12 products in a specific order, a monitoring vitrine of pinned analytics entries, an A/B test variant that's purely "pinned query, no other content, deleted next week" — pure assembly of existing entries with no surrounding copy.
+
+## Task
+
+1. Ask the user for `pageDescription`, `numberOfPagesOfThisShape`, and `hasContentTypeMatch` if not already provided.
+2. Check for a URL-pattern hard constraint. If the user mentions a URL that needs entry-field variables (e.g. `/blog/{{entry.slug}}`, `/products/{{entry.handle}}`), force **Connected** — Freeform URL patterns only support `{{composition_uid}}` plus `{{environment}}` / `{{branch}}`; they cannot resolve `{{entry.*}}` or `{{content_type_uid}}`. (`{{locale}}` is accepted by the pattern engine but not recommended for either flavor — locale belongs in your routing layer + the SDK `locale` query option.)
+3. Otherwise apply the decision tree in order. **Bias toward Connected at every branch**; only return Freeform after explicitly confirming ALL THREE Freeform conditions (Step 4 below).
+ - (a) `numberOfPagesOfThisShape = many-with-CT-backing` (many pages, each maps to one entry of a single CT) → **Connected**. Canonical examples: blog posts, products, recipes, author profiles.
+ - (b) `numberOfPagesOfThisShape = one` AND `hasContentTypeMatch = yes` (one-off page that maps cleanly to one entry of a CT) → **Connected** with a hard-coded URL. Cheaper than Freeform because Connected auto-binds template fields against the connected entry.
+ - (c) `numberOfPagesOfThisShape = many` AND `hasContentTypeMatch = no` or `partial` → **propose Connected via a new or extended CT**. Ask: can a content type be created (or an existing one extended) to back these pages? If yes (which is usually the case), model the CT first → Connected.
+ - (d) `numberOfPagesOfThisShape = one` AND `hasContentTypeMatch = no` → ask: does the page own any copy of its own (hero title, subheading, CTA, callouts)? If YES → **Connected** with a single-entry CT (typical for homepages, about, contact, pricing, campaign landing pages — they all own their copy). The page-specific copy IS content; model it. If NO (the page owns literally zero copy and purely assembles existing entries) → continue to Step 4 (Freeform candidate).
+
+4. **Freeform last-resort check.** If — and only if — branch (d) led here with the user confirming the page owns no copy, run the three-AND rule. Re-confirm by asking explicitly:
+ - (i) Does this page have an **expected end date**? (Campaign, splash, takeover — not "one-off that lives forever.")
+ - (ii) Does the page own **ZERO content of its own**? No hero copy, no page-specific title, no subheading, no CTA text, no callouts, no closing message — literally nothing the page itself authors. (Hero copy IS content. Throwaway HTML IS content in disguise.)
+ - (iii) Is the visible content **100% assembled from existing entries** — pinned entries, pinned queries, references into existing CTs?
+
+ If the user confirms all three → **Freeform**. If even one answer wavers or is "yes, but a hero title" → flip back to **Connected** with a single-entry CT (or a `campaign` CT for recurring campaign shapes). Hero copy / page-specific copy means owned content; owned content means Connected.
+4. Print the recommendation in this exact shape:
+ ```
+ Recommendation: template
+ Rationale:
+ Next skill:
+ ```
+5. If the recommendation is Freeform, append TWO reminder lines:
+ - `Reconsider: did you rule out Connected via a single-entry CT or an extended CT? Freeform is the exception, not the default — re-check before proceeding.`
+ - `Before running build-freeform-template, verify the project-level "Enable Freeform Feature" toggle is on (Studio → Settings → Configuration). Many teams keep it OFF deliberately to enforce the every-page-maps-to-a-CT discipline; if your project has it off, the right move is usually to leave it off and model a CT.`
+6. Stop. Do not create the template yourself; the user (or the next skill invocation) runs the chosen build skill.
+
+## Inputs needed from the user
+
+- **pageDescription** — one sentence, free text. Used only for the rationale line.
+- **numberOfPagesOfThisShape** — one of `one`, `many`, `many-with-CT-backing`.
+- **hasContentTypeMatch** — one of `yes`, `no`, `partial`.
+
+If the user volunteers a URL pattern in `pageDescription`, parse it for `{{entry.*}}` or `{{content_type_uid}}` and apply the hard constraint from step 2 before the decision tree.
+
+## Acceptance
+
+- Exactly one recommendation is printed (Connected OR Freeform — never both, never "it depends").
+- The rationale references the user's actual inputs, not generic copy.
+- The `Next skill` line names a real, runnable skill in the docs (`build-connected-template` or `build-freeform-template`).
+- If the URL needs `{{entry.*}}`, the output is Connected and the rationale explicitly cites the Freeform URL-variable limitation.
+- If the output is Freeform, the Enable Freeform Feature reminder is present.
+- No template is created in Studio by this skill.
+
+## Common pitfalls
+
+| Pitfall | Why it bites | Right move |
+|---|---|---|
+| Recommending Freeform for "many blog posts" because the user said the word "landing" once | Freeform is 1:1 — each blog post would need its own template, exploding maintenance | If `many-with-CT-backing`, always Connected, regardless of stylistic words |
+| Recommending Freeform when the URL contains `{{entry.slug}}` | Freeform URL patterns only resolve `{{composition_uid}}`, `{{environment}}`, `{{branch}}` — `{{entry.*}}` and `{{content_type_uid}}` will not interpolate | Force Connected the moment an entry-field variable appears |
+| Treating "partial CT match" as automatic Freeform | A partial match often means the CT just needs one extra field; Connected is still right if the page shape repeats | Ask whether the CT can be extended before falling back to Freeform |
+| Recommending Freeform without checking the project toggle | `Enable Freeform Feature` may be off at the project level; `build-freeform-template` will not see the option in the create-template modal | Always print the toggle reminder when the answer is Freeform |
+| Choosing Freeform for a one-off page that maps to one entry | You lose Connected's auto-resolution of `template.*` bindings and have to wire Link Entry / Pinned Queries by hand | One-off + clean CT match → Connected with a hard-coded URL |
+| Reaching for Freeform on a one-off page because "there's no obvious CT" | A single-entry CT (one CT, one entry) is almost always cheaper than Freeform for the homepage, about page, contact page, etc. Connected gives you auto-binding, locales, variants, and a content-first edit flow that Freeform doesn't | If the page has even one stable content shape, model it as a single-entry CT and go Connected |
+| Treating Freeform as the default for "marketing / campaign" pages | The label "marketing page" doesn't decide the question — the data shape does. A "campaign" page that always has hero + promos + CTA can be a single-entry CT (Connected) | Ask what the *data* on the page looks like, not what the page is called. Connected wins whenever the shape repeats or stabilizes |
+| Confusing Repeater iteration bindings with template bindings during the conversation | Inside a Repeater, fields bind as `repeater.` (RepeaterBindingValue, discriminated by `repeaterUID`); at the template level they bind as `template.` (TemplateBindingValue). This skill doesn't bind — it just chooses — but if the user asks "can I bind X?", route them to the matching build skill instead of guessing | Defer all binding shape questions to `build-connected-template` / `build-freeform-template` |
+| Forgetting that references inside a Repeater always need a Condition Block | True for both single-CT and multi-CT reference fields — relevant when the user asks whether their repeater-of-refs page is feasible | Confirm feasibility, then hand off; the build skill handles the Condition Block step |
diff --git a/codex/configure-csr-vs-ssr/SKILL.md b/codex/configure-csr-vs-ssr/SKILL.md
new file mode 100644
index 0000000..e28a881
--- /dev/null
+++ b/codex/configure-csr-vs-ssr/SKILL.md
@@ -0,0 +1,235 @@
+# configure-csr-vs-ssr
+
+
+## When to use
+
+Pick and wire the right render path (CSR via `useCompositionData` vs SSR/RSC via `sdk.fetchCompositionData`) for a Studio-rendered page.
+
+Use when integrating @contentstack/studio-react into Next.js (App/Pages), Remix, Astro, or Vite/SPA and deciding between client- and server-side fetch. Phrases — "CSR or SSR", "Next App Router setup", "hydration mismatch", "SEO meta missing", "Live Preview broken after switching render mode". Also for diagnosing broken Studio iframe overrides after a render-mode change.
+
+# Configure CSR vs SSR for a Studio composition route
+
+## Read this first — SSR is available on every host
+
+The SDK ships two App Router paths (client-component SSR via the main entry, and an RSC entry with a ~142-byte structural bundle), plus recipes for Node/Express/Fastify, Next Pages Router, Remix, Astro, and Gatsby. Every host renders the full composition into the server HTML — `curl` returns real content, SEO works, no placeholder-defer.
+
+Pick your recipe from the framework recipes chapter and copy it verbatim:
+
+- **Node (Express/Fastify)** — CI-tested source of truth.
+- **Next.js App Router — RSC entry** — CI-tested on Next 14 + Next 16. Smallest possible client bundle.
+- **Next.js App Router — client-component SSR** — main-entry path, simplest wiring, full SEO.
+- **Next.js Pages Router**, **Remix / React Router 7**, **Astro**, **Gatsby** — adapted recipes.
+
+Same three-call contract everywhere (`fetchCompositionData` → `` → `getSSRStyleTags` / ``), plus `getCompositionMetadata` for SEO. Sections below cover the render-strategy decision itself — the mechanics live in the recipes above.
+
+## Context
+
+A Studio composition can be rendered via two data-fetching paths. Both return the same `StudioComponentSpecOptions` shape; only *where* the fetch runs differs.
+
+- **CSR** — `useCompositionData({ url })` hook. Runs in the browser. Best for Vite SPAs / routes where SEO is not required.
+- **SSR / RSC** — `sdk.fetchCompositionData({ url, searchQuery }, { locale })`. Runs on the server. Required for SEO and for routes where the initial HTML must contain the rendered composition.
+
+`` is the single renderer for both visitor pages and pages opened inside Studio's builder iframe — it auto-detects mode and attaches the Visual Builder overlay when needed. `` is a separate client-only component for the Studio canvas route and is **not** a render-strategy decision.
+
+**SDK shape.** `studioSdk` is the imported namespace; you must call `studioSdk.init({ stackSdk, contentTypeUid })` once at boot and use the **returned** object. Server fetches go through `sdk.fetchCompositionData(...)` on that returned object.
+
+```ts
+import { studioSdk } from "@contentstack/studio-react";
+export const sdk = studioSdk.init({ stackSdk, contentTypeUid });
+// later, server-side:
+const specOptions = await sdk.fetchCompositionData({ url, searchQuery }, { locale });
+```
+
+Reference doc: `docs/10-setup/app-prerequisites/choosing-between-csr-and-ssr-rendering.md` and `05-ssr-composition-query.md`.
+
+## The render-pipeline rule (read this first)
+
+What determines **true SSR** (the composition DOM in the server HTML) is whether `` renders through a **`renderToString` pipeline** (Pages Router / Vite custom server / Remix — server React dispatcher present, built-ins execute server-side) OR through **React Server Components** (App Router — built-ins are `"use client"` and the SDK renderer detects them as `Symbol.for("react.client.reference")` → emits `data-cs-defer-builtin` placeholders, deferring real render to client hydration). The data-fetch function (`fetchCompositionData` vs `useCompositionData`) is orthogonal and does **not** by itself produce SSR.
+
+**Net:** every mainstream host — including Next App Router (main entry and RSC entry), Pages Router, Remix, Astro, Gatsby, and plain Node — renders the composition into the server HTML via the SDK's three-call contract. See the framework recipes for the per-host wiring. The historical `data-cs-defer-builtin` placeholder path was removed in the SDK's dist-ESM restructure (#871 / #874).
+
+## Decision table
+
+| Situation | Path | True SSR? (composition DOM in server HTML) |
+| --- | --- | --- |
+| Vite / React SPA, no SEO | **CSR** — `useCompositionData` | No (client only) |
+| **Next.js Pages Router** | **SSR** — `getServerSideProps` → `sdk.fetchCompositionData` | **Yes** — `renderToString` |
+| **Next.js App Router (main entry)** | `sdk.fetchCompositionData` in Server Component → client wrapper renders `` | **Yes** — Next server-renders the client component's initial output |
+| **Next.js App Router (RSC entry)** | `sdk.fetchCompositionData` in Server Component → `` + `` from `@contentstack/studio-react/rsc` | **Yes** — ~142B client bundle for structural content; custom components register as client islands automatically |
+| Vite custom-server SSR | `renderToString` + `hydrateRoot` + `sdk.fetchCompositionData` (mirror SDK's `test-resources/ssr-react`) | **Yes** |
+| Remix | `loader` → `sdk.fetchCompositionData` | **Yes** — `renderToString` |
+| Static / rarely changing | **SSG/ISR** — `getStaticProps` + `revalidate` | **Yes** (at build/revalidate; via `renderToString`) |
+
+CSR works on **any** framework including App Router — the `isLoading` gate keeps built-ins out of the server prerender entirely. Use CSR as the universal fallback when true SSR isn't viable on a given framework.
+
+## Task
+
+1. **Pick the path** from the decision table using `framework`. Run only the matching block below; skip the others.
+
+2. **CSR (Vite / SPA — also the universal fallback on any framework, including App Router).**
+ ```tsx
+ "use client";
+ import { useCompositionData, StudioComponent } from "@contentstack/studio-react";
+
+ export default function Page() {
+ // window is browser-only — guard for any framework that prerenders this page (Next does, even with "use client").
+ const url = typeof window !== "undefined" ? window.location.pathname : "/";
+ const { specOptions, isLoading, error } = useCompositionData({ url });
+ if (isLoading) return ;
+ if (error) return ;
+ if (!specOptions?.spec) return ;
+ return ;
+ }
+ ```
+ The hook reads `window.location.search` automatically — no `searchQuery` argument required. CSR is the safe universal path: built-ins never enter a server pre-render, so the App-Router RSC crash from registering basics on the server can't happen.
+
+ Ensure **built-ins + custom components are registered before render** (see `register-component`). On CSR, the registration must run client-side at module init.
+
+ **Live Preview wiring is separate.** If this route needs to reflect author edits in real time, add the `onEntryChange` bridge from `install-live-preview` (step 7). CSR pages call the hook's `refetchSpec` inside the loop-safe callback.
+
+3. **App Router (RSC) — not true SSR; renders bound content on the client.** Use this only when SEO/server-HTML content isn't required (for SEO go to step 4: Pages Router). Initialise the sdk in a server-only module (no `"use client"`). Server Component fetches; a client wrapper renders. Built-ins (`Page`/`Section`/`Repeater`/…) are `"use client"` — the SDK detects them as `react.client.reference` on the server and emits `data-cs-defer-builtin` placeholders → real render happens on hydration.
+ ```tsx
+ // app/[[...slug]]/page.tsx — NO "use client"
+ import { sdk } from "@/lib/studio.server";
+ import { ClientRender } from "./ClientRender";
+ import { notFound } from "next/navigation";
+
+ export default async function Page({ params, searchParams }) {
+ const url = "/" + (params.slug?.join("/") ?? "");
+ const searchQuery = new URLSearchParams(searchParams as Record).toString();
+ const specOptions = await sdk.fetchCompositionData(
+ { url, searchQuery },
+ { locale: "en-us" },
+ );
+ if (!specOptions.hasSpec || !specOptions.hasTemplate) notFound();
+ return ;
+ }
+
+ export async function generateMetadata({ params, searchParams }) {
+ const url = "/" + (params.slug?.join("/") ?? "");
+ const { seo } = await sdk.fetchCompositionData({
+ url,
+ searchQuery: new URLSearchParams(searchParams).toString(),
+ });
+ return seo
+ ? { title: seo.pageTitle, description: seo.pageDescription,
+ openGraph: { images: seo.openGraphImage ? [seo.openGraphImage] : [] } }
+ : {};
+ }
+ ```
+ Client wrapper (just renders — no Live Preview wiring here):
+ ```tsx
+ "use client";
+ import { StudioComponent } from "@contentstack/studio-react";
+
+ export function ClientRender({ specOptions }) {
+ if (!specOptions.hasSpec) return null;
+ return ;
+ }
+ ```
+
+ **Live Preview wiring is separate.** If this route needs real-time author updates, mount the `LivePreviewBridge` from `install-live-preview` (step 7) once in the app shell (e.g. in `app/layout.tsx` inside a client boundary). App Router routes call `router.refresh()` in the loop-safe callback. Do NOT put `[router]` in the effect deps — it produces an infinite refetch loop.
+ Canvas route stays client-only:
+ ```tsx
+ // app/studio-canvas/page.tsx
+ "use client";
+ import { StudioCanvas } from "@contentstack/studio-react";
+ export default function Canvas() { return ; }
+ ```
+
+4. **SSR — Next.js Pages Router (the true-SSR path).** This is the recommended Next.js path when SEO matters: the composition DOM lands in the server HTML via `renderToString`. Register all components (built-ins + custom) at module scope in `pages/_app.tsx` with **static imports** so the same React instance runs server + client.
+ ```ts
+ // pages/blog/[slug].tsx
+ export async function getServerSideProps(context) {
+ const searchQuery = context.req.url.split("?")[1] ?? "";
+ const url = `/blog/${context.params.slug}`;
+ const specOptions = await sdk.fetchCompositionData({ url, searchQuery });
+ if (!specOptions.hasSpec) return { notFound: true };
+ return { props: { specOptions } };
+ }
+ ```
+ Page component renders ``; `specOptions` is JSON-serialisable.
+
+ **Live Preview wiring is separate.** If this route needs real-time author updates, mount the `LivePreviewBridge` from `install-live-preview` (step 7) in `pages/_app.tsx` inside a client boundary. Pages Router routes call `router.replace(router.asPath, undefined, { scroll: false })` in the loop-safe callback. Do NOT put `[router]` in the effect deps — the router identity changes on every replace, which would produce an infinite refetch loop.
+
+ ⚠️ Verify true SSR with a **prod build** (`next build && next start`), not `next dev`. Dev is per-boot racy (recompile + module-init order + StrictMode double-invoke); prod is deterministic. Acceptance: `curl ` → 200 with the composition DOM (incl. Repeater iteration items) in `` after stripping `