Skill 07 · Sanity Studio Upgrade
Subchapter 7.6
references/detect.mdMarkdown15 KBView on GitHub
Everything here is read-only. Run it before consulting any breaking-change list, so the list can be filtered against facts rather than assumptions.
A Studio is the directory containing sanity.config.ts (v3 and later) or sanity.json (v2). It may not be the repository root.
# v3+ Studio
find . -name "sanity.config.*" -not -path "*/node_modules/*"
# v2 Studio
find . -name "sanity.json" -not -path "*/node_modules/*" -maxdepth 3
# v2-era packages, which have no `sanity` package at all
grep -E '"@sanity/(base|desk-tool|default-layout|dashboard)"' package.jsonCheck the floor before anything else. Studio v2 is a hard stop for this skill. Any of these means v2:
sanity major is below 3sanity dependency and the project depends on v2-era packages such as @sanity/base or @sanity/desk-toolsanity.json and no sanity.config.*If so, stop and report per the scope section of SKILL.md. Do not continue through the rest of this file, and do not write a plan file.
If both sanity.json and sanity.config.* exist, the resolved sanity version wins. A stray sanity.json in a v3+ project is cleanup worth mentioning, not a reason to stop.
If you find several Studios, ask which one to plan for, or plan for each and say so. Monorepos with one Studio per tenant or per environment are common.
Read the lockfile, not package.json. "sanity": "^4.20.0" tells you what was requested; the lockfile tells you what is installed, and the gap between them is often several minor versions of breaking changes.
Every command in this section runs from the Studio directory, not the repository root. The Studio is frequently a subdirectory, and the node_modules and lockfile that matter are the ones governing it. Set the directory from the path chosen in step 1, and do not auto-pick when step 1 found more than one Studio:
STUDIO=studio # or `.`, or apps/studio, packages/cms, whatever step 1 found
cd "$STUDIO"Two things to check before trusting a path here. In a pnpm or Yarn workspace the lockfile usually sits at the workspace root while node_modules is local to the Studio, so the two facts come from different directories. And a Studio with no lockfile of its own is being resolved by a parent manifest, which is itself a finding worth reporting.
# Fastest reliable read, if node_modules exists
cat node_modules/sanity/package.json | grep '"version"'
# Otherwise, from the lockfile governing this directory (may be at the workspace root)
grep -A2 '"sanity@' package-lock.json | head -20 # npm
grep -A2 "^ sanity@" yarn.lock | head -20 # yarn
grep -A2 " sanity@" pnpm-lock.yaml | head -20 # pnpm
# Whole tree at once, if installed
npm ls sanity @sanity/vision @sanity/ui @sanity/icons @sanity/client styled-components react react-domRecord the resolved version of: sanity, every @sanity/* package, react, react-dom, styled-components, and every third-party plugin (anything matching sanity-plugin-* or that appears in the config’s plugins array).
Note the package manager and its version. It determines whether deduplication is overrides, resolutions, or pnpm.overrides, and pnpm’s stricter resolution changes how duplicate majors behave.
Any package appearing more than once in the tree is a finding to report now, not only something to verify after the upgrade.
Two majors of a shared package such as @sanity/ui, or a plugin dragging in @sanity/util and @sanity/types from a different Studio major than the installed core, means the tree is already in the state that produces duplicate-context errors and unstyled components. That is worth telling the reader before they change anything, because it may explain bugs they already have, and because fixing it first removes a variable from the upgrade.
Read the dependency tree output rather than only the top-level list. Nested entries are where this shows up:
pnpm why --json @sanity/ui @sanity/icons @sanity/client @sanity/util @sanity/types react styled-components
# npm
npm ls --all --json @sanity/ui @sanity/icons @sanity/client @sanity/util @sanity/types react styled-componentsUse the package manager’s own output, and treat the grep below as a last resort. pnpm why and npm ls parse the lockfile with the tool that wrote it; a regex does not. It will keep working when the lockfile format changes and it cannot confuse one package’s name for a substring of another’s. --json also makes the result parseable rather than scraped, which matters when you need the parent that pulled a version in, not just the version.
Reach for the lockfile directly only when neither is available: no node_modules, or the matching package manager is not on the machine. Then anchor the package name and keep prerelease suffixes, or you will report findings that do not exist. A loose pattern turns babel-plugin-styled-components@5.1.36 into a styled-components v5 duplicate, @sanity/ui@5.0.0-alpha.3 into a nonexistent stable 5.0.0, and @sentry/react@8.55.2 into a React version:
grep -oE "[/'\"]@?[a-zA-Z0-9@/._-]+@[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?" pnpm-lock.yaml \
| sed -E "s|^['\"/]+||" \
| grep -E "^(@sanity/(ui|icons|client|util|types)|styled-components|react)@" \
| sort -uPut what you find in the report as a finding with the specific versions and which dependency pulled each one in. Do not reduce it to a generic “check for duplicates after installing” line.
This is the observed tree, so it is a finding. What the tree will look like after the upgrade is a prediction, and package-coupling.md covers why a planner must not assert it.
A repository can contain several installs, and they can disagree. Common shapes: a Studio in a subdirectory with its own lockfile while the repository root has another; a root manifest that depends on sanity for linting; a monorepo with a Studio and a frontend using different package managers.
When this happens:
sanity versions too, because two different Studio majors resolving in one repository will confuse editor TypeScript and can mislead anyone reading versions from the wrong placeRead these in full, not by grep. They are short and they answer many questions at once.
sanity.config.ts (or .js, or the workspace array if there are several)sanity.cli.ts (or .js)package.json, including the scripts blocksanity.json if this is v2From sanity.config.*, record whether each of these is present:
| Look for | Why it matters |
|---|---|
auth block, auth.providers, auth.mode, auth.loginMethod | Highest-severity v6 change; affects login |
search or enableLegacySearch | Whether the v6 search default change is already overridden |
plugins array contents | Each entry needs a compatibility check |
document.actions or document.badges | Custom actions need testing against newer document contexts |
tools | Custom tools often reach into Studio internals |
form or beta.form | Some of these options were removed |
scheduledPublishing | Deprecated; separate migration |
| Multiple workspaces | Workspace routing behavior changed |
basePath | Interacts with the CLI-level basePath |
From sanity.cli.ts, record:
| Look for | Why it matters |
|---|---|
deployment.autoUpdates | Determines the whole auto-update section, and whether the 6.9.0 enforcement change matters |
deployment.appId | Needed for version-channel selection |
studioHost | See the trap note in package-coupling.md |
vite | Custom Vite config is the most likely v6 build failure |
reactStrictMode | Whether the v6 default is already set |
typegen, schemaExtraction | Whether codegen is wired up |
| Values from a function or env vars | Signals multi-tenant or multi-environment deploys |
If config values come from a helper function or environment variables, read the helper. A templated studioHost or a per-tenant projectId means there are multiple deployments to upgrade, which belongs in the plan.
cat tsconfig.jsonYou need the effective moduleResolution. Recent @sanity/* packages resolve types through exports and drop typesVersions, so legacy node10 resolution cannot resolve subpath types at all.
If tsconfig.json has an extends pointing outside the repository, follow it if you can and say so if you cannot. An unresolved moduleResolution is a genuine blocker for any plan involving subpath imports, and it belongs in the human questions section by name rather than being quietly skipped.
# Resolves the full inherited config, including anything reached via `extends`.
# `--no-install` is required: plain `npx tsc` downloads TypeScript when it is not
# already present, and this skill does not install anything.
# Select the fields rather than truncating: `head` will cut a long config off
# above `moduleResolution` and leave you reporting it as unset.
npx --no-install tsc --showConfig 2>/dev/null \
| grep -E '"(moduleResolution|module|target|jsx|strict|paths)"'If that prints nothing, TypeScript is not installed in this directory. Read tsconfig.json and follow its extends chain by hand instead of installing anything, and if the chain leaves the repository, say so rather than guessing. An unresolved moduleResolution belongs in the human questions section by name.
Also record whether typescript and @types/react-dom are actual devDependencies or are being inherited from a workspace root.
The size of the upgrade is mostly determined by this. Count, then look at examples.
# Which Sanity UI primitives are used, and how much
grep -rn "from '@sanity/ui'" src/ --include=*.ts --include=*.tsx | wc -l
grep -rn "from '@sanity/ui'" src/ --include=*.ts --include=*.tsx
# Icons: barrel imports break when @sanity/icons drops them
grep -rn "from '@sanity/icons'" src/ --include=*.ts --include=*.tsx
# Custom Studio components
grep -rln "defineField\|defineType\|defineArrayMember" src/ | wc -l
grep -rn "components:\s*{" src/ --include=*.ts --include=*.tsx | head -20
# Reaching into Studio internals: highest risk, no compatibility guarantee
grep -rEn "sanity/_internal|sanity/_singletons" src/
# Portable Text editor DOM coupling, in source, styles, and tests
grep -rn "data-slate" . --include=*.ts --include=*.tsx --include=*.js --include=*.css --include=*.scss --include=*.styl
grep -rn "data-slate" e2e/ tests/ cypress/ playwright/ 2>/dev/nullIf sanity/_internal or sanity/_singletons imports exist, raise it prominently. Internal APIs carry no compatibility guarantee and change without changelog entries. A multi-major upgrade over internal APIs is a different, riskier project, and the honest recommendation may be to get off them first.
Look at one or two representative custom components rather than all of them. One example reveals the patterns in use, which is what determines whether a rename is twenty edits or four hundred.
grep -rEn "unstable_use|useTimeLineStore|useDocumentVersionInfo|enableLegacySearch|enhancedObjectDialog|sheetList|useClickOutside|useElementRect|useForwardedRef|useArrayProp|ConditionalWrapper" src/ sanity.config.* sanity.cli.*
# Sanity UI props removed in its v4
grep -rn "space={" src/ --include=*.tsx | wc -l
grep -rEn "<Grid[^>]*(columns|rows)=" src/ --include=*.tsx
grep -rEn "focusFirst|focusLast|boundaryElement|allowedAutoPlacements" src/ --include=*.tsx
# Previews that resolve through a reference: relevant to the search strategy change
grep -rEn "(title|subtitle|description):\s*'[A-Za-z0-9_]+\.[A-Za-z0-9_.]+'" src/node -v
cat .nvmrc .node-version 2>/dev/null
grep -n '"engines"' -A4 package.json
# Sanity CLI invocations: newer CLIs reject unknown flags instead of ignoring them
grep -rEn "sanity (start|dev|build|deploy|undeploy|schema|schemas|typegen|dataset|datasets|graphql|tokens|exec)" \
package.json .github/ .gitlab-ci.yml Dockerfile* 2>/dev/nullThe Node version that matters is the one in CI and on the build host, not the one on this machine. If you can only see the local version, say so and ask.
Also check whether the Studio shares a repository with other applications. If it does, a Node bump should be scoped to this project’s CI job rather than the whole runner, and hoisted React may be shared with an app that pins a different version.
Carry this forward into the report. If a value is unknown, write “unknown” rather than guessing, and add it to the human questions.
Studio path:
Tree planned for: path to the lockfile every version below came from
Other trees in repo: paths + their resolved `sanity` versions
Config format: sanity.config.* (v3+ required; v2 is a hard stop)
Workspaces: names, count
Multiple deployments: yes/no, how they differ
sanity (resolved):
@sanity/* (resolved):
Third-party plugins:
react / react-dom:
styled-components:
Duplicate packages:
Package manager:
Pre-existing duplicates: package@version pairs + what pulled each in
Node: local / CI / build host
engines.node:
moduleResolution: value, or unresolved and why
typescript a devDep: yes/no/inherited
auth block: absent | providers | mode | loginMethod
search config: absent | strategy
autoUpdates: true | false | unset
appId: set | unset
studioHost: set | unset
custom vite config: yes/no
reactStrictMode: set | unset
@sanity/ui import sites: count
@sanity/icons imports: count, barrel or subpath
custom components: count + kinds
document actions/badges: yes/no
custom tools: yes/no
structure customization: yes/no
internal API imports: yes/no ← raise prominently if yes
data-slate references: yes/no ← raise prominently if yes
deprecated APIs found:
CLI invocations in CI: