Type something to search...
Building Reusable Object Types in Sanity

Building Reusable Object Types in Sanity

A reusable object type in Sanity is a group of fields defined once with defineType({ type: 'object', ... }), given a unique name and registered in your schema, so any document or array can use it simply by setting type to that name. Instead of repeating the same SEO, link or call-to-action fields across ten document types, you define seo, link or cta in one file and write defineField({ name: 'seo', type: 'seo' }) wherever you need it. Change the object once, and every place that uses it updates.

Reusable objects keep your schema small, consistent and easy to change, and they pair naturally with reusable frontend components and GROQ fragments. This guide shows the difference between inline and named objects, how to build and register them, practical examples you'll use on most projects, how to use them in arrays and page builders, how to customise them per use, and the pitfalls to avoid.

Inline Objects vs Named Object Types

You can define an object inline, directly inside a field:

defineField({
  name: 'seo',
  type: 'object',
  fields: [
    defineField({ name: 'metaTitle', type: 'string' }),
    defineField({ name: 'metaDescription', type: 'text', rows: 2 }),
  ],
}),

That's fine for a one-off. But if post, page and product all need SEO fields, you'd copy that block three times and inevitably let them drift. A named object type solves this:

Inline objectNamed object type
DefinedInside one fieldIn its own file, registered in the schema
ReuseCopy and pastetype: 'seo' anywhere
In arraysMust be defined inline each timeListed by name, and each item stores its _type
Generated typesInline shapeA named type in sanity.types.ts
Best forOne-off groupsAnything used twice or more

Objects are never standalone: they live inside documents and can't be referenced. If you need to share the content, not just the shape, use a document and a reference instead, as explained in how to use references in Sanity schemas.

Step 1: Create the Object Type

A common layout is to separate documents and objects:

schemaTypes/
├── documents/
│   ├── page.ts
│   └── post.ts
├── objects/
│   ├── seo.ts
│   ├── link.ts
│   └── cta.ts
└── index.ts

Define the object:

// schemaTypes/objects/seo.ts
import { defineField, defineType } from 'sanity';

export const seo = defineType({
  name: 'seo',
  title: 'SEO',
  type: 'object',
  options: { collapsible: true, collapsed: true },
  fields: [
    defineField({
      name: 'metaTitle',
      title: 'Meta title',
      type: 'string',
      validation: (rule) => rule.max(60).warning('Search engines may truncate long titles'),
    }),
    defineField({
      name: 'metaDescription',
      title: 'Meta description',
      type: 'text',
      rows: 2,
      validation: (rule) => rule.max(160).warning('Keep descriptions under 160 characters'),
    }),
    defineField({
      name: 'ogImage',
      title: 'Social sharing image',
      type: 'image',
    }),
    defineField({
      name: 'noIndex',
      title: 'Hide from search engines',
      type: 'boolean',
      initialValue: false,
    }),
  ],
});

options.collapsible and collapsed render the object as a collapsed panel, which keeps long forms tidy.

Step 2: Register It

Named types must be in the schema's types array, alongside your documents:

// schemaTypes/index.ts
import { page } from './documents/page';
import { post } from './documents/post';
import { cta } from './objects/cta';
import { link } from './objects/link';
import { seo } from './objects/seo';

export const schemaTypes = [
  // Documents
  post,
  page,
  // Objects
  seo,
  link,
  cta,
];

Registration order doesn't matter. Names must be unique across all types, documents and objects alike.

Step 3: Use It Anywhere

// schemaTypes/documents/post.ts
import { defineField, defineType } from 'sanity';

export const post = defineType({
  name: 'post',
  title: 'Post',
  type: 'document',
  fields: [
    defineField({ name: 'title', type: 'string' }),
    defineField({ name: 'slug', type: 'slug', options: { source: 'title' } }),
    defineField({ name: 'seo', title: 'SEO', type: 'seo' }),
  ],
});

When you use a named type, you can still set field-level properties such as title, description, group, hidden and validation on that particular field. What you can't do is add or remove fields inside the object from the outside. For that, see the factory pattern later in this article.

Practical Examples

A Link Object With Internal and External Options

