Type something to search...
How to Create a Custom Sanity Plugin

How to Create a Custom Sanity Plugin

To create a custom Sanity plugin, wrap a piece of Studio configuration in definePlugin from the sanity package and add it to the plugins array in sanity.config.ts. A plugin can contribute anything a Studio config can: schema types, tools, document actions, badges, form inputs and more. For a plugin you want to share across projects or publish to npm, scaffold a package with npx @sanity/plugin-kit init <name>, test it in a real Studio with npm run link-watch and yalc, then publish it with npm publish and version it with semver.

Most plugins start as config that has outgrown sanity.config.ts or been copied into a second project. This guide covers how definePlugin works, plugin options, adding schema types, tools, document actions and inputs, scaffolding a package with plugin-kit, testing locally, publishing to npm and versioning releases without breaking the Studios that depend on you.

What a Plugin Is

A Sanity plugin is a partial Studio configuration with a name. When the Studio starts, it merges every plugin's config into the main one. That's all there is to it: no special runtime, no registration API, just objects and functions that the Studio already understands.

Plugin propertyWhat it addsTypical use
schema.typesDocument and object typesA seo object, a colourSwatch type
toolsTop-level Studio toolsA report, a calendar, a media browser
document.actionsButtons in the document footer"Duplicate to locale", "Send to translation"
document.badgesLabels in the document headerWorkflow or sync status
form.componentsCustom inputs and field wrappersCharacter counters, external pickers
studio.componentsLayout, navbar and logoBranding, environment banners
pluginsOther pluginsBundling dependencies together

If you only want to install existing plugins, start with the guide to Sanity Studio plugins instead.

Your First Plugin With definePlugin

Start inside your own Studio. Move a piece of config into its own file and swap defineConfig for definePlugin:

// plugins/hello/index.ts
import { definePlugin } from 'sanity'

export const helloPlugin = definePlugin({
  name: 'hello-plugin',
  document: {
    badges: (prev) => [
      ...prev,
      () => ({ label: 'Hello', color: 'primary' }),
    ],
  },
})

Then add it to the Studio:

// sanity.config.ts
import { defineConfig } from 'sanity'
import { structureTool } from 'sanity/structure'
import { helloPlugin } from './plugins/hello'
import { schemaTypes } from './schemaTypes'

export default defineConfig({
  name: 'default',
  title: 'My Studio',
  projectId: '<project-id>',
  dataset: 'production',
  plugins: [structureTool(), helloPlugin()],
  schema: { types: schemaTypes },
})

Notice that helloPlugin is called as a function. definePlugin always returns a function, even when the plugin takes no options, so every plugin is used the same way.

Give it a unique name, and match it to the npm package name once you publish.

Accepting Options

Most useful plugins are configurable. Pass a function to definePlugin instead of an object, and type the options with a generic:

// plugins/word-count/index.ts
import { definePlugin, type DocumentBadgeComponent } from 'sanity'

type Block = { _type: string; children?: { text?: string }[] }

function createWordCountBadge(maxWords: number): DocumentBadgeComponent {
  return (props) => {
    const body = ((props.draft ?? props.published)?.body ?? []) as Block[]
    const text = body
      .filter((block) => block._type === 'block')
      .flatMap((block) => block.children?.map((child) => child.text ?? '') ?? [])
      .join(' ')
    const words = text.split(/\s+/).filter(Boolean).length

    return { label: `${words} words`, color: words > maxWords ? 'warning' : 'primary' }
  }
}

export interface WordCountOptions {
  /** Document types that get the word count badge */
  types?: string[]
  /** Show a warning colour above this many words */
  maxWords?: number
}

export const wordCount = definePlugin<WordCountOptions | void>((options) => {
  const { types = ['post'], maxWords = 2500 } = options ?? {}

  return {
    name: 'sanity-plugin-word-count',
    document: {
      badges: (prev, context) =>
        types.includes(context.schemaType)
          ? [...prev, createWordCountBadge(maxWords)]
          : prev,
    },
  }
})

The | void in the generic lets people call wordCount() with no arguments. Always provide defaults, and keep the option names descriptive, because they become part of your public API the moment someone installs the plugin.

plugins: [wordCount({ types: ['post', 'page'], maxWords: 3000 })]

Adding Schema Types

A plugin can ship its own types. They're merged with the Studio's types, so users can reference them in their own documents:

// src/schema/colourSwatch.ts
import { defineField, defineType } from 'sanity'

