How to Dynamically Filter Payload CMS Relationship Fields Based on Sibling Data in Array Fields

Use siblingData-powered filterOptions to keep relationship dropdowns in sync inside Payload arrays.

·Matija Žiberna·
How to Dynamically Filter Payload CMS Relationship Fields Based on Sibling Data in Array Fields

📚 Comprehensive Payload CMS Guides

Detailed Payload guides with field configuration examples, custom components, and workflow optimization tips to speed up your CMS development process.

No spam. Unsubscribe anytime.

I was building an inventory management system with Payload CMS when I hit a frustrating wall. I had an array field with products and variants, and I needed the variant dropdown to show only variants belonging to the selected product. Sounds simple, right? The standard filterOptions approach wasn't working, custom field components with useFormFields weren't triggering re-queries, and I kept seeing all variants in the dropdown regardless of which product was selected.

After several hours of debugging and trying different approaches, I discovered the correct pattern. This guide shows you exactly how to implement dynamic relationship filtering in Payload CMS array fields using server-side filtering with siblingData. If you prefer building fully custom React components to fetch options on the client, I walk through that alternative in How to Create Dynamic Cross-Collection Dropdowns in Payload CMS v3.

The Problem: Relationship Fields Showing All Options

When you have nested fields in Payload CMS arrays, filtering relationship options based on sibling field values isn't straightforward. Here's the scenario I encountered:

I had a collection with an array field containing products and their variants. Each array row had two relationship fields: one for selecting a product, and one for selecting a variant. The variant field needed to show only variants that belonged to the selected product in that same row.

The typical approaches that developers try first all fail in this context:

Static filterOptions don't work because they can't access the dynamic product selection. You might try something like this:

// File: src/collections/DeliveryWindowBatches.ts
{
  name: "variant",
  type: "relationship",
  relationTo: "product-variants",
  filterOptions: {
    product: {
      equals: 2  // This is static - doesn't help
    }
  }
}

This filters variants, but only to a hardcoded product ID. It doesn't respond to user selections.

Custom field components seem promising but fail because they modify filterOptions client-side, which doesn't trigger the RelationshipField to re-fetch options from the server. I tried building a custom component using useFormFields to read the product value and modify the field config:

// File: src/components/fields/FilteredVariantField.tsx (DOESN'T WORK)
'use client'

import { RelationshipField, useFormFields } from '@payloadcms/ui'

export const FilteredVariantField = (props) => {
  const { path, field } = props
  const productPath = path.replace('.variant', '.product')

  const product = useFormFields(([fields]) => {
    return fields[productPath] || null
  })

  const productId = product?.value

  const fieldWithFilter = {
    ...field,
    filterOptions: productId ? {
      product: { equals: productId }
    } : {}
  }

  return <RelationshipField {...props} field={fieldWithFilter} />
}

The problem here is that Payload's fields are stored flat with dot notation as keys like fields['products.0.product'], not nested like fields.products[0].product. Even after fixing that path issue, the component reads the product value correctly and creates the filter, but the RelationshipField component doesn't re-query the API when the filterOptions object changes. The dropdown still shows all variants because the filter is applied client-side after the data has already been fetched.

Error messages you might encounter:

  • "Payload CMS filterOptions not working" - The filter object is ignored
  • "Payload relationship field shows all options" - Dropdown shows everything
  • "useFormFields relationship filter" - The hook returns data but doesn't trigger re-fetches

The core issue is that filterOptions is evaluated server-side when Payload makes the API call to fetch relationship options. Modifying the field config client-side has no effect on that server-side query.

The Solution: Server-Side filterOptions with siblingData

The correct approach uses filterOptions as a function that runs server-side and has access to siblingData. This function is called when Payload fetches the relationship options, and it receives context about other fields in the same array row.

Here's the working implementation:

// File: src/collections/DeliveryWindowBatches.ts
{
  name: "products",
  type: "array",
  fields: [
    {
      type: "row",
      fields: [
        {
          name: "product",
          type: "relationship",
          relationTo: "products",
          required: true,
          filterOptions: {
            inventoryAllocationMode: {
              equals: 'delivery_window',
            },
          },
        },
        {
          name: "variant",
          type: "relationship",
          relationTo: "product-variants",
          required: false,
          filterOptions: ({ siblingData }) => {
            // siblingData contains other fields in the same row
            if (siblingData?.product) {
              return {
                product: {
                  equals: siblingData.product,
                },
              };
            }
            return {};
          },
        },
      ],
    },
    // ... other fields
  ],
}