Links are everywhere: navigation, buttons, cards. One object handles both internal and external targets with conditional fields:

// schemaTypes/objects/link.ts
import { defineField, defineType } from 'sanity';

export const link = defineType({
  name: 'link',
  title: 'Link',
  type: 'object',
  fields: [
    defineField({
      name: 'linkType',
      title: 'Link type',
      type: 'string',
      options: {
        list: [
          { title: 'Internal page', value: 'internal' },
          { title: 'External URL', value: 'external' },
        ],
        layout: 'radio',
        direction: 'horizontal',
      },
      initialValue: 'internal',
    }),
    defineField({
      name: 'internal',
      title: 'Page',
      type: 'reference',
      to: [{ type: 'page' }, { type: 'post' }],
      hidden: ({ parent }) => parent?.linkType !== 'internal',
    }),
    defineField({
      name: 'external',
      title: 'URL',
      type: 'url',
      hidden: ({ parent }) => parent?.linkType !== 'external',
      validation: (rule) => rule.uri({ scheme: ['https', 'http', 'mailto', 'tel'] }),
    }),
    defineField({
      name: 'openInNewTab',
      title: 'Open in new tab',
      type: 'boolean',
      initialValue: false,
    }),
  ],
});

Inside an object, parent in hidden refers to the object's own values, which is what makes it reusable: the logic works wherever the object is placed.

A Call to Action That Reuses Another Object

Objects can contain other named objects:

// schemaTypes/objects/cta.ts
import { defineField, defineType } from 'sanity';

export const cta = defineType({
  name: 'cta',
  title: 'Call to action',
  type: 'object',
  fields: [
    defineField({
      name: 'label',
      title: 'Label',
      type: 'string',
      validation: (rule) => rule.required().max(30),
    }),
    defineField({ name: 'link', title: 'Link', type: 'link' }),
    defineField({
      name: 'style',
      title: 'Style',
      type: 'string',
      options: { list: ['primary', 'secondary'], layout: 'radio' },
      initialValue: 'primary',
    }),
  ],
  preview: {
    select: { title: 'label', subtitle: 'style' },
  },
});

Building small objects and composing them is more flexible than one large object that tries to do everything.

Using Objects in Arrays and Page Builders

Named objects really pay off in arrays. Each item in an array of named objects stores its _type, so your frontend knows which component to render:

// schemaTypes/documents/page.ts
import { defineArrayMember, defineField, defineType } from 'sanity';

export const page = defineType({
  name: 'page',
  title: 'Page',
  type: 'document',
  fields: [
    defineField({ name: 'title', type: 'string' }),
    defineField({ name: 'slug', type: 'slug', options: { source: 'title' } }),
    defineField({
      name: 'sections',
      title: 'Sections',
      type: 'array',
      of: [
        defineArrayMember({ type: 'hero' }),
        defineArrayMember({ type: 'featureGrid' }),
        defineArrayMember({ type: 'cta' }),
      ],
    }),
    defineField({ name: 'seo', type: 'seo' }),
  ],
});

Here hero and featureGrid are named object types defined and registered the same way as cta. The stored data looks like this:

"sections": [
  { "_key": "k1", "_type": "hero", "heading": "Welcome" },
  { "_key": "k2", "_type": "cta", "label": "Get started", "style": "primary" }
]

Give every section object a preview so the array reads like an outline in Studio rather than a list of "Untitled" items. For more on array options, see working with arrays in Sanity schemas.

Customising Objects Per Use With Factories

Sometimes you need a variation, such as an image object that requires alt text in one place but not another. A factory function returns a configured type:

// schemaTypes/objects/accessibleImage.ts
import { defineField, defineType } from 'sanity';

export function accessibleImage(options: { name: string; altRequired?: boolean }) {
  return defineType({
    name: options.name,
    title: 'Image',
    type: 'image',
    options: { hotspot: true },
    fields: [
      defineField({
        name: 'alt',
        title: 'Alternative text',
        type: 'string',
        validation: (rule) => (options.altRequired ? rule.required() : rule),
      }),
    ],
  });
}

export const contentImage = accessibleImage({ name: 'contentImage', altRequired: true });
export const decorativeImage = accessibleImage({ name: 'decorativeImage' });

