How to Pass searchParams in Payload CMS Blocks Using Next.js 15.4+ Server Components

Enable server-side filtering and personalization in Payload CMS by forwarding async searchParams through your block architecture.

·Matija Žiberna·
How to Pass searchParams in Payload CMS Blocks Using Next.js 15.4+ Server Components

While working on an e-commerce project with Payload CMS, I found myself needing to implement sophisticated product filtering that could work seamlessly with URL parameters. I wanted customers to be able to bookmark filtered product views, share specific product configurations via URL, and have the browser's back button work intuitively with filter states.

The challenge was that Payload CMS uses a block-based architecture where content flows through multiple rendering layers before reaching the final components. I needed a way to pass URL search parameters down through this entire chain so that my product filter blocks could access them for server-side filtering.

This guide shows you exactly how I solved this problem. We'll walk through implementing URL-based filtering and state management using searchParams in a Payload CMS block system with Next.js App Router. By the end, you'll have search parameters flowing seamlessly from your page URLs down to any block component that needs them.

Why This Matters

  • Server-Side Filtering: Fetch only the data you need based on URL parameters
  • SEO Friendly: All filter states are in the URL, making them indexable and shareable
  • Clean Architecture: Client-side filters update the URL, triggering server-side re-renders with fresh data
  • User Experience: Users can bookmark filtered states and use browser back/forward navigation

Tech Stack Used

  • Payload CMS (Block-based content management)
  • Next.js 15 (App Router)
  • React Server Components
  • TypeScript

Problem Setup

The Challenge

In Payload CMS's block-based architecture, you have deeply nested components that need access to URL search parameters. The challenge is passing these parameters down through multiple layers of the Payload CMS block rendering system without knowing in advance what parameters might be needed.

Payload CMS Block Rendering Flow:

Page → RenderBlocks Component → Block Coordinator → Specific Block Component

Each layer needs to forward searchParams to make them available to the final block implementations.

Tech Context

This pattern is essential when building Payload CMS sites with:

  • E-commerce product blocks that need filtering parameters
  • Dynamic content blocks that change based on URL state
  • Form blocks that pre-populate from URL parameters
  • Any Payload CMS block that needs to respond to URL-based state

Approach Exploration

Different Solutions Discussed

Option 1: useSearchParams Hook

  • Cons: Only works in client components, loses server-side rendering benefits
  • Cons: Can't be used for initial data fetching on the server

Option 2: Pass Through Component Props

  • Pros: Works with server components
  • Pros: Maintains server-side rendering
  • Pros: Allows server-side data fetching based on parameters
  • Pros: Clean, predictable data flow

Option 3: Context API

  • Cons: Requires client components for providers
  • Cons: More complex setup

Implementation

Step 1: Update the Main Page Component

Let's start at the top level - your main page component. This is where Next.js first provides us with the searchParams from the URL. In my e-commerce project, this was the entry point where I needed to capture parameters like ?color=red&size=large&category=shoes and ensure they flow down to my product filtering blocks.

The key thing to understand here is that in Next.js 15, both params and searchParams are now promises, so we need to await them before we can use them.

// src/app/(frontend)/[...slug]/page.tsx

type Props = {
  params: Promise<{ slug?: string[] }>
  searchParams: Promise<Record<string, string | string[] | undefined>>
}

export async function generateMetadata({ params, searchParams }: Props): Promise<Metadata> {
  const resolvedParams = await params
  const resolvedSearchParams = await searchParams
  const { slug } = resolvedParams
  
  // Your existing metadata logic...
  // resolvedSearchParams is now available if needed for metadata
}

