---
title: "How to Create Dynamic Cross-Collection Dropdowns in Payload CMS v3"
slug: "dynamic-cross-collection-dropdowns-payload-cms-v3"
published: "2025-09-29"
updated: "2025-12-25"
validated: "2025-10-20"
categories:
  - "Payload"
tags:
  - "Payload CMS"
  - "Payload v3"
  - "dynamic dropdown"
  - "cross-collection"
  - "custom field"
  - "React field"
  - "Drizzle"
  - "async options"
  - "Shopify variants"
  - "Next.js"
llm-intent: "reference"
audience-level: "advanced"
framework-versions:
  - "payload cms v3"
  - "react"
  - "typescript"
  - "next.js"
status: "stable"
llm-purpose: "Guide to building dynamic cross-collection dropdowns in Payload CMS v3 using custom React field components"
llm-prereqs:
  - "Access to Payload CMS v3"
  - "Access to React"
  - "Access to TypeScript"
  - "Access to Next.js"
llm-outputs:
  - "Completed outcome: Guide to building dynamic cross-collection dropdowns in Payload CMS v3 using custom React field components"
---

**Summary Triples**
- (Payload v3 select field, does not support, async options during field initialization (options cannot be a Promise))
- (Attempting async select options, produces error, 'field.options.map is not a function')
- (Root cause, is that, field initialization runs synchronously at app startup before DB connections are available)
- (Limitation, applies to, Payload v3 when using the Drizzle adapter)
- (Workaround, is to, implement a custom React admin field component that fetches cross-collection data client-side)
- (Custom React field, can, issue async requests to populate dropdown options from other collections)
- (Server-side options property, cannot, be relied on for cross-collection dynamic dropdowns in Payload v3)

### {GOAL}
Guide to building dynamic cross-collection dropdowns in Payload CMS v3 using custom React field components

### {PREREQS}
- Access to Payload CMS v3
- Access to React
- Access to TypeScript
- Access to Next.js

### {STEPS}
1. Follow the detailed walkthrough in the article content below.

<!-- llm:goal="Guide to building dynamic cross-collection dropdowns in Payload CMS v3 using custom React field components" -->
<!-- llm:prereq="Access to Payload CMS v3" -->
<!-- llm:prereq="Access to React" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:prereq="Access to Next.js" -->
<!-- llm:output="Completed outcome: Guide to building dynamic cross-collection dropdowns in Payload CMS v3 using custom React field components" -->

# How to Create Dynamic Cross-Collection Dropdowns in Payload CMS v3
> Guide to building dynamic cross-collection dropdowns in Payload CMS v3 using custom React field components
Matija Žiberna · 2025-09-29

I was building a Shopify-style product variant system when I hit a frustrating wall. I needed dropdown options in my ProductVariants collection to dynamically pull data from my Products collection - exactly like how Shopify lets you define variant option types on products and then select from those options when creating variants. After discovering that Payload CMS v3's native select fields don't support async data fetching, I had to find a workaround. This guide shows you exactly how to build custom React field components that fetch cross-collection data and create truly dynamic dropdowns.

## The Core Problem: Async Options Don't Work

The natural approach would be to use Payload's built-in select field with an async options function:

```typescript
// This DOES NOT work in Payload CMS v3
{
  name: 'variantType',
  type: 'select',
  options: async ({ req }) => {
    const products = await req.payload.find({
      collection: 'products'
    })
    return products.docs.map(p => ({ label: p.name, value: p.id }))
  }
}
```

When you try this approach, you'll get the error: `field.options.map is not a function`. This happens because Payload's field initialization runs synchronously during app startup, before database connections are available. The field configuration expects an immediate array, not a Promise.

This limitation exists specifically in Payload v3 with the Drizzle adapter. The framework simply cannot handle async functions in the options property, making cross-collection dynamic dropdowns impossible through standard configuration.

## The Solution: Custom React Field Components

The only reliable way to achieve dynamic cross-collection dropdowns is through custom React components that handle the async data fetching client-side. These components use Payload's field hooks to access form data and make API calls to populate dropdown options.

Let me walk you through implementing this for a product variant system where ProductVariants need to pull option types from Products.

## Setting Up the Collections Structure

First, establish your collections with the proper relationships. The Products collection defines the variant option types:

```typescript
// File: src/collections/Products.ts
{
  name: "variantOptionTypes",
  type: "array",
  admin: {
    condition: (data) => data.hasVariants === true,
  },
  fields: [
    {
      name: "name",
      type: "text",
      required: true,
      admin: {
        placeholder: "e.g., color, size, material",
      },
    },
    {
      name: "label",
      type: "text", 
      required: true,
      admin: {
        placeholder: "e.g., Color, Size, Material",
      },
    },
    {
      name: "values",
      type: "array",
      required: true,
      minRows: 1,
      fields: [
        {
          name: "value",
          type: "text",
          required: true,
          admin: {
            placeholder: "e.g., Red, 100g, Small",
          },
        },
      ],
    },
  ],
}
```

This creates a flexible structure where each product can define multiple variant option types, each with their own set of possible values. For example, a product might have "color" options with values like "Red", "Blue", "Green" and "size" options with values like "Small", "Medium", "Large".

The ProductVariants collection references these options through custom field components:

```typescript
// File: src/collections/ProductVariants.ts
{
  name: "variantValues",
  type: "array",
  fields: [
    {
      name: "optionName",
      type: "text", // Must be text type for custom components
      required: true,
      admin: {
        components: {
          Field: '@/components/fields/VariantOptionTypeSelect',
        },
      },
    },
    {
      name: "value", 
      type: "text", // Must be text type for custom components
      required: true,
      admin: {
        components: {
          Field: '@/components/fields/VariantValueSelect',
        },
      },
    },
  ],
}
```

Notice that both fields use `type: "text"` rather than `type: "select"`. This is crucial because custom field components require a base field type that Payload can manage, even though we'll render them as select dropdowns.

## Building the Option Type Selector Component

The first custom component fetches variant option types from the selected product:

```typescript
// File: src/components/fields/VariantOptionTypeSelect.tsx
'use client'

import React, { useEffect, useState } from 'react'
import { useFormFields, useField } from '@payloadcms/ui'

interface VariantOptionType {
  name: string
  label: string
  values: { value: string }[]
}

const VariantOptionTypeSelect: React.FC<any> = (props) => {
  const { path, field } = props
  const { setValue, value } = useField({ path })
  const [options, setOptions] = useState<{ label: string; value: string }[]>([])
  const [loading, setLoading] = useState(false)

  // Get the product ID from the form
  const productField = useFormFields(([fields]) => fields.product)
  const productId = productField?.value

  useEffect(() => {
    const loadOptions = async () => {
      if (!productId) {
        setOptions([])
        return
      }

      setLoading(true)
      try {
        // Fetch the product data via API
        const response = await fetch(`/api/products/${productId}`)
        if (response.ok) {
          const product = await response.json()
          
          if (product.variantOptionTypes && Array.isArray(product.variantOptionTypes)) {
            const optionTypeOptions = product.variantOptionTypes.map((optionType: VariantOptionType) => ({
              label: optionType.label || optionType.name,
              value: optionType.name,
            }))
            setOptions(optionTypeOptions)
          } else {
            setOptions([])
          }
        }
      } catch (error) {
        console.error('Error loading variant option types:', error)
        setOptions([])
      } finally {
        setLoading(false)
      }
    }

    loadOptions()
  }, [productId])

  if (!productId) {
    return (
      <div style={{ padding: '8px', color: '#666', fontStyle: 'italic' }}>
        Please select a product first
      </div>
    )
  }

  return (
    <div>
      <label style={{ display: 'block', marginBottom: '4px', fontWeight: 'bold' }}>
        {typeof field.label === 'string' ? field.label : field.label?.en || 'Option Type'}
      </label>
      <select
        value={value || ''}
        onChange={(e) => setValue(e.target.value)}
        disabled={loading}
        required={field.required}
        style={{
          width: '100%',
          padding: '8px',
          border: '1px solid #ccc',
          borderRadius: '4px',
        }}
      >
        <option value="">
          {loading ? 'Loading...' : 'Select option type'}
        </option>
        {options.map((option) => (
          <option key={option.value} value={option.value}>
            {option.label}
          </option>
        ))}
      </select>
    </div>
  )
}

export default VariantOptionTypeSelect
```

This component demonstrates the key pattern for cross-collection data fetching in Payload. It uses `useFormFields` to access the product ID from the form, then makes an API call to fetch the product data. The `useEffect` hook ensures the options update whenever the product selection changes.

The component handles loading states and gracefully degrades when no product is selected. Using native HTML select elements instead of Payload's SelectInput component avoids React rendering issues with complex object structures.

## Creating the Cascading Value Selector

The second component is more complex because it needs to watch for changes in the sibling option type field:

