Simplify your stack and build anything. Or everything.
Build tomorrow’s web with a modern solution you truly own.
Code-based nature means you can build on top of it to power anything.
It’s time to take back your content infrastructure.
AuthorSandro Wegmann, 10xMedia

Using Payload’s search plugin for custom search experiences

Community Guide
AuthorSandro Wegmann, 10xMedia

Let’s explore why the Payload search plugin is beneficial, how to install and configure it, and how to customize search fields for better results.

In this guide, we’ll focus on the official Payload Search Plugin, which provides:

  • A dedicated search collection to optimize queries.
  • Cross-collection searching with priority sorting.
  • Improved performance compared to default like-queries.
  • Support for relationship fields, making related data (e.g., author names) searchable.

Understanding search in Payload

There are two primary ways to implement search in Payload:

  1. Default Like-Query Search (Built-in)
  • Works by performing a RegExp query on specified fields. Uses listSearchableFields in a collection’s config. Works well for small datasets, but slows down with large collections
  1. Payload Search Plugin (Recommended)
  • More performant. Creates a separate search collection for indexed documents. Allows custom field selection to improve search accuracy. Enables cross-collection searching and priority sorting. Stores relationship fields (e.g., author names), making them searchable

Note: While third-party search alternatives are useful, they require additional setup and maintenance. The Payload Search Plugin offers a built-in solution without external dependencies.

Setting up a custom admin search component

Before we implement the actual search logic, we’ll scaffold a minimal component that appears in the Payload admin. This lets us display search results inside the admin UI — without modifying Payload’s built-in dashboard.

Create a new file at src/components/BeforeDashboard.tsx.

We’ll fill this in later, but for now, just register it in your Payload config:

1
admin: {
2
components: {
3
beforeDashboard: [BeforeDashboard],
4
},
5
},

Make sure you import the component:

1
import BeforeDashboard from './components/BeforeDashboard';

Once that's wired up, you’ll see your custom UI render above the default dashboard in the Payload admin.

We'll come back and build out this component after setting up the plugin.

Installing the Payload Search Plugin

First, install the official search plugin: pnpm add @payloadcms/plugin-search

Next, open your Payload config (payload.config.ts) and register the plugin:

1
import { searchPlugin } from '@payloadcms/plugin-search';
2
3
export default buildConfig({
4
plugins: [
5
searchPlugin({
6
collections: ['blogArticles', 'authors'],
7
}),
8
],
9
});

This configuration: Enables search for blogArticles and authors. Creates a new search collection (searchResults). Now, restart the development server: pnpm dev

Once the Payload Admin Panel reloads, you should see a new "Search Results" collection.

Indexing Data for Search

By default, the search collection will be empty. To populate it, we need to index existing data.

Manually Reindexing

Payload provides a Reindex button in the Admin Panel.

  1. Go to Search Results in the Admin Panel.
  2. Click "Reindex" button and then "All Collections" to populate the search collection.

Any new blog articles or authors added after this will be indexed automatically.

Customizing Search Fields

By default, the search plugin only stores the title. We can improve search accuracy by adding custom fields.

1. Adding a "Description" Field

Modify the plugin config to include a description field:

1
searchPlugin({
2
collections: ['blogArticles', 'authors'],
3
searchOverrides: {
4
fields: (defaultFields) => [
5
...defaultFields,
6
{
7
name: 'description',
8
type: 'text',
9
label: 'Description',
10
admin: { readOnly: true },
11
},
12
],
13
},
14
});

This ensures each search entry stores a description.

2. Prepare the data for search

Payload uses Lexical for rich text, which stores content in a deeply nested JSON structure. That’s not searchable by default.

To make it searchable, we flatten that content into plain text using a utility function.

We do this inside the beforeSync hook, which gives us full control over what gets indexed.

In this hook, we extract and map rich text and relationship fields into unified title and description fields for better search results.

But first, we'll need to create a utility function into utils/extractPlaintText.ts.

1
const extractTextFromNode = (node: any): string => {
2
if (!node || typeof node !== 'object') {
3
return ''
4
}
5
6
// Extract text from text nodes
7
if (node.type === 'text' && node.text) {
8
return node.text
9
}
10
11
// Recursively extract text from child nodes
12
if (Array.isArray(node.children)) {
13
return node.children
14
.map((child: any) => extractTextFromNode(child))
15
.join(' ')
16
.replace(/\s+/g, ' ') // Replace multiple spaces with a single space
17
.trim()
18
}
19
20
// Fallback for other node types
21
return ''
22
}
23
24
export const extractPlainText = (content: any): string => {
25
if (!content || typeof content !== 'object') {
26
// If the content is not an object, return an empty string
27
return ''
28
}
29
30
// Check if the content is in Lexical's serialized editor state format
31
if (content.root && Array.isArray(content.root.children)) {
32
// Traverse the children of the root node
33
return content.root.children
34
.map((child: any) => extractTextFromNode(child))
35
.join(' ')
36
.replace(/\s+/g, ' ') // Replace multiple spaces with a single space
37
.trim()
38
}
39
40
// Fallback for other formats
41
return ''
42
}

