👀 Our most exciting product launch yet 🚀 Join us May 8th for Sanity Connect

Migrating Plugins

How plugins work and compose together

In the strictest sense, Sanity plugins are collections of pre-configured configuration properties that work together.*

import {definePlugin} from 'sanity'
import {MyPluginAction} from '...'

const myPlugin = definePlugin({
  name: 'myPlugin',
  schema: {
    types: [
      {
        name: 'myPluginType',
        type: 'document',
        fields: [{name: 'name', type: 'string'}],
      },
    ],
  },
  document: {
    actions: [MyPluginAction],
  },
})

Info

* many plugins in the Sanity ecosystem today don’t actually use the plugin system. Using a broad definition, plugins can also include published library functions that were designed to work well with Sanity. For the purposes of this article, a plugin is formally a set of pre-configured configuration properties.

Plugins are defined with the definePlugin function. This function accepts either:

1. a configuration object

import {definePlugin} from 'sanity'

export const myPlugin = definePlugin({
  name: 'myPlugin',
  schema: {types: [/* ... */]},
})

2. or a function that returns a configuration object

import {definePlugin} from 'sanity'

interface MyPluginOptions {
  foo: string
}

export const myPlugin = definePlugin<MyPluginOptions>(({foo}) => ({
  name: 'myPlugin',
  schema: {types: [/* ... */]},
}))

The result of definePlugin is always a function that returns a configuration object. This can be passed to a plugins array in either defineConfig or definePlugin itself (plugins can have plugins).

// for either 1 or 2 above
import {defineConfig} from 'sanity'
import {myPlugin} from './plugin'

export default defineConfig({
  name: 'default',
  title: 'My Cool Project',
  projectId: 'my-project-id',
  dataset: 'production',
  plugins: [myPlugin()]
})

v2 Plugins vs v3 Plugins

v2 Plugins

Plugins in v2 were powered by parts and required a custom webpack bundler integration to work.

v2 plugin authors would publish a series of compiled entries points along with a sanity.json file that pointed to those entry points in the form of parts.

The following is the sanity.json for the code-input plugin.

// sanity.json
{
  "paths": {
    "source": "./src",
    "compiled": "./lib"
  },
  "parts": [
    {
      "name": "part:@sanity/form-builder/input/code",
      "description": "An editor for code in sanity"
    },
    {
      "implements": "part:@sanity/form-builder/input/code",
      "path": "CodeInput"
    },
    {
      "name": "part:@sanity/form-builder/input/code/schema",
      "description": "An editor for code in sanity"
    },
    {
      "implements": "part:@sanity/base/schema-type",
      "path": "schema"
    },
    {
      "implements": "part:@sanity/form-builder/input/code/schema",
      "path": "deprecatedSchema"
    }
  ]
}

You’ll notice that these parts aren’t only pointing to part implementations.

In v2, plugins could use parts to do two jobs:

  • Pre-configure parts defined by the core of sanity (e.g. pre-configure schema types with part:@sanity/base/schema-type)
  • Define its own parts that the user (or even other plugins) could utilize to configure the plugin itself. Anywhere within a plugin’s implementation, a part that it defines can be imported.

v3 Plugins

Plugins in v3 are meant to work with normal javascript modules without any bundler integrations or sort of special loading.

A v3 plugin author could publish a single bundled entry point that includes the result of definePlugin then follow normal package authoring standards to get it published and imported into other bundlers (e.g. via package.json exports package entry points).

// ./src/index.ts
import {definePlugin} from 'sanity'
import {CodeInput as DefaultCodeInput} from './CodeInput'
import {CodeBlockIcon} from './CodeBlockIcon'

interface CodeInputOptions {
  codeInputComponent?: ReactComponentType
}

export const codeInput = definePlugin<CodeInputOptions>(
  ({codeInputComponent: CodeInput = DefaultCodeInput}) => ({
    schema: {
      types: [
        {
          name: 'code',
          type: 'object',
          title: 'Code',
          inputComponent: CodeInput,
          icon: CodeBlockIcon,
          fields: [
            {name: 'language', title: 'Language', type: 'string'},
            {name: 'filename', title: 'Filename', type: 'string'},
            {name: 'code', title: 'Code', type: 'text'},
            {
              name: 'highlightedLines',
              title: 'Highlighted lines',
              type: 'array',
              of: [{type: 'number', title: 'Highlighted line'}],
            },
          ],
        },
      ],
    },
  })
)

After this plugin is published using the above as the entry point, it can be used like so:

import {codeInput} from '@sanity/code-input'
import {defineConfig} from 'sanity'
import {CustomCodeInput} from './CustomCodeInput';

export default defineConfig({
  // ...
  plugins: [codeInput({codeInputComponent: CustomCodeInput})],
})

