Trendy Coder Logo
  • Home
  • About
  • Blog
  • Newsletter

Boost Payload CMS with Search: Step-by-Step Tutorial

Posted on: August 11 2025
By Dave Becker
Hero image for Boost Payload CMS with Search: Step-by-Step Tutorial

One of the key essential features in any modern web applications is the ability to search for specific content. The Payload CMS Search plugin provides a simple and flexible way to optimize search on your site.

The plugin can efficiently query and retrieve relevant content by indexing fields intelligently and supporting advanced filtering and data synchronization.

Overview

In this post, I'll show how to setup and leverage this plugin to super charge your site's search capabilities, including:

  • What is the Payload CMS Search Plugin?
  • How does the search plugin actually work?
  • How to optimize a search model for multiple types of documents.
  • How to customize searchable fields.
  • How to create a global search bar to query your site's content.

Helping users find what they are looking for could be the difference in promoting your site's potential and profitability.

So, let's dive into this plugin and see how to optimize search on your site.

Installation and setup

The search plugin requires the following Payload plugin library to installed.

pnpm add @payloadcms/plugin-search

or

npm i @payloadcms/plugin-search

Search Plugin Configuration

The plugin configuration has a few options:

  • collections: An array of collections to enabled search on.
  • beforeSync: A function to run to sync and update the search data.
  • searchOverrides: Override any Payload config setting and also sets the fields the search Collection should define.
  • defaultPriorities: An object of Collection keys and the priority level they get in the search results. Can either be a number or derived function.
  • reindexBatchSize: Sets the batch size increments when re-indexing a Collection to avoid lengthy transaction locks.
export default buildConfig({
  // ...
  plugins: [
    searchPlugin({
      /* array of search managed collections */
      collections: ['pages', 'posts', 'movies'],

      /* sync function */
      beforeSync: () => { /* do sync */ }),

      /* config overrides */
      searchOverrides: {
        fields: ({ defaultFields }) => {
          return [
            ...defaultFields,
            // define your own here
            ...customSearchFields
          ]
        },
        // modify any other config here
      },

      /* result priorities */
      defaultPriorities: {
        'movies': 60,
        'pages': 50,
        'posts': 30
      },

      /* defaults to 50 */
      reindexBatchSize: 50
  }),

  // ...
})

How Does the Payload CMS Search Plugin Work?

The search plugin creates its own fully optimized search Collection that can be searched on more efficiently, rather than querying on the original Collections directly.

These key fields are inserted into a new search Collection document, which is optimized by synchronizing and indexing.

Each new document added to the search Collection contains:

  • Key search fields from the original Collection document.
  • A relationship reference doc to the original document.
  • The original docUrl path.
Shows a diagram of collections pointing to a common search collection.

Making your website searchable

Now, let's build a simple example to illustrate how the search plugin works. I'll define some new collections to hold a movie database and seed it with some useful data.

Here's the movies Collection config.

Simple movies Collection
import type { CollectionConfig } from 'payload'

export const Movies: CollectionConfig = {
  slug: 'movies',
  admin: {
    useAsTitle: 'title',
  },
  access: {
    read: () => true,
  },
  fields: [
    {
      name: 'title',
      label: 'Title',
      type: 'text',
      required: true,
    },
    {
      name: 'year',
      label: 'Year',
      type: 'number',
    },
    {
      name: 'runtime',
      label: 'Runtime',
      type: 'number',
    },
    {
      name: 'releaseDate',
      label: 'Release Date',
      type: 'text',
    },
    {
      name: 'categories',
      label: 'Categories',
      type: 'relationship',
      relationTo: 'categories',
      hasMany: true,
    },
    {
      name: 'storyline',
      label: 'Storyline',
      type: 'text',
    },
  ],
}

And here's the categories relationship field.

Categories relationship field config
import type { CollectionConfig } from 'payload'

export const Categories: CollectionConfig = {
  slug: 'categories',
  admin: {
    useAsTitle: 'name',
  },
  access: {
    read: () => true,
  },
  fields: [
    {
      name: 'name',
      label: 'Category Name',
      type: 'text',
    },
  ],
}

Default search fields

The Payload Search plugin defines some default fields that are passed to the searchOverrides function in the plugin config.

  • title: doc field
  • priority: doc priority
  • doc: A relationTo reference to the actual doc, in this case it's a particular movies doc.
  • docUrl: The doc path url to the actual

These fields will be defined in the the search Collection by default unless overriden.

Which fields should I use?