```typescript
// File: src/components/fields/VariantValueSelect.tsx
'use client'

import React, { useEffect, useMemo, useRef, useState } from 'react'
import { useFormFields, useField } from '@payloadcms/ui'

interface VariantOptionType {
  name: string
  label: string
  values: { value: string }[]
}

const VariantValueSelect: React.FC<any> = (props) => {
  const { path, field } = props
  const { setValue, value } = useField({ path })
  const optionNamePath = useMemo(() => {
    const segments = path.split('.')
    if (segments.length === 0) return path
    segments[segments.length - 1] = 'optionName'
    return segments.join('.')
  }, [path])
  const { value: optionNameFieldValue } = useField<string>({ path: optionNamePath })
  const [options, setOptions] = useState<{ label: string; value: string }[]>([])
  const [loading, setLoading] = useState(false)

  const productField = useFormFields(([fields]) => fields.product)
  const productId = productField?.value

  const selectedOptionName = optionNameFieldValue ?? ''
  const previousOptionNameRef = useRef<string>(selectedOptionName)

  useEffect(() => {
    if (!selectedOptionName && value) {
      setValue('')
    }
  }, [selectedOptionName, setValue, value])

  useEffect(() => {
    if (
      selectedOptionName &&
      previousOptionNameRef.current &&
      previousOptionNameRef.current !== selectedOptionName &&
      value
    ) {
      setValue('')
    }

    previousOptionNameRef.current = selectedOptionName
  }, [selectedOptionName, setValue, value])

  useEffect(() => {
    if (value && (!options.length || !options.some((option) => option.value === value))) {
      setValue('')
    }
  }, [options, setValue, value])

  useEffect(() => {
    const loadOptions = async () => {
      if (!productId || !selectedOptionName) {
        setLoading(false)
        setOptions([])
        return
      }

      setLoading(true)
      try {
        const response = await fetch(`/api/products/${productId}`)
        if (response.ok) {
          const product = await response.json()

          if (product.variantOptionTypes && Array.isArray(product.variantOptionTypes)) {
            const optionType = product.variantOptionTypes.find(
              (ot: VariantOptionType) => ot.name === selectedOptionName
            )

            if (optionType?.values && Array.isArray(optionType.values)) {
              const valueOptions = optionType.values.map((valueObj: any) => ({
                label: valueObj.value,
                value: valueObj.value,
              }))
              setOptions(valueOptions)
            } else {
              setOptions([])
            }
          } else {
            setOptions([])
          }
        } else {
          setOptions([])
        }
      } catch (error) {
        console.error('Error loading variant values:', error)
        setOptions([])
      } finally {
        setLoading(false)
      }
    }

    loadOptions()
  }, [productId, selectedOptionName])

  if (!productId) {
    return (
      <div style={{ padding: '8px', color: '#666', fontStyle: 'italic' }}>
        Please select a product first
      </div>
    )
  }

  if (!selectedOptionName) {
    return (
      <div style={{ padding: '8px', color: '#666', fontStyle: 'italic' }}>
        Please select an option type first
      </div>
    )
  }

  return (
    <div>
      <label style={{ display: 'block', marginBottom: '4px', fontWeight: 'bold' }}>
        {typeof field.label === 'string' ? field.label : field.label?.en || 'Value'}
      </label>
      <select
        value={value || ''}
        onChange={(e) => setValue(e.target.value)}
        disabled={loading}
        required={field.required}
        style={{
          width: '100%',
          padding: '8px',
          border: '1px solid #ccc',
          borderRadius: '4px',
        }}
      >
        <option value="">
          {loading ? 'Loading...' : 'Select value'}
        </option>
        {options.map((option) => (
          <option key={option.value} value={option.value}>
            {option.label}
          </option>
        ))}
      </select>
    </div>
  )
}

export default VariantValueSelect
```

By deriving the sibling `optionName` path with `useMemo` and subscribing to it via `useField`, the component no longer relies on DOM polling or timers. Every time the option type changes we clear incompatible selections, fetch the correct values, and keep the form state consistent, eliminating the infinite render loop that the earlier DOM approach triggered.

## The Complete User Experience

With both components implemented, users get a seamless Shopify-style experience:

1. Select a product in the ProductVariants form
2. The option type dropdown immediately populates with types defined on that product
3. Select an option type (like "color")  
4. The value dropdown appears with only the values for that option type ("Red", "Blue", "Green")
5. Server-side validation ensures all combinations are unique and match the product definitions

This creates truly dynamic cross-collection relationships that weren't possible with Payload's native field options. Because the cascading logic now stays entirely inside Payload's form state, the UI remains snappy and predictable even as rows are added, reordered, or removed.

## Hardening the Server-Side Validation

On the backend, remember that relationship fields can arrive as strings, numbers, objects, or nested arrays depending on how Payload serialises them. I introduced a tiny helper in `ProductVariants.ts`:

```ts
const resolveRelationshipId = (input: unknown): string | number | undefined => {
  // …normalises strings, numbers, arrays, and objects with id/value keys…
}
```

Every hook now calls `resolveRelationshipId(data.product)` before querying Payload. That single change eliminated the 404 warnings, the `NaN` parameters in duplicate checks, and the failure to update `hasVariants`. Combine that with the updated field components and the entire create/update flow is finally clean.

## Key Takeaways

When you need dynamic dropdown options from other collections in Payload CMS v3, native select fields still can’t fetch async data. Custom React field components remain the answer, but you don’t have to fall back to DOM polling—`useField` and `useMemo` give you everything you need to react to sibling values safely.

Pair the declarative client-side approach with resilient server hooks (normalising relationship IDs before querying) and you’ll have production-ready cascading dropdowns without console noise or infinite render loops.

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

Thanks, Matija

## LLM Response Snippet
```json
{
  "goal": "Guide to building dynamic cross-collection dropdowns in Payload CMS v3 using custom React field components",
  "responses": [
    {
      "question": "What does the article \"How to Create Dynamic Cross-Collection Dropdowns in Payload CMS v3\" cover?",
      "answer": "Guide to building dynamic cross-collection dropdowns in Payload CMS v3 using custom React field components"
    }
  ]
}
```