@droplab/sanity-tools
v0.3.6
Published
Reusable Sanity schema helpers for common block and content patterns.
Maintainers
Readme

@droplab/sanity-tools
Reusable Sanity schema helpers for common block/content patterns. NOTE: Under heavy development as we are road testing this in a current project. Check back often our release cycles are fast at the moment!
Install (consumers)
pnpm add @droplab/sanity-tools(Use npm install @droplab/sanity-tools or yarn add @droplab/sanity-tools if you do not use pnpm.)
Required peers: sanity, @sanity/ui, react, react-dom — install them in your Studio if they are not already present.
Optional peers (install when you use the related feature):
| Package | Used by |
| --- | --- |
| @sanity/color-input | BrandColorInput — inject Settings → Brand colours into type: 'color' fields |
| @sanity/code-input | json field type in block_customData() / card Additional fields (jsonValue code editor) |
| @sanity/orderable-document-list | helper_orderableList / singletons.orderableList() |
| sanity-plugin-stl-table | block_table |
| react-icons | getHelperIcon (falls back gracefully when omitted) |
Import schemas, types, and GROQ fragments from the package root:
import
{
document_page,
block_richText,
block_richText_query,
type block_hero,
} from '@droplab/sanity-tools'import { groq } from 'next-sanity'
import { page_builder_query } from '@droplab/sanity-tools/groq'
export const PAGE_QUERY = groq`
*[_type == "page" && _id == $id][0] {
_id,
title,
"blocks": blocks[] { ${page_builder_query} }
}
`Each fragment expands references (->) and drops conditionally hidden / stale fields with defined() and inline condition => { ... } projections. See groq/README.md. Prefer @droplab/sanity-tools/groq for queries and @droplab/sanity-tools/utils for resolver helpers in Next.js App Router so Studio input modules are not pulled into the RSC graph; the package root re-exports the same symbols.
The published package exposes three entry points: @droplab/sanity-tools (Studio), @droplab/sanity-tools/groq, and @droplab/sanity-tools/utils (package.json exports). Studio inputs and other modules under dist/ are implementation details for those entries — they are not a public import path.
Shared field knobs (name, title, required, hidden, readOnly, description, group) live on BaseFieldOptions. Prefer an explicit name in production — omitting it auto-generates {prefix}_{uuid}, which is unstable across rebuilds.
Nested blocks inside block_section should pass configOptions: NESTED_IN_SECTION_CONFIG so only Enabled, List label, and ID appear (theme/layout stay on the section).
Catalog
Documents
| Helper | Default _type | What it is |
| --- | --- | --- |
| document_announcement | announcement | Sitewide announcement singleton (enabled, message, link) |
| document_company | company | Sitewide company singleton (name, contact, logos, offices) |
| document_settings | settings | Sitewide singleton (site, brand, SEO, navigation, links, taxonomy) |
| document_page | page | Page with a page-builder array |
| document_person | person | Person / author |
| document_client | client | Client / brand with logo marks |
| document_article | article | Article with rich text |
| document_news | news | News item (internal body or external URL) |
Shared field factories used by those documents: documentFieldGroups, documentTitleAndSlugFields, documentTagsField, documentVariantsField, documentAuthorField, documentPublishDateField, documentListLabelField, documentEnabledField, documentEnvironmentField, documentVisibilityField.
Blocks
| Helper | Role |
| --- | --- |
| block_actionFields | Action type picker + payload (spread into fields) |
| block_card | Card: variant, copy, image, background media, CTA, additional fields |
| block_carousel | Carousel of image / video / card slides |
| block_config | Enabled, list label, ID, theme, layout |
| block_customData | Dynamic typed fields (richText, json, …) for one-off components |
| block_grid | Column grid of image / video / card cells |
| block_hero | Hero: tagline, blurb, CTA, background |
| block_image | Image object (optional Settings tab) |
| block_link | Internal / external / action link |
| block_references | Array of document references |
| block_richText | Portable Text with optional embeds |
| block_section | Section header, background, and nested page-builder blocks |
| block_spacer | Vertical spacing |
| block_table | STL table object + Settings |
| block_tableField | Thin stl_table field |
| block_variantFields | Variant picker (spread into fields) |
| block_video | Single- or multi-provider video |
Related: block_linkArrayMember, block_linkType, block_linkAnnotation, block_imagePortableMember, block_videoField, block_videoPortableMember, block_videoType, block_richTextType, block_gridPortableMember, block_tablePortableMember.
Helpers
| Helper | Role |
| --- | --- |
| brandColorPairingsChooser | Pick a Brand Color Pairing by pairingId |
| helper_seo | Title, description, OG image, optional keywords |
| helper_phone | Country code + number object |
| helper_singletons | Structure pins + create/delete guards |
| helper_orderableList | Searchable orderable desk lists (singleton-aware via singletons.orderableList) |
| getHelperIcon | Studio list icons used by the helpers |
Validation: noDuplicates.
document_company
document_company() returns a document type for the sitewide company profile — it is not a page-builder block. Register it in schema.types and pin it as a singleton (document id company). Place it in structure directly above Settings.
Fields: companyName, companyEmail, companyPhone, companyLogo, companyLogoAlt, offices. Each office is { address, suite, city, state, postalCode, country, email, phone } — address and city are required; per-office email/phone override company-level values.
Options (DocumentCompanyOptions): _type (default company), title (default Company), description, additionalFields.
Pin with helper_singletons. Fetch with document_company_query from @droplab/sanity-tools/groq.
Usage
import { document_company, document_settings, helper_singletons } from '@droplab/sanity-tools';
const singletons = helper_singletons();
// schema.types
document_company(),
document_settings({ referenceTypes: ['page', 'post'] }),
// structure — Company above Settings
singletons.documentByType(S, 'company'),
singletons.documentByType(S, 'settings'),Fetch company (GROQ)
import { groq } from 'next-sanity';
import { document_company_query } from '@droplab/sanity-tools/groq';
export const COMPANY_QUERY = groq`
*[_type == "company"][0]
{
${document_company_query}
}
`;document_settings
document_settings() returns a document type for sitewide global settings — it is not a page-builder block, so it goes straight into schema.types rather than into a fields array. Fields are split across six tabs and Studio's synthetic All fields tab is hidden.
| Tab | Fields |
| --- | --- |
| Site (default) | siteTitle, siteLogo, siteFavicons, copyright |
| Brand | brandColors, brandColorPairings |
| SEO | isSearchable (default true), seo (a helper_seo object), llmsTxt |
| Navigation | mainNavigation, footerNavigation, redirects (Redirects fieldset) |
| Links | socialLinks, generalLinks, legalLinks |
| Taxonomy | variants, tags, actions |
The SEO tab reuses helper_seo rather than redefining meta fields, so sitewide defaults and per-document SEO share one shape. Sitewide isSearchable (default true) sits at the top of the tab, and seo nests seo.title, seo.description, and the OG-validated seo.image. llmsTxt holds plain-text content for /llms.txt. Pass seoOptions to configure the nested object — for example { withKeywords: true } to add seo.keywords, or { imageRequired: true } to require the share image. withIsSearchable is forced off there so the flag is never duplicated.
Navigation stores mainNavigation, footerNavigation, and redirects at the root of the settings document (not nested under a navigation object). Each nav array accepts either a direct block_link or a Link Menu (label + nested links). document_settings_query projects those same root keys; empty footer navigation falls back to main. Announcement (enabled, message, link) lives on the announcement singleton — fetch with document_announcement_query. Links (socialLinks, generalLinks, legalLinks) are flat block_link lists. All of them use block_linkArrayMember, so referenceTypes controls which documents editors may target for internal links.
Brand Colors (brandColors) is an array of { name, colorId, color } — colorId is a slug generated from the name, and color accepts hex (#RGB / #RRGGBB) or rgb() / rgba() strings. Items use BrandColorItemInput (live preview swatch beside the value). Use BrandColorInput on any type: 'color' field to inject those colours into @sanity/color-input's colorList. Use brandColorPairingsChooser() (or BrandColorPairingsChooserInput) on string fields that should pick a Brand Color Pairing by pairingId — block_card bgColor, block_hero backgroundColor, and block_section backgroundColor do this when withBackgroundColor is enabled.
Brand Color Pairings (brandColorPairings) is an array of { pairingId, backgroundColor, foregroundColor } where colour values are Brand Color colorId slugs and pairingId is a required unique string for deterministic frontend lookups (e.g. brandColorPairings[pairingId == "lilac-x-charcoal"][0]). Items use BrandColorPairingInput with BrandColorPairingPreview in list/collapsed views. When empty, Pairing ID is seeded to {background}-x-{foreground} once both colours are chosen (and stays in sync with that pattern until manually edited). Editors pick colours from searchable swatch pills (SettingsListReactSelect / BrandColorSelectInput). Background and foreground must differ — the sibling value is hidden from each list, and validation rejects matching pairs. Duplicate pairingId values are rejected.
General links opt into block_linkArrayMember({ withLinkId: true }), which adds a required unique linkId slug (Generate prefers the link’s branch label). The frontend can resolve a link deterministically, e.g. generalLinks[linkId.current == "contact"][0]. Other link arrays leave withLinkId off (the default).
Each redirects entry is { label, origin, destination }, and duplicate origin values fail validation. Each variants and tags entry is { label, slug, description? }, with duplicate slugs rejected within each array. Each actions entry is { actionType, actionSlug, actionValues? } — actionSlug is derived from actionType, and actionValues declares optional payload fields as { name, type }[] (types: string, number, boolean, url). block_link lists those actionType values on action.actionType (plus Custom, which reveals action.actionTypeCustom); choosing an action reveals typed inputs for each declared field, stored as action.actionValues[]{ name, type, value }. Values are required and validated by type (number / boolean / url / string). Duplicate action slugs and duplicate field names within an action are rejected. Duplicate brand colorId values are rejected. Brand colour pairings require a unique pairingId and must use two different colorId values. Company profile fields live on document_company.
Asset validation:
seo.image— inherited fromhelper_seo: JPG or PNG only, exactly 1200px wide and 620–630px tall.siteFavicons(Site tab) — the PNG/SVG/ICO icon set, each entry labelled for the slot it fills (apple-touch-icon,favicon-192x192, …).
Options (see DocumentSettingsOptions): _type (default settings), title (default Settings), description, referenceTypes, seoOptions.
Pin Settings (with Company and Home) with helper_singletons. Fetch with document_settings_query from @droplab/sanity-tools/groq.
Usage
// sanity.config.ts
import { defineConfig } from 'sanity';
import { document_company, document_settings } from '@droplab/sanity-tools';
export default defineConfig({
// projectId, dataset, plugins, ...
schema: {
types: [
document_company(),
document_settings({ referenceTypes: ['page', 'post'] }),
// ...your other types
],
},
});Fetch settings (GROQ)
document_settings_query projects fields at the document root, including mainNavigation, footerNavigation (falls back to main when empty), and redirects.
import { groq } from 'next-sanity';
import { document_settings_query } from '@droplab/sanity-tools/groq';
export const SETTINGS_QUERY = groq`
*[_type == "settings"][0]
{
${document_settings_query}
}
`;document_page
document_page() returns a Page document with Content, SEO, and Settings tabs. Content holds title and a page-builder array (contentBlocks by default). Settings holds slug, list label, environment, visibility, author, publish date, tags, and variants.
blockMembers is required — the same defineArrayMember(block_*(…)) list you would pass to block_section. Do not include block_section inside itself; pages may include sections.
Options (DocumentPageOptions): _type (default page), title, description, blockMembers, blocksFieldName (default contentBlocks; pass pageBuilder if you prefer that name), blocksTitle, personType (default person), settingsType (default settings), seoOptions, slugValidation (prefer singletons.slugFieldValidation()).
Usage
import { defineArrayMember } from 'sanity';
import
{
document_page,
block_hero,
block_richText,
block_card,
helper_singletons,
} from '@droplab/sanity-tools';
const singletons = helper_singletons();
export const pageSchema = document_page({
blockMembers: [
defineArrayMember(block_hero({ name: 'hero', title: 'Hero' })),
defineArrayMember(block_richText({ name: 'body', title: 'Body' })),
defineArrayMember(block_card({ name: 'card', title: 'Card' })),
],
slugValidation: singletons.slugFieldValidation(),
});Register it in schema.types next to document_settings and document_person. Query the array with page_builder_query (retarget _type names if your member names differ from the playground).
document_person
document_person() returns a Person document with Content and Settings tabs (no SEO). Content: name, role, bio, image. Settings: list label.
Options (DocumentPersonOptions): _type (default person), title, description, imageOptions (forwarded to block_image; block config is off).
Usage
import { document_person } from '@droplab/sanity-tools';
export const personSchema = document_person();
export const authorSchema = document_person({
_type: 'author',
title: 'Author',
imageOptions: { withCaption: false },
});Pass personType: 'author' to document_page / document_article / document_news if you rename the type.
document_client
document_client() returns a Client document with Content, SEO, and Settings tabs. Content: name, slug, logo, logoAlt. Settings: enabled, list label, environment, visibility.
Options (DocumentClientOptions): _type (default client), title, description, logoOptions, logoAltOptions (forwarded to block_image; block config is off), seoOptions, slugValidation.
Usage
import { document_client } from '@droplab/sanity-tools';
export const clientSchema = document_client();
export const brandSchema = document_client({
_type: 'brand',
title: 'Brand',
logoOptions: { withCaption: false },
});document_article
document_article() returns an Article with Content, SEO, and Settings. Content: title, subtitle, summary, imageThumbnail, richText. Settings: slug, list label, environment, visibility, author, publish date, tags, variants.
Options (DocumentArticleOptions): _type (default article), title, description, personType, settingsType, seoOptions, richTextOptions (name is fixed to richText), imageOptions, referenceTypes (internal links in the body; default ['page', 'article', 'news']).
Usage
import { document_article } from '@droplab/sanity-tools';
export const articleSchema = document_article({
referenceTypes: ['page', 'article'],
richTextOptions: { withImage: true, withVideo: true },
imageOptions: { withAlt: true },
});document_news
document_news() returns a News document. Content: title, subtitle, summary, imageThumbnail, and a content type toggle — internal shows slug + rich text; external shows a URL. Settings: slug, list label, environment, author, publish date, tags, variants, visibility.
Options (DocumentNewsOptions) match document_article (richTextOptions apply when content type is internal).
Usage
import { document_news } from '@droplab/sanity-tools';
export const newsSchema = document_news({
referenceTypes: ['page', 'article', 'news'],
richTextOptions: { withImage: true },
});Shared document fields
These factories build the tabs and Settings fields used by document_page / document_article / document_news / document_person. Use them when composing a custom document that should match that shape.
import { defineType } from 'sanity';
import
{
documentFieldGroups,
documentTitleAndSlugFields,
documentTagsField,
documentVariantsField,
documentAuthorField,
documentPublishDateField,
documentListLabelField,
documentEnvironmentField,
documentVisibilityField,
DOCUMENT_CONTENT_GROUP,
DOCUMENT_SETTINGS_GROUP,
} from '@droplab/sanity-tools';
export const customDoc = defineType({
name: 'resource',
type: 'document',
groups: documentFieldGroups(),
fields: [
...documentTitleAndSlugFields('title', DOCUMENT_CONTENT_GROUP),
documentListLabelField(DOCUMENT_SETTINGS_GROUP),
documentEnvironmentField(),
documentVisibilityField(),
documentAuthorField('person'),
documentPublishDateField(),
documentTagsField({ settingsType: 'settings', group: DOCUMENT_SETTINGS_GROUP }),
documentVariantsField({ settingsType: 'settings', group: DOCUMENT_SETTINGS_GROUP }),
],
});documentFieldGroups({ withSeo: false })— omit the SEO tab (person).- Tags / variants read the arrays on
document_settingsTaxonomy. Duplicate values are rejected.
block_actionFields
Returns the fields for an action picker: actionType (from Settings → Taxonomy actions, plus Custom), actionTypeCustom, and actionValues ({ name, type, value }[]). Spread into a parent fields array. Resolve the stored pair on the frontend with resolveBlockAction().
Options (BlockActionFieldsOptions): name (default actionType), title, description, customName, customTitle, settingsType (default settings), group.
Usage
import { defineType, defineField } from 'sanity';
import { block_actionFields } from '@droplab/sanity-tools';
export const ctaButton = defineType({
name: 'ctaButton',
type: 'object',
fields: [
...block_actionFields(),
defineField({ name: 'label', type: 'string' }),
],
});block_link already nests this picker on the action branch. Use block_actionFields only when you need a standalone action object.
block_card
Card with variant (sitewide taxonomy from document_settings), tagline, blurb, image, background media (none / image / video / optional color), CTA (block_link with a None type), optional dynamic Additional fields (same typed name / type / value pattern and list previews as block_customData), and block_config.
Options (BlockCardOptions): variantOptions, withTagline, withBlurb, withImage, imageOptions, withAdditionalFields, additionalFieldTypes, additionalFieldsReferenceTypes, bgImageOptions, bgVideoOptions, ctaOptions, configOptions, withBackgroundColor (adds Color media + bgColor via brandColorPairingsChooser()).
GROQ: block_card_query.
Usage
import { defineType, defineField } from 'sanity';
import { block_card } from '@droplab/sanity-tools';
export const pageSchema = defineType({
name: 'page',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string' }),
block_card({
name: 'featuredCard',
title: 'Featured card',
ctaOptions: { referenceTypes: ['page'] },
bgVideoOptions: { provider: 'mux-plugin' },
configOptions: { additionalThemes: ['brand-night'] },
}),
],
});block_carousel
Carousel of image / video / card slides, plus title, blurb, carousel settings (transitionType: slide | fade), and optional block_config.
Options (BlockCarouselOptions): withConfig, configOptions, imageOptions, videoOptions, cardOptions, withTitle, withBlurb, itemsTitle, carouselSettingsTitle.
GROQ: block_carousel_query.
Usage
import { defineType, defineField } from 'sanity';
import { block_carousel } from '@droplab/sanity-tools';
export const pageSchema = defineType({
name: 'page',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string' }),
block_carousel({
name: 'contentCarousel',
title: 'Content carousel',
videoOptions: { withMux: true },
cardOptions: { ctaOptions: { referenceTypes: ['page'] } },
}),
],
});block_config
Shared block metadata: enabled, Studio-only label, optional id (HTML / hash scroll target for page-builder blocks only — hidden when block_config is a direct document field; not mirrored to document Settings), optional theme (light / dark plus additionalThemes or a full themes list), optional layout (fullWidth | fullBleed | fill | inline). Most block_* objects already include this on a Settings tab.
Options (BlockConfigOptions): enabledInitialValue, themes, additionalThemes, themeInitialValue, layoutInitialValue, withTheme, withLayout, withLabel.
GROQ: block_config_query. Use label in preview.select; it is not site content. Use config.domId (projected from schema id) as the element id (or #{domId} in links). Frontends should still return disabled blocks (for preview) and hide them with config.enabled.
Usage
import { defineType, defineField } from 'sanity';
import { block_config } from '@droplab/sanity-tools';
export const ctaBlock = defineType({
name: 'ctaBlock',
type: 'object',
fields: [
block_config({
name: 'config',
title: 'Block settings',
additionalThemes: ['high-contrast'],
}),
defineField({ name: 'buttonText', type: 'string' }),
],
});For a block nested in block_section, pass NESTED_IN_SECTION_CONFIG instead of a custom theme/layout list.
block_customData
block_customData() creates an object field for one-off component data: a stable id plus a dynamic fields array. Each field has name, fieldType, and a typed value branch (stringValue, numberValue, imageValue, richTextValue, jsonValue, etc.). Editors can add fields without schema changes; the frontend maps id to a React component and reads values by field name.
Stored shape (simplified):
{
id: 'promoBanner',
fields: [
{ name: 'headline', fieldType: 'string', stringValue: 'Hello' },
{ name: 'count', fieldType: 'number', numberValue: 3 },
{ name: 'body', fieldType: 'richText', richTextValue: [/* PT blocks */] },
{ name: 'config', fieldType: 'json', jsonValue: { code: '{ "a": 1 }', language: 'json' } },
],
config: { enabled, label, theme, layout }
}Field types: string, text, number, boolean, url, reference, image, video, richText, json. Pass fieldTypes to limit the list (default: all). referenceTypes controls which documents appear for reference fields (default ['page']).
| Type | Value field | Notes |
| --- | --- | --- |
| richText | richTextValue | Portable Text via block_richText({ withBlockConfig: false }) (text blocks + links) |
| json | jsonValue | @sanity/code-input (type: 'code', language JSON). Studio stores { code, language }. Validation accepts JSON5-style syntax and requires a parsed object or array. Register codeInput() from @sanity/code-input in sanity.config. |
List previews: block title CUSTOM DATA: {label|id}. Each field item title is {TYPE}: {name} (uppercase type prefix, e.g. JSON: form_fields); subtitle shows a truncated value preview.
Frontend: register components by id. References stay as { _ref } unless your GROQ projection expands them. For json, parse jsonValue.code (or project a parsed value in GROQ).
Options (see BlockCustomDataOptions): referenceTypes, fieldTypes, configOptions, plus BaseFieldOptions. GROQ: block_customData_query (typed branches via dynamic_typed_fields_query).
Studio: register @sanity/code-input when using the json type:
import { defineConfig } from 'sanity';
import { codeInput } from '@sanity/code-input';
export default defineConfig({
plugins: [codeInput(), /* … */],
});Usage
import { defineType, defineField } from 'sanity';
import { block_customData } from '@droplab/sanity-tools';
export const pageSchema = defineType({
name: 'page',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string' }),
block_customData({
name: 'promoData',
title: 'Promo data',
referenceTypes: ['page'],
fieldTypes: [
'string',
'text',
'number',
'boolean',
'url',
'image',
'reference',
'richText',
'json',
],
}),
],
});block_grid
Column grid (minColumns required; no max) of image, video, or card cells. Columns default to 3. Optional title, blurb, gutter, and block_config.
Options (BlockGridOptions): minColumns, withConfig, configOptions, imageOptions, videoOptions, cardOptions, withTitle, withBlurb, columnsTitle, itemsTitle, useGutterInitialValue.
GROQ: block_grid_query. block_gridPortableMember() is the Portable Text of member (_type: 'block_grid').
Usage
import { defineType, defineField } from 'sanity';
import { block_grid } from '@droplab/sanity-tools';
export const pageSchema = defineType({
name: 'page',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string' }),
block_grid({
name: 'contentGrid',
title: 'Content grid',
minColumns: 1,
videoOptions: { withMux: true },
cardOptions: { ctaOptions: { referenceTypes: ['page'] } },
}),
],
});block_hero
Hero with variant (sitewide taxonomy from document_settings), optional tagline, blurb, CTA link array, and background (none / image / video / optional color). Nested background video uses HLS / file / YouTube / Vimeo by default. Pass backgroundVideoOptions: { withMux: true } to include Mux (requires the Mux Studio plugin).
Options (BlockHeroOptions): variantOptions, configOptions, withTagline, withBlurb, withCta, ctaOptions, backgroundImageOptions, backgroundVideoOptions, withBackgroundColor (adds Color background + backgroundColor via brandColorPairingsChooser()).
GROQ: block_hero_query (includes the cta link array when present).
Usage
import { defineType, defineField } from 'sanity';
import { block_hero, NESTED_IN_SECTION_CONFIG } from '@droplab/sanity-tools';
export const pageSchema = defineType({
name: 'page',
type: 'document',
fields: [
defineField({ name: 'slug', type: 'slug' }),
block_hero({ name: 'hero' }),
block_hero({
name: 'campaignHero',
title: 'Campaign hero',
withTagline: true,
withBlurb: true,
backgroundVideoOptions: {
withMux: true,
withLoop: true,
withAutoplay: true,
withControls: false,
},
configOptions: NESTED_IN_SECTION_CONFIG,
}),
],
});block_image
Image field. By default this is an object with a Settings tab (block_config) and an image sub-field. Set withBlockConfig: false when a parent (hero, card, person) already owns config — then the field is a plain image.
Options (BlockImageOptions): withBlockConfig, configOptions, hotspot, withAlt, withCaption, withUnsplash (requires sanity-plugin-asset-source-unsplash).
GROQ: block_image_query. block_imagePortableMember() embeds the same image in Portable Text (_type: 'block_image').
Usage
import { defineType, defineField } from 'sanity';
import { block_image } from '@droplab/sanity-tools';
export const articleSchema = defineType({
name: 'article',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string' }),
block_image({ name: 'thumbnail' }),
block_image({
name: 'heroImage',
title: 'Hero image',
hotspot: true,
withAlt: true,
withCaption: true,
required: true,
}),
],
});Query a configured image as thumbnail.image and thumbnail.config.
block_link
Object for internal (document reference + optional anchor), external URL, or action (Settings taxonomy). Optional None type, variant picker, and block_config.
Options (BlockLinkOptions): referenceTypes, withBlockConfig, withNoneLinkType, linkTypes (internal | external | action), configOptions, variantOptions, actionOptions.
GROQ: block_link_query.
Related helpers:
block_linkArrayMember()— arrayofmember (navigation, CTAs).withLinkId: trueadds a unique slug.block_linkType()— register once asschema.types, thendefineField({ name: 'cta', type: 'block_link' }).block_linkAnnotation()— Portable Text mark annotation (block_richTextuses this whenwithLink: true).
Usage
import { defineType, defineField } from 'sanity';
import { block_link, block_linkArrayMember } from '@droplab/sanity-tools';
export const pageSchema = defineType({
name: 'page',
type: 'document',
fields: [
block_link({
name: 'primaryLink',
title: 'Primary link',
referenceTypes: ['page', 'article'],
required: true,
}),
defineField({
name: 'links',
type: 'array',
of: [block_linkArrayMember({ referenceTypes: ['page'] })],
}),
],
});block_references
block_references() creates an object field with an array of document references and a custom Studio input. Editors pick a document _type source (All types or one allowed type); the reference browser updates to match. Already-chosen documents are removed from the available list, and configured singletons are excluded by default (settings, plus page with slug.current == "home"). Duplicate refs also fail validation via noDuplicates().
When referenceTypes is omitted, every registered document type is offered (minus excludeTypes). Pass referenceTypes to restrict. The selected Document source is stored as sourceType.
Empty list auto-fill (GROQ): If references is empty, block_references_query loads public documents for that source (visibility public, or unset), ordered by tag priority → featured → everything else, each tier by publish date desc. When sourceType is set, auto-fill is restricted to that _type; otherwise public non-system documents are returned (excluding settings, sanity.*, system.*, and the homepage). Auto-fill is capped at 100 documents (GROQ cannot slice on a stored field). maxResults, usePagination, itemsPerPage, and paginationStyle are returned for the frontend to apply. Resolved docs include title and name so person/client documents render without a title field.
GROQ: block_references_query.
Usage
import { defineType, defineField } from 'sanity';
import { block_references } from '@droplab/sanity-tools';
export const pageSchema = defineType({
name: 'page',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string' }),
block_references({
name: 'related',
title: 'Related content',
referenceTypes: ['page', 'article', 'news'],
max: 8,
}),
],
});block_richText
Portable Text field with optional link annotations, image / video / table / grid members, and a callout style.
Options (BlockRichTextOptions): styles (default normal, h2, h3, blockquote), lists, decorators, withLink, linkOptions, withImage, imageOptions, withVideo, videoFieldOptions, videoProviders, withTable, tableOptions, withGrid, gridOptions, withCallout, previewComponents, configOptions.
GROQ: block_richText_query / block_richText_content_query. block_richTextType() registers a reusable type named block_richText.
Usage
import { defineType, defineField } from 'sanity';
import { block_richText } from '@droplab/sanity-tools';
export const blogPostSchema = defineType({
name: 'blogPost',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string' }),
block_richText({ name: 'summary' }),
block_richText({
name: 'body',
title: 'Body content',
withLink: true,
linkOptions: { referenceTypes: ['page', 'article'] },
withImage: true,
withVideo: true,
videoFieldOptions: { withMux: true },
withTable: true,
withGrid: true,
gridOptions: { minColumns: 1 },
}),
],
});block_section
Object wrapper with block_config (theme/layout), optional header fields (variant, title, subtitle, blurb), a mutually exclusive Background (none | image | video | color via brandColorPairingsChooser), and a nested blocks array. Pass the same palette as the page builder excluding block_section (no recursion). Nested members should use NESTED_IN_SECTION_CONFIG.
Background video defaults to multi-provider HLS / file / YouTube / Vimeo (embedded playback). Pass backgroundVideoOptions: { withMux: true } for Mux, or provider to restrict to one backend.
Options (BlockSectionOptions): blockMembers (required), blocksFieldName (default blocks), blocksTitle, configOptions, variantOptions, backgroundImageOptions, backgroundVideoOptions, withBackgroundColor (default true), collapsible, initiallyCollapsed.
GROQ: block_section_query (projects header, backgroundType, conditional background branches, and nested blocks[] via page_builder_members_query).
Usage
import { defineArrayMember, defineType, defineField } from 'sanity';
import
{
block_section,
block_hero,
block_richText,
NESTED_IN_SECTION_CONFIG,
} from '@droplab/sanity-tools';
export const pageSchema = defineType({
name: 'page',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string' }),
block_section({
name: 'intro',
title: 'Intro',
collapsible: true,
withBackgroundColor: true,
backgroundVideoOptions: { withLoop: true },
blockMembers: [
defineArrayMember(
block_hero({
name: 'hero',
title: 'Hero',
configOptions: NESTED_IN_SECTION_CONFIG,
})
),
defineArrayMember(block_richText({ name: 'lead', title: 'Lead copy' })),
],
}),
],
});block_spacer
block_spacer() creates an object field for vertical spacing. Block settings on the Settings tab are Enabled and List label only (no theme/layout). Preset and custom size options:
smallmediumlargercustom(string, must be a valid CSS unit value like24px,2rem,10vh, or50%)
showLine (default false) draws a centered horizontal rule. GROQ: block_spacer_query.
Usage
import { defineType, defineField } from 'sanity';
import { block_spacer } from '@droplab/sanity-tools';
export const pageSchema = defineType({
name: 'page',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string' }),
block_spacer({ name: 'spaceSm', sizeInitialValue: 'small' }),
block_spacer({
name: 'spaceCustom',
title: 'Custom spacing',
sizeInitialValue: 'custom',
}),
],
});STL Table Integration (block_table / block_tableField)
This package includes two helpers for the Sanity STL table plugin and the wider structured-table / STL ecosystem:
block_tableField()— thin field wrapper aroundtype: 'stl_table'block_table()— object block with nestedtableand optionalconfigblock_tablePortableMember()— Portable Textofmember (_type: 'block_table')
Peer dependency (optional in npm): sanity-plugin-stl-table — install it in the Studio that uses these fields.
GROQ: block_table_query / block_tableField_query.
1) Install the package in your Studio
pnpm add sanity-plugin-stl-table(Use npm / yarn if you do not use pnpm.)
The current npm release ships a schema export (stlTableBlock) and does not provide a plugins: [stlTable()] function you register in sanity.config. Integration is: add the dependency, register a schema type (next section), and optionally add the Studio preview components (section 3).
2) Register a schema type named stl_table
sanity-plugin-stl-table exports stlTableBlock (a block named stlTableBlock with stlString, caption, etc.). The helpers in this package expect a registered type with name: 'stl_table' and the same field shape, so the Studio can render the plugin’s TableInput for that type.
Add a type that re-exports the plugin block under stl_table and list it in schema.types:
import { defineType } from 'sanity';
import { stlTableBlock } from 'sanity-plugin-stl-table';
export const stlTableType = defineType({
...stlTableBlock,
name: 'stl_table',
title: 'STL table',
type: 'object',
});If TypeScript complains about the spread, narrow stlTableBlock to ObjectDefinition (omit name / title) before passing it to defineType.
You can also register the plugin’s stlTableBlock in addition to stl_table if you want that name inside portable text; block_table / block_tableField only need stl_table.
3) In-Studio table preview (structured-table React components)
The plugin’s built-in preview in the editor uses structured-table and expects a React renderer to be registered. Without the generated components, the STL editor can still work, but the live preview inside Studio may look empty or break.
Do this in the Sanity Studio app.
A. Install the CLI
pnpm add -D structured-table-cliB. Generate the React table components (from the directory where sanity.config lives)
Standalone Studio (a pnpm-lock.yaml next to that package.json):
pnpm exec stl-cli add reactBy default the CLI creates a folder of components; you can pick the output path:
pnpm exec stl-cli add react --path ./schemaTypes/stl-table-react(npx stl-cli is fine too if you prefer; use the same package manager your Studio uses so installs stay consistent.)
In a pnpm (or yarn) workspace, structured-table-cli only looks for a lockfile in the current working directory. Run it from the Studio package directory that has (or can see) that lockfile so the CLI does not pick the wrong package manager.
C. Register the generated register module in sanity.config.ts
Add an import that runs the registration side effects before the rest of the config. Path must match the folder you generated (look for a register file inside it):
import './schemaTypes/stl-table-react/register';
import { defineConfig } from 'sanity';
// ... other imports
export default defineConfig({
// projectId, dataset, plugins, schema, ...
});If you used the CLI default output layout, the path is often ./components/stl-render-react/latest/register instead.
D. Restart the Studio (sanity dev or your package script) so Vite loads the new files.
4) Use the helpers in your schema
Use either helper in your schema:
import { defineType, defineField } from 'sanity';
import { block_table, block_tableField } from '@droplab/sanity-tools';
export const pageSchema = defineType({
name: 'page',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string' }),
// Thin wrapper: direct stl_table field
block_tableField({
name: 'pricingTable',
title: 'Pricing table',
}),
// Rich block: nested table + optional shared config
block_table({
name: 'comparisonTable',
title: 'Comparison table',
withConfig: true,
configOptions: { additionalThemes: ['brand-night'] },
}),
],
});5) Fetch table data (GROQ)
Minimal GROQ shape for frontend rendering:
*[_type == "page" && slug.current == $slug][0]{
title,
pricingTable,
comparisonTable{
config,
table
}
}- Use
pricingTabledirectly when usingblock_tableField. - Use
comparisonTable.tablewhen usingblock_table. - Use
comparisonTable.configfor optional render decisions (theme/layout/enabled).
6) Frontend usage on your site (structured-table)
The stl_table value is intended to be passed into your table renderer from the structured-table ecosystem.
import { TableView } from '@/components/stl-render-react/latest';
type PageData = {
pricingTable?: unknown;
comparisonTable?: {
config?: {
enabled?: boolean;
theme?: string;
layout?: 'fullWidth' | 'fullBleed' | 'fill' | 'inline';
};
table?: unknown;
};
};
export function PageTables({ data }: { data: PageData })
{
const tableData = data.comparisonTable?.table ?? data.pricingTable;
const isEnabled = data.comparisonTable?.config?.enabled !== false;
if (!isEnabled || !tableData) return null;
return <TableView data={tableData} />;
}Notes:
- The exact table JSON shape is managed by
sanity-plugin-stl-tableand consumed by your chosenstructured-tablerenderer. - For SSR frameworks, keep table querying server-side and pass the resolved table data into your component tree.
block_variantFields
Returns variant (an array of slugs from Settings → Taxonomy variants, plus Custom) and variantCustom. Spread into a parent fields array.
GROQ: block_variant_query resolves both custom and Settings-backed selections into the same shape as Settings taxonomy entries — { label, slug, description? }[] — so frontends never need a separate variantCustom branch. resolveBlockVariant() still returns a string[] of slugs from either the resolved objects or the raw Studio pair. Use blockVariantDataAttribute() for a space-separated data-variant attribute.
Options (BlockVariantFieldsOptions): name (default variant), title, description, customName, customTitle, settingsType, group.
block_link already nests this on links. Use the factory for a custom object that needs the same picker.
Usage
import { defineType, defineField } from 'sanity';
import { block_variantFields } from '@droplab/sanity-tools';
export const promoBlock = defineType({
name: 'promoBlock',
type: 'object',
fields: [
...block_variantFields(),
defineField({ name: 'headline', type: 'string' }),
],
});block_video
Video object. Pass provider for a single backend (hls | file | youtube | vimeo, or mux-plugin when opted in), or omit it for a multi-provider union (same as block_videoField()). Default backends are HLS, local file, YouTube, and Vimeo. Playback toggles: withAutoplay, withLoop, withControls. embedded: true sets ambient defaults (muted, autoplay, loop, no controls) — block_hero / block_card backgrounds do this automatically.
Options (BlockVideoOptions / BlockVideoFieldOptions): withBlockConfig, configOptions, provider / providers, withMux, withAutoplay, withLoop, withControls, deferLeafRequired, embedded.
GROQ: block_video_query.
Related:
block_videoField()— always multi-provider (limit withproviders).block_videoPortableMember()— Portable Textofmember.block_videoType()— register asschema.types, thendefineField({ name: 'video', type: 'block_video' }).
Mux is opt-in. It registers Sanity type mux.video, which does not exist unless the Mux Studio plugin is installed (sanity-plugin-mux-input or @sanity/asset-source-mux). Pass withMux: true, include 'mux-plugin' in providers, or use provider: 'mux-plugin'. HLS (hls) takes a public .m3u8 playlist URL and an optional poster image.
Usage
import { defineType } from 'sanity';
import { block_video, block_videoField } from '@droplab/sanity-tools';
export const videoPageSchema = defineType({
name: 'videoPage',
type: 'document',
fields: [
block_video({
name: 'featuredVideo',
provider: 'youtube',
withControls: true,
}),
block_videoField({
name: 'video',
withMux: true,
withAutoplay: false,
}),
],
});helper_seo
Object for title, description, OG image (JPEG/PNG, 1200×620–630), optional keywords, and optional Is Searchable. Used on documents and nested on document_settings as seo.
Options (HelperSeoOptions): withKeywords (default false), withIsSearchable (default true; forced off inside settings), imageRequired, imageOptions.
GROQ: helper_seo_query.
Usage
import { defineType } from 'sanity';
import { helper_seo } from '@droplab/sanity-tools';
export const siteSettingsSchema = defineType({
name: 'siteSettings',
type: 'document',
fields: [
helper_seo({
name: 'defaultSeo',
title: 'Default SEO',
withKeywords: true,
imageRequired: false,
}),
],
});brandColorPairingsChooser
String field that lists Brand Color Pairings from document_settings (Brand tab). Stores the pairing’s pairingId for frontend lookups such as brandColorPairings[pairingId == $id][0].
Options (BrandColorPairingsChooserOptions): settingsType (default settings), plus shared field knobs (name, title, required, group, …).
Related Studio inputs (also exported from the package root for custom schemas):
| Export | Role |
| --- | --- |
| BrandColorInput | @sanity/color-input wrapper — lists Settings → Brand colours as swatches |
| BrandColorItemInput | Array item editor for brandColors entries |
| BrandColorPairingInput | Array item editor for brandColorPairings entries |
| BrandColorPairingPreview | List/collapsed preview for pairing items |
| BrandColorSelectInput | Searchable swatch picker for a single Brand Color colorId |
| BrandColorPairingsChooserInput | Pairing picker used by brandColorPairingsChooser() |
| brandColorStringValidation / cssBrandColor | Hex / rgb validation and CSS helpers |
block_card({ withBackgroundColor: true }) wires this as bgColor on the Background media → Color branch. block_hero({ withBackgroundColor: true }) and block_section({ withBackgroundColor: true }) wire it as backgroundColor on the Background → Color branch.
GROQ / frontend: block queries project the stored pairingId string when the colour background branch is active (mediaType == "color" on cards, backgroundType == "color" on hero/section). Resolve colours from Settings:
const pairing = settings.brandColorPairings?.find((p) => p.pairingId === block.bgColor);
const bg = settings.brandColors?.find((c) => c.colorId === pairing?.backgroundColor);Usage
import { defineField } from 'sanity';
import { brandColorPairingsChooser } from '@droplab/sanity-tools';
defineField({
name: 'section',
type: 'object',
fields: [
brandColorPairingsChooser({
name: 'themePairing',
title: 'Theme pairing',
}),
],
});helper_phone
Object { countryCode, number } with a combined Studio input. Default dial-code list is PHONE_COUNTRY_CODES (override with countryCodes).
Options (HelperPhoneOptions): countryCodes, countryCodeInitialValue.
Usage
import { defineType, defineField } from 'sanity';
import { helper_phone } from '@droplab/sanity-tools';
export const contactSchema = defineType({
name: 'contact',
type: 'document',
fields: [
defineField({ name: 'title', type: 'string' }),
helper_phone({
name: 'phone',
title: 'Phone',
required: true,
countryCodeInitialValue: '+1',
}),
],
});document_company already uses this for company and per-office phones.
helper_singletons
Factory for Studio structure + document-config helpers around singleton documents. It treats two kinds of singleton:
- Type-level — one document for the whole schema type (
document_company/document_settings, document idscompany/settings). Hidden fromdocumentTypeListItems()and Create menus. - Document-scoped — one document inside a multi-document type (
document_pagematched byslug.current == "home"and/or_id == "home"). Hidden from that type’s document list.
helper_singletons() with no arguments uses those defaults (Company and Settings as type-level; Home as document-scoped). A document array replaces the homepage default only — Company and Settings stay as type-level singletons unless you pass types.
import { helper_singletons, getHelperIcon } from '@droplab/sanity-tools';
import type { StructureResolver } from 'sanity/structure';
const singletons = helper_singletons();
export const structure: StructureResolver = (S, context) =>
S.list()
.title('Content')
.items([
singletons.documentBySlug(S, 'home'),
// Prefer orderableList for drag-and-drop + search; see helper_orderableList.
singletons.orderableList({
type: 'page',
title: 'Pages',
icon: getHelperIcon('page'),
S,
context,
}),
...singletons.filterDocumentTypeListItems(
S.documentTypeListItems().filter((item) => item.getId() !== 'page')
),
S.divider(),
singletons.documentByType(S, 'company'),
singletons.documentByType(S, 'settings'),
]);documentByType, documentBySlug, and documentById share a three-argument shape: (S, stringValue, options?).
singletons.documentByType(S, 'settings')
singletons.documentByType(S, 'company', {
title: 'Company',
icon: getHelperIcon('company'),
})
singletons.documentBySlug(S, 'home', { icon: getHelperIcon('page') })
singletons.documentById(S, 'landing', { icon: getHelperIcon('page') })Wire create guards so editors cannot spawn another Settings doc or reuse the Home slug:
// sanity.config.ts
plugins: [singletons.plugin(), structureTool({ structure })]
// page schema
document_page({
blockMembers,
slugValidation: singletons.slugFieldValidation(),
})singletons.plugin() hides type-level singletons (company, settings) from Create menus and templates, and blocks duplicate / delete / unpublish on all singleton docs (type-level and document-scoped). slugFieldValidation() rejects reserved slugs (e.g. home) on any document that is not the configured singleton _id.
Each document entry needs slug and/or id (id defaults to slug; type defaults to page). Pass a document array, or { types, slugs } for full control — empty arrays disable that category:
const singletons = helper_singletons([
{ type: 'page', slug: 'home', id: 'home', title: 'Home' },
{ type: 'page', id: 'landing', title: 'Landing' },
])
export const structure: StructureResolver = (S) =>
S.list().title('Content').items([
singletons.documentBySlug(S, 'home'),
singletons.documentById(S, 'landing'),
S.listItem()
.title('Custom pin')
.child(singletons.documentById(S, 'abc-123', { as: 'document' })),
S.listItem()
.title('Pages')
.child(singletons.documentTypeList(S, 'page', 'Pages')),
S.divider(),
singletons.documentByType(S, 'company'),
singletons.documentByType(S, 'settings'),
])documentBySlug/documentById/documentByType— structure list items ((S, stringValue, options?); passas: 'document'for.child(…))documentTypeList— type list with matching document singletons removed (by slug and/or_id)orderableList— searchable orderable desk item that auto-excludes document singletons for that type (seehelper_orderableList)plugin()— hides type-level singletons from Create menus + templates; blocks duplicate / delete / unpublish on all singleton docsslugFieldValidation()— only the configured singleton_idmay use reserved slugs (e.g.home)document—{ newDocumentOptions, actions }if you prefer spreading intodefineConfigmanuallyfilterDocumentTypeListItems— drop type-level singletons fromS.documentTypeListItems()newDocumentOptions/actions/filterTemplates/canHandleIntent— individual guardsreferenceFilter— GROQ filter so reference pickers cannot select configured singletons
helper_orderableList
Wraps @sanity/orderable-document-list’s orderableDocumentListDeskItem with:
- a live search field at the top of the list
- optional singleton exclusion (same GROQ filters as
documentTypeList)
Prefer singletons.orderableList({ … }) so document-scoped singletons (e.g. Home on page) are always filtered out. Use helper_orderableList directly when you do not need singleton handling, or pass singletons yourself. The underlying list UI is SearchableOrderableDocumentList (also exported for custom structure if needed).
Requirements
- Install the peer plugin in your Studio:
pnpm add @sanity/orderable-document-list- Add
orderRankFieldto the document schema (required by the plugin):
import { orderRankField } from '@sanity/orderable-document-list';
import { document_page } from '@droplab/sanity-tools';
export const page = document_page({
blockMembers,
slugValidation: singletons.slugFieldValidation(),
additionalFields: [
orderRankField({ type: 'page' }),
],
});- Use a structure resolver that receives
context((S, context) => …) and pass bothSandcontextinto the helper.
On first load, open the list menu → Reset Order so documents get ranks.
Usage
import { helper_singletons, getHelperIcon } from '@droplab/sanity-tools';
import type { StructureResolver } from 'sanity/structure';
const singletons = helper_singletons();
export const structure: StructureResolver = (S, context) =>
S.list()
.title('Content')
.items([
singletons.documentBySlug(S, 'home'),
singletons.orderableList({
type: 'page',
title: 'Pages',
icon: getHelperIcon('page'),
// Appended to defaults; duplicates are dropped
additionalSearchFields: ['seo.title'],
S,
context,
}),
S.divider(),
singletons.documentByType(S, 'company'),
singletons.documentByType(S, 'settings'),
]);Without singletons (or with an explicit instance):
import { helper_orderableList } from '@droplab/sanity-tools';
helper_orderableList({
type: 'article',
title: 'Articles',
S,
context,
// singletons, // optional — same exclusion as documentTypeList
})Search
Typing in the search box updates the list in realtime via GROQ match.
Default fields: title, name, label, slug.current (see DEFAULT_ORDERABLE_SEARCH_FIELDS).
| Option | Description |
| --- | --- |
| additionalSearchFields | Extra GROQ paths appended to the defaults (or to searchFields when that is an array). Trimmed and de-duplicated. |
| searchFields | Replace the base list entirely, or pass false to disable search and use the stock orderable list. |
| searchPlaceholder | Placeholder text for the search input. |
Options (HelperOrderableListOptions)
| Option | Description |
| --- | --- |
| type | Schema _type (required). |
| title | Sidebar item and list pane header title (default Orderable ${type}). |
| id | Structure list id (default orderable-${type}). Required when you have multiple orderable lists of the same type. |
| icon | List icon (default getHelperIcon(type)). |
| filter / params | Extra GROQ filter (AND’d with singleton exclusion when applicable). |
| menuItems | Extra structure menu items. |
| createIntent | When false, hide “Create new”. |
| S / context | From structure: (S, context) => … (required). |
| singletons | When set (always for singletons.orderableList), excludes configured document singletons for type. |
| searchFields | Replace the base searchable fields, or false to disable search. |
| additionalSearchFields | Extra fields appended to the defaults (de-duplicated). |
| searchPlaceholder | Search input placeholder. |
Query ordered documents with GROQ: *[_type == "page"]|order(orderRank).
getHelperIcon / getHelperIconType
Studio icons used by the document and block helpers (page, person, hero, card, …). getHelperIcon(key) returns a theme-aware React component; getHelperIconType(key) returns the raw react-icons component. Keys also match prefixes (block_hero → hero).
Usage
import { defineType } from 'sanity';
import { getHelperIcon } from '@droplab/sanity-tools';
export const landing = defineType({
name: 'landing',
type: 'document',
icon: getHelperIcon('page'),
fields: [],
});Utilities
noDuplicates()
Array validation that rejects repeated entries. Unlike the built-in rule.unique(), it compares items after stripping _key and blank members, so object and reference items are actually caught. Blank entries are skipped (pair with rule.required() if they should be flagged), and each error is attached to the offending array item so the Studio highlights it.
import { defineArrayMember, defineField } from 'sanity';
import { noDuplicates, noDuplicatesValidator } from '@droplab/sanity-tools';
defineField({
name: 'tags',
type: 'array',
of: [defineArrayMember({ type: 'string' })],
validation: noDuplicates(),
});
defineField({
name: 'offices',
type: 'array',
of: [defineArrayMember({ type: 'office' })],
// Compare specific fields instead of the whole item.
validation: noDuplicates({ key: ['city', 'state'], label: 'Office' }),
});
defineField({
name: 'related',
type: 'array',
of: [defineArrayMember({ type: 'reference', to: [{ type: 'page' }] })],
// Chain alongside other rules.
validation: (rule) => rule.required().min(1).custom(noDuplicatesValidator()),
});Options (NoDuplicatesOptions):
| Option | Description |
| --- | --- |
| key | Field path ('label', 'link.href'), array of paths for a compound key, or a function deriving the compared value. Defaults to the reference _ref when present, otherwise the whole item. |
| caseSensitive | Compare strings case-sensitively. Defaults to false. |
| label | Noun used in the message, e.g. Tag "news" is listed more than once. |
| message | String, or a function receiving DuplicateInfo (value, index, firstIndex, display, label). |
| warning | Report as a warning rather than an error. |
noDuplicatesValidator(options) returns the bare custom validator for chaining onto an existing rule.