The key insight is that filterOptions accepts a function, not just an object. This function receives an argument object with several properties, and the one we need is siblingData. According to Payload's documentation, the function is called with these properties:

  • siblingData - An object containing document data scoped to fields within the same parent
  • data - The full collection or global document being edited
  • id - The current document ID
  • relationTo - The collection slug to filter against
  • user - The currently authenticated user
  • req - The Payload Request object

For array fields specifically, siblingData gives you access to other fields in the same array row. When a user selects a product in products.0.product, the variant field at products.0.variant can access that selection through siblingData.product.

The function runs server-side when Payload constructs the API query to fetch relationship options. This means the filtering happens at the database level, and only the relevant variants are returned to the client. The dropdown shows exactly what it should.

Understanding the Data Flow

To understand why this works, you need to grasp how Payload handles relationship fields:

When a RelationshipField component renders, it makes an API call to fetch available options from the related collection. This API call happens on the server, and Payload constructs a database query based on the field's filterOptions. If filterOptions is a static object, that filter is applied to every query. If it's a function, Payload calls that function server-side, passing in context about the current document state, and uses the returned filter object for the query.

For array fields, the siblingData parameter provides the current values of other fields in the same array row. This happens in real-time as the user interacts with the form. When you select a product, Payload updates the form state, and when you click on the variant dropdown, it calls the filterOptions function with the updated siblingData containing your product selection.

The crucial distinction is server-side versus client-side filtering. Custom field components run client-side and can read form state using hooks like useFormFields, but they cannot modify the server-side query that fetches relationship options. The filterOptions function runs server-side as part of query construction, which is why it works.

Implementation Checklist

When implementing this pattern in your own Payload collections, follow these steps:

First, identify the relationship between your fields. In my case, variants belong to products through a product relationship field in the product-variants collection. The variant field in my array needs to filter based on the product field in the same row.

Second, structure your array fields with the parent field first. Place the product field before the variant field so users select the product first, which makes the UX intuitive.

Third, implement the filterOptions function on the dependent field. Use the pattern shown above, checking if the required sibling field has a value before returning the filter object. If the sibling field is empty, return an empty object to show all options or no options, depending on your requirements.

Fourth, test the behavior carefully. Select different products and verify the variant dropdown updates correctly. Check that the filter works when creating new documents and when editing existing ones. Test with multiple array rows to ensure each row filters independently.

Common Pitfalls

A few mistakes can trip you up when implementing this pattern:

Accessing the wrong data structure. Form fields in Payload are stored flat with dot notation as keys, not nested objects. If you're debugging with useFormFields, remember that you access fields['products.0.product'] not fields.products[0].product. This matters if you try to build custom components.

Forgetting the function signature. The filterOptions function receives a single argument object, not separate parameters. Destructure the properties you need like ({ siblingData, data, user }).

Returning the wrong value. The function must return either a Where query object, true to not filter, or false to prevent all options. Returning undefined or null will cause errors. Always return an empty object {} if you want to show all options.

Not handling empty state. Check if the sibling field has a value before using it in your filter. If the user hasn't selected a product yet, siblingData.product will be undefined, and you need to handle that gracefully.

Extending the Pattern

This pattern works for any relationship filtering scenario in array fields. You can filter based on multiple sibling fields by checking multiple values in siblingData. You can combine static filters with dynamic filters by merging filter objects. You can even access nested data or perform complex logic before returning the filter.

For example, you could filter based on a select field's value:

filterOptions: ({ siblingData }) => {
  if (siblingData?.category === 'electronics') {
    return {
      type: { in: ['phone', 'tablet', 'laptop'] }
    };
  }
  return {};
}

Or combine multiple conditions:

filterOptions: ({ siblingData, data }) => {
  const filters = {};

  if (siblingData?.product) {
    filters.product = { equals: siblingData.product };
  }

  if (data?.category) {
    filters.category = { equals: data.category };
  }

  return filters;
}

The key is understanding that this function runs server-side with full access to the document context, so you can implement any filtering logic your application needs.

Conclusion

Dynamic relationship filtering in Payload CMS array fields requires using filterOptions as a function with siblingData, not custom client-side components. This server-side approach ensures filters are applied during the API query, giving users exactly the options they should see based on their selections in sibling fields.

You now know how to implement this pattern, understand why client-side approaches fail, and have working code for server-side filtering. This technique solves the common problem of relationship dropdowns showing all options when they should be filtered based on user context.

Let me know in the comments if you have questions, and subscribe for more practical development guides.

Thanks, Matija

5

Comments

Leave a Comment

Your email will not be published

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.

You might be interested in