export default async function Page({
  params: paramsPromise,
  searchParams: searchParamsPromise,
}: {
  params: Promise<{ slug?: string[] }>
  searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
  // Await both parameters
  const params = await paramsPromise
  const searchParams = await searchParamsPromise
  const { slug } = params

  // Your existing page logic...
  
  // Pass searchParams to render components
  return (
    <div>
     <RenderGeneralBlocks 
          pageType={page.pageType} 
          blocks={page.layout} 
          searchParams={searchParams}
        />
    </div>
  )
}

This code handles the async nature of Next.js 15's parameters. The Record<string, string | string[] | undefined> type matches exactly what Next.js expects for search parameters, which can be either single values or arrays when the same parameter appears multiple times. We await both the params and searchParams promises first, then destructure them into clean variables we can work with. The key change here is that we're now passing searchParams down to our render components, which starts the flow that makes URL parameters available throughout our entire component tree.

Step 2: Update Payload CMS Render Block Components

Now we need to modify the components that actually render your Payload CMS blocks. In my project, I had components like RenderGeneralBlocks that take an array of blocks and render them one by one. This is where we need to act as a bridge, taking the searchParams we received from the page and making sure each individual block gets access to them.

Think of this step as setting up a pipeline - we're ensuring that every block in your Payload CMS layout can potentially access URL parameters, even if we don't know which blocks will need them.

// src/blocks/RenderProductPageBlocks.tsx

export const RenderGeneralBlocks: React.FC<{
  pageType: ProductPage["pageType"],
  blocks: ProductPage["layout"],
  searchParams?: Record<string, string | string[] | undefined>
}> = (props) => {
  const { blocks, pageType, searchParams } = props

  const hasBlocks = blocks && Array.isArray(blocks) && blocks.length > 0

  if (hasBlocks) {
    return (
      <Fragment>
        {blocks.map((block, index) => {
          const { blockType } = block
          if (blockType && blockType in blockComponents) {
            const Block = blockComponents[blockType]

            if (Block) {
              const blockContent = (
                <React.Fragment key={index}>
                  <Block {...block} disableInnerContainer searchParams={searchParams} />
                </React.Fragment>
              )

              // Special handling for specific block types if needed
              if (blockType === "gallery") {
                return (
                  <div className="" key={index}>
                    {blockContent}
                  </div>
                )
              } else {
                return blockContent
              }
            }
          }
          return null
        })}
      </Fragment>
    )
  }

  return null
}

The beauty of this approach is its flexibility. We make searchParams optional with the ? operator so existing blocks continue working without modification. When we spread the block props with {...block}, we're forwarding not just the block's configuration data but also our searchParams to every single block component. This means any block in your Payload CMS setup can suddenly access URL parameters without you having to know in advance which blocks might need them. TypeScript keeps everything type-safe, ensuring consistent interfaces across all your render components.

Step 3: Update Payload CMS Block Coordinator Components

Here's where Payload CMS's template system comes into play. Many blocks have coordinator components that decide which template to render based on the block's configuration. In my product form blocks, I had different templates for different product types, but I needed all of them to have access to the same URL parameters.

This step ensures that no matter which template gets chosen, the searchParams continue flowing down the component tree.

// src/blocks/shop/ProductForm/components/index.tsx

const ProductFormBlockCoordinator = async ({ ...block }: ProductFormBlock & { searchParams?: Record<string, string | string[] | undefined> }) => {
  switch (block.template) {
    case 'default':
    default:
      return (
        <DefaultProductFormComponent
          {...block}
        />
      );
  }
};

export default ProductFormBlockCoordinator;

This coordinator component acts as a router between your block configuration and the actual template implementation. By using intersection types with ProductFormBlock & { searchParams?: ... }, we're extending the existing block type to include our search parameters without breaking anything. The spread operator ensures that everything gets passed down, including our searchParams. This maintains Payload's flexible template system while giving every template access to URL parameters.

Step 4: Update Final Payload CMS Block Components

This is the payoff - where we actually use those URL parameters in our block logic. In my e-commerce setup, this was where I could pre-select product variants, set default quantities, filter product lists, and even track referral sources, all based on what's in the URL.

The beautiful thing is that all this logic runs on the server, so we can make database queries and fetch filtered data before sending anything to the browser.

// src/blocks/shop/ProductForm/components/DefaultProductFormComponent/index.tsx

async function DefaultProductFormComponent(block: ProductFormBlock & { searchParams?: Record<string, string | string[] | undefined> }) {
  const { 
    product,
    colourScheme = 'primary',
    showTitle = true,
    // ... other props
    searchParams,
  } = block

  // Console log to test searchParams (remove in production)
  console.log('🔍 DefaultProductFormComponent searchParams:', searchParams)

  // Extract product ID and fetch product data
  const productId = extractId(product);
  const productData = productId ? await getProductById(productId) : null;

  // Use searchParams for component logic
  const selectedVariant = searchParams?.variant
  const selectedColor = searchParams?.color
  const preSelectedQuantity = searchParams?.quantity ? parseInt(searchParams.quantity as string) : 1
  const referralSource = searchParams?.ref

  // Handle case where product doesn't exist
  if (!productData) {
    return (
      <ContainedSection>
        <div className="text-center">
          <p className="text-muted-foreground">Izdelek ni na voljo ali ni bil najden.</p>
        </div>
      </ContainedSection>
    )
  }

  return (
    <ContainedSection>
      {/* Use searchParams in your component */}
      <div className="grid lg:grid-cols-2 gap-12 items-start">
        <div className="space-y-6">
          <ProductGallery 
            product={productData}
            selectedVariant={selectedVariant} // Use URL parameter
          />
        </div>
        
        <div className="space-y-6">
          <Card>
            <CardContent className="space-y-6">
              <ProductInfo 
                product={productData} 
                preSelectedColor={selectedColor} // Use URL parameter
              />
              
              <Link href={`/narocilo?productId=${productData.id}&ref=${referralSource || 'product-page'}`}>
                <Button className="w-full">
                  {block.ctaText ?? "Naroči"}
                </Button>
              </Link>
            </CardContent>
          </Card>
        </div>
      </div>
    </ContainedSection>
  )
}

export default DefaultProductFormComponent

This is where the magic happens. We're destructuring searchParams alongside our regular block properties and immediately putting them to work. URL parameters come as strings, so we convert them to the right types when needed - like using parseInt for quantities. We provide sensible defaults for when parameters aren't present, then use these values to customize our component's behavior. The real power is that all this logic runs on the server, so we can make database queries, fetch filtered data, and pre-populate forms based on URL parameters before sending anything to the browser.

Step 5: Fix Home Page Compatibility

One small housekeeping task - since we've updated our main page component to expect searchParams, we need to make sure our home page (which often reuses the same component) continues to work. This is just a matter of providing an empty searchParams object to keep TypeScript happy and maintain backward compatibility.

// src/app/(frontend)/page.tsx

export async function generateMetadata(): Promise<Metadata> {
  return await generateSlugMetadata({ 
    params: Promise.resolve({ slug: ['home'] }),
    searchParams: Promise.resolve({}) // Provide empty searchParams
  })
}

export default TenantSlugPage

Since we're reusing the main page component for the home route, we need to provide searchParams even when there aren't any. This compatibility layer ensures our home page continues working by providing an empty searchParams object that satisfies TypeScript's requirements while maintaining our established pattern of component reuse.

Pitfalls & Debugging

Common Mistakes and Solutions

💡 Tip: Next.js 15 Async Parameters

// ❌ Wrong - treating as synchronous
const { searchParams } = props

// ✅ Correct - awaiting the promise
const searchParams = await searchParamsPromise

⚠️ Common Bug: Missing searchParams in Component Chain If you get TypeScript errors about missing searchParams, make sure you've updated ALL components in the chain:

  1. Main page component
  2. Render block components
  3. Block coordinator components
  4. Final implementation components
// Error: Property 'searchParams' does not exist on type...
// Solution: Add searchParams to the component interface

⚠️ Common Bug: Type Mismatches SearchParams can be strings or arrays. Always handle both cases:

// ❌ Wrong - assumes string
const color = searchParams?.color

// ✅ Correct - handle string or array
const color = Array.isArray(searchParams?.color) 
  ? searchParams.color[0] 
  : searchParams?.color

Debugging Tips

1. Add Console Logs Add console logs at each level to trace searchParams flow:

console.log('🔍 Page searchParams:', searchParams)
console.log('🔍 RenderBlocks searchParams:', searchParams) 
console.log('🔍 BlockCoordinator searchParams:', searchParams)
console.log('🔍 FinalComponent searchParams:', searchParams)

2. Check TypeScript Errors Run TypeScript checking to catch interface mismatches:

npx tsc --noEmit --skipLibCheck

3. Test with Different URL Patterns Test your implementation with various URL patterns:

  • /products/item?color=red
  • /products/item?color=red&size=large&quantity=2
  • /products/item (no parameters)

Best Practice: Optional Parameters Always make searchParams optional to maintain backward compatibility:

searchParams?: Record<string, string | string[] | undefined>

Final Working Version

Complete Flow Overview

Here's how the complete implementation works:

URL: /products/shoe?color=red&size=42&quantity=2
  ↓
[...slug]/page.tsx 
  - Extracts searchParams: { color: "red", size: "42", quantity: "2" }
  ↓
RenderProductPageBlocks 
  - Receives and forwards searchParams
  ↓  
ProductFormBlockCoordinator 
  - Routes to appropriate template, forwards searchParams
  ↓
DefaultProductFormComponent 
  - Uses searchParams for business logic:
    * Pre-select red color variant
    * Set size to 42
    * Default quantity to 2
    * Fetch product data based on parameters

Conclusion

This approach gives you a powerful way to pass URL search parameters down through your entire Payload CMS block system. Once you have this pipeline set up, you can pass anything from the page component down to your blocks using the exact same pattern - whether that's user authentication state, theme preferences, locale information, or any other data your blocks might need.

The beauty of this solution is that it works with Payload CMS's existing architecture while maintaining all the benefits of server-side rendering. Your blocks can make informed decisions about what data to fetch, how to render themselves, and what default states to show, all based on what's in the URL.

Whether you're building e-commerce filters, content personalization, or any other URL-driven functionality, this pattern provides a solid foundation that scales with your needs.

Thanks, Matija

4

Comments

Enjoyed this article?
Subscribe to my newsletter for more insights and tutorials.
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