export const colourSwatch = defineType({
  name: 'colourSwatch',
  title: 'Colour swatch',
  type: 'object',
  fields: [
    defineField({ name: 'label', type: 'string' }),
    defineField({
      name: 'hex',
      type: 'string',
      validation: (rule) => rule.regex(/^#[0-9a-f]{6}$/i).error('Use a six-digit hex value'),
    }),
  ],
})
// src/index.ts
import { definePlugin } from 'sanity'
import { colourSwatch } from './schema/colourSwatch'

export const colourSwatches = definePlugin({
  name: 'sanity-plugin-colour-swatches',
  schema: { types: [colourSwatch] },
})

Type names are global, so a clash with the Studio's own types stops the schema compiling. Prefix names when there's any chance of one. Using defineType and defineField covers the schema helpers themselves.

Adding a Tool

Tools appear in the Studio's top navigation. A tool is an object with a name, title, icon and React component:

// src/tool/index.tsx
import { definePlugin } from 'sanity'
import { CalendarIcon } from '@sanity/icons'
import { CalendarTool } from './CalendarTool'

export const contentCalendar = definePlugin<{ title?: string } | void>((options) => ({
  name: 'sanity-plugin-content-calendar',
  tools: (prev) => [
    ...prev,
    {
      name: 'content-calendar',
      title: options?.title ?? 'Calendar',
      icon: CalendarIcon,
      component: CalendarTool,
    },
  ],
}))

CalendarTool is an ordinary React component. Inside it, useClient({ apiVersion: '2025-02-19' }) gives you a client for fetching content. Use @sanity/ui for the interface so your tool matches the Studio and respects the user's light or dark theme, along with any customisations from Studio theming. Using the function form of tools, with prev, keeps the user's existing tools in place.

Adding Document Actions and Badges

Document actions follow the same resolver pattern. Accept a list of types in the options and only add your action where it applies:

// src/actions/copyIdAction.tsx
import type { DocumentActionComponent } from 'sanity'
import { CopyIcon } from '@sanity/icons'

export const CopyIdAction: DocumentActionComponent = (props) => ({
  label: 'Copy document ID',
  icon: CopyIcon,
  onHandle: () => {
    navigator.clipboard.writeText(props.id)
    props.onComplete()
  },
})
// src/index.ts
import { definePlugin } from 'sanity'
import { CopyIdAction } from './actions/copyIdAction'

export const copyId = definePlugin<{ types?: string[] } | void>((options) => ({
  name: 'sanity-plugin-copy-id',
  document: {
    actions: (prev, context) =>
      !options?.types || options.types.includes(context.schemaType)
        ? [...prev, CopyIdAction]
        : prev,
  },
}))

Append to prev rather than replacing it. Several plugins can contribute actions to the same document, and the Studio runs each resolver in turn, so a plugin that returns a fresh array silently removes everyone else's buttons. Adding custom document actions in Sanity explains operations, dialogs and wrapping built-in actions.

Adding Custom Inputs

There are two ways to ship an input. The first ties it to a schema type your plugin defines:

import { defineType } from 'sanity'
import { CharacterCountInput } from './CharacterCountInput'

export const countedString = defineType({
  name: 'countedString',
  title: 'Counted string',
  type: 'string',
  components: { input: CharacterCountInput },
})

Users then write type: 'countedString' and get your input. The second is to decorate existing inputs through form.components, which affects every matching field in the Studio:

// src/index.tsx
import { definePlugin, type InputProps } from 'sanity'
import { CharacterCountWrapper } from './CharacterCountWrapper'

export const characterCount = definePlugin({
  name: 'sanity-plugin-character-count',
  form: {
    components: {
      input: (props: InputProps) =>
        props.schemaType.jsonType === 'string' && props.schemaType.options?.showCount
          ? <CharacterCountWrapper {...props} />
          : props.renderDefault(props),
    },
  },
})

Always fall back to props.renderDefault(props), which passes the props down the chain so other plugins' inputs still run, and make the behaviour opt-in, as options.showCount does here. Building custom input components goes deeper on useFormValue, patches and accessibility.

Scaffolding a Package With plugin-kit

When a plugin needs to live outside one Studio, use Sanity's @sanity/plugin-kit. It generates a package with the build setup, package.json fields and scripts that Studio plugins need:

npx @sanity/plugin-kit@latest init sanity-plugin-word-count
cd sanity-plugin-word-count

The command asks for details such as the description, author and licence, then creates a directory with:

FilePurpose
src/index.tsEntry point that exports your definePlugin call
package.jsonESM-only exports, peer dependencies and scripts
package.config.tsBuild config for @sanity/pkg-utils
tsconfig.jsonTypeScript settings for the build
README.mdInstall and usage template to fill in

Run init --help to see the current flags. The generated plugin only logs a hello message to the browser console, so replace src/index.ts with your own code.

Dependencies and Peer Dependencies

Get this right and most install problems disappear. Anything the Studio already provides must be a peer dependency, never a regular one:

{
  "name": "sanity-plugin-word-count",
  "version": "1.0.0",
  "type": "module",
  "files": ["dist", "src"],
  "peerDependencies": {
    "react": "^19.2",
    "sanity": "^5"
  },
  "dependencies": {
    "@sanity/icons": "^3",
    "@sanity/ui": "^3"
  }
}

If sanity or react ends up as a regular dependency, the Studio can load two copies, and hooks like useClient fail with confusing errors. Libraries only your plugin uses go in dependencies. Keep the peer range as wide as you can genuinely support.

Once you've edited the package, run the checker:

npx @sanity/plugin-kit@latest verify-package

It reports missing scripts, a wrong files list, old build tooling and other conventions without changing anything.

Testing Locally

Publishing every change to npm to test it would be painful. plugin-kit uses yalc, a local package registry, to link the plugin into a real Studio. In the plugin directory:

npm run link-watch

This builds the plugin, publishes it to the local yalc store and rebuilds whenever you save. The log prints the command to run in your Studio, which looks like this:

npx yalc add sanity-plugin-word-count && npx yalc link sanity-plugin-word-count && npm install

Import the plugin in sanity.config.ts, start the Studio with npm run dev, and changes in the plugin appear after a reload. When you're done, clean up so the Studio's package.json doesn't keep pointing at your local copy:

npx yalc remove sanity-plugin-word-count
npm install

Why Not npm link?

npm link symlinks your plugin folder, including its own node_modules, so the Studio can end up loading two copies of react and sanity. The result is "invalid hook call" errors and empty context. yalc copies the built files instead, so peer dependencies resolve from the Studio as they will after a real install. If the plugin and Studio share a monorepo, workspaces solve the same problem without yalc.

Before a release, also test in a clean Studio: install from scratch, call the plugin with no options, combine it with other plugins, check light and dark mode, and run npx sanity build.

Publishing to npm

With the plugin tested, log in and publish:

npm login
npm publish

The prepublishOnly script that plugin-kit adds builds the package and verifies the output first, so a broken build never reaches npm. If you publish under a scope, such as @acme/sanity-plugin-word-count, the package is private by default. Make it public in package.json:

{
  "publishConfig": { "access": "public" }
}

Name public plugins sanity-plugin-<name> or @scope/sanity-plugin-<name>, and write a README with the install command, a config example, every option with its default and the supported Studio versions. To list the plugin in Sanity's plugin directory, submit it through Sanity Exchange.

Versioning and Releases

Studios depend on your plugin through a semver range, so version numbers are a promise:

ChangeVersion bumpExamples
Bug fix, no API changePatch (1.0.1)Fixing a crash, a style tweak
New feature, backwards compatibleMinor (1.1.0)A new option with a default, a new action
Anything that can break a StudioMajor (2.0.0)Renaming a schema type or field, removing an option, dropping a Studio major

The schema row is the one people miss. Renaming a type or field doesn't break your code, but it orphans every piece of content created with the old name. Treat stored data as part of your API, and ship a migration script or clear instructions with any major release that changes it.

To automate releases, plugin-kit can inject a semantic-release workflow:

npx @sanity/plugin-kit@latest inject --preset-only --preset semver-workflow
npm install

Run it with no uncommitted changes. It adds commit hooks, semantic-release and a GitHub Actions workflow, so conventional commits (fix:, feat:, feat!:) decide the next version and publish it.

When Sanity releases a new Studio major, test against it and widen your sanity peer range if everything works.


FAQ: Creating Sanity Plugins

No. Any object passed through definePlugin and added to the plugins array is a plugin, and that's all you need inside one Studio. plugin-kit is for packaging a plugin to share across projects or publish to npm, where the build setup and package.json conventions matter.

definePlugin always returns a function so every plugin is added the same way, as myPlugin(). For plugins without options, type the generic as void or leave it out, and call the function with no arguments.

Usually because two copies of React or sanity are loaded. Make sure both are peer dependencies, not regular dependencies, and test with npm run link-watch and yalc rather than npm link, which can resolve them from the plugin's own node_modules folder.

Yes. Add a plugins array inside the definePlugin config. This is a good way to bundle a set of related features, or to depend on another plugin, such as adding a tool that relies on a specific input plugin being present.

Prefix your type names with something specific to your plugin, and document every type it adds. Two types with the same name stop the schema from compiling, and renaming a type later is a breaking change because existing content uses the old name.

Whenever an upgrade could break a Studio: renaming or removing schema types or fields, removing or renaming options, changing default behaviour significantly, or dropping support for a Studio major version. Additions with sensible defaults are minor releases.


Conclusion

A Sanity plugin is Studio configuration with a name. definePlugin turns a block of config into a reusable function, options make it adaptable, and the same properties you already use in sanity.config.ts let it add schema types, tools, document actions, badges and inputs. Append to prev and fall back to renderDefault, and your plugin will play nicely with everyone else's.

When the plugin needs to travel, scaffold it with plugin-kit, keep sanity and react as peer dependencies, test with link-watch and yalc, and publish with a clear README. Treat schema names as part of your public API, version by semver, and automate releases once people depend on you. Following Sanity best practices in the Studio itself makes the jump to a shared plugin much easier.

Tags :
Share :

Related Posts

Building a Static Site With Astro and Sanity

Building a Static Site With Astro and Sanity

To build a static site with Astro and Sanity, keep Astro's default output: 'static', configure @sanity/astro with useCdn: false, and fetch

Dive Deeper
Common Sanity Mistakes and How to Fix Them

Common Sanity Mistakes and How to Fix Them

The most common Sanity mistakes are using the CDN where you need fresh data, fetching whole documents with ..., leaving slugs unvalidated, ship

Dive Deeper
How to Deploy Sanity Studio to the Web

How to Deploy Sanity Studio to the Web

The quickest way to deploy Sanity Studio is to run npx sanity deploy from your Studio folder. The CLI builds the Studio, asks you to choose a h

Dive Deeper