You’ll notice that the ability for a plugin to specify parts to implement has been replaced by functions specifying parameters to pass. These parameters can then be used to change how the plugin pre-configures the Studio.

Parts would let you replace almost anything in the studio, which is no longer possible. However, we think this trade-off is worth it because of the simplicity/maintainability it provides.

Migrating Part Imports

The part system was also the dependency injection mechanism that allowed users to import/inject pre-configured resource from elsewhere. In the Config API, this responsibility has been delegated to React Context and hooks.

Two things to note:

  • If not in a React component, the way to grab a pre-configured resource will be to pass it as a parameter in a function or inline that function into a component. Because React context only works in React, we loose the ability to globally import pre-configured parts (has to come through a hook).

Here's how the depency injection mechanism worked in v2:

import client from 'part:@sanity/base/client'
import schema from 'part:@sanity/base/schema'

const handleClick = () => {
  // use the client
  // use the schema
}

export function MyComponent() {
  return <button onClick={handleClick} />
}

And this is how to do it with the Config API in v3:

import {useSource} from 'sanity'

export function MyComponent() {
  const {client, schema} = useSource()

  // some things will have to be moved into the component
  // because the client is no longer globally importable
  const handleClick = () => {
    // use the client
    // use the schema
  }

  return <button onClick={handleClick} />
}

Writing plugins

Plugins let you define a set of pre-configured configuration properties. You define a plugin like you would a workspace except you leave out the project ID and dataset.

This plugin can then be added to the plugins array of a workspace definition or even another plugin.

import codeInput from '@sanity/code-input'
import {definePlugin, defineConfig} from 'sanity'

const myPlugin = definePlugin({
  tools: [{/* ... */}],
  schema: {types:(prev, {user}) => {}},
  document: {
    actions: (prev, {type, user, schema}) => {
      
    }
  },
  plugins: [codeInput()],
})



export default defineConfig({
	projectId: '<my-cool-project-ID>',
  dataset: 'production',
  plugins: [myPlugin()],
})

Writing plugins as functions

Plugins can also be defined with a function and can accept any arbitrary props.

import {definePlugin, defineConfig} from 'sanity'

interface MyPluginOptions {
  toolTitle: string
}

const myPlugin = definePlugin(({toolTitle}: MyPluginOptions) => ({
  tools: [
		{
			title: toolTitle,
			// ...
		}
	],
}))

export default defineConfig({
	projectId: 'kamsd3n7',
  dataset: 'production',
  plugins: [myPlugin({toolTitle: 'My Tool'})],
})

Using plugins shared configurations

Plugins can also be used to shared configuration between two places that accept plugins. For example, you can create two workspaces that only differ by dataset like so:

import {defineConfig, definePlugin} from 'sanity'
import {deskTool} from 'sanity/desk'

const sharedConfig = definePlugin({
  schema: {/* ... */},
  plugins: [deskTool()],
	documentActions: /* ... */,
})

export default defineConfig([
	{
    projectId: 'kamsd3n7',
		dataset: 'production',
		subtitle: 'production',
		plugins: [sharedConfig()],
	},
	{
    projectId: 'kamsd3n7',
		dataset: 'staging',
		subtitle: 'staging',
		plugins: [sharedConfig()],
	},
])

Why not spread?

You may be thinking that you’d rather create then spread a configuration object onto both workspace definitions but this may not always result in what you want. The create Config API knows how to merge multiple configurations into one so it’s best to use this pattern whenever applicable.

Composing plugin outputs

For most cases, the Config API knows what to do when two plugins configure the same configuration property but there are some cases where more control is desired. In those scenarios, many configuration properties offer an additional function-based API.

For example, the schema.types configuration property accepts a function in addition to an array. If an array is provided to schema.types then the Config API will concatenate the given array to the current values of schema.types.

If a function is given, the Config API will invoke that function passing in the current schema.types as the first argument. The result of this function will then become the current value of schema.types.

This could be used augment schema type definitions for advanced use cases (though unsure, we should recommend this!).

import {definePlugin, defineConfig} from 'sanity'
import codeInput from '@sanity/code-input'

const augmentCodeInput = definePlugin({
  schema: {
    types: (prevTypes) =>
      prevTypes.map((type) => {
        // early return for non-code types
        if (type.name !== 'code') return type

        return {
          ...type,
          fields: [
            // spread the previous fields
            ...type.fields,
            // add an extra field
            {name: 'extraField', type: 'string'},
          ],
        }
      }),
  },
})

export default definePlugin({
  projectId: 'kamsd3n7',
  dataset: 'production',
  plugins: [codeInput, augmentCodeInput], // NOTE: order matters!
})

Feedback or questions?

These guides are under development. If you have questions or suggestions for improvement, then we want to hear from you. Get in touch via our GitHub discussions page.

Was this article helpful?