Studio Docs

Partial Adoption: Coexist With a Code-Driven App

This recipe is for teams whose app already owns its routes (there’s a working app/products/[slug]/page.tsx, app/collections/[slug]/page.tsx, app/page.tsx, hand-coded shop and home pages) and you want to add Studio incrementally without taking over the routes that already work.

This is the most common enterprise starting point and it’s been the least documented. The pattern below is doc-aligned and uses only mechanisms the SDK officially supports.

Ask your LLM: “Run convert-project-to-studio.”

Skill: convert-project-to-studio. It asks whether you want the minimal conversion (this recipe’s § 1, recommended), one page migrated, or a full-site plan, then wires the catch-all below with your existing 404 kept and every locale’s Base URL set.

Where this came from. The audit (STUDIO-ASSUMPTIONS-AUDIT.md A1) flagged that earlier guidance led readers toward inventing a /studio/* route namespace or a “fallback hybrid” pattern, neither documented. The catch-all + code-route precedence approach below uses only what’s in template-preview-routes.md and configure-csr-vs-ssr.md.


Do It With a Skill

embed-composition is the mechanism for this: one editable Studio-managed region inside a page your code still owns, via useCompositionData({ compositionUid }).

curl -fsSL https://studio-documentation.contentstackapps.com/install.sh | sh

The Decision Tree

Pick the right tool per page-kind:

Three patterns for adding Studio to a code-driven app: catch-all route for new pages, full route-swap migration for pages you fully manage in Studio, and embedded composition for one editable region inside a code-owned page.

Three patterns, three recipes:

PatternRecipeWhat it gives you
Catch-all + code precedenceThis recipe, § 1 belowNew content types get Studio rendering automatically at their natural URLs. Existing code routes stay untouched
Full route-swap migrationMigrate hand-coded pagesAn existing code page becomes 100% Studio-rendered. Old JSX preserved as comments
Embedded compositionembed-composition-in-code-pageOne editable band inside an otherwise code-owned page

You’ll likely mix all three over time. Start with the catch-all (§ 1) so new content types Just Work. Do route-swaps or embeds on existing pages incrementally as the team gets confident.


What We Are NOT Recommending

Two patterns the audit explicitly flags as doc-vacuum inventions to avoid:

  1. Avoid a /studio/* route namespace. Don’t sandbox compositions under app/studio/[...slug]/page.tsx. It forces authors to hand-edit template URLs to insert /studio/, which is exactly the friction Studio is supposed to remove. Mount Studio at the natural URL space, the catch-all approach below.
  2. Avoid a “migrate-with-fallback” hybrid that renders Studio if a composition is published, else the hand-coded JSX. This is undocumented and creates two divergent rendering paths per page that drift apart. The documented migration is a full route swap, done route-by-route. If you need a non-Studio fallback on a route, that’s a sign you should embed (pattern 3) instead of swap.

§ 1: the Catch-All + Code-Route Precedence Pattern

The Next.js (or React Router) catch-all route is registered LAST. Existing specific routes (app/products/[slug]/page.tsx, app/collections/[slug]/page.tsx, app/page.tsx, app/shop/page.tsx) win by router precedence. Studio’s catch-all serves every URL the code routes don’t claim.

The Next.js root-collision caveat

You have an existing app/page.tsx? Then the optional-catch-all app/[[...slug]]/page.tsx collides with it at / (both match the root). Use the non-optional form app/[...slug]/page.tsx: your existing app/page.tsx keeps owning /, and the catch-all matches /anything-else. This is the load-bearing precision the audit flagged.

Cited from Next.js docs (dynamic-routes):

“The difference between catch-all and optional catch-all segments is that with optional, the route without the parameter is also matched (/shop in the example above).”

So app/[[...slug]]/page.tsx matches BOTH / AND nested URLs, which is exactly where app/page.tsx lives. Two pages claiming the same URL = collision. The non-optional [...slug] form requires at least one segment, so it does NOT match /: that’s why it coexists cleanly with app/page.tsx.

So:

Next.js app/ folder layout with the Studio catch-all alongside existing code routes. Only app/[...slug]/page.tsx is new. Everything else is code.

Step 1: Write the catch-all

// app/[...slug]/page.tsx — Server Component (SSR variant)
// One file handles every URL the code routes don't claim.
import { notFound } from "next/navigation";
import { csStudio } from "@/lib/studio";   // additive — see install-studio § 0 pre-flight gate
import { resolveComposition } from "@/lib/resolve-composition";  // configure-csr-vs-ssr
import { StudioRouteClient } from "./StudioRouteClient";

export default async function StudioRoute({ params, searchParams }) {
  // Same fetch for direct visitors AND for the in-Studio iframe preview —
  // StudioComponent switches render mode internally. Forward the full request
  // searchQuery so Studio's iframe overrides reach the SDK.
  const url = "/" + (params.slug ?? []).join("/");
  const searchQuery = new URLSearchParams(searchParams as Record<string,string>).toString();

  // NOT a bare `await csStudio.fetchCompositionData(...)`: that call REJECTS on a
  // miss, so a `if (!specOptions?.spec) notFound()` guard below it never runs and
  // every unmatched URL becomes a 500. resolveComposition classifies the three
  // known not-found ids and rethrows everything else, which must stay a 500.
  const { specOptions, notFound: miss } = await resolveComposition(csStudio, { url, searchQuery });

  // No composition matches this URL → standard 404. Don't fall back to anything;
  // the existing code routes already claimed everything they own.
  if (miss) {
    notFound();
  }

  return <StudioRouteClient specOptions={specOptions} />;
}
// app/[...slug]/StudioRouteClient.tsx — Client Component
"use client";
import { StudioComponent } from "@contentstack/studio-react";

export function StudioRouteClient({ specOptions }) {
  return <StudioComponent specOptions={specOptions} />;
}

resolveComposition is the small wrapper from configure-csr-vs-ssr — roughly ten lines in lib/resolve-composition.ts. Copy it from there; it classifies COMPOSITION_NOT_FOUND, COMPOSITION_NOT_FOUND_BY_URL and PREVIEW_ENTRY_NOT_FOUND as misses and rethrows everything else, so a CDA outage or expired token still surfaces as a 500 instead of a silent 404.

Test the 404 with two URLs, not one. /this-does-not-exist fails at pattern match. A bad slug under a connected template (/blog/zzz-not-an-entry) matches the pattern and fails later, at entry lookup, with a different error id. Handling only the first still 500s on every mistyped slug.

Step 2: Verify precedence

Next.js and most React routers prefer more-specific routes over catch-alls, so the existing pages keep priority over Studio’s catch-all. To confirm:

  1. Visit /products/widget-x: your existing PDP renders (code wins).
  2. Visit /: your existing home renders (code wins).
  3. Visit /about: your existing about renders (code wins).
  4. Visit /campaigns/spring-2026: assuming you’ve authored a Studio composition at this URL, Studio’s catch-all renders it.
  5. Visit /this-does-not-exist: 404 (no code route, no composition).

If a code route is being shadowed by the catch-all, the issue is route specificity. Move the file or constrain the catch-all (e.g. app/(studio)/[...slug]/page.tsx inside a route group that excludes specific paths).

Step 3: Add a new content type, and Studio renders it for free

Now the payoff compounds. To add a Studio-managed campaigns CT:

  1. Create the campaigns content type in Contentstack (open Content Models, then New CT).
  2. Set the CT’s Custom Preview URL to /campaigns/{{entry.url}} (or your slug field). Once. Dev does this. Authors never touch URLs again.
  3. In Studio, build a Connected Template against campaigns. Studio auto-derives the URL pattern from the CT’s Custom Preview URL.
  4. Publish a campaign entry. Visit /campaigns/spring-2026 in the browser: the Studio composition renders. No new route file, no per-template wiring, no engineer involvement.

That’s the payoff: every new CT inherits Studio rendering for free.

Step 4: When you eventually want to fully Studio-ify an existing code page

When the team is comfortable and a code-owned page (e.g. app/collections/[slug]/page.tsx) is ready to become Studio-managed:

  1. Run Migrate hand-coded pages Step 5, migrate-page-to-studio skill. It rewrites the route file to <StudioComponent specOptions={...} />, preserving the old JSX as comments.
  2. Delete the now-redundant per-route file once you’re confident: the catch-all picks up /collections/<slug> automatically.

That’s the gradual destination: catch-all serves everything, and over time the per-route files disappear.


React Router (vite) Variant

// src/main.tsx
import { Routes, Route } from "react-router-dom";
import { CanvasRoute } from "./routes/CanvasRoute";
import { StudioRoute } from "./routes/StudioRoute";
import { ExistingPDP } from "./routes/ExistingPDP";
import { ExistingHome } from "./routes/ExistingHome";

<Routes>
  {/* Existing code routes — registered FIRST, take precedence */}
  <Route path="/" element={<ExistingHome />} />
  <Route path="/products/:slug" element={<ExistingPDP />} />
  <Route path="/collections/:slug" element={<ExistingCollection />} />
  <Route path="/canvas" element={<CanvasRoute />} />   {/* Studio's section authoring */}

  {/* Studio catch-all — registered LAST */}
  <Route path="*" element={<StudioRoute />} />