A good rule of thumb, for which fields to add could be as follows:

  • Any common searchable fields like, title, description, tag or keywords from each Collection in the plugin collections array.
  • If you're using the SEO plugin also define the standard meta fields.
  • Any unique fields that are essential for searching on.

The goal is to create a union of fields that are common and essential to search on.

Not every Collection will use the same search fields, for example, the storyline will probably only pertain to the movies Collection but it will be added so it can be searched on.

Search overrides

The following additional search fields will be added:

  • meta: fields for the SEO info which are automatically added to collections if using the SEO plugin.
  • categories: fields for the categories relationship field.
  • storyline: The storyline only pertains to movies but it's added so it can be queried.

It's important to set text fields to index: true, so Payload can index and optimize the database schema for searching.

The categories field can just use a where conditional query to find matches so it doesn't really need to be indexed.

/src/search/fieldOverrides.ts
export const searchFields: Field[] = [
  {
    name: 'slug',
    type: 'text',
    index: true,
    admin: {
      readOnly: true,
    },
  },
  /* 
   Defines the common meta fields if using the SEO plugin 
  */
  {
    name: 'meta',
    label: 'Meta',
    type: 'group',
    index: true, // this group field is indexed
    admin: {
      readOnly: true,
    },
    fields: [
      {
        type: 'text',
        name: 'title',
        label: 'Title',
      },
      {
        type: 'text',
        name: 'description',
        label: 'Description',
      },
      {
        name: 'image',
        label: 'Image',
        type: 'upload',
        relationTo: 'media',
      },
    ],
  },
  /* 
   A categories array that holds
   the relation info. 
  */
  {
    label: 'Categories',
    name: 'categories',
    type: 'array',
    admin: {
      readOnly: true,
    },
    fields: [
      {
        name: 'relationTo',
        type: 'text',
      },
      {
        name: 'id',
        type: 'text',
      },
      {
        name: 'title',
        type: 'text',
      },
    ],
  },
  /*
  holds a simple storyline text string from each movie
  */
  {
    name: 'storyline',
    type: 'text',
    index: true, // this field is indexed
    admin: {
      readOnly: true,
    },
  },
]

Configuring search overrides

Any search overrides need to be added to the search plugin config as follows:

