Every Next.js + Sanity project I've seen starts the same way. You build the site. You run `sanity init`. You get a Studio that lives on a weird subdomain like abc123.sanity.studio, needs its own `sanity deploy` step, and sits in a completely separate mental bucket from your actual codebase.
I wanted none of that. So when I set up the CMS for this portfolio, I tried something different: embedding Sanity Studio directly inside the Next.js app itself - no separate build, no separate deployment, no second domain. The Studio lives at anilsuthar.com/studio, it ships automatically every time I push to main, and it's just... part of the app.
This post is a complete walkthrough of how that works - including every file, every config, and the exact bugs I hit along the way.
The Core Idea
Sanity Studio is a React application. And since Next.js 13+ (App Router), your entire site is also a React application. So the question becomes: can you just render Sanity Studio as a React component inside your Next.js app?
Yes. That's exactly what the next-sanity package makes possible. It ships a component called <NextStudio /> that renders the entire Studio - document editor, structure panel, media library, everything - as a normal React subtree. No iframes. No separate server. Just React components.
When Next.js builds your site, it bundles the Studio into the same JavaScript output as everything else. There's nothing special about it at all from the bundler's perspective.
The File Structure
Here's everything you need. I'll walk through each file in detail.
app/
├── layout.jsx <- root layout (owns <html>/<body>)
├── page.jsx <- homepage
├── blog/
│ └── page.jsx
└── studio/
├── layout.jsx <- studio segment layout (no <html>/<body>!)
└── [[...tool]]/
├── page.jsx <- catch-all route handler
└── StudioClient.jsx <- 'use client' wrapper
sanity.config.js <- studio config with basePath
sanity.cli.js <- CLI config (projectId, dataset)Step 1: Let's install the dependencies
If you're already using Sanity for content fetching, you almost certainly have all of this:
npm install sanity next-sanity styled-componentsThe versions that work together reliably (at time of writing):
{
"next": "16.2.9",
"react": "19.2.4",
"react-dom": "19.2.4",
"sanity": "^5.31.1",
"next-sanity": "^13.1.0",
"styled-components": "^6.4.2"
}Important: styled-components is required. NextStudio uses it internally for its UI. If it's missing you'll get runtime errors about styled not being defined.
Also important: you need Next.js 16+ and the React 19.2.4 canary build that ships with it. Earlier versions of Next.js (15.x) use a stable React 19 build that is missing the useEffectEvent hook, which Sanity's structureTool depends on. The Studio's Structure panel will crash with a TypeError if you're on Next.js 15.
Step 2: Set up sanity.config.js
This is the main Studio configuration file. The single most important setting for embedded studio is basePath.
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { schema } from './sanity/schemaTypes'
import { dataset, projectId } from './sanity/lib/client'
import { media } from 'sanity-plugin-media'
export default defineConfig({
projectId,
dataset,
title: 'Anil Suthar Blogs',
// This MUST match the folder path: app/studio/[[...tool]]
// Without it, Sanity generates links like /structure/post
// instead of /studio/structure/post - every nav click 404s.
basePath: '/studio',
schema,
plugins: [structureTool(), media()],
})Without basePath, Sanity's internal router doesn't know where it lives. It generates navigation links relative to the site root instead of relative to /studio, so clicking on anything inside the Studio takes you to a 404.
Step 3: Configure sanity.cli.js
This is the configuration file for the Sanity CLI tooling - used when you run npx sanity exec or sanity dev. It's separate from sanity.config.js because the CLI needs to know the projectId and dataset before it can load your full config.
import { defineCliConfig } from 'sanity/cli'
export default defineCliConfig({
api: {
projectId: '3nfbwknm',
dataset: 'production',
},
})Step 4: Create the Studio layout
In Next.js App Router, you can place a layout.jsx at any route segment. That layout becomes the root of the layout tree for that route and all its children.
The Studio needs its own layout for one reason: to prevent the portfolio's global CSS from interfering with Sanity's UI. If the Studio inherits the root layout's styles, you get font mismatches, color overrides, and broken Sanity components.
But there's a critical constraint: only the top-level app/layout.jsx can render <html> and <body>. Nested layouts that try to add their own <html>/<body> cause a React hydration mismatch - the server renders the Studio's body with inline styles, the client renders the root layout's plain body, and React throws a tree mismatch error.
The correct approach is to just pass children through. NextStudio manages its own CSS reset and scoped styles via styled-components internally - it doesn't need a bare body tag.
// app/studio/layout.jsx
// NOTE: No <html> or <body> here.
// Only app/layout.jsx (the root) can own those tags.
// Nested layouts that add <html>/<body> cause hydration mismatches.
//
// NextStudio handles its own CSS isolation via styled-components.
export const metadata = {
title: 'Studio | Anil Suthar',
robots: { index: false, follow: false },
}
export default function StudioLayout({ children }) {
return <>{children}</>
}Step 5: Add the catch-all route
Sanity Studio has its own client-side router. When you navigate inside the Studio - for example clicking Structure -> Posts -> Draft - the URL updates to something like /studio/structure/post;some-id. These are Studio-internal paths that Next.js knows nothing about.
To handle this, we use Next.js's optional catch-all route: a folder named [[...tool]]. The double brackets make the parameter optional, so it matches both /studio (no tool segment) and /studio/anything/nested/deeply.
// app/studio/[[...tool]]/page.jsx
// force-dynamic: prevents Next.js from statically rendering this page
// at build time. The Studio reads runtime env vars and user session
// data - it must boot fresh in the browser each time.
export const dynamic = 'force-dynamic'
import StudioClient from './StudioClient'
export default function StudioPage() {
return <StudioClient />
}Step 6: Build the StudioClient component
This is the actual component that renders Sanity Studio. It must be a Client Component - marked with 'use client' - because Studio is 100% browser-side React. It has event handlers, local state, real-time collaboration sockets, and user session management. None of that can run on a server.
// app/studio/[[...tool]]/StudioClient.jsx
'use client'
import { NextStudio } from 'next-sanity/studio'
import config from '../../../sanity.config'
// NextStudio renders the entire Sanity Studio as a React subtree.
// It handles:
// - Internal routing (defers to the [[...tool]] catch-all)
// - Sanity auth (reads your sanity.io session cookie)
// - All Studio plugins (Structure, Vision, Media, etc.)
// - Its own styled-components CSS in a scoped shadow
export default function StudioClient() {
return <NextStudio config={config} />
}That's the whole component. Three lines of actual logic. Everything else is handled by NextStudio under the hood.
Step 7: Set your environment variables
These go in .env at the root of the project. All three variants are listed because different parts of the stack read different prefixes.
# Read by the Sanity Studio (client-side, prefixed NEXT_PUBLIC_)
NEXT_PUBLIC_SANITY_PROJECT_ID=your_project_id
NEXT_PUBLIC_SANITY_DATASET=production
NEXT_PUBLIC_SANITY_API_VERSION=2023-05-03
# Read by the Sanity CLI tools (sanity exec, sanity dev)
SANITY_STUDIO_SANITY_PROJECT_ID=your_project_id
SANITY_STUDIO_SANITY_DATASET=production
SANITY_STUDIO_SANITY_API_VERSION=2023-05-03How the routing actually works
This is the part that confused me most when I first set it up. Here's exactly what happens when you navigate to anilsuthar.com/studio/structure/post:abc123:
Browser request: /studio/structure/post;abc123
|
v
Next.js App Router
|
matches: app/studio/layout.jsx -> renders <>{children}</>
|
matches: app/studio/[[...tool]]/page.jsx
params.tool = ['structure', 'post;abc123']
|
renders: <StudioClient />
|
renders: <NextStudio config={...} /> -> full Studio UI in browser
|
Sanity Studio reads: params.tool array
-> reconstructs its internal route: /studio/structure/post;abc123
-> renders the Post document editor
|
Data: Studio -> Sanity API (api.sanity.io)
Direct HTTPS from browser, Next.js server not involvedThe key insight is the data separation: your Next.js server only serves the Studio's JavaScript bundle. All actual content reads and writes happen directly from the browser to Sanity's API. Your server is not a proxy for CMS data.
Pushing documents from scripts
One of the nicest side effects of this setup is that you can push documents to Sanity from scripts using your local CLI session - no API token needed in your environment variables.
The trick is getCliClient() from 'sanity/cli'. When you run scripts through the Sanity CLI with the --with-user-token flag, it automatically injects your logged-in session token.
// scripts/seed-posts.mjs
import { getCliClient } from 'sanity/cli'
// Reads projectId/dataset from sanity.cli.js
// Reads auth token from your local sanity login session
const client = getCliClient({ apiVersion: '2024-01-01' })
async function createPost() {
const result = await client.create({
_type: 'post',
title: 'My New Post',
slug: { _type: 'slug', current: 'my-new-post' },
publishedAt: new Date().toISOString(),
body: [
{
_type: 'block',
style: 'normal',
children: [{ _type: 'span', text: 'Hello world.' }],
},
],
})
console.log('Created:', result._id)
}
createPost()npx sanity exec scripts/seed-posts.mjs --with-user-tokenCompare this to the alternative: creating a token at sanity.io/manage, adding it to .env, remembering to rotate it, keeping it out of git. With getCliClient() you skip all of that for local development scripts.
The bugs I hit (and how to fix them)
Bug 1: Hydration mismatch on /studio
Symptom: React throws "A tree hydrated but some attributes didn't match" with the error pointing at app/layout.jsx line 78 (<body>).
Cause: The studio layout.jsx had its own <html>/<body> with inline styles. The server rendered the Studio's styled body, but the React client hydrated using the root layout's plain body. Mismatch.
Fix: Remove <html>/<body> from the studio layout. Only the root layout can own those tags. NextStudio handles its own CSS internally.
// Wrong - causes hydration error
export default function StudioLayout({ children }) {
return (
<html lang="en">
<body style={{ margin: 0 }}>
{children}
</body>
</html>
)
}
// Correct
export default function StudioLayout({ children }) {
return <>{children}</>
}Bug 2: Structure tool crash - useEffectEvent is not a function
Symptom: Navigating to /studio/structure throws "TypeError: (0, react__WEBPACK_IMPORTED_MODULE_2__.useEffectEvent) is not a function". The entire Structure panel is replaced with an error screen.
Cause: Sanity's structureTool uses React.useEffectEvent, which is only available in React's canary/experimental builds - not in stable React 19. Next.js 15 bundles stable React 19. Next.js 16 bundles a React 19 canary that includes useEffectEvent.
Fix: Upgrade from Next.js 15 to Next.js 16.
npm install next@16.2.9 react@19.2.4 react-dom@19.2.4 eslint-config-next@16.2.9After upgrading, delete .next and restart the dev server to ensure the new React canary is bundled cleanly.
rm -rf .next
npm run devsanity deploy vs Embedded: a real comparison
sanity deploy Embedded (this project)
---------------------------------------------------------------------
Studio URL abc123.sanity.studio anilsuthar.com/studio
Deployment Separate CLI step Automatic with next build
CI/CD pipelines Two (site + studio) One
Custom domain Paid plan required Uses your domain (free)
Auth Sanity-managed Sanity session cookie
Bundle control None Full (Turbopack/webpack)
CORS setup Auto Add localhost in dev
Cold start ~2s (Sanity CDN) ~400ms (Next.js 16 Turbopack)The complete file listing
For reference, here are all the files in their final form.
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { schema } from './sanity/schemaTypes'
import { dataset, projectId } from './sanity/lib/client'
import { media } from 'sanity-plugin-media'
export default defineConfig({
projectId,
dataset,
title: 'Anil Suthar Blogs',
basePath: '/studio',
schema,
plugins: [structureTool(), media()],
})export const metadata = {
title: 'Studio | Anil Suthar',
robots: { index: false, follow: false },
}
export default function StudioLayout({ children }) {
return <>{children}</>
}export const dynamic = 'force-dynamic'
import StudioClient from './StudioClient'
export default function StudioPage() {
return <StudioClient />
}'use client'
import { NextStudio } from 'next-sanity/studio'
import config from '../../../sanity.config'
export default function StudioClient() {
return <NextStudio config={config} />
}Wrapping up
The embedded studio approach took me a few hours to get right - mostly because of the two bugs above. But once it clicked, it felt obvious. Of course Sanity Studio should live inside the Next.js app. It's just React. Next.js is just React. There was never a reason they had to be separate.
The result is a setup I actually enjoy working with. One repo. One deploy. One domain. The Studio is at anilsuthar.com/studio, it updates every time I push code, and there's nothing to think about beyond writing content.
If you're setting this up yourself and hit any of the bugs above, I hope this saved you a couple of hours of digging through GitHub issues.


![Spaceship - The cheapest [.com] domain provider](https://cdn.sanity.io/images/3nfbwknm/production/10a304bba16276ff4fa5defef68d7f2500492fb2-600x301.png?fit=max&auto=format)