And be sure to import that into your Payload config: import { extractPlainText } from './utils/extractPlainText';

Next, we'll define the beforeSync hook:

1
searchPlugin({
2
3
collections: ['blog-articles', 'blog-authors'],
4
5
searchOverrides: {
6
fields: ({ defaultFields }) => [
7
...defaultFields,
8
{
9
name: 'description',
10
type: 'textarea',
11
label: 'Description',
12
admin: {
13
readOnly: true,
14
},
15
},
16
],
17
18
// Map content from your original documents into the shape expected in the search index
19
beforeSync: ({ originalDoc, searchDoc }) => {
20
const collection = searchDoc.doc.relationTo;
21
22
// If the document is from the blog-articles collection...
23
if (collection === 'blog-articles') {
24
return {
25
...searchDoc,
26
// Map the 'heading' field from the article as the search result title
27
title: originalDoc.heading,
28
29
// Extract and flatten the rich text content to make it searchable
30
description: extractPlainText(originalDoc.content),
31
};
32
}
33
34
// If the document is from the blog-authors collection...
35
if (collection === 'blog-authors') {
36
return {
37
...searchDoc,
38
// Use the author's name as the title
39
title: originalDoc.name,
40
41
// Extract plain text from the bio for consistent searching
42
description: extractPlainText(originalDoc.bio),
43
};
44
}
45
46
// For any other collections not explicitly handled, fall back to the default
47
return searchDoc;
48
},
49
},
50
}),

This ensures:

  • Blog articles store heading as the title and content as the description.
  • Authors store name as the title and bio as the description.

Return to the admin panel and reindex all the collections—you should see we have the title as either the author name or blog title, and the description field will have the content of either the author profile or blog article.

3. Building the search admin UI

Now that indexing is set up, we’ll return to the BeforeDashboard.tsx component we registered earlier and implement the actual search logic.

Next, we'll build a custom search input that queries the unified search collection created by the plugin. It performs a like search on both title and description, then displays results in a dropdown that links directly to the related documents in the Payload admin.

1
'use client'
2
3
import { SelectInput } from '@payloadcms/ui'
4
import React, { useEffect } from 'react'
5
import qs from 'qs'
6
7
// Custom debounce hook
8
function useDebounce(value: string, delay: number) {
9
const [debouncedValue, setDebouncedValue] = React.useState(value)
10
11
useEffect(() => {
12
const timer = setTimeout(() => {
13
setDebouncedValue(value)
14
}, delay)
15
16
return () => {
17
clearTimeout(timer)
18
}
19
}, [value, delay])
20
21
return debouncedValue
22
}
23
24
export default function BeforeDashboard() {
25
const [searchResults, setSearchResults] = React.useState({ docs: [] })
26
const [searchTerm, setSearchTerm] = React.useState('')
27
const debouncedSearchTerm = useDebounce(searchTerm, 500)
28
29
// Effect to trigger search when debounced value changes
30
useEffect(() => {
31
if (debouncedSearchTerm) {
32
searchDocs(debouncedSearchTerm)
33
} else {
34
setSearchResults({ docs: [] })
35
}
36
}, [debouncedSearchTerm]) // Added initialSearchResults to dependencies
37
38
async function searchDocs(search: string) {
39
const queryObj = {
40
where: {
41
or: [
42
{
43
title: {
44
like: search,
45
},
46
},
47
{
48
description: {
49
like: search,
50
},
51
},
52
],
53
},
54
}
55
const response = await fetch(`/api/search?${qs.stringify(queryObj)}`)
56
const data = await response.json()
57
58
setSearchResults(data)
59
}
60
61
return (
62
<div>
63
<SelectInput
64
onInputChange={(value) => {
65
setSearchTerm(value)
66
}}
67
onChange={(value: any) => {
68
// Open new tab with participant
69
window.open(`/admin/collections/search/${value.value}`, '_blank')
70
}}
71
options={searchResults.docs.map((res: any) => ({
72
label: `${res.title} - ${res.description} | ${res.doc.relationTo}`,
73
value: res.id,
74
}))}
75
className=""
76
label=""
77
name="search"
78
path="search"
79
/>
80
</div>
81
)
82
}

Final thoughts

By implementing the Payload Search Plugin, we:

  • Improved search performance
  • Enabled search across multiple collections
  • Made relationship fields searchable
  • Optimized search for rich text and date fields
  • Created a unified search experience