Register both contentImage and decorativeImage, and use whichever fits. Factories are plain TypeScript, which is one of the benefits of schema-as-code. Keep them simple, though: if a factory has many switches, two separate types are usually clearer.

Matching GROQ Fragments and Components

The same "define once" idea applies on the frontend. Keep a GROQ fragment for each object:

// src/sanity/fragments.ts
export const linkFragment = `
  linkType,
  external,
  openInNewTab,
  "href": select(
    linkType == "internal" => "/" + internal->slug.current,
    external
  )
`;

export const ctaFragment = `label, style, link{ ${linkFragment} }`;
const PAGE_QUERY = `*[_type == "page" && slug.current == $slug][0]{
  title,
  sections[]{
    _key,
    _type,
    _type == "cta" => { ${ctaFragment} }
  }
}`;

And a matching React component:

// src/components/Cta.tsx
type CtaProps = {
  label: string;
  style: 'primary' | 'secondary';
  link: { href: string | null; openInNewTab?: boolean };
};

export function Cta({ label, style, link }: CtaProps) {
  if (!link?.href) return null;
  return (
    <a
      href={link.href}
      className={style === 'primary' ? 'btn btn-primary' : 'btn btn-secondary'}
      target={link.openInNewTab ? '_blank' : undefined}
      rel={link.openInNewTab ? 'noopener noreferrer' : undefined}
    >
      {label}
    </a>
  );
}

If you use Sanity TypeGen, named objects become named types in sanity.types.ts, which makes these props easy to type. The GROQ projections guide shows more ways to shape the output.

Pitfalls to Avoid

  • Name collisions: an object named image or link might clash with a plugin's type. Prefix if needed, for example siteLink.
  • Renaming a type used in arrays: array items store _type, so renaming the object breaks existing items until you migrate them.
  • Deep nesting: objects inside objects inside arrays quickly become hard for editors. Two or three levels is a sensible limit.
  • Giant catch-all objects: one object with 30 optional fields is harder to use than three focused ones.
  • Expecting shared content: objects share structure, not data. Editing an SEO object on one post doesn't change another post.

FAQ: Reusable Object Types in Sanity

A document is a top-level item with its own ID that editors create, publish and reference. An object is a group of fields embedded inside a document or array. Objects can't be referenced or published on their own, but named object types can be reused across many documents.

No. References only point to documents. If you need several documents to share the same data, rather than the same structure, make it a document type and link to it with a reference field.

You can set field-level properties such as title, description, group, hidden and validation where you use the object, but you can't add or remove its inner fields from outside. For variations, use a factory function that returns differently configured types.

Studio can't find a field to use as a title. Add a preview to the object type with a select mapping to a meaningful field, such as heading or label, and optionally a prepare function for a subtitle and media.

Items in arrays store a _type value matching the object's name, so you can switch on it and render the right component. Include _type and _key in your GROQ projection for array items so the frontend has both the type and a stable key.

A common convention is a schemaTypes/objects folder with one file per object, and a schemaTypes/documents folder for document types. Import all of them into schemaTypes/index.ts and export a single array for sanity.config.ts.


Conclusion

Reusable object types are one of the simplest ways to keep a Sanity schema maintainable. Define a group of fields once as a named object, register it, and use it anywhere with type: 'yourObject'. Objects like seo, link and cta show up in almost every project, and composing small objects into larger ones keeps each piece easy to understand.

Carry the same idea to your frontend with matching GROQ fragments and components, add previews so arrays stay readable, and reach for factories only when you need real variations. For the field-level rules that make these objects reliable, read how to add validation rules to Sanity fields, and for wider modelling advice see content modelling best practices.

Tags :
Share :

Related Posts

Building a Static Site With Astro and Sanity

Building a Static Site With Astro and Sanity

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

Dive Deeper
Common Sanity Mistakes and How to Fix Them

Common Sanity Mistakes and How to Fix Them

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

Dive Deeper
How to Create a Custom Sanity Plugin

How to Create a Custom Sanity Plugin

To create a custom Sanity plugin, wrap a piece of Studio configuration in definePlugin from the sanity package and add it to the plugins ar

Dive Deeper