From d1a3634acba21e5e2c89973758456ab565aee47b Mon Sep 17 00:00:00 2001 From: Sumit Jain <266980249+sumitjain-cstk@users.noreply.github.com> Date: Mon, 2 Nov 2026 14:47:44 +0530 Subject: [PATCH] studio: update CSR/SSR and 404 guides with latest drafts Bring in the updated drafts: isCompositionNotFound in every 404 branch, cache() for one fetch per request, the canvas note on unpublished templates, builder-mode stackDetails requirements, init .catch, the ?builder=true handshake check, and the first-instance rule for a second init (all verified against live-preview-utils 4.5.3). Keep the SDK fixes from review: clear the shared stack's preview hash before each SSR fetch, refetchSpec fallback for freeform compositions on studio-react 2.0.1, typed snippets (tsc strict passes), and isCompositionNotFound on the CSR route. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 4 +- skills/studio/csr-and-ssr-requirements.md | 114 ++++++++++++++-------- skills/studio/handle-404-pages.md | 114 ++++++++++++++-------- 3 files changed, 147 insertions(+), 85 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99ef266..21a0902 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,11 @@ All notable changes to the Contentstack agent skills bundle. Versions match the ### New studio guides - **`csr-and-ssr-requirements`:** what a Studio page needs as CSR or SSR on the visitor site, the Live Preview pane, Visual Editor and the canvas. - - The five settings that must agree, plus preview mode vs builder mode. + - The five settings that must agree, plus preview mode vs builder mode. Builder mode needs `stackDetails.apiKey` and `.environment`, and a frame-and-listen check confirms Visual Editor's handshake without signing in. - Where the Edit button shows, and which refetch to call on studio-react 2.0.1: `refetchData` ignores freeform compositions. - Converting a page from one mode to the other. - **`handle-404-pages`:** unknown URLs and bad slugs render the site's own 404 page on Next.js (App and Pages Router), Remix and React Router. - - SSR sends a real 404 through `resolveComposition`; CSR uses `noindex`. + - SSR sends a real 404 from a `try`/`catch`, clears the shared stack's preview hash before each fetch, and shares one fetch per request between the page and `generateMetadata`. CSR uses `noindex`. - The 404 is decided by `isCompositionNotFound` only, so a bad token or an outage stays an error. ### Changed skills diff --git a/skills/studio/csr-and-ssr-requirements.md b/skills/studio/csr-and-ssr-requirements.md index 57505f4..d72c171 100644 --- a/skills/studio/csr-and-ssr-requirements.md +++ b/skills/studio/csr-and-ssr-requirements.md @@ -14,7 +14,7 @@ Do NOT use for: framework-specific route wiring for Pages Router, Remix and SSG | Need | CSR | SSR | | --- | --- | --- | -| Fetch | `useCompositionData({ url })` in the browser | `resolveComposition(sdk, { url, searchQuery })` on the server ([helper](configure-csr-vs-ssr.md#resolve-composition-helper)) | +| Fetch | `useCompositionData({ url })` in the browser | `sdk.fetchCompositionData({ url, searchQuery })` on the server, after clearing the shared stack's preview hash ([`resolveComposition`](configure-csr-vs-ssr.md#resolve-composition-helper) does both) | | Query string | The hook reads `window.location` itself | Pass the full request query string as `searchQuery` | | Unknown URL (`isCompositionNotFound`) | Render the 404 page, status 200 + `noindex` | Real 404 ([`handle-404-pages`](handle-404-pages.md)) | | Studio-authored styles | Nothing to add | `getSSRStyles(specOptions.spec)` on the server | @@ -22,8 +22,8 @@ Do NOT use for: framework-specific route wiring for Pages Router, Remix and SSG | Live Preview `ssr` | `false` | `true` | | Live Preview `stackSdk` | `stack.config` | Leave it out | | How the preview hash arrives | In the URL inside Visual Editor; through Live Preview's handshake in the Live Preview pane, written onto the stack via `stackSdk` | In the URL, as `live_preview`. Reaches the server only if `searchQuery` is forwarded | -| Showing an author's edit | `onEntryChange` → `refetchData()` | Nothing to wire. The editor reloads the page | -| Preview hash on a shared server | Not applicable | `resolveComposition` clears it before every fetch | +| Showing an author's edit | `onEntryChange` → `refetchData()` (connected template) or `refetchSpec()` (freeform) | Nothing to wire. The editor reloads the page | +| Preview hash on a shared server | Not applicable | Clear it before every fetch | | Hydration | Not applicable | Never fetch again in the browser | ## In each editor @@ -45,7 +45,7 @@ Visual Editor and Live Preview work on a Studio page only when all five agree. E | The Delivery stack's `live_preview` | The stack passed to `studioSdk.init` | `enable: true`, the preview token, your region's preview host | Studio shows published content in the editors | | Edit tags | `studioSdk.init({ cslp: { appendTags: true } })` | `true` (the default is `false`) | Nothing is clickable, and no Edit button appears | | `mode` | Live Preview `init` | `"builder"` | Visual Editor can't edit the page and shows Live Preview's Edit button | -| `ssr` | Live Preview `init`, overridden by `live_preview.ssr` on the stack | `true` for server-fetched routes, `false` for browser-fetched ones | Edits don't show up | +| `ssr` | Live Preview `init`, overridden by `live_preview.ssr` on the stack passed as `stackSdk` | `true` for server-fetched routes, `false` for browser-fetched ones | Edits don't show up | They're separate switches: turning on Live Preview for the stack doesn't set `mode`, and `mode` doesn't set `ssr`. @@ -56,7 +56,7 @@ They're separate switches: turning on Live Preview for the stack doesn't set `mo | `mode` | What runs | Inside Visual Editor | Needs | | --- | --- | --- | --- | | `"preview"` (default) | Live Preview: content updates, and the hover Edit button opens the entry | Shows Live Preview's Edit button; the editor can stay on "Initializing the editor…" | `stackSdk` for CSR pages | -| `"builder"` | Visual Editor's editing layer: click a field on the page to edit it | Works | `stackDetails.apiKey` and an environment, or `init` throws | +| `"builder"` | Visual Editor's editing layer: click a field on the page to edit it | Works | `stackDetails.apiKey` and `stackDetails.environment`, or `init` throws (the environment falls back to `stackSdk`'s; the API key never does) | **Visual Editor decides which one runs, not `mode` alone.** In builder mode, Live Preview asks the page around it which editor it's in. Visual Editor answers `builder`; the Live Preview pane answers `preview`. So the same page shows Live Preview behaviour in the pane and Visual Editor behaviour in Visual Editor, by design. With no answer (the page isn't in an editor, or the handshake fails), no editing layer starts. @@ -81,27 +81,34 @@ const appHost = import.meta.env.VITE_CONTENTSTACK_APP_HOST ?? "app.contentstack. // Imported here, not at the top: Live Preview draws its Edit button as soon as it's imported. const livePreview = previewToken && typeof window !== "undefined" - ? import("@contentstack/live-preview-utils").then(({ default: lp }) => { - lp.init({ - enable: true, - mode: "builder", // both modes - ssr: false, // CSR: false · SSR: true - stackSdk: stack.config as IStackSdk, // CSR only · SSR: remove this line - stackDetails: { apiKey, environment }, // apiKey is always required in builder mode - clientUrlParams: { host: appHost }, - editButton: { enable: true }, - editInVisualBuilderButton: { enable: false }, // else a "Start Editing" link on every page - }); - return lp; - }) + ? import("@contentstack/live-preview-utils") + .then(({ default: lp }) => { + lp.init({ + enable: true, + mode: "builder", // both modes + ssr: false, // CSR: false · SSR: true + stackSdk: stack.config as IStackSdk, // CSR only · SSR: remove this line + stackDetails: { apiKey, environment }, // both required in builder mode, or init throws + clientUrlParams: { host: appHost }, + editButton: { enable: true }, + editInVisualBuilderButton: { enable: false }, // else a "Start Editing" link on every page + }); + return lp; + }) + // init throws on a bad config; without this the rejection is easy to miss and the page + // silently runs without Live Preview or Visual Editor + .catch((err) => { + console.warn("[live-preview] init failed", err); + return null; + }) : null; // CSR pages: refetch the data after each author edit. export function useLivePreviewEdits(refetch: () => void) { useEffect(() => { let uid: string | undefined; - livePreview?.then((lp) => (uid = lp.onEntryChange(refetch))); - return () => void livePreview?.then((lp) => uid && lp.unsubscribeOnEntryChange(uid)); + livePreview?.then((lp) => { if (lp) uid = lp.onEntryChange(refetch); }); + return () => void livePreview?.then((lp) => lp && uid && lp.unsubscribeOnEntryChange(uid)); }, [refetch]); } ``` @@ -109,10 +116,11 @@ export function useLivePreviewEdits(refetch: () => void) { Region and non-production hosts: [`install-live-preview`](install-live-preview.md). - **`mode: "builder"`.** Without it, Live Preview never completes Visual Editor's handshake. Visual Editor can't edit the page, and the Edit button stays visible inside it. +- **`stackDetails: { apiKey, environment }`.** In builder mode `init` throws "api key is required" or "environment is required" without them. The environment falls back to `stackSdk`'s when it isn't in `stackDetails`, so a CSR init gets it from `stack.config`; an SSR init has no `stackSdk` and must pass it. The API key never falls back: always pass it. A throw stops Live Preview completely, so `mode: "builder"` is in the code and Visual Editor still can't edit the page. - **The dynamic import.** A static `import` of Live Preview draws the Edit button for every visitor, even when `init` never runs. The `PURGE_PREVIEW_SDK` build flag from the Live Preview docs doesn't remove it on Vite. - **Exactly one `init`.** A second call doesn't restart Live Preview, but it can overwrite the first one's settings. Search first: `grep -rn "ContentstackLivePreview.init\|lp.init(" src app lib`. -- **`ssr` that matches how the page renders.** It tells the Live Preview pane and Visual Editor whether to reload the page or send it the change. Live Preview reads the Delivery stack's own `live_preview.ssr` before `init({ ssr })`, so don't set it on the stack, or keep both the same. -- **Restart the dev server after changing `init`.** Hot reload keeps the old Live Preview setup, and `NEXT_PUBLIC_` / `VITE_` values are read at server start. +- **`ssr` that matches how the page renders.** It tells the Live Preview pane and Visual Editor whether to reload the page or send it the change. Live Preview reads `live_preview.ssr` on the stack passed as `stackSdk` before `init({ ssr })`, so don't set it on the stack, or keep both the same. Always pass `ssr`: left out, it defaults to `false` when `stackSdk` is passed and `true` when it isn't. +- **Restart the dev server after changing `init`.** Live Preview keeps the instance from its first `init`: a second `init`, which is what hot reload runs, takes the new settings but returns the old instance, and Visual Editor's part is only created by the first one. So `mode: "builder"` added while the dev server runs never sends Visual Editor's handshake until a restart. `NEXT_PUBLIC_` / `VITE_` values are also read at server start. - **A canvas route** with ``, client-only ([`setup-section-preview`](setup-section-preview.md)). ### Where the Edit button shows @@ -154,7 +162,7 @@ import { useCallback } from "react"; import { useCompositionData, StudioComponent, isCompositionNotFound } from "@contentstack/studio-react"; import { useLivePreviewEdits } from "../lib/live-preview"; import NotFound from "../pages/NotFound"; -import ErrorPage from "../pages/ErrorPage"; +import ErrorState from "../pages/ErrorState"; export function StudioPage() { const { pathname } = useLocation(); @@ -165,7 +173,7 @@ export function StudioPage() { const refetch = useCallback(() => (connected ? refetchData() : refetchSpec()), [connected, refetchData, refetchSpec]); useLivePreviewEdits(refetch); - if (error) return isCompositionNotFound(error) ? : ; + if (error) return isCompositionNotFound(error) ? : ; if (isLoading || !specOptions) return
Loading…
; return ; @@ -184,21 +192,20 @@ The hook fetches again when the URL changes, so a catch-all route keeps working ## What an SSR page needs -1. `resolveComposition(sdk, { url, searchQuery })` on the server, with the full request query string ([helper](configure-csr-vs-ssr.md#resolve-composition-helper)). -2. A real 404 when it returns `notFound: true`; anything else stays a 500 ([`handle-404-pages`](handle-404-pages.md)). +1. `sdk.fetchCompositionData({ url, searchQuery })` on the server, with the full request query string. +2. `try` / `catch`: a real 404 when `isCompositionNotFound(err)`, rethrow anything else so it stays a 500 ([`handle-404-pages`](handle-404-pages.md)). 3. `getSSRStyles(specOptions.spec)` and metadata, both on the server. 4. `` in a `"use client"` wrapper (App Router). 5. The Live Preview module with `ssr: true` and no `stackSdk`, imported once from a client component in the root layout. -6. On a deployment with a preview token, the shared stack's preview hash is cleared before every fetch. `resolveComposition` does it; a direct `sdk.fetchCompositionData` call doesn't ([`configure-csr-vs-ssr`](configure-csr-vs-ssr.md) § A server with a preview token can serve drafts to visitors). +6. On a deployment with a preview token, clear the shared stack's preview hash before every fetch: `stack.livePreviewQuery({ live_preview: "" })`. The SDK sets a request's hash but never clears it, so without this the next visitor gets the last editor's drafts. When the request carries a hash, the SDK sets it again itself ([`configure-csr-vs-ssr`](configure-csr-vs-ssr.md) § A server with a preview token can serve drafts to visitors). Next.js App Router: ```tsx // app/[[...slug]]/page.tsx — Server Component, no "use client" import { notFound } from "next/navigation"; -import { getSSRStyles } from "@contentstack/studio-react"; -import { sdk } from "@/lib/studio.server"; -import { resolveComposition } from "@/lib/resolve-composition"; +import { getSSRStyles, isCompositionNotFound, type StudioComponentSpecOptions } from "@contentstack/studio-react"; +import { sdk, stack } from "@/lib/studio.server"; import { StudioRender } from "./StudioRender"; type Props = { @@ -213,9 +220,14 @@ export default async function Page({ params, searchParams }: Props) { Object.entries(await searchParams).flatMap(([k, v]) => (Array.isArray(v) ? v.map((x) => [k, x]) : v === undefined ? [] : [[k, v]])), ).toString(); - const result = await resolveComposition(sdk, { url, searchQuery }); - if (result.notFound) notFound(); - const { specOptions } = result; + let specOptions: StudioComponentSpecOptions; + try { + stack.livePreviewQuery({ live_preview: "" }); // shared stack: drop the last request's preview hash + specOptions = await sdk.fetchCompositionData({ url, searchQuery }); + } catch (err) { + if (!isCompositionNotFound(err)) throw err; // a real failure stays a 500 + notFound(); + } return ( <> @@ -256,13 +268,13 @@ Converting means swapping one "What … needs" list for the other. **CSR to SSR:** -1. Move the fetch to the server through `resolveComposition`, and pass `searchQuery`. +1. Move the fetch to the server, and pass `searchQuery`. 2. Remove `useCompositionData` and `useLivePreviewEdits` from the route. 3. Add `getSSRStyles` and server metadata. 4. Change the 404 branch to a real 404. 5. In the Live Preview module, set `ssr: true` and remove `stackSdk`. Import it from the root layout. 6. Keep the delivery token in a server-only module. -7. On a preview deployment, keep every server fetch on `resolveComposition` so the preview hash is cleared per request. +7. On a preview deployment, clear the shared stack's preview hash before every server fetch. **SSR to CSR:** @@ -283,19 +295,35 @@ Succeeds only when ALL of the following are true: - [ ] Visual Editor: the page is editable, an edit appears, and **no Edit button** shows. - [ ] Studio's canvas: a template opens and renders. - [ ] Unknown URL: shows the 404 page (SSR: status 404). -- [ ] Exactly one Live Preview `init`, with `mode: "builder"` and the `ssr` value that matches the route. +- [ ] Exactly one Live Preview `init`, with `mode: "builder"`, `stackDetails: { apiKey, environment }` and the `ssr` value that matches the route. +- [ ] Framed with `?builder=true`, the page sends Visual Editor's `init` handshake with `isSSR` matching `ssr` (§ Troubleshooting). - [ ] SSR: no `useCompositionData` on a route that fetched on the server, and no hydration warning in the console. If acceptance fails, report which check broke and stop. ## Troubleshooting: Visual Editor shows Live Preview behaviour -Open the page in Visual Editor. In the console of the **site's frame** (pick it in the DevTools context menu): +**First, without signing in:** on any page of the site, frame the same page with `?builder=true` and listen for Visual Editor's handshake: + +```js +// in the console of a page on the site (e.g. http://localhost:3000/) +window.addEventListener("message", (e) => { + if (e.data?.channel === "visual-builder") console.log(e.data.type, e.data.payload); +}); +const f = document.createElement("iframe"); +f.src = location.pathname + "?builder=true"; +document.body.append(f); +``` + +Builder mode logs `init` with `{ isSSR: … }` (matching your `ssr`) within a few seconds. Only `live-preview` messages, or nothing, means the page isn't in builder mode, its `init` threw, or the dev server still runs an older setup. + +Then open the page in Visual Editor. In the console of the **site's frame** (pick it in the DevTools context menu): 1. `location.search` contains `builder=true`. If not, the page isn't open in Visual Editor; it's probably the Live Preview pane, where Live Preview behaviour is correct. 2. `document.getElementById("cslp-tooltip")` returns `null`. If it returns an element, the builder handshake didn't happen: check `mode: "builder"`, then the next steps. -3. No "You have already initialized the Live Preview SDK" warning. If it's there, a second `init` ran and may have changed `mode` or `ssr`. -4. Live Preview loaded at all. If the preview token was added after the dev server started, the import never ran. Restart the dev server, then hard-reload the editor. +3. No "You have already initialized the Live Preview SDK" warning. If it's there, a second `init` ran (another module, or hot reload) and may have changed `mode` or `ssr`, while the first one's instance stays: restart the dev server. +4. No "Live preview SDK: api key is required" or "environment is required" error: builder mode needs both in `stackDetails`. +5. Live Preview loaded at all. If the preview token was added after the dev server started, the import never ran. Restart the dev server, then hard-reload the editor. ## Common pitfalls @@ -304,7 +332,8 @@ Open the page in Visual Editor. In the console of the **site's frame** (pick it | Live Preview off for the stack | Every piece works locally and nothing reaches the editors | Turn it on ([`enable-visual-experience`](enable-visual-experience.md)) | | `cslp.appendTags` not set | The default is `false`: no edit tags, so nothing to click and no Edit button | `studioSdk.init({ …, cslp: { appendTags: true } })` | | `init` without `mode: "builder"` | Visual Editor's handshake never completes: the page isn't editable and the Edit button shows inside Visual Editor | Add `mode: "builder"` | -| `stackDetails.apiKey` missing in builder mode | `init` throws, even when `stackSdk` is passed | Always pass `stackDetails: { apiKey, environment }` | +| `stackDetails.apiKey` or `.environment` missing in builder mode | `init` throws: the API key never comes from `stackSdk`, and without `stackSdk` (SSR) neither does the environment | Always pass `stackDetails: { apiKey, environment }` | +| `init` inside `.then()` with no `.catch` | A throw becomes an unhandled rejection: the page renders, Live Preview never starts, and Visual Editor can't edit it although the code says `mode: "builder"` | Add `.catch` and log the error | | A second `init` call elsewhere | It can overwrite the first one's settings, so `mode` or `ssr` silently changes | Keep one `init`. Search for others | | `editButton.exclude: ["outsideLivePreviewPortal"]` | Hides the Edit button on the preview site in its own tab | Remove `exclude` | | Static `import` of Live Preview | It draws the Edit button on import, so production visitors see it. `PURGE_PREVIEW_SDK` doesn't help on Vite | Import it dynamically, only with a preview token | @@ -313,12 +342,13 @@ Open the page in Visual Editor. In the console of the **site's frame** (pick it | `live_preview.ssr` set on the Delivery stack | It takes precedence over `init({ ssr })` | Remove it, or keep both the same | | CSR page calls `refetchSpec` on every edit of a connected template | It reloads the composition and its sections each time: four requests per edit instead of one | Call `refetchData` | | CSR freeform page calls only `refetchData` (2.0.1) | `refetchData` ignores a composition with no connected content type, so edits never appear | Fall back to `refetchSpec` when `connectedContentTypeUid` is empty | -| SSR route calls `sdk.fetchCompositionData` directly | The preview hash stays on the shared stack, so later visitors get drafts; and a bare `catch` turns outages into 404s | Use `resolveComposition` | +| SSR fetch without clearing the preview hash | The SDK sets a request's hash on the shared stack and never clears it, so later visitors get drafts | `stack.livePreviewQuery({ live_preview: "" })` before each fetch, or use `resolveComposition` | +| SSR `catch { notFound() }` with no check | A bad token or an outage becomes a 404 on every page | 404 only when `isCompositionNotFound(err)`; rethrow the rest | | CSR `init` without `stackSdk` | Live Preview can't write the preview hash onto the stack, so refetches return published content | Pass `stackSdk: stack.config` | | SSR route with an `onEntryChange` → `router.refresh()` bridge | With `ssr: true`, Live Preview never calls `onEntryChange` | Delete it. The editor reloads the page | | SSR `searchQuery` not forwarded | The preview hash and Studio's editor context never reach the server | Pass the full request query string | | Fetching again in the browser after an SSR fetch | Hydration mismatch | Pass the server's `specOptions` to the client wrapper | -| `init` changed but nothing changed in the editor | Hot reload keeps the old Live Preview setup | Restart the dev server and hard-reload the editor | +| `init` changed but nothing changed in the editor | Live Preview keeps its first instance: a hot-reloaded `init` never creates Visual Editor's part | Restart the dev server and hard-reload the editor | ## See also diff --git a/skills/studio/handle-404-pages.md b/skills/studio/handle-404-pages.md index 0e82e54..57ad504 100644 --- a/skills/studio/handle-404-pages.md +++ b/skills/studio/handle-404-pages.md @@ -12,12 +12,15 @@ Do NOT use for: choosing CSR or SSR ([`configure-csr-vs-ssr`](configure-csr-vs-s ## The rule -**If no composition exists for the URL, render the 404 page. Any other failure stays an error.** +**If there's no composition for the URL, render the 404 page. Any other failure is an error, not a 404.** - `useCompositionData` (CSR) returns the failure in `error`. -- `sdk.fetchCompositionData` (SSR) throws it. Call it through the [`resolveComposition`](configure-csr-vs-ssr.md#resolve-composition-helper) helper, which returns `notFound: true` for a miss and rethrows everything else. +- `sdk.fetchCompositionData` (SSR) throws it. +- `isCompositionNotFound(error)` (from `@contentstack/studio-react`) tells "no composition for this URL" apart from everything else: the preview host down, a bad token, a network failure. -Decide with `isCompositionNotFound(error)` from `@contentstack/studio-react`, the check the SDK documents for the 404 decision, and nothing else. A bad token, a wrong region or a network failure is not a missing page: show it as a 500 (SSR) or an error state (CSR), or an outage looks like every page was deleted. Never check `specOptions.spec`, `hasSpec` or `hasTemplate`: a successful fetch always returns a spec, and `hasTemplate` is `false` for every freeform composition (the SDK marks it deprecated for this reason). +Branch on that and nothing else. Never check `specOptions.spec`, `hasSpec` or `hasTemplate`: a successful fetch always returns a spec, and `hasTemplate` is `false` for every freeform composition. + +**Why not 404 on every error:** an outage would show visitors "Page not found" with status 404, and search engines drop pages that answer 404. A real failure has to stay a 500 (SSR) or an error state (CSR), so it's noticed and retried. ## Why this matters @@ -48,13 +51,13 @@ Vite / React Router: import { useLocation } from "react-router-dom"; import { useCompositionData, StudioComponent, isCompositionNotFound } from "@contentstack/studio-react"; import NotFound from "../pages/NotFound"; // the site's existing 404 page -import ErrorPage from "../pages/ErrorPage"; // the site's error state +import ErrorState from "../pages/ErrorState"; // the site's error page export function StudioPage() { const { pathname } = useLocation(); const { specOptions, isLoading, error } = useCompositionData({ url: pathname }); - if (error) return isCompositionNotFound(error) ? : ; + if (error) return isCompositionNotFound(error) ? : ; if (isLoading || !specOptions) return
Loading…
; return ; @@ -84,16 +87,18 @@ React 19 hoists the `` into ``. On React 18, set it with your head m ## SSR: `sdk.fetchCompositionData` -Fetch through [`resolveComposition`](configure-csr-vs-ssr.md#resolve-composition-helper) and send the 404 when it returns `notFound: true`. The helper also clears the shared stack's preview hash before each fetch, so a visitor is never served an editor's drafts. Pass the full request query string as `searchQuery`: Studio's editor context (locale, variant, edit mode) and the preview hash reach the server only that way. +Wrap the fetch in `try` / `catch`. In `catch`, send the 404 when `isCompositionNotFound(err)`, and rethrow anything else so it stays a 500. Pass the full request query string as `searchQuery`: Studio's editor context (locale, variant, edit mode) and its preview hash reach the server only that way. + +**Clear the preview hash before each fetch.** The stack in `lib/studio.server` is shared by every request. The SDK sets a request's preview hash on it but never clears it, so on a deployment with a preview token the next visitor is served the last editor's drafts. `stack.livePreviewQuery({ live_preview: "" })` before the fetch prevents that; when the request carries a hash, the SDK sets it again itself. The [`resolveComposition`](configure-csr-vs-ssr.md#resolve-composition-helper) helper does this and the `isCompositionNotFound` check in one call, if you'd rather not repeat them per route. ### Next.js App Router ```tsx // app/[[...slug]]/page.tsx — Server Component, no "use client" +import { cache } from "react"; import { notFound } from "next/navigation"; -import { getSSRStyles } from "@contentstack/studio-react"; -import { sdk } from "@/lib/studio.server"; -import { resolveComposition } from "@/lib/resolve-composition"; +import { getSSRStyles, isCompositionNotFound, type StudioComponentSpecOptions } from "@contentstack/studio-react"; +import { sdk, stack } from "@/lib/studio.server"; import { StudioRender } from "./StudioRender"; type Props = { @@ -101,19 +106,31 @@ type Props = { searchParams: Promise>; }; -async function load({ params, searchParams }: Props) { +// one fetch per request, shared by the page and generateMetadata +const getComposition = cache((url: string, searchQuery: string) => { + stack.livePreviewQuery({ live_preview: "" }); // shared stack: drop the last request's preview hash + return sdk.fetchCompositionData({ url, searchQuery }); +}); + +async function toQuery({ params, searchParams }: Props) { const { slug } = await params; const url = "/" + (slug ?? []).join("/"); const searchQuery = new URLSearchParams( Object.entries(await searchParams).flatMap(([k, v]) => (Array.isArray(v) ? v.map((x) => [k, x]) : v === undefined ? [] : [[k, v]])), ).toString(); - return resolveComposition(sdk, { url, searchQuery }); + return { url, searchQuery }; } export default async function Page(props: Props) { - const result = await load(props); // a real failure throws here and stays a 500 - if (result.notFound) notFound(); // renders app/not-found.tsx with status 404 - const { specOptions } = result; + const { url, searchQuery } = await toQuery(props); + + let specOptions: StudioComponentSpecOptions; + try { + specOptions = await getComposition(url, searchQuery); + } catch (err) { + if (!isCompositionNotFound(err)) throw err; // a real failure stays a 500 + notFound(); // renders app/not-found.tsx with status 404 + } return ( <> @@ -124,10 +141,14 @@ export default async function Page(props: Props) { } export async function generateMetadata(props: Props) { - const result = await load(props); - if (result.notFound) return {}; // the page itself sends the 404 - const { seo } = result.specOptions; - return seo ? { title: seo.pageTitle, description: seo.pageDescription } : {}; + const { url, searchQuery } = await toQuery(props); + + try { + const { seo } = await getComposition(url, searchQuery); + return seo ? { title: seo.pageTitle, description: seo.pageDescription } : {}; + } catch { + return {}; // the page itself sends the 404 or the 500 + } } ``` @@ -141,24 +162,28 @@ export function StudioRender({ specOptions }: { specOptions: StudioComponentSpec } ``` -Call `notFound()` outside any `try`. It works by throwing, so inside a `try` your own `catch` swallows it. Branching on `resolveComposition`'s result keeps it outside. +Call `notFound()` in `catch`, never inside `try`. It works by throwing, so inside `try` your own `catch` swallows it. `cache()` makes the page and `generateMetadata` share one fetch per request instead of two. ### Next.js Pages Router ```tsx // pages/[[...slug]].tsx import type { GetServerSidePropsContext } from "next"; -import { getSSRStyles, StudioComponent, type StudioComponentSpecOptions } from "@contentstack/studio-react"; -import { sdk } from "@/lib/studio.server"; -import { resolveComposition } from "@/lib/resolve-composition"; +import { getSSRStyles, isCompositionNotFound, StudioComponent, type StudioComponentSpecOptions } from "@contentstack/studio-react"; +import { sdk, stack } from "@/lib/studio.server"; export async function getServerSideProps({ resolvedUrl }: GetServerSidePropsContext) { // resolvedUrl, not req.url: on client-side navigation req.url is /_next/data/....json const { pathname, search } = new URL(resolvedUrl, "http://localhost"); - const result = await resolveComposition(sdk, { url: pathname, searchQuery: search.slice(1) }); - if (result.notFound) return { notFound: true as const }; // renders pages/404.tsx with status 404 - const { specOptions } = result; + let specOptions: StudioComponentSpecOptions; + try { + stack.livePreviewQuery({ live_preview: "" }); // shared stack: drop the last request's preview hash + specOptions = await sdk.fetchCompositionData({ url: pathname, searchQuery: search.slice(1) }); + } catch (err) { + if (!isCompositionNotFound(err)) throw err; // a real failure stays a 500 + return { notFound: true as const }; // renders pages/404.tsx with status 404 + } return { props: { @@ -184,16 +209,20 @@ export default function Page({ specOptions, css }: { specOptions: StudioComponen // app/routes/$.tsx import { json, type LoaderFunctionArgs } from "@remix-run/node"; import { useLoaderData } from "@remix-run/react"; -import { getSSRStyles, StudioComponent } from "@contentstack/studio-react"; -import { sdk } from "~/lib/studio.server"; -import { resolveComposition } from "~/lib/resolve-composition"; +import { getSSRStyles, isCompositionNotFound, StudioComponent, type StudioComponentSpecOptions } from "@contentstack/studio-react"; +import { sdk, stack } from "~/lib/studio.server"; export async function loader({ request }: LoaderFunctionArgs) { const { pathname, search } = new URL(request.url); - const result = await resolveComposition(sdk, { url: pathname, searchQuery: search.slice(1) }); - if (result.notFound) throw new Response("Not Found", { status: 404 }); // rendered by the route's ErrorBoundary - const { specOptions } = result; + let specOptions: StudioComponentSpecOptions; + try { + stack.livePreviewQuery({ live_preview: "" }); // shared stack: drop the last request's preview hash + specOptions = await sdk.fetchCompositionData({ url: pathname, searchQuery: search.slice(1) }); + } catch (err) { + if (!isCompositionNotFound(err)) throw err; // a real failure stays a 500 + throw new Response("Not Found", { status: 404 }); // rendered by the route's ErrorBoundary + } return json({ specOptions, css: getSSRStyles(specOptions.spec) }); } @@ -227,12 +256,14 @@ A dedicated route (`/blog/:slug` rather than a catch-all) still fetches, and a b ## Inside Studio's canvas +An unpublished template still opens in the canvas. Studio loads the canvas with its preview `hash` in the URL, and with the query string passed through (`searchQuery` on SSR; the hook reads it itself on CSR) the fetch returns the unpublished template, so the 404 branch never fires for it. Checked on a Next.js App Router site: the same unpublished template's URL with Studio's hash renders on the server; without the hash, the fetch fails. So the 404 branch needs no editor exception, as long as the query string reaches the fetch. + - **CSR:** the hook reports the failure to Studio, so the canvas shows the cause and the page shows `NotFound`. - **SSR:** a 404 stops the page before it renders, so nothing on that page starts the browser SDK. Call the browser `studioSdk.init()` from the root layout, as [`install-studio`](install-studio.md) wires it, so Studio still connects. With init only inside the page component, Studio reports "Studio Can't Connect to Your Site", which points at the wrong problem. ## Optional: a 404 page authors can edit -Build a freeform composition at `/404` ([`build-freeform-template`](build-freeform-template.md)). In the 404 branch, fetch `{ url: "/404" }` (through `resolveComposition` on SSR) and render it. Keep the hard-coded page as the fallback when that fetch also fails. On SSR, still return status 404. +Build a freeform composition at `/404` ([`build-freeform-template`](build-freeform-template.md)). In the 404 branch, fetch `{ url: "/404" }` and render it. Keep the hard-coded page as the fallback when that fetch also fails. On SSR, still return status 404. ## Acceptance @@ -243,10 +274,10 @@ Succeeds only when ALL of the following are true: - [ ] SSR: both URLs return status 404. Check with `curl -s -o /dev/null -w '%{http_code}' `. - [ ] CSR: the 404 page carries ``. - [ ] A real page still renders, and its `generateMetadata` (App Router) returns its title. -- [ ] A broken credential (temporarily wrong delivery token) shows an error (SSR: status 500), not the 404 page. +- [ ] A real failure isn't a 404: with the delivery token changed to a wrong one, a page returns 500 (SSR) or the error state (CSR), not the 404 page. - [ ] A new, unpublished template opens in Studio's canvas without showing the 404 page. - [ ] React Router: only one `path="*"` route exists. -- [ ] No route checks `specOptions.spec`, `hasSpec` or `hasTemplate` to decide a 404. Every 404 decision goes through `isCompositionNotFound` (directly, or via `resolveComposition`). +- [ ] No route checks `specOptions.spec`, `hasSpec` or `hasTemplate` to decide a 404, and every 404 branch checks `isCompositionNotFound`. If acceptance fails, report which check broke and stop. @@ -254,16 +285,17 @@ If acceptance fails, report which check broke and stop. | Pitfall | Why it bites | Fix | | --- | --- | --- | -| `await sdk.fetchCompositionData()` with no `try` | The fetch throws on a miss, so the framework's error page shows a 500 | Use `resolveComposition` and branch on `notFound` | -| `catch { notFound() }` around the fetch | A bad token, wrong region or network failure becomes a 404 on every page, so an outage looks like deleted content and search engines drop the pages | 404 only when `isCompositionNotFound(err)`; rethrow the rest (`resolveComposition` does both) | -| `if (!specOptions?.spec)`, `!hasSpec` or `!hasTemplate` as the 404 test | A successful fetch always has a spec, so the check never fires. `hasTemplate` is `false` on every freeform composition, so that one 404s working pages | `isCompositionNotFound(error)` (CSR) or `resolveComposition`'s `notFound` (SSR) only | +| `await sdk.fetchCompositionData()` with no `try` | The fetch throws on a miss, so the framework's error page shows a 500 | Wrap it and send the 404 from `catch` | +| Every error sent to the 404 page | An outage (preview host down, bad token) shows "Page not found" with status 404, and search engines drop the pages | 404 only when `isCompositionNotFound(err)`; rethrow anything else | +| `if (!specOptions?.spec)`, `!hasSpec` or `!hasTemplate` as the 404 test | A successful fetch always has a spec, so the check never fires. `hasTemplate` is `false` on every freeform composition, so that one 404s working pages | Branch on `error` (CSR) or `catch` (SSR) only | | `if (error) return ` on the CSR hook | Every unknown URL looks like a crash | `isCompositionNotFound(error) ? : ` | -| `notFound()` inside `try` | It throws, and your own `catch` swallows it | Call it outside the `try` | -| `generateMetadata` calls the fetch with no not-found branch | A miss throws there first, and the page errors instead of 404ing | Return `{}` when `resolveComposition` says `notFound` | -| Calling `sdk.fetchCompositionData` directly on a server with a preview token | The SDK sets a request's preview hash on the shared stack and never clears it, so later visitors get drafts | Fetch through `resolveComposition`, which clears it first | +| `notFound()` inside `try` | It throws, and your own `catch` swallows it | Call it in `catch` | +| `generateMetadata` without `try` / `catch` | A miss throws there first, and the page errors instead of 404ing | Return `{}` from `catch` | | Old `} />` kept | The Studio catch-all matches first, so it never renders | Delete it. The catch-all's `error` branch renders `NotFound` | | A new 404 page when the site already has one | Two designs, and the old one silently stops showing | Reuse the existing page | -| SSR `searchQuery` not forwarded | Studio's editor context never reaches the server, so the canvas renders the wrong locale or variant | Pass the full request query string | +| SSR `searchQuery` not forwarded | Studio's editor context and preview hash never reach the server, so the canvas renders the wrong locale or variant, and an unpublished template 404s in the canvas | Pass the full request query string | +| `generateMetadata` calling `fetchCompositionData` itself | Two fetches per request | Wrap the fetch in React's `cache()` and call that from both | +| SSR fetch without clearing the preview hash | The SDK sets a request's preview hash on the shared stack and never clears it, so later visitors get the last editor's drafts | `stack.livePreviewQuery({ live_preview: "" })` before each fetch, or use `resolveComposition` | | Testing with one unknown URL only | A bad slug under a connected template fails at a later step | Test both URLs in Acceptance | | SSR browser init only inside the page component | On a 404 the page never mounts, and Studio shows "Can't Connect" | Init from the root layout |