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 ?? {}),
};
},
}),
...
],
});
01234567891011121314151617181920export 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.
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:

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);
}
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_SIZEis used here - the number of articles on the blog page (6);select:SelectType, which fields of thepostthe 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
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>
);
};
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.
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>
);
};
0123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172import { 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.



