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.

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:
- Main page component
- Render block components
- Block coordinator components
- 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