Unique slugs for nested projects within clients in Sanity schema

18 replies
Last updated: Nov 29, 2025
Morning everyone 🙂
If I wanted to create a structure like this, how would I best do it?


Clients -> Projects
ie.

Client One
   - A Cool Project
   - Project Example
   - Project X
Client Two
   - Another Project
   - Project X
   - Something Else

Clients
would include a unique slug for the client name, and
Projects
would be nested under
Clients
, again with a unique slug.
The question mark for me is I don't want one
Client
to include projects with the same slug, as the URL would look like
client-one/code-veronica
so would need to be unique. However, another
Client
could include a matching slug, as it would still be unique in this case.
ie.

client-one/project-x
- Valid
client-one/project-x
- Invalid (matches previous)
client-two/project-x
- Valid
AI Update

Great question! This is a common pattern for client/project relationships. You'll want to use reference fields to connect projects to clients, and implement custom validation to ensure slug uniqueness within each client.

Schema Structure

Create two document types with a reference relationship:

// schemas/client.js
export default {
  name: 'client',
  type: 'document',
  fields: [
    {
      name: 'name',
      type: 'string',
      validation: Rule => Rule.required()
    },
    {
      name: 'slug',
      type: 'slug',
      options: {
        source: 'name'
      },
      validation: Rule => Rule.required()
    }
  ]
}

// schemas/project.js
export default {
  name: 'project',
  type: 'document',
  fields: [
    {
      name: 'title',
      type: 'string',
      validation: Rule => Rule.required()
    },
    {
      name: 'client',
      type: 'reference',
      to: [{type: 'client'}],
      validation: Rule => Rule.required()
    },
    {
      name: 'slug',
      type: 'slug',
      options: {
        source: 'title'
      },
      validation: Rule => Rule.required().custom(async (slug, context) => {
        const {document, getClient} = context
        const client = getClient({apiVersion: '2023-01-01'})
        
        if (!slug?.current || !document?.client?._ref) {
          return true // Let required validation handle empty values
        }
        
        const query = `count(*[
          _type == "project" && 
          slug.current == $slug && 
          client._ref == $clientRef &&
          _id != $id
        ])`
        
        const params = {
          slug: slug.current,
          clientRef: document.client._ref,
          id: document._id
        }
        
        const count = await client.fetch(query, params)
        
        return count === 0 ? true : 'This slug is already used by another project for this client'
      })
    }
  ]
}

How It Works

  1. Projects reference clients using a reference field, which creates the parent-child relationship
  2. Custom validation on the project slug checks for duplicates within the same client using a GROQ query
  3. The validation allows client-one/project-x and client-two/project-x to coexist, but prevents duplicate slugs within the same client

Structure Tool Setup

To get a nice nested view in Studio, configure your structure builder:

// structure/index.js
export const structure = (S) =>
  S.list()
    .title('Content')
    .items([
      S.listItem()
        .title('Clients')
        .child(
          S.documentTypeList('client')
            .title('Clients')
            .child((clientId) =>
              S.documentList()
                .title('Projects')
                .filter('_type == "project" && client._ref == $clientId')
                .params({clientId})
            )
        ),
      S.divider(),
      ...S.documentTypeListItems()
    ])

This gives you a nested view where clicking a client shows their projects.

Querying for URLs

When you need to build URLs on your frontend:

*[_type == "project"] {
  title,
  "slug": slug.current,
  "clientSlug": client->slug.current,
  "fullPath": client->slug.current + "/" + slug.current
}

The custom validation ensures your URL structure stays unique exactly as you described! The slug field type handles the URL-friendly formatting, and reference fields create the bidirectional relationship between clients and projects.

Show original thread
18 replies

Sanity – Build the way you think, not the way your CMS thinks

Sanity is the developer-first content operating system that gives you complete control. Schema-as-code, GROQ queries, and real-time APIs mean no more workarounds or waiting for deployments. Free to start, scale as you grow.

Was this answer helpful?