Skip to content
← All articles

How we designed the Sanity schema for innatus.digital

The content model behind our rebuild. Singletons, field groups, multi-purpose schemas, and the decisions that shaped how editors work with the site.

How we designed the Sanity schema for innatus.digital

Every CMS rebuild starts with the same question: how should the content be structured? Get it wrong and editors fight the system for years. Get it right and they barely notice it.

This is how we designed the Sanity schema for innatus.digital.

siteSettings as a singleton

The homepage hero, the shared CTA section, the about page, B Corp content, CSR sections. All of it lives in one document called `siteSettings`. Not five documents. Not a page builder. One document with field groups.

export const siteSettings = defineType({
  name: 'siteSettings',
  title: 'Site Settings',
  type: 'document',
  fields: [
    defineField({ name: 'heroEyebrow', title: 'Homepage Hero Eyebrow', type: 'string', group: 'hero' }),
    defineField({ name: 'heroHeadline', title: 'Homepage Hero Headline', type: 'string', group: 'hero' }),
    defineField({ name: 'heroHighlight', title: 'Hero Highlight Word', type: 'string', group: 'hero' }),
    // ... more fields per group
  ],
  groups: [
    { name: 'hero', title: 'Homepage Hero' },
    { name: 'cta', title: 'CTA Section' },
    { name: 'about', title: 'About Page' },
    { name: 'bcorp', title: 'B Corp' },
    { name: 'csr', title: 'CSR' },
  ],
})

The groups turn what could be a chaotic 30-field form into five clean tabs. An editor clicks "B Corp", sees the B Corp fields, updates them, publishes. No scrolling through unrelated content.

In `sanity.config.ts`, a custom desk structure pins it as a singleton:

structureTool({
  structure: (S) =>
    S.list()
      .title('Content')
      .items([
        S.listItem()
          .title('Site Settings')
          .id('siteSettings')
          .child(
            S.document()
              .schemaType('siteSettings')
              .documentId('siteSettings')
          ),
        S.divider(),
        ...S.documentTypeListItems().filter(
          (item) => !['siteSettings'].includes(item.getId()!)
        ),
      ]),
}),

This means there is exactly one siteSettings document. Always. No accidental duplicates.

Service schema with field groups

Each service page has a lot going on: title, slug, description, illustration, features grid, testimonial reference, FAQs, CTA overrides. Without organisation, this would be a wall of fields.

Field groups split it into three tabs: Content, Detail, and CTA.

export const service = defineType({
  name: 'service',
  title: 'Service',
  type: 'document',
  groups: [
    { name: 'content', title: 'Content', default: true },
    { name: 'detail', title: 'Detail' },
    { name: 'cta', title: 'CTA' },
  ],
  fields: [
    defineField({ name: 'title', title: 'Title', type: 'string', group: 'content', validation: r => r.required() }),
    defineField({ name: 'description', title: 'Short Description', type: 'text', group: 'content', rows: 4, validation: r => r.required().max(300) }),
    defineField({
      name: 'faqs',
      title: 'FAQs',
      type: 'array',
      group: 'detail',
      of: [{
        type: 'object',
        fields: [
          defineField({ name: 'question', title: 'Question', type: 'string', validation: r => r.required() }),
          defineField({ name: 'answer', title: 'Answer', type: 'text', rows: 4, validation: r => r.required() }),
        ],
      }],
    }),
  ],
})

The FAQs array does double duty. It renders on the service page and generates FAQPage structured data for Google. One source of truth, two outputs.

Validation matters here. The description is capped at 300 characters because it appears in cards and meta descriptions. Required fields on FAQs prevent half-finished entries from breaking the schema markup.

One schema, three categories

We needed partner logos, charity logos, and certification badges. Three separate schemas would mean three separate lists in the studio, three separate queries, three sets of identical fields.

Instead, one `logoPartner` schema with a category field:

export const logoPartner = defineType({
  name: 'logoPartner',
  title: 'Logo / Partner',
  type: 'document',
  fields: [
    defineField({ name: 'name', title: 'Name', type: 'string', validation: r => r.required() }),
    defineField({ name: 'logo', title: 'Logo', type: 'image', validation: r => r.required() }),
    defineField({
      name: 'category',
      title: 'Category',
      type: 'string',
      options: { list: [
        { title: 'Partner', value: 'partner' },
        { title: 'Charity', value: 'charity' },
        { title: 'Certification', value: 'certification' },
      ]},
      validation: r => r.required(),
    }),
    defineField({ name: 'url', title: 'Website URL', type: 'url' }),
    defineField({ name: 'order', title: 'Display Order', type: 'number' }),
  ],
})

Frontend queries filter by category. The editor manages all logos in one list. Simple.

What I would change

The `heroHighlight` field, where you type which word in the headline gets the brand colour treatment. It works, but it is fragile. If you change the headline and forget to update the highlight word, nothing breaks visibly, it just stops highlighting. A mark-based approach in Portable Text would be more reliable.

But for a small site with one editor (me), it is fine. Pragmatism over perfection.

Next in the series: [Migrating 90 posts from WordPress to Sanity](/digital-insights/migrating-90-posts-from-wordpress-to-sanity)

Chris Ryan

Chris Ryan

Managing Director

17+ years in full-stack web development, most of it leading teams agency-side across e-commerce, CMS platforms, and bespoke applications. Specialises in infrastructure, system integration, and data privacy, with hands-on experience as a Data Protection Officer. Founded Innatus Digital in 2020 to offer the kind of honest, technically-led partnership that he felt was missing from the agency world.