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, three recipes:
| Pattern | Recipe | What it gives you |
|---|---|---|
| Catch-all + code precedence | This recipe, § 1 below | New content types get Studio rendering automatically at their natural URLs. Existing code routes stay untouched |
| Full route-swap migration | Migrate hand-coded pages | An existing code page becomes 100% Studio-rendered. Old JSX preserved as comments |
| Embedded composition | embed-composition-in-code-page | One 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:
- 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.
- 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.](../assets/diagrams/recipes-partial-adoption-file-layout.png)
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:
- Visit /products/widget-x: your existing PDP renders (code wins).
- Visit /: your existing home renders (code wins).
- Visit /about: your existing about renders (code wins).
- Visit /campaigns/spring-2026: assuming you’ve authored a Studio composition at this URL, Studio’s catch-all renders it.
- 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:
- Create the campaigns content type in Contentstack (open Content Models, then New CT).
- Set the CT’s Custom Preview URL to /campaigns/{{entry.url}} (or your slug field). Once. Dev does this. Authors never touch URLs again.
- In Studio, build a Connected Template against campaigns. Studio auto-derives the URL pattern from the CT’s Custom Preview URL.
- 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:
- 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.
- 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:
| Situation | Use 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 one | Zero 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 own | Per-route swap via Migrate hand-coded pages. Not the catch-all |
Common Pitfalls
| Pitfall | Why it bites | Fix |
|---|---|---|
| Used [[...slug]] with existing app/page.tsx | Optional catch-all collides with the root page. Next.js errors at build time OR runtime ambiguity | Use non-optional [...slug], see § 1 caveat above |
| Catch-all calls notFound() aggressively, code routes broken | Catch-all somehow gets matched before specific routes | Verify 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 URL | The 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 users | Catch-all doesn’t know about your auth middleware | Add auth checks INSIDE the catch-all before fetchCompositionData, OR register the protected paths as explicit code routes |
| Catch-all caches stale 404s | Studio’s CDA query result is cacheable | Add 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
- Migrate hand-coded pages to Studio: full per-route swap when you’re ready to retire a code-owned page entirely
- Embed a Studio-managed region in a code-owned page: bounded zones inside a kept-code page
- Template preview routes: the canonical catch-all reference + framework matrix
- CSR vs SSR: server-side fetch variant for the catch-all
- URL variables: the entry.url vs entry.title distinction, configuring Custom Preview URL