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 ?? {}),
};
},
}),
...
],
});
01234567891011121314151617181920
export const getTextFromRichText = (content: unknown): string => {
const texts: string[] = [];
const extractText = (data: unknown) => {
if (Array.isArray(data)) {
data.forEach((item) => extractText(item));
} else if (data && typeof data === 'object') {
Object.entries(data).forEach(([key, value]) => {
if (key === 'text' && typeof value === 'string') {
texts.push(value);
} else {
extractText(value);
}
});
}
};
extractText(content);
return texts.join(' ');
};
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:

The next step is to create a global to configure the SEO settings of the search page.
012345678910111213
import 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:

Frontend implementation
Let's start implementing search directly into the site by creating a search page where the posts will be searched.
01234567891011121314151617181920212223242526272829303132333435363738394041424344454647
import { 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);
}
012345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
...
type SearchPostsArguments = {
page?: number;
query: string;
limit?: number;
select?: SelectType;
};
type SearchPostsResult = {
data: Post[];
totalPages: number;
totalDocs: number;
};
export const searchPosts = async (args?: SearchPostsArguments): Promise<SearchPostsResult> => {
const { page = 1, query, limit = PAGE_SIZE, select } = args ?? {};
const payload = await getPayload({ config: configPromise });
const searches = (await payload.find({
collection: 'search',
draft: false,
limit,
page,
where: {
and: [
{
title: {
like: query,
},
},
{
content: {
like: query,
},
},
],
},
})) as unknown as { docs: Search[]; totalPages: number; totalDocs: number };
const postIds = searches.docs.map(({ doc }) =>
typeof doc.value === 'string' ? doc.value : doc.value.id,
);
const posts = (
(await payload.find({
collection: 'posts',
draft: false,
limit: PAGE_SIZE,
...(select
? {
select,
}
: {}),
where: {
id: {
in: postIds,
},
_status: {
equals: 'published',
},
},
})) as unknown as { docs: Post[] }
).docs;
return {
data: posts,
totalPages: searches.totalPages,
totalDocs: searches.totalDocs,
} as SearchPostsResult;
};
So, the page accepts the following query string parameters:
q
- a search querypage
- 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 variablePAGE_SIZE
is used here - the number of articles on the blog page (6);select
:SelectType
, which fields of thepost
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 poststotalPages
- number of pagestotalDocs
- number of results
0123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
import { 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>
);
};
012345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
'use client';
import { useRouter } from 'next/navigation';
import React, { ComponentProps, FormEventHandler, useState } from 'react';
import { Cross } from '@/icons/Cross';
import { Search } from '@/icons/Search';
import { cn } from '@/utils/cn';
type SearchFormProps = {
defaultQuery: string;
hideClear?: boolean;
buttonsPosition?: 'left' | 'right';
} & Omit<ComponentProps<'form'>, 'onSubmit' | 'onChange'>;
export const SearchForm = ({
defaultQuery,
className,
hideClear = false,
buttonsPosition = 'right',
...props
}: SearchFormProps) => {
const router = useRouter();
const [query, setQuery] = useState(defaultQuery);
const handleSubmit: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
router.push(`/search?q=${encodeURIComponent(query)}`);
};
return (
<form onSubmit={handleSubmit} className={cn('mb-12', className)} {...props}>
<div className="max-w-[600px] mx-auto flex flex-col gap-2">
<div
className={cn('flex items-center border-b border-gray-300 py-2 search-wrapper', {
'flex-row-reverse': buttonsPosition === 'left',
})}
>
<input
type="text"
autoComplete="off"
placeholder="Enter your search query"
className="appearance-none bg-transparent border-none w-full mr-3 py-1 px-2 leading-tight focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{!hideClear && (
<button
type="button"
onClick={() => setQuery('')}
className="flex-shrink-0 mr-2 p-1 reset"
aria-label="Clear search"
>
<Cross className="size-[20px]" />
</button>
)}
<button type="submit" aria-label="Submit search" className="flex-shrink-0 py-1">
<Search />
</button>
</div>
</div>
</form>
);
};
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:

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.
01234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
import 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>
);
};
0123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
import { ReactNode } from 'react';
type HighlightQueryProps = {
text: string;
query?: string;
removeOffset?: number;
maxLength?: number;
};
export const HighlightQuery = ({
text,
query,
removeOffset = -1,
maxLength,
}: HighlightQueryProps) => {
if (!query?.trim()) return text.split(/\s+/).slice(0, maxLength).join(' ');
const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const regexPattern = escapedQuery.replace(/%/g, '.*').replace(/_/g, '.');
const regex = new RegExp(regexPattern, 'gi');
const parts = text.split(regex);
const matches = text.match(regex) || [];
if (!matches.length) return <>{text}</>;
const processText = (input: string, isFirstPart: boolean) => {
const words = input.split(/\s+/);
if (isFirstPart && removeOffset >= 0) {
const trimmedWords = words.slice(-Math.max(removeOffset + 1, 0));
return {
text: (words.length > removeOffset ? '...' : '') + trimmedWords.join(' '),
wordCount: trimmedWords.length,
};
}
return {
text: input,
wordCount: words.length,
};
};
const result: ReactNode[] = [];
let addedWords = 0;
parts.forEach((part, index) => {
if (maxLength && addedWords >= maxLength) return;
const processedPart = processText(part, index === 0);
if (maxLength && addedWords + processedPart.wordCount > maxLength) {
const remainingWords = maxLength - addedWords;
result.push(processedPart.text.split(/\s+/).slice(0, remainingWords).join(' '));
return;
}
result.push(processedPart.text);
addedWords += processedPart.wordCount;
if (index < matches.length) {
const matchWords = matches[index].split(/\s+/);
if (!maxLength || addedWords + matchWords.length <= maxLength) {
result.push(<mark key={`match-${index}`}>{matches[index]}</mark>);
addedWords += matchWords.length;
}
}
});
return <>{result}</>;
};
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:

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.