While not always essential, a search feature significantly improves the user experience by helping users quickly find what they’re looking for. In this article, we will implement a post search for a Payload CMS site. We will cover both CMS customization and front-end implementation. We will also make the search card as informative as possible

If you are only interested in the code, check out the sections: creating a search collection, search global, search page, search form, post card, GitHub.

Creating a search collection

Payload CMS provides a Search plugin to handle the search. It generates a Search collection, which we will use to search for posts.

0123456789101112131415161718192021222324252627282930... import { searchPlugin } from '@payloadcms/plugin-search'; import { getTextFromRichText } from '@/utils/getTextFromRichText'; ... export default buildConfig({ ... plugins: [ ... searchPlugin({ collections: ['posts'], searchOverrides: { fields: ({ defaultFields }) => [ ...defaultFields, { name: 'content', type: 'textarea', admin: { readOnly: true }, }, ], }, beforeSync: ({ originalDoc, searchDoc }) => { return { ...searchDoc, content: getTextFromRichText(originalDoc?.content ?? {}), }; }, }), ... ], });

So, we added searchPlugin to payload.config.ts specifying posts as a collection and adding a content field. We will use this field value for the search itself.

We also added a beforeSync hook that fills the content field with the text value of the lexical editor of the content field of the Post collection. Here's what it looks like in the admin panel:

Search result content in admin panel
Search result content in admin panel

The next step is to create a global to configure the SEO settings of the search page.

payload/globals/Search/index.ts
012345678910111213import type { GlobalConfig } from 'payload'; import { revalidateGlobal } from '@/payload/globals/hooks/revalidateGlobal'; export const Search: GlobalConfig = { slug: 'searchTemplate', access: { read: () => true, }, hooks: { afterChange: [revalidateGlobal('searchTemplate')], }, fields: [], };

So, we added a global searchTemplate without any customization to then add it into seoPlugin to the globals field. Here's what it looks like in the admin panel:

Search template fields in admin panel
Search template fields in admin panel

Frontend implementation

Let's start implementing search directly into the site by creating a search page where the posts will be searched.

01234567891011121314151617181920212223242526272829303132333435363738394041424344454647import { notFound } from 'next/navigation'; import type { Metadata } from 'next/types'; import React from 'react'; import { BlogTemplate } from '@/components/BlogTemplate'; import { generateSearchMetadata } from '@/utils/generateMetadata'; import { searchPosts } from '@/utils/posts'; type Args = { searchParams: Promise<{ q: string; page: string; }>; }; export default async function Page({ searchParams: searchParamsPromise }: Args) { const { q: query, page = '1' } = await searchParamsPromise; const { data, totalPages } = await searchPosts({ page: Number(page), query }); if (query && totalPages && !data?.length && Number(page) > 1) { return notFound(); } return ( <BlogTemplate isSearch title="Search" posts={query ? data : []} page={Number(page)} totalPages={query ? totalPages : 0} searchQuery={query} /> ); } export async function generateMetadata({ searchParams: searchParamsPromise, }: Args): Promise<Metadata> { const { q: query, page = '1' } = await searchParamsPromise; const { totalDocs, data } = await searchPosts({ page: Number(page), query }); if (query && totalDocs && !data?.length) { return notFound(); } return await generateSearchMetadata(Number(page), query); }

So, the page accepts the following query string parameters:

  • q - a search query
  • page - search page number. The default value is 1.

If there is a search query and it has results, but there are no results for the current page, it means that the user got to a page that has no results (for example, there are 2 pages of results, but in queryString page === 3), then returns a 404 error.

The search itself takes place in the searchPosts function. You can see it in the second tab.

So, arguments

  • page: number, default: 1;
  • query: string;
  • limit: number, how many results are on each page. The variable PAGE_SIZE is used here - the number of articles on the blog page (6);
  • select: SelectType, which fields of the post the collection should be retrieved.

The function makes 2 requests to Payload:

1. It gets the results of the search collection for which the query corresponds to the like operator for the title and content fields (text value from the lexical editor of the post) into the searches variable, which is used to get the id of the corresponding posts into the postIds array.

2. Retrieves posts by array of their ids from the previous step.

How does like operator works?

Case-insensitive string must be present. If string of words, all words must be present, in any order.

The function returns:

  • data - an array of found posts
  • totalPages - number of pages
  • totalDocs - number of results
