@maciejtrzcinski/sanity-plugin-section-builder
v1.0.0
Published
Visual page-builder section picker for Sanity Studio — thumbnail previews in the array editor, edit pane, and insert menu, driven by a single section registry.
Maintainers
Readme
sanity-plugin-section-builder
Visual page-builder section picker for Sanity Studio. Auto-detect your sections from the schema (or list them explicitly) and get thumbnail previews everywhere:
- in the array editor (each section item shows its thumbnail),
- in the "Edit X Section" pane,
- in the native insert menu grid.
No more copy-pasting components: {preview: …} into every section schema — the
plugin attaches the preview globally, keyed by schema type name. Preview images
resolve by filename convention, so there's nothing to map by hand.
Install
npm install @maciejtrzcinski/sanity-plugin-section-builderRequires sanity ≥ 3.36 (for the insert-menu grid previewImageUrl API) and
react 18 or 19 (both are peer dependencies — they come from your studio).
Usage
1. Define your sections
Either auto-detect them from your schema (recommended) or list them explicitly.
Auto-detect (recommended)
Drop in a *Section object type and it's picked up automatically — no registry to
maintain. Preview filenames default to the kebab-cased type name (heroSection →
hero-section.png); see Preview images for how resolution works.
// sections.ts
import type {SectionBuilderConfig} from '@maciejtrzcinski/sanity-plugin-section-builder'
import {schemaTypes} from './schemaTypes'
export const SECTIONS = {
previewBaseUrl: '/static/section-previews',
detect: {
types: schemaTypes,
// isSection defaults to (t) => t.name.endsWith('Section')
include: ['quoteSectionTitled'], // sections that don't match the predicate
order: ['heroSection'], // pin to the top; sort: 'name' for the rest
},
} satisfies SectionBuilderConfigdetect options: isSection, include, exclude, previewImage (deriver),
group, overrides, order, sort ('name' or a comparator). order is
applied after sort, so you can alphabetize everything and still pin a few to the
front. You can also call detectSections(types, options) directly to build the
list and tweak it before passing it as sections.
Explicit
export const SECTIONS = {
previewBaseUrl: '/static/section-previews',
sections: [
{type: 'heroSection', previewImage: 'hero.png'},
{type: 'faqSection', previewImage: 'faq.png'},
{type: 'blogIndexSection', group: 'index'}, // no preview yet — that's fine
],
} satisfies SectionBuilderConfigpreviewImage may be a bare filename (resolved against previewBaseUrl) or an
absolute URL / root-relative path used as-is.
Preview images
There's no hand-maintained map from section → image — the image is resolved by
filename convention: ${toKebabCase(type)}.png under previewBaseUrl. Name a
section heroSection and drop hero-section.png in the folder; it just appears.
Only reference files that actually exist. The browser can't check the filesystem, so the default deriver returns a filename for every section. That's fine for the array-item / edit-pane preview (a missing image hides itself, no broken icon) — but the native insert-menu grid can't hide a broken image. So the robust pattern is to feed the deriver a set of the files that exist, generated from the folder at build time (a "manifest"):
// preview-manifest.ts — generated by a tiny script that reads the folder, e.g.
// node -e "…readdirSync('static/section-previews')…" > committed file
export const PREVIEW_FILES = new Set<string>([
'hero-section.png',
'faq-section.png',
// …
])import {toKebabCase} from '@maciejtrzcinski/sanity-plugin-section-builder'
import {PREVIEW_FILES} from './preview-manifest'
detect: {
types: schemaTypes,
previewImage: (name) => {
const file = `${toKebabCase(name)}.png`
return PREVIEW_FILES.has(file) ? file : undefined // undefined → no preview, not a 404
},
}A section with no matching file simply gets no thumbnail — everywhere, cleanly.
2. Register the plugin
// sanity.config.ts
import {defineConfig} from 'sanity'
import {sectionBuilder} from '@maciejtrzcinski/sanity-plugin-section-builder'
import {SECTIONS} from './sections'
export default defineConfig({
// …
plugins: [sectionBuilder(SECTIONS)],
})3. Build your page-builder field
import {defineType} from 'sanity'
import {sectionField} from '@maciejtrzcinski/sanity-plugin-section-builder'
import {SECTIONS} from './sections'
export const page = defineType({
name: 'page',
type: 'document',
fields: [sectionField(SECTIONS, {group: 'content'})],
})
// One registry can power several fields via a filter:
sectionField(SECTIONS, {name: 'indexBuilder', filter: (s) => s.group === 'index'})That's it. Every registered type now renders with its thumbnail, and the insert menu shows a visual grid.
Migrating an existing studio
If your section schemas already wire previews per type, the move is mechanical:
- Register the plugin (step 2 above). The global
form.components.previewresolver now renders the thumbnail for every registered section type. - Delete the per-schema wiring — remove the
components: {preview: …}line and its import from each section'sdefineType. A one-line codemod over your section files handles all of them at once. (Renderer/preview no longer needed per file.) - Replace any hand-rolled page-builder field with
sectionField(SECTIONS, …)(step 3). It builds the arrayofmembers and wires the insert-menu grid for you. - Name preview images by convention —
${toKebabCase(type)}.png— or keep your existing names by settingpreviewImageper section (explicit mode) or viadetect.previewImage/overrides.
The data schema is unaffected (preview components aren't part of the schema), so
sanity schema extract output stays byte-for-byte identical.
Guard against missing assets (CI / tests)
If you gate previewImage by the folder manifest (above), there are no broken
references by construction. auditPreviewAssets is still handy to surface unused
files (dead assets) and sections that still need a thumbnail — and it's pure,
so it stays browser-safe (no node:fs in the bundle). Pass it the resolved section
list and the files on disk; build the list with detectSections when you use
auto-detect (the config's sections is only populated in explicit mode):
import {readdirSync} from 'node:fs'
import {auditPreviewAssets, detectSections} from '@maciejtrzcinski/sanity-plugin-section-builder'
import {schemaTypes} from './schemaTypes'
const sections = detectSections(schemaTypes, {include: ['quoteSectionTitled']})
const {missing, unused, withoutPreview} = auditPreviewAssets(
sections,
readdirSync('static/section-previews'),
)
expect(missing).toEqual([]) // every referenced filename exists on disk
// `unused` — preview files no section references (dead assets)
// `withoutPreview` — sections that still need a thumbnailAPI
| Export | Entry | Purpose |
| --------------------------------------------- | ----- | ----------------------------------------------------------------------------- |
| sectionBuilder(config) | . | Studio plugin — globally attaches the preview thumbnail. |
| sectionField(config, opts?) | . | Builds the page-builder array field + insert-menu grid. |
| sectionArrayMembers(config, filter?) | . | Just the defineArrayMember[] (if you build the field yourself). |
| detectSections(types, options?) | . | Derive the section list from schema types (predicate + order/sort/overrides). |
| toKebabCase(name) | . | The default preview-filename deriver. |
| makeSectionItemPreview(map, height) | . | Builds the per-type preview component (the plugin uses this internally). |
| SectionPreviewImage({src, height}) | . | The thumbnail <img> (hides itself on load error). |
| auditPreviewAssets(sections, files) | . | Pure check for missing/unused preview assets (feed it readdirSync(...)). |
| resolvePreviewUrl, sectionsMissingPreview | . | Helpers used internally; exported for convenience. |
Exported types: SectionBuilderConfig, SectionDefinition, DetectConfig,
DetectSectionsOptions, SchemaTypeLike, SectionFilter, SectionFieldOptions,
ResolvedSectionBuilderConfig, AssetAuditResult.
SectionDefinition
interface SectionDefinition {
type: string // schema type name of the section object
previewImage?: string // filename | absolute URL | root-relative path
group?: string // optional grouping for filtered fields
}SectionBuilderConfig
interface SectionBuilderConfig {
sections?: readonly SectionDefinition[] // explicit list, OR…
detect?: DetectConfig // …auto-detect from schema types
previewBaseUrl?: string // default '/static/section-previews'
previewHeight?: number // default 320
}
type DetectConfig = {
types: ReadonlyArray<{name: string; type?: string}>
isSection?: (def) => boolean // default: name.endsWith('Section')
include?: string[] // force-include non-matching types
exclude?: string[] // drop matching types
previewImage?: (name) => string | undefined // default: `${kebab(name)}.png`
group?: (name) => string | undefined
overrides?: Record<string, Partial<Omit<SectionDefinition, 'type'>>>
order?: string[] // pin to front (after sort)
sort?: 'name' | ((a, b) => number)
}SectionFieldOptions (second arg to sectionField)
interface SectionFieldOptions {
name?: string // default 'pageBuilder'
title?: string // default 'Page Builder'
group?: string // fieldset/group the field belongs to
filter?: (s: SectionDefinition) => boolean // restrict which sections are offered
fieldOptions?: Partial<ArrayDefinition> // merged into the resulting defineField
}Why a plugin (vs. native insert menu)?
Sanity's native insert menu already supports a grid with previewImageUrl — and
this plugin wires that for you. Its extra value is auto-detection + convention-based
previews (no registry, no per-section image map) and the thumbnail on the
array item and edit pane, which the native insert menu does not cover, all
without per-schema boilerplate.
How it works
- Previews: registered via Sanity's global
form.components.previewresolver, which keys off the schema type name — so no per-schema component is needed. - Insert-menu grid: wired through the array field's
options.insertMenu(previewImageUrl), built for you bysectionField. - Section list:
detectfilters your schema types by a predicate at config time; nothing is read from the running dataset.
The package is browser-safe — it pulls in no node: modules, so it bundles cleanly
into the Studio.
Developing
Requires Node ≥ 20 and pnpm (packageManager is pinned in package.json).
pnpm install
pnpm build # bundle ESM + CJS + d.ts into dist/ (pkg-utils)
pnpm watch # rebuild on change
pnpm test # vitest
pnpm type-check # tsc --noEmit
pnpm check # lint + type-check + format:check + knip + test (CI gate)Releases are managed with Changesets:
run pnpm changeset to record a change, then pnpm release publishes.
License
MIT © Maciej Trzciński
