@gearbox-built/sanity-schema-tool
v2.0.1
Published
Helper framework to build schema for Sanity Studio V3
Downloads
457
Readme
⚡️ Gearbox Sanity Schema Tool
A fully-typed schema builder for Sanity Studio V3/V4 that provides thoughtful defaults, succinct syntax, and powerful helpers to make writing schemas faster and more maintainable.
Built on top of Sanity's defineField functions to benefit from their improved developer experience while adding helpful abstractions.
Features
- 🎯 Shorthand Syntax - Write schemas faster with functions like
F.slug(),F.title(),F.string({ name: 'foo' }) - 🧩 Custom Fields - Pre-built composite fields for common patterns (hero, link, media, seo)
- 📦 Smart Defaults - Sensible defaults that follow Sanity best practices
- 🎨 Group & Fieldset Helpers - Organize complex schemas with simple, readable syntax
- ⚙️ Configurable - Set global defaults for field types across your entire project
- 🔌 Extensible - Add custom field types that integrate seamlessly with the tool
- 📘 Fully Typed - Complete TypeScript support with inference and autocomplete
Installation
# yarn
yarn add @gearbox-built/sanity-schema-tool
# npm
npm install @gearbox-built/sanity-schema-tool
# pnpm
pnpm add @gearbox-built/sanity-schema-toolQuick Start
Import the schema tool and start building schemas:
import {F} from '@gearbox-built/sanity-schema-tool'
export const movie = F.document({
name: 'movie',
fields: [
F.title(),
F.slug(),
F.image({name: 'poster'}),
F.text({name: 'synopsis'}),
F.array({name: 'cast', of: [F.string()]}),
F.publishedDate(),
],
})That's equivalent to:
export const movie = {
type: 'document',
name: 'movie',
title: 'Movie',
fields: [
{
type: 'string',
name: 'title',
title: 'Title',
},
{
type: 'slug',
name: 'slug',
title: 'Slug',
required: true,
description: 'Leave blank to autofill.',
options: {
source: 'title',
maxLength: 96,
},
validation: (Rule) => Rule.required(),
},
{
type: 'image',
name: 'poster',
title: 'Poster',
},
{
type: 'text',
name: 'synopsis',
title: 'Synopsis',
},
{
type: 'array',
name: 'cast',
title: 'Cast',
of: [{type: 'string'}],
},
{
type: 'date',
name: 'publishedDate',
title: 'Published Date',
required: true,
options: {
dateFormat: 'MMMM DD, YYYY',
},
initialValue: () => new Date().toISOString().split('T')[0],
validation: (Rule) => Rule.required(),
},
],
}Core Concepts
The schema tool exports five main namespaces:
F- Fields (all Sanity field types + custom composite fields)G- Groups (tab organization helpers)P- Preview (preview configuration helpers)V- Validation (validation helpers)FS- Fieldsets (fieldset helpers)
Auto-generated Names and Titles
Fields automatically generate names and titles using lodash utilities:
- Names:
camelCaseof the title (if title provided) or explicit name - Titles:
startCaseof the name (if name provided) or explicit title
// These are equivalent:
F.string({name: 'firstName'}) // title becomes "First Name"
F.string({title: 'First Name'}) // name becomes "firstName"
F.string({name: 'firstName', title: 'Given Name'}) // uses both as specifiedRequired Fields
The required boolean prop is a shorthand that automatically adds validation:
F.string({name: 'email', required: true})
// Equivalent to:
F.string({
name: 'email',
validation: (Rule) => Rule.required(),
})API Reference
F - Fields
Core Field Types
All standard Sanity field types are available:
// String-based types
F.string(props) // Basic string
F.text(props) // Multi-line text
F.email(props) // Email validation
F.url(props) // URL validation
F.slug(props) // Slug with smart defaults
// Number and date types
F.number(props) // Number input
F.date(props) // Date picker
F.datetime(props) // Date and time picker
// Media types
F.image(props) // Image upload
F.file(props) // File upload
// Structural types
F.boolean(props) // Boolean toggle
F.object(props) // Object with nested fields
F.array(props) // Array of items
F.reference(types, props) // Reference to other documents
F.block(props) // Block content
F.geopoint(props) // Geographic coordinates
// Document type
F.document(props) // Document definitionExample:
const product = F.document({
name: 'product',
fields: [
F.string({name: 'sku', required: true}),
F.number({name: 'price', validation: (Rule) => Rule.min(0)}),
F.boolean({name: 'inStock', initialValue: true}),
F.geopoint({name: 'location'}),
],
})Custom Composite Fields
Pre-built fields for common patterns:
F.title(props) // Title field with defaults
F.excerpt(props) // Excerpt/summary field
F.publishedDate(props) // Published date with formatting
F.checkbox(props) // Boolean as checkbox
F.radio(list, props) // Radio button group
F.dropdown(list, props) // Dropdown select
F.category(types, props) // Single category reference
F.categories(types, props) // Multiple categories
F.multiReference(types, props) // Multiple references
F.blockContent(props) // Rich text content
F.message(text, props) // Display-only message
F.formField(props) // Form field configurationRadio & Dropdown:
F.radio(['small', 'medium', 'large'], {name: 'size'})
F.dropdown(['draft', 'published', 'archived'], {name: 'status'})
// With custom labels:
F.radio(
[
{title: 'Small (SM)', value: 'small'},
{title: 'Large (LG)', value: 'large'},
],
{name: 'size'},
)References:
// Single category
F.category('productCategory', {name: 'category'})
// Multiple categories
F.categories(['category', 'tag'], {name: 'categories'})
// Custom multi-reference
F.multiReference(['post', 'page'], {
name: 'relatedContent',
title: 'Related Content',
})Advanced Composite Fields
Complex fields with conditional sub-fields:
Hero Field
F.hero(props)
// With custom configuration:
F.hero({
name: 'hero',
args: {
label: false, // Disable label field
heading: {name: 'heading', required: true},
content: {name: 'content'},
link: {
name: 'cta',
conditions: {none: []}, // Add 'none' option
},
media: {
args: {caption: false}, // Disable caption in media
},
align: {initialValue: 'center'},
},
})The hero field includes:
label- Optional eyebrow labelheading- Main heading textcontent- Rich text content (blockContentSimple)link- Call-to-action linkmedia- Image or videoalign- Content alignment (left/center/right)
Link Field
F.link(props)
// Basic link with defaults:
F.link({name: 'cta'})
// Custom configuration:
F.link({
name: 'mainLink',
types: ['page', 'post', 'product'], // Document types for internal links
conditions: {
none: [], // Add 'none' option
internal: [], // Internal page links (default)
external: [], // External URLs (default)
download: [], // File downloads (default)
video: [], // Video links
},
args: {
label: {name: 'label', required: true},
linkStyle: false, // Disable style options
target: true, // Show "open in new tab" option
},
})The link field includes conditional sub-fields based on link type:
- Internal: page reference, hash anchor options
- External: URL input, target (new tab) option
- Download: file upload
- Video: video file upload
- Common: label, link style (text/button/ghost), size
Media Field
F.media(props)
// Basic media:
F.media({name: 'heroMedia'})
// Custom configuration:
F.media({
name: 'featuredMedia',
conditions: {
none: [], // Add 'none' option
image: [], // Image upload (default)
video: [], // Video upload (default)
lottie: [], // Lottie animation
},
args: {
caption: {name: 'caption', hidden: ({parent}) => !parent?.image},
ratio: {name: 'aspectRatio', initialValue: '16:9'},
},
})The media field includes:
- Condition selector (image/video)
- Conditional image or video fields
- Optional caption
- Optional aspect ratio
SEO Field
F.seo(props)
// Basic SEO field:
F.seo() // Uses 'seo' as name
// Custom name:
F.seo({name: 'pageSeo', title: 'Page SEO'})Note: The SEO field currently expects a custom 'seo' type to be defined in your Sanity schema. It's an alias field that references your project's SEO object type.
Content Group
Returns an array of fields (not a single object field):
F.contentGroup(props)
// Basic usage:
const fields = F.contentGroup()
// Custom configuration:
const fields = F.contentGroup({
label: {name: 'eyebrow'},
heading: {name: 'title', required: true},
content: false, // Disable content field
link: {name: 'cta'},
})
// Use in document:
F.document({
name: 'section',
fields: [
...F.contentGroup(), // Spreads label, heading, content, link fields
F.image({name: 'background'}),
],
})Utility Functions
F.field(type, props) // Generic field creator
F.apply(props, fields) // Apply props to multiple fieldsExample:
// Apply common props to multiple fields:
const requiredFields = F.apply({required: true, group: 'contact'}, [
F.string({name: 'email'}),
F.string({name: 'phone'}),
])G - Groups
Groups organize fields into tabs in the Sanity Studio:
G.define(name, props) // Define a group
G.group(name, fields) // Assign fields to a group
G.content() // Predefined 'content' group
G.meta() // Predefined 'meta' group
G.options() // Predefined 'options' groupExample:
import {AiOutlineHome as icon} from 'react-icons/ai'
import {F, G} from '@gearbox-built/sanity-schema-tool'
export const page = F.document({
icon,
name: 'page',
// Define the tabs:
groups: [G.define('content'), G.define('meta'), G.define('seo', {title: 'SEO'})],
// Assign fields to tabs:
fields: [
...G.group('content', [F.title(), F.field('hero'), F.field('components')]),
...G.group('meta', [F.slug(), F.field('path'), F.publishedDate()]),
...G.group('seo', [F.seo()]),
],
})Predefined Groups:
// These are pre-configured groups you can use:
groups: [G.content(), G.meta(), G.options()]
// Equivalent to:
groups: [
{name: 'content', title: 'Content', default: true},
{name: 'meta', title: 'Meta'},
{name: 'options', title: 'Options'},
]P - Preview
Preview configuration helpers:
P.preview(props) // Generic preview config
P.titleImage(props) // Title + image preview (default)
P.text(title) // Text-only preview
P.richText(blocks) // Convert rich text to preview
P.link(props) // Link preview helper
P.contentGroup(props) // Content group previewExample:
export const article = F.document({
name: 'article',
fields: [F.title(), F.image({name: 'cover'}), F.publishedDate()],
// Simple preview:
preview: P.titleImage(), // Uses 'title' and 'image'
// Custom preview:
preview: P.preview({
title: 'title',
media: 'cover',
subtitle: 'publishedDate',
}),
// Custom prepare function:
preview: {
select: {
title: 'title',
date: 'publishedDate',
},
prepare: ({title, date}) => ({
title: title || 'Untitled',
subtitle: date ? new Date(date).toLocaleDateString() : 'No date',
}),
},
})Text-only preview:
export const category = F.document({
name: 'category',
fields: [F.title()],
preview: P.text('title'),
})V - Validation
Validation helpers:
V.required // Required field validation
V.validation(customValidator) // Custom validation functionExample:
import {V} from '@gearbox-built/sanity-schema-tool'
F.string({
name: 'email',
validation: V.required, // Note: this is a function, not a call
})
// Custom validation:
F.string({
name: 'username',
validation: V.validation((value) => {
if (!value) return true
return value.length >= 3 || 'Username must be at least 3 characters'
}),
})
// Multiple validations:
F.string({
name: 'password',
validation: (Rule) => Rule.required().min(8).max(100),
})FS - Fieldsets
Fieldset helpers for grouping related fields:
FS.define(name, props) // Define a fieldset
FS.fieldset(name, fields) // Assign fields to a fieldset
FS.seo() // Predefined SEO fieldsetExample:
export const product = F.document({
name: 'product',
fieldsets: [
FS.define('pricing', {
title: 'Pricing',
options: {collapsible: true, collapsed: false},
}),
],
fields: [
F.title(),
...FS.fieldset('pricing', [
F.number({name: 'price'}),
F.number({name: 'salePrice'}),
F.boolean({name: 'onSale'}),
]),
],
})Configuration
Use withConfig() to set global defaults for field types across your project.
Basic Configuration
Create a configuration file:
// lib/schemaTool.ts
import {F as _F, withConfig} from '@gearbox-built/sanity-schema-tool'
export const {F, FS, G, P, V} = withConfig({
// Set defaults for image fields:
image: {
fields: [_F.string({name: 'alt', title: 'Alt Text'}), _F.string({name: 'caption'})],
},
// Make slugs optional by default:
slug: {
required: false,
},
// Add default validation to strings:
string: {
validation: (Rule) => Rule.max(200),
},
// Set default for published dates:
publishedDate: {
options: {
dateFormat: 'YYYY-MM-DD',
},
},
})
// Re-export types:
export type * from '@gearbox-built/sanity-schema-tool'Then use your configured schema tool:
// schemas/product.ts
import {F} from '@/lib/schemaTool'
export const product = F.document({
name: 'product',
fields: [
F.title(),
F.slug(), // Automatically optional (from config)
F.image({name: 'photo'}), // Automatically includes alt and caption
],
})All Configurable Fields
You can configure defaults for these field types:
withConfig({
// Core types:
array: {},
block: {},
blockContent: {},
boolean: {},
date: {},
datetime: {},
document: {},
email: {},
file: {},
geopoint: {},
image: {},
number: {},
object: {},
reference: {},
slug: {},
string: {},
text: {},
url: {},
// Custom types:
categories: {},
category: {},
checkbox: {},
contentGroup: {},
excerpt: {},
formField: {},
hero: {},
link: {},
media: {},
message: {},
multiReference: {},
publishedDate: {},
radio: {},
seo: {},
title: {},
})Extension
Extend the schema tool with custom field types using the second parameter of withConfig().
Adding Custom Fields
// lib/schemaTool.ts
import {
F as _F,
withConfig,
type StringField,
type FieldReturn,
type RadioList,
} from '@gearbox-built/sanity-schema-tool'
// Define custom types:
export const customTypes = {
F: {
// Custom dropdown field:
dropdown: (list: RadioList, props?: StringField): FieldReturn =>
_F.string({
...props,
options: {
list: list.map((item) =>
typeof item === 'string' ? {title: item, value: item.toLowerCase()} : item,
),
layout: 'dropdown',
},
}),
// Custom phone field:
phone: (props?: StringField): FieldReturn =>
_F.string({
...props,
name: props?.name || 'phone',
validation: (Rule) =>
Rule.regex(
/^[+]?[(]?[0-9]{3}[)]?[-\s.]?[0-9]{3}[-\s.]?[0-9]{4,6}$/,
'Please enter a valid phone number',
),
}),
// Custom color picker:
color: (props?: StringField): FieldReturn =>
_F.string({
...props,
name: props?.name || 'color',
options: {
list: [
{title: 'Red', value: '#ff0000'},
{title: 'Blue', value: '#0000ff'},
{title: 'Green', value: '#00ff00'},
],
},
}),
},
}
// Export configured schema tool with custom types:
export const {F, FS, G, P, V} = withConfig(
{
// Your config here
slug: {required: false},
},
customTypes,
)
export type * from '@gearbox-built/sanity-schema-tool'Using Custom Types
import {F} from '@/lib/schemaTool'
export const contact = F.document({
name: 'contact',
fields: [
F.string({name: 'name'}),
F.phone(), // Custom phone field
F.dropdown(['Mr', 'Mrs', 'Ms', 'Dr'], {name: 'title'}), // Custom dropdown
F.color({name: 'favoriteColor'}), // Custom color picker
],
})Adding Custom Validators
const customTypes = {
V: {
email: () => (Rule: any) => Rule.regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, 'Invalid email format'),
url: () => (Rule: any) => Rule.uri({scheme: ['http', 'https']}),
},
}
// Usage:
F.string({
name: 'email',
validation: V.email(),
})Adding Custom Previews
const customTypes = {
P: {
articlePreview: () => ({
select: {
title: 'title',
author: 'author.name',
date: 'publishedDate',
image: 'coverImage',
},
prepare: ({title, author, date, image}) => ({
title: title || 'Untitled',
subtitle: `${author} - ${new Date(date).toLocaleDateString()}`,
media: image,
}),
}),
},
}Complete Example
Here's a complete example showing various features:
// lib/schemaTool.ts
import {F as _F, withConfig} from '@gearbox-built/sanity-schema-tool'
const customTypes = {
F: {
dropdown: (list, props) => _F.string({...props, options: {list, layout: 'dropdown'}}),
},
}
export const {F, FS, G, P, V} = withConfig(
{
image: {
fields: [_F.string({name: 'alt', title: 'Alt Text'})],
},
slug: {required: false},
},
customTypes,
)
export type * from '@gearbox-built/sanity-schema-tool'// schemas/blogPost.ts
import {AiOutlineFileText as icon} from 'react-icons/ai'
import {F, G, P} from '@/lib/schemaTool'
export const blogPost = F.document({
icon,
name: 'blogPost',
title: 'Blog Post',
groups: [G.define('content'), G.define('meta'), G.define('seo', {title: 'SEO'})],
fields: [
// Content group:
...G.group('content', [
F.title({required: true}),
F.excerpt(),
F.image({name: 'coverImage', title: 'Cover Image'}),
F.hero({
name: 'hero',
args: {
label: false,
media: {
conditions: {
none: [],
image: [],
video: [],
},
},
},
}),
F.blockContent({name: 'body'}),
F.array({
name: 'tags',
of: [F.string()],
options: {layout: 'tags'},
}),
]),
// Meta group:
...G.group('meta', [
F.slug(),
F.reference(['author'], {name: 'author', title: 'Author'}),
F.categories(['category', 'topic'], {name: 'categories'}),
F.publishedDate(),
F.dropdown(['draft', 'review', 'published'], {
name: 'status',
initialValue: 'draft',
}),
]),
// SEO group:
...G.group('seo', [F.seo()]),
],
preview: P.preview({
title: 'title',
subtitle: 'excerpt',
media: 'coverImage',
}),
orderings: [
{
title: 'Published Date, New',
name: 'publishedDateDesc',
by: [{field: 'publishedDate', direction: 'desc'}],
},
],
})TypeScript
This package is fully typed. Import types as needed:
import type {
FieldReturn,
FieldProps,
StringField,
ImageField,
ArrayField,
ConfigType,
CustomTypes,
} from '@gearbox-built/sanity-schema-tool'License
MIT © Gearbox Development Inc.
Develop & Test
This plugin uses @sanity/plugin-kit with default configuration for build & watch scripts.
See Testing a plugin in Sanity Studio on how to run this plugin with hotreload in the studio.
Build Commands
# Build the package
yarn build
# Type check
yarn ts
# Watch mode for development
yarn watch
# Link and watch for local testing
yarn link-watch
# Linting
yarn lint
# Format code
yarn format
# Run tests
yarn test
# Test with coverage
yarn test:coverage