</Routes>

Same pattern: opt-out specific routes by registering them BEFORE the catch-all.


When NOT to Use the Catch-All

The catch-all is the right default for partial adoption, but skip it if any of these apply:

SituationUse instead
You only want ONE editable band inside a code page (PDP marketing strip, home hero, etc.)Embedded composition: render by compositionUid inline, no catch-all needed
Your app has no existing routes. You want Studio to render everything from day oneZero to first page, then the standard catch-all setup, with no precedence carveouts
You need a Studio-rendered page at a path your code routes ownPer-route swap via Migrate hand-coded pages. Not the catch-all

Common Pitfalls

PitfallWhy it bitesFix
Used [[...slug]] with existing app/page.tsxOptional catch-all collides with the root page. Next.js errors at build time OR runtime ambiguityUse non-optional [...slug], see § 1 caveat above
Catch-all calls notFound() aggressively, code routes brokenCatch-all somehow gets matched before specific routesVerify route file structure. Specific routes (app/products/[slug]/page.tsx) MUST take precedence: this is a Next.js guarantee, so usually the catch-all isn’t actually running
Studio template URL pattern doesn’t resolve to a URLThe CT’s Custom Preview URL wasn’t set. The template defaulted to /<ct_uid>/<composition_uid>/{{entry.title}} (ugly + uses title not slug)Set the CT’s Custom Preview URL once
Catch-all + auth: protected pages get fetched against Studio for unauth usersCatch-all doesn’t know about your auth middlewareAdd auth checks INSIDE the catch-all before fetchCompositionData, OR register the protected paths as explicit code routes
Catch-all caches stale 404sStudio’s CDA query result is cacheableAdd appropriate cache-control / revalidate to the route per your Next.js setup

Author UX Once This is Wired

The win for marketers is exactly the friction that was missing:

  • They open Studio. They pick a content type. They compose. They Save + Publish.
  • The composition appears at its natural URL: /campaigns/spring-2026, /blog/ai-101, /promotions/black-friday.
  • No URL editing. No “change /studio/ to / flag. No developer involvement per page.

That’s the win that the /studio/* sandbox blocked. Catch-all unblocks it.


See Also