How to Build a Custom Studio Input in Sanity
Your content team keeps asking for a color picker that respects the brand palette, a slug field that shows a live URL preview, and an alt-text field that stops shipping empty.
Your content team keeps asking for a color picker that respects the brand palette, a slug field that shows a live URL preview, and an alt-text field that stops shipping empty. In most content backends you answer with the same shrug: the editor is fixed, so you get an extension slot in the sidebar, a marketplace plugin, or a settings toggle, none of which actually replace the field an editor stares at all day. The gap between what the schema needs and what the interface offers becomes friction that compounds on every content type.
Sanity closes that gap because Sanity Studio is a customizable React application, not a fixed editor. Sanity is the Content Operating System for the AI era, an intelligent backend that adapts to how your business models content rather than forcing your team to work its way. A custom input in the Studio is a real React component you register alongside your schema, receiving the field value, a change handler, and a callback to the built-in UI you are wrapping or replacing.
This guide walks the mechanism end to end: where components register, how renderDefault lets you wrap versus replace, how to read sibling values and write real-time-safe patches, and how to keep design-system fidelity. The point is editor-as-code, not editor-by-toggle.
Why the fixed editor becomes a modeling problem
The failure mode is subtle at first. You model a field as a string, an editor types a hex code by hand, and three sprints later half your color values are typos that no validation caught in a way anyone noticed. You model a slug as a string and nobody sees the URL it produces until it is live. You add an image field and alt text stays blank because nothing in the interface makes the absence feel like a problem. None of these are data-model failures. They are interface failures, and in a fixed editor the interface is the one thing you cannot change.
That matters because content modeling and content editing are the same decision made twice. When you decide a field is a color, you have also decided how a human should pick a color, and if the editor cannot express that second decision, the model leaks. Teams paper over the leak with documentation, review checklists, and Slack reminders, which is the classic legacy-CMS move: scale people to compensate for a rigid tool. Every new content type adds another line to the onboarding doc.
Sanity's first pillar, model your business, is explicitly about the interface being part of the model. Because the Studio is a React application you ship rather than a hosted editor you configure, the color field can render a swatch grid constrained to your palette, the slug field can render the resolved URL as you type, and the alt-text field can block publish until it is filled. The interface stops being a fixed cost you route around and becomes a place you encode intent. That reframing, interface as an extension of the schema, is what the rest of this guide operationalizes.
Where custom components register: form.components
Every customization enters through one configuration property: form.components. You can set it at three levels, and the level you choose is a scoping decision, not a stylistic one. At the root, inside defineConfig in your sanity.config.ts, a component applies Studio-wide. Inside a plugin, it ships as reusable behavior other projects can install. On an individual field, inside defineField, it applies to exactly that field and nowhere else. Reach for the narrowest scope that solves the problem; a Studio-wide input override is a large blast radius for a small need.
form.components accepts four customization points, and knowing which one you want saves you from over-reaching. The input component replaces the control an editor interacts with. The field component wraps the whole field including its label, description, and validation messages. The item component customizes a single entry inside an array. The preview component changes how a value is summarized in lists and references. A live-URL slug is an input; a field that needs a custom label layout is a field; a rich array item is an item.
A common pattern is to scope by schema type inside a root-level override, so one function decides per field whether to intervene. For example, input: (props) => props.schemaType?.name === 'string' ? <CustomStringInput {...props} /> : props.renderDefault(props). This says: for string fields, use my component; for everything else, fall through to the default. Because the components are registered in code next to the schema and typed by TypeGen, the shape of props is known at compile time. This is the editor-as-code posture. The customization is a versioned, reviewable, testable artifact in your repository, not a configuration record living in someone's admin panel.
renderDefault: the difference between wrapping and replacing
Every custom component receives a renderDefault callback in its props, and understanding it is the single most important thing about the Studio's component API. Calling props.renderDefault(props) renders the built-in component that would have appeared if you had customized nothing. That one callback gives you two fundamentally different strategies from the same entry point.
Wrap: call renderDefault and decorate around it. You render your own markup, then call props.renderDefault(props) somewhere inside it, and the default input appears nested in your UI. This is how you add a character counter beneath a string field, a helper button beside a URL, or a live preview panel next to a slug while keeping all the built-in editing behavior, validation, and real-time collaboration untouched. You are adding, not rebuilding.
Replace: omit renderDefault entirely and render your own control. Now you own the interface completely, which is right for a color swatch grid or a map coordinate picker where the default string box is simply the wrong tool. The cost is that you are also responsible for reading and writing the value correctly, which the next section covers.
The mechanism underneath is a middleware chain. Component customizations compose, so a field-level override, a plugin, and a root config can all wrap the same input in sequence, each calling the next through renderDefault. This is why a component that forgets to call renderDefault when it means to wrap does not just lose the default UI, it breaks the chain for every customization registered after it. The rule is blunt and worth internalizing: if you intend to wrap, you must call renderDefault; if you intend to replace, you deliberately do not. Deciding which one you are doing before you write the component prevents the most common category of Studio customization bug.
Reading and writing values: useFormValue and patches
A replacement input has two jobs: show the current value and write changes back. Reading its own value is direct, it arrives in props. Reading other fields on the document is where the useFormValue hook comes in. useFormValue takes a path and returns the live value at that path in the current document, so a slug input can read the title field to suggest a slug, or a shipping input can read the country field to change which options it shows. This is how cross-field behavior works in practice: read siblings with useFormValue, then have each field write only itself.
Writing is where correctness lives. You do not mutate the value directly; you call props.onChange with a patch describing the change. Prefer the modern patch creators and onChange({ set: { ... } }) or onChange({ unset: [] }) rather than hand-constructing PatchEvent objects. set replaces the value at the field, unset removes it. A key constraint to internalize: a component's onChange patches only its own field. You cannot reach across and set a sibling field from inside another field's onChange; cross-field writes are a schema-design and workflow concern, not something you force through one input's change handler.
Why patches instead of plain assignment? Because the Studio is collaborative and real-time. Two editors can be in the same document, and the Content Lake reconciles concurrent edits. A set patch on a specific field composes cleanly with someone else editing a different field; a wholesale value replacement does not. Patching is what makes your custom input safe inside multiplayer editing rather than a source of clobbered changes. Build your component to emit the smallest, most specific patch that expresses the edit, and the collaboration layer does the hard part for you.
Design-system fidelity with @sanity/ui
A custom input that looks bolted on undermines the reason you built it. If your color picker has different spacing, type, and focus states from every other field, editors read it as a foreign object and trust it less. The fix is not to hand-roll CSS that approximates the Studio; it is to build with the same primitives the Studio itself is built from.
@sanity/ui is the component library behind the Studio. Composing your input from its primitives, Stack for vertical rhythm, Card for surfaces, Text for typography, Button, Grid, and the rest, means your component inherits the Studio's spacing scale, color tokens, dark and light themes, and accessibility defaults for free. A swatch grid built from Card and Grid respects theme changes automatically; a helper note built from Text sits at the right size and muted tone without you specifying a pixel. The result reads as a native part of the editor because it is assembled from the same parts.
This is a concrete expression of a broader difference. In an editor where customization lives in extension slots or sidebar apps, your custom UI is visibly a guest in a host layout, and matching the host's look is your ongoing problem. Because the Studio is a React application composed from a published design system, your input is a first-class citizen using the same building blocks as the built-in fields. Pair that with TypeGen deriving TypeScript types from your schemas and GROQ queries, and your component gets typed props and typed query results end to end, so a rename in the schema surfaces as a type error in the component rather than a runtime surprise an editor discovers in production.
A worked example: an AI-assisted alt-text input
Tie the pieces together with a field teams chronically neglect: image alt text. The failure mode is familiar, alt text stays blank because writing it is friction and nothing in the interface makes the blank feel urgent. Model this as a string field with a custom input that both nudges and assists.
Start with a wrap. Your component calls props.renderDefault(props) to keep the ordinary text editing, then decorates around it with @sanity/ui: a Card containing the default input, a Text hint when the value is empty, and a Button that offers to draft alt text from the image. To know which image to describe, read the sibling field with useFormValue on the parent image path; the alt string lives beside the asset reference, so useFormValue is exactly the right tool. When the editor accepts a suggestion, write it with props.onChange({ set: suggestedText }), a single set patch on this field alone, which stays safe if a colleague is editing the caption at the same time.
The drafting itself is where Sanity's AI surface fits. Agent Actions are schema-aware APIs for generating, transforming, and translating content with LLMs, exposed over HTTP anywhere you can run code, so your Button can call an action that returns a caption grounded in the actual field and document context rather than a generic guess. This is Sanity being built for AI rather than bolting it on: the enrichment understands your schema because it lives in the same platform as your content. The same shape generalizes to translation inputs, moderation gates, and summarizers. Crucially, the AI is a helper inside a governed editorial loop, the editor still reviews, edits, and approves through the normal patch and publish flow, so automation raises output without removing the human review that keeps content trustworthy.
Editor customization: how developers extend the content interface
| Feature | Sanity | Contentful | Payload | Strapi |
|---|---|---|---|---|
| How custom UI enters the editor | input, field, item, and preview components registered at form.components in sanity.config.ts, or per-field in defineField; a swap-in React component beside the schema. | App Framework hosts React apps and UI extensions in defined slots; devs build apps into the layout rather than swapping the core input. | Code-first: custom field components and admin overrides declared in the config, closest peer on code-defined inputs. | Admin panel extended via plugins and custom field plugins scaffolded through the plugin system rather than a single typed config. |
| Wrap vs replace the default control | renderDefault callback in every component: call it to wrap and decorate the built-in UI, omit it to fully replace, composed as a middleware chain. | Extensions render their own UI in a slot; wrapping the native field control is not the primary model. | Custom field components replace the control; you own the rendering rather than decorating a provided default via one callback. | Field plugins render custom UI; the pattern is replacement of the control, not a chainable wrap-the-default callback. |
| Reading other fields' values | useFormValue(path) returns live document state so an input can react to sibling fields, with each field patching only itself. | Field editors read entry data through the extensions SDK; cross-field logic uses SDK APIs within the slot model. | Components access document data through provided hooks and context in the admin app. | Custom fields read form state via the admin app's React context and store. |
| Writing changes safely | props.onChange with set/unset patches; smallest-possible patches compose with concurrent edits reconciled by Content Lake. | setValue through the SDK updates the field; concurrency behavior depends on the entry editing model. | setValue-style handlers update field state in the admin form. | onChange handlers update the admin form state on the client. |
| Design-system fidelity | Built from @sanity/ui primitives (Stack, Card, Text, Grid), so inputs inherit Studio spacing, theming, and accessibility with no re-implementation. | Forma 36 design system available so apps can match the UI; fidelity is your responsibility inside the hosted slot. | Reuses the admin UI's React components and styles for a consistent look. | Design system components available to keep custom fields on-brand with the admin panel. |
| Typed props end to end | TypeGen derives TypeScript types from schemas and GROQ queries, so component props and query results are typed and schema renames surface as compile errors. | TypeScript typings for the extensions SDK; content-shape typing is a separate codegen concern. | TypeScript-first with generated types from the config-defined collections. | TypeScript support with generated types available for content structures. |