Adding custom search fields.
export default buildConfig({
  // ...
  plugins: [
    searchPlugin({
      collections: ['pages', 'posts', 'movies'],
      searchOverrides: {
        fields: ({ defaultFields }) => {
          return [
            ...defaultFields,
            ...searchFields
          ]
        }
      },
  })
})

Synchronizing your search data

Now that the fields have been identified, we can add the interactive built-in synchronization to keep everything updated.

The search plugin adds a Collection level AfterChange hook on each of the target collections defined in the config.

Any changes to documents that are managed by the search plugin, will call the beforeSync function to update the search Collection.

So basically the document that changes, needs to copy over any updates to the search Collection to keep it in sync.

BeforeSync example

The beforeSync function is passed the following args:

  • originalDoc
  • searchDoc

The beforeSync function copies search field values from the orginalDoc into the searchDoc, so it can be saved.

Here's an example of how to do the update.

/src/search/beforeSync.ts
import { BeforeSync, DocToSync } from '@payloadcms/plugin-search/types'

export const beforeSyncWithSearch: BeforeSync = async ({ originalDoc, searchDoc }) => {
  const {
    doc: { relationTo: collection },
  } = searchDoc

  const { id, slug, meta, title, categories, storyline } = originalDoc

  const modifiedDoc: DocToSync = {
    ...searchDoc,
    slug,
    meta: {
      ...meta,
      title: meta?.title || title,
      image: meta?.image?.id || meta?.image,
      description: meta?.description,
    },
    title,
    storyline,
    categories: [],
  }

  if (categories && Array.isArray(categories) && categories.length > 0) {
    // extract key properties for search model
    try {
      const flattenedCategories = categories.map((category) => {
        const { id, title } = category

        return {
          relationTo: 'categories',
          id,
          title,
        }
      })

      modifiedDoc.categories = flattenedCategories
    } catch (_err) {
      console.error(
        `Failed: syncing categories collection '${collection}' with id: '${id}' to search.`,
      )
    }
  }

  return modifiedDoc
}

The beforeSync function needs to be added to search plugin config as follows.

Configuring the beforeSync hook
export default buildConfig({
  // ...
  plugins: [
    searchPlugin({
      // ... other search settings

      // set the before sync hook
      beforeSync: beforeSyncWithSearch
  })
})

The Search Results page

Payload CMS will automatically generate a readonly Search Results page in the admin section with the following benefits:

  • Provides extensive keyword search and filters.
  • Select which columns to show in the result.
  • Change the priority in the config to see preferred document sorting in results.
  • The page is secured in the admin section and can easily open any found document in edit mode.
  • Any Collection can be reindexed at any time by using the dropdown menu at the top of the page.
Shows the default admin table listing view of the search plugin collection.

The only catch is you need to be logged in and have admin access to view the Search Results page.

Also, any results from a search, will open the document for editing which is not exactly ideal for public facing users.

Creating a public facing search page

Since the Search Results page is only accessible for logged users, how can we use the search plugin to build a public facing search bar for a web application?

The Payload Search plugin was designed for this very purpose, to allow developers to create custom search pages for any purpose needed.

Let's take a look at an example of how to query the new search Collection by building a simple page for users to lookup movies.

Search page example

Here's the interface I'll create, so that when a user types some keywords, it searches all of the available documents.

Shows a search bar component listing movie query results.

Building a search bar interface

Let's start with the page.tsx which will be a server component in Next.js.

This page will take advantage of Payload's Local API to query the search Collection and pass the results to a client component.

The directory structure

The search page will use the following app route.

Search page folder
└── src
    └── app
        └── (yoursite) // route group
            └── search
                ├── SearchBar.tsx
                └── page.tsx

Create a new search page

Let's start by creating a React Server Page (RSC) so all of the querying can be done on the server side. The page will be responsible for the following:

  • Looks for the search param called q that has the search phrase entered in the search input field.
  • Uses the Payload Local API to construct a query based on the search phase.
  • Passes the results of the search to the SearchBar component which is a client component.

Here's the search page code.

/app/(yoursite)/search/page.tsx
import configPromise from '@payload-config'
import { getPayload } from 'payload'
import React, { Suspense } from 'react'
import { SearchBar } from './SearchBar'

type Props = {
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}

export default async function Page({ searchParams }: Props) {
  const { q } = await searchParams
  const results = await searchByQuery({
    q: q ? (Array.isArray(q) ? q.join(' ') : q) : undefined,
  })

  return (
    <div>
      <Suspense>
        <SearchBar results={results} />
      </Suspense>
    </div>
  )
}

const searchByQuery = async ({ q }: { q?: string }) => {
  if (!q) return []

  const payload = await getPayload({ config: configPromise })

  try {
    const result = await payload.find({
      collection: 'search',
      limit: 100,
      pagination: false,
      overrideAccess: false,
      where: {
        or: [
          {
            title: {
              like: q,
            },
          },
          {
            storyline: {
              like: q,
            },
          },
        ],
      },
    })

    return result.docs || []
  } catch (e) {
    return []
  }
}

Create the SearchBar client component

The client SearchBar component will do the following:

  • Sets the query phrase state using a hook.
  • Adds a simple debounce to add a delay for input field key strokes.
  • Builds a link with a public facing URL path and opens the selected document in a new tab.
/app/(yoursite)/search/SearchBar.tsx
'use client'

import React, { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { Search } from '@payload-types'

type SearchProps = {
  results?: Search[]
}
export const SearchBar: React.FC<SearchProps> = ({ results }) => {
  const [value, setValue] = useState('')
  const router = useRouter()

  const searchValue = useDebounce(value)

  useEffect(() => {
    router.push(`/search${searchValue ? `?q=${searchValue}` : ''}`)
  }, [searchValue, router])

  return (
    <div className="relative">
      <div id="overlay" className="bg-black/10 absolute w-screen h-screen">
        <div className="bg-white border border-gray-300 my-10 mx-auto w-[70%] z-50">
          <div className="border-b border-gray-300 p-3">
            <form
              onSubmit={(e) => {
                e.preventDefault()
              }}
            >
              <div className="flex justify-start items-center gap-5">
                <label htmlFor="search" className="sr-only">
                  Search
                </label>
                <input
                  name="search"
                  onChange={(event) => {
                    setValue(event.target.value)
                  }}
                  placeholder="Search"
                  className="w-full h-15 p-5"
                />
                <button type="submit" className="sr-only">
                  submit
                </button>
              </div>
            </form>
          </div>

          {results && results?.length > 0 ? (
            <div className="overflow-y-scroll max-h-[80vh]">
              <div className="px-5">
                <h1 className="text-xl ">Search Results</h1>
              </div>
              <div>
                <ul className="w-full">
                  {results.map(({ slug, title, storyline, doc: { relationTo, value } }: Search, i) => {
                    /* 
                      Example admin page:
                      const linkToDoc = `/admin/collections/${relationTo}/${value}`
                    */
                    /* 
                      Example:
                      /movies/[id] OR
                      /movies/[slug] 
                    */
                      const linkToDoc = `/${relationTo}/${slug}` 

                    return (
                      <li key={i} className="border-b border-gray-300 p-5">
                        <div className="flex flex-col w-full">
                          <div className="flex flex-col justify-start items-start">
                            <a
                              href={linkToDoc}
                              target="_blank"
                              rel="noopener noreferrer"
                              className="text-lg underline"
                            >
                              {title}
                            </a>
                          </div>
                          <div>{storyline}</div>
                        </div>
                      </li>
                    )
                  })}
                </ul>
              </div>
            </div>
          ) : (
            <div className="flex justify-center p-5">No search results.</div>
          )}
        </div>
      </div>
    </div>
  )
}

/* Simple debounce for input field key strokes */
export function useDebounce<T>(value: T, delay = 200): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value)

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value)
    }, delay)

    return () => {
      clearTimeout(handler)
    }
  }, [value, delay])

  return debouncedValue
}