0123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172import { PostCard } from '@/components/PostCard'; import { Pagination } from '@/components/ui/Pagination'; import { SearchForm } from '@/components/ui/SearchForm'; import { Post } from '@/payload-types'; import { cn } from '@/utils/cn'; type BlogTemplateProps = { posts: Post[]; page?: number; totalPages: number; title?: string; isSearch?: boolean; searchQuery?: string; }; export const BlogTemplate = ({ page = 1, posts, totalPages, title, isSearch, searchQuery, }: BlogTemplateProps) => { const baseUrl = '/blog'; return ( <div className="container break-words"> <div className="my-6 mb:mt-0"> {title && ( <h1 className="mb-5"> {title} {page !== 1 && <> - Page {page}</>} </h1> )} {isSearch && <SearchForm defaultQuery={searchQuery ?? ''} />} <div className="mb-8"> {posts?.length > 0 ? ( <div className={cn('grid grid-cols-1 gap-6', 'sm:grid-cols-3')}> {posts?.map((post, index) => { return ( <PostCard key={post.slug} title={post.title} slug={post.slug} image={post.image} content={post.content} publishedAt={post.publishedAt} isFirst={index === 0} searchQuery={searchQuery} /> ); })} </div> ) : ( <div>Nothing found</div> )} </div> {totalPages > 1 && ( <Pagination searchQuery={searchQuery} currentPage={page} totalPages={totalPages} baseUrl={baseUrl} /> )} </div> </div> ); };

Small changes have been added to BlogTemplate.tsx. The following params were added:

  • isSearch: boolean, the name speaks for itself;
  • searchQuery: string, search query.

isSearch is used as a condition for adding SearchForm. searchQuery is passed to the Pagination component to form link's href in pagination.

The component SearchForm is a simple form that has a submit handler that redirects the user to the page with the appropriate query (router.push(`/search?q=${encodeURIComponent(query)}`)). I don't see the point in describing the component in detail, as it is quite simple and has minimal functionality. The value of the query is stored in the state and is changed when the value of the input changes. There is also a button to clear the query value. The component functionality can be extended: for example, by adding validation with react-hook-form and yup to limit the minimum number of characters in the search.

That's it. The search is done. Here's what it looks like:

Search results on website
Search results on website

Improving post cards

Even though the search is already ready, we can significantly improve user experience if highlight the search query in the post card. And not just by highlighting it in the first lines of the article that are shown in the card, but, in case the search query is not in the first lines, show the text where it is.

01234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465import Link from 'next/link'; import React from 'react'; import { Media } from '@/components/Media'; import { HighlightQuery } from '@/components/ui/HighlightQuery'; import type { Post } from '@/payload-types'; import { cn } from '@/utils/cn'; import { getTextFromRichText } from '@/utils/getTextFromRichText'; type PostCardProps = Pick<Post, 'title' | 'slug' | 'image' | 'content' | 'publishedAt'> & { isFirst: boolean; searchQuery?: string; }; export const PostCard = ({ image, slug, title, content, isFirst, searchQuery }: PostCardProps) => { const link = `/blog/${slug}`; const loading = isFirst ? 'eager' : 'lazy'; return ( <article className="bg-white rounded-lg shadow-md overflow-hidden flex flex-col"> <Link href={link} className={cn( 'w-full aspect-[4/3] rounded-t-lg overflow-hidden', 'focus-visible:outline-0 focus-visible:border-4 focus-visible:border-black', )} > {image && ( <Media width="464" height="248" source={image} className={cn( 'w-full m-0 h-full object-cover transition duration-300 scale-100', 'hover:scale-110', )} sizes={`(min-width: 1440px) 464px, 100vw`} loading={loading} /> )} </Link> <div className="p-6 flex flex-col flex-grow"> <header> <Link href={link} className={cn( 'not-prose block font-bold text-xl text-left text-black mb-2', 'hover:underline', )} > {title} </Link> </header> <p className="text-gray-600 mb-4 flex-grow max-h-[80px] line-clamp-3"> <HighlightQuery text={getTextFromRichText(content)} query={searchQuery} removeOffset={3} maxLength={40} /> </p> </div> </article> ); };

The only change to PostCard is the addition of a new HighlightQuery component call to show appropriate content.

The HighlightQuery component accepts props such as:

  • text: string - the full text of the article;
  • query: string - search query;
  • removeOffset: number - word offset before the first highlighted query. This allows to add more context. We use a 3-word offset before the first highlighted query.
  • maxLength: number - how many characters should be returned.

Here's what the end result looks like:

Search results with highlighted query on website
Search results with highlighted query on website

Conclusion

In this article, we implemented a post search for a Payload CMS site.

For this purpose we

  • used the Payload CMS search plugin;
  • built the logic of search posts related to search collection;
  • added changes in necessary components;
  • added search query highlightings for better UX.

All the code from the article is available on GitHub. Feel free to use.