Linking to documents

How you build the links from the search documents is entirely up to the needs of your app.

The search results will provide sufficient data to build a link to point to your preferred view page.

Sample search doc value

/* Sample of a single search result object */
const {
  slug,
  title,
  storyline,
  // ... other fields
  doc: {
    /* this could be any collection the search plugin manages, in this case it's movies.*/
    relatedTo,
    value // ID
  },
  docUrl
} = result;

/* uses the relationTo to determine which Collection to link to.*/
const linkToDoc = `/${relationTo}/${slug}`

What are the advantages of the search plugin?

So let's take a minute to recap on some key points and also analyze why the search plugin is beneficial.

  • Manages any Collection: The plugin can manage any Collection by simply adding it to the collections array in the config.

  • No direct queries on Collection: There's no need to search directly on the actual Collection but instead run queries on the search Collection which is indexed and light weight.

  • Access control: By searching on the search Collection directly, which is essentially public by default, you won't need to modify your existing Collection's access control for public search access.

  • Automatic synchronization: Your routine updating of docs automatically syncs the search Collection with the latest changes so your search data is always acurrate. Plus you can reindex when needed.

In Conclusion

So I hope this helps to shed some light on what this plugin actually does.

This plugin really takes the complexity out of building your own search method and provides a powerful tool for excellent search capabilities.

When coupled together with the SEO plugin, you can get just about full search coverage on your documents internally as well as publicly.

The idea is to use this as a simple way to organize what fields are essential for searching your documents. This is a must have when building large apps.

Topics

SEOLinuxSecuritySSHEmail MarketingMore posts...

Related Posts

Hero image for Boost Payload CMS with Search: Step-by-Step Tutorial
Posted on: August 11 2025
By Dave Becker
Boost Payload CMS with Search: Step-by-Step Tutorial
Hero image for Server-Side Pagination Made Easy in Payload CMS
Posted on: August 11 2025
By Dave Becker
Server-Side Pagination Made Easy in Payload CMS
Hero image for Payload CMS: Getting Started Using the New Join Field
Posted on: August 11 2025
By Dave Becker
Payload CMS: Getting Started Using the New Join Field
Hero image for Maximizing Efficiency: The Power of Payload CMS Blocks
Posted on: August 11 2025
By Dave Becker
Maximizing Efficiency: The Power of Payload CMS Blocks
Hero image for Create Custom Forms Using Payload CMS Form Builder Plugin
Posted on: August 11 2025
By Dave Becker
Create Custom Forms Using Payload CMS Form Builder Plugin
Hero image for Payload CMS SEO Plugin: Boosting Your Site's Search Ranking
Posted on: April 04 2025
By Dave Becker
Payload CMS SEO Plugin: Boosting Your Site's Search Ranking
Hero image for GraphQL Optimization in Payload CMS
Posted on: April 04 2025
By Dave Becker
GraphQL Optimization in Payload CMS
Hero image for Exploring the Game-Changing Features of Payload CMS 3.0
Posted on: April 04 2025
By Dave Becker
Exploring the Game-Changing Features of Payload CMS 3.0
Hero image for Document Nesting With Payload's Nested Docs Plugin
Posted on: April 04 2025
By Dave Becker
Document Nesting With Payload's Nested Docs Plugin
Hero image for Payload CMS Collections: How They Streamline Content Management
Posted on: April 04 2025
By Dave Becker
Payload CMS Collections: How They Streamline Content Management
Trendy Coder Logo
Resources
  • Blog
Website
  • Home
  • About us
Subscribe

Get the latest news and articles to your inbox periodically.

We respect your email privacy

© TrendyCoder.com. All rights reserved.