We are making amazing progress. We are now in the final stretch. In this section, we will look at Search and Pagination.
Let's jump in and look at how to implement search with Next.js and Strapi CMS.
First, create a new file inside our src/components/custom
folder called search.tsx
and add the following code.
1"use client";
2import { usePathname, useRouter, useSearchParams } from "next/navigation";
3import { useDebouncedCallback } from "use-debounce";
4import { Input } from "@/components/ui/input";
5
6export function Search() {
7 const searchParams = useSearchParams();
8 const { replace } = useRouter();
9 const pathname = usePathname();
10
11 const handleSearch = useDebouncedCallback((term: string) => {
12 console.log(`Searching... ${term}`);
13 const params = new URLSearchParams(searchParams);
14 params.set("page", "1");
15
16 if (term) {
17 params.set("query", term);
18 } else {
19 params.delete("query");
20 }
21
22 replace(`${pathname}?${params.toString()}`);
23 }, 300);
24
25 return (
26 <div>
27 <Input
28 type="text"
29 placeholder="Search"
30 onChange={(e) => handleSearch(e.target.value)}
31 defaultValue={searchParams.get("query")?.toString()}
32 />
33 </div>
34 );
35}
The secret to understanding how our Search component works is to be familiar with the following hooks from Next.js.
useSearchParams
docs reference It is a Client Component hook that lets you read the current URL's query string.We use it in our code to get our current parameters from our url.
Then, we use the new URLSearchParams
to update our search parameters. You can learn more about it here.
useRouter
docs reference : Allows us to access the router object inside any function component in your app. We will use the replace method to prevent adding a new URL entry into the history stack.
usePathname
docs reference: This is a Client Component hook that lets you read the current URL's pathname. We use it to find our current path before concatenating our new search parameters.
useDebouncedCallback
npm reference: Used to prevent making an api call on every keystroke when using our Search component.
Install the use-debounce
package from npm with the following command.
yarn add use-debounce
Now, let's look at the flow of our Search component's work by looking at the following.
1return (
2 <div>
3 <Input
4 type="text"
5 placeholder="Search"
6 onChange={(e) => handleSearch(e.target.value)}
7 defaultValue={searchParams.get("query")?.toString()}
8 />
9 </div>
10);
We are using the defaultValue
prop to set the current search parameters from our URL.
When the onChange
fires, we pass the value to our handleSearch
function.
1const handleSearch = useDebouncedCallback((term: string) => {
2 console.log(`Searching... ${term}`);
3 const params = new URLSearchParams(searchParams);
4 params.set("page", "1");
5
6 if (term) {
7 params.set("query", term);
8 } else {
9 params.delete("query");
10 }
11
12 replace(`${pathname}?${params.toString()}`);
13}, 300);
Inside the handleSearch
function, we use replace
to set our new search parameters in our URL.
So whenever we type the query in our input field, it will update our URL.
Let's now add our Search
component to our code.
Navigate to the src/app/dashboard/summaries/page.tsx
file and update it with the following code.
1import Link from "next/link";
2import { getSummaries } from "@/data/loaders";
3import ReactMarkdown from "react-markdown";
4
5import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
6import { Search } from "@/components/custom/search";
7
8interface LinkCardProps {
9 documentId: string;
10 title: string;
11 summary: string;
12}
13
14function LinkCard({ documentId, title, summary }: Readonly<LinkCardProps>) {
15 return (
16 <Link href={`/dashboard/summaries/${documentId}`}>
17 <Card className="relative">
18 <CardHeader>
19 <CardTitle className="leading-8 text-pink-500">
20 {title || "Video Summary"}
21 </CardTitle>
22 </CardHeader>
23 <CardContent>
24 <ReactMarkdown
25 className="card-markdown prose prose-sm max-w-none
26 prose-headings:text-gray-900 prose-headings:font-semibold
27 prose-p:text-gray-600 prose-p:leading-relaxed
28 prose-a:text-pink-500 prose-a:no-underline hover:prose-a:underline
29 prose-strong:text-gray-900 prose-strong:font-semibold
30 prose-ul:list-disc prose-ul:pl-4
31 prose-ol:list-decimal prose-ol:pl-4"
32 >
33 {summary.slice(0, 164) + " [read more]"}
34 </ReactMarkdown>
35 </CardContent>
36 </Card>
37 </Link>
38 );
39}
40
41interface SearchParamsProps {
42 searchParams?: {
43 query?: string;
44 };
45}
46
47export default async function SummariesRoute({
48 searchParams,
49}: SearchParamsProps) {
50 const search = await searchParams;
51 const query = search?.query ?? "";
52 console.log(query);
53 const { data } = await getSummaries();
54
55 if (!data) return null;
56 return (
57 <div className="grid grid-cols-1 gap-4 p-4">
58 <Search />
59 <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
60 {data.map((item: LinkCardProps) => (
61 <LinkCard key={item.documentId} {...item} />
62 ))}
63 </div>
64 </div>
65 );
66}
Excellent. Now that our Search
component shows up, let's move on to the second part. We'll pass our query search through our getSummaries
function and update it accordingly to allow us to search our queries.
To make the search work, we will rely on the following parameters.
sorting: we will sort all of our summaries in descending order, ensuring that the newest summary appears first.
filters: The filters we will use $or
operator to combine our search conditions. This means the search will return summaries based on the fields we will filter using $containsi,
which will ignore case sensitivity.
Let's look inside our src/data/loaders.ts
file and update the following code inside our getSummaries
function.
1export async function getSummaries() {
2 const url = new URL("/api/summaries", baseUrl);
3 return fetchData(url.href);
4}
Here are the changes we are going to make.
1export async function getSummaries(queryString: string) {
2 const query = qs.stringify({
3 sort: ["createdAt:desc"],
4 filters: {
5 $or: [
6 { title: { $containsi: queryString } },
7 { summary: { $containsi: queryString } },
8 ],
9 },
10 });
11 const url = new URL("/api/summaries", baseUrl);
12 url.search = query;
13 return fetchData(url.href);
14}
We will create a query
to filter our summaries on the title
and summary.
Now, we have one more step before testing our search. Let's navigate back to the src/app/dashboard/summaries
folder and make the following changes inside our page.tsx
file.
We cannot pass and utilize our query params since we just updated our getSummaries
function.
1const { data } = await getSummaries(query);
Now, let's see if our search is working.
Great. Now that our search is working, we can simply move forward with our pagination.
Pagination involves dividing content into separate pages and providing users with controls to navigate to the first, last, previous, following, or specific page.
This method improves performance by reducing the amount of data loaded simultaneously. It makes it easier for users to find specific information by browsing through a smaller subset of data.
Let's implement our pagination, but first, let's walk through the code example below, which we will use for our PaginationComponent
inside of our Next.js project.
It is based on the Shadcn UI component that you can take a look at here;
So, run the following command to get all the necessary dependencies.
npx shadcn@latest add pagination
And add the following code to your components/custom
folder file called pagination-component.tsx
.
1"use client";
2import { FC } from "react";
3import { usePathname, useSearchParams, useRouter } from "next/navigation";
4
5import {
6 Pagination,
7 PaginationContent,
8 PaginationItem,
9} from "@/components/ui/pagination";
10
11import { Button } from "@/components/ui/button";
12
13interface PaginationProps {
14 pageCount: number;
15}
16
17interface PaginationArrowProps {
18 direction: "left" | "right";
19 href: string;
20 isDisabled: boolean;
21}
22
23const PaginationArrow: FC<PaginationArrowProps> = ({
24 direction,
25 href,
26 isDisabled,
27}) => {
28 const router = useRouter();
29 const isLeft = direction === "left";
30 const disabledClassName = isDisabled ? "opacity-50 cursor-not-allowed" : "";
31
32 return (
33 <Button
34 onClick={() => router.push(href)}
35 className={`bg-gray-100 text-gray-500 hover:bg-gray-200 ${disabledClassName}`}
36 aria-disabled={isDisabled}
37 disabled={isDisabled}
38 >
39 {isLeft ? "«" : "»"}
40 </Button>
41 );
42};
43
44export function PaginationComponent({ pageCount }: Readonly<PaginationProps>) {
45 const pathname = usePathname();
46 const searchParams = useSearchParams();
47 const currentPage = Number(searchParams.get("page")) || 1;
48
49 const createPageURL = (pageNumber: number | string) => {
50 const params = new URLSearchParams(searchParams);
51 params.set("page", pageNumber.toString());
52 return `${pathname}?${params.toString()}`;
53 };
54
55 return (
56 <Pagination>
57 <PaginationContent>
58 <PaginationItem>
59 <PaginationArrow
60 direction="left"
61 href={createPageURL(currentPage - 1)}
62 isDisabled={currentPage <= 1}
63 />
64 </PaginationItem>
65 <PaginationItem>
66 <span className="p-2 font-semibold text-gray-500">
67 Page {currentPage}
68 </span>
69 </PaginationItem>
70 <PaginationItem>
71 <PaginationArrow
72 direction="right"
73 href={createPageURL(currentPage + 1)}
74 isDisabled={currentPage >= pageCount}
75 />
76 </PaginationItem>
77 </PaginationContent>
78 </Pagination>
79 );
80}
Again, we are using our familiar hooks from before to make this work, usePathname,
searchParams,
and useRouter
in a similar way as we did before.
We are receiving pageCount
via props to see our available pages. Inside the PaginationArrow
component, we use useRouter
to programmatically update our URL via the push
method on click.
Let's add this component to our project. In our components/custom
folder, create a PaginationComponent.tsx
file and paste it into the above code.
Now that we have our component, let's navigate to src/app/dashboard/summaries/page.tsx
and import it.
1import { PaginationComponent } from "@/components/custom/pagination-component";
Now update our SearchParamsProps
interface to the following.
1interface SearchParamsProps {
2 searchParams?: {
3 page?: string;
4 query?: string;
5 };
6}
Now, let's create a currentPage
variable to store the current page we get from our URL parameters.
1const currentPage = Number(searchParams?.page) || 1;
Now, we can pass our currentPage
to our getSummaries
function.
1const { data } = await getSummaries(query, currentPage);
Now, let's go back to our loaders. tsx
file and update the getSummaries
with the following code to utilize our pagination.
1export async function getSummaries(queryString: string, currentPage: number) {
2 const PAGE_SIZE = 4;
3
4 const query = qs.stringify({
5 sort: ["createdAt:desc"],
6 filters: {
7 $or: [
8 { title: { $containsi: queryString } },
9 { summary: { $containsi: queryString } },
10 ],
11 },
12 pagination: {
13 pageSize: PAGE_SIZE,
14 page: currentPage,
15 },
16 });
17 const url = new URL("/api/summaries", baseUrl);
18 url.search = query;
19 return fetchData(url.href);
20}
In the above example, we use Strapi's pagination
fields and pass pageSize
and page
fields. You can learn more about Strapi's pagination here;
Now back in our src/app/dashboard/summaries/page.tsx
, let's make a few more minor changes before hooking everything up.
If we look at our getSummaries
call, we have access to another field that Strapi is returning called meta.
Let's make sure we are getting it from our response with the following.
1const { data, meta } = await getSummaries(query, currentPage);
2console.log(meta);
We will see the following data if we console log our meta
field.
1{ pagination: { page: 1, pageSize: 4, pageCount: 1, total: 4 } }
Notice that we have our pageCount
property. We will use it to tell our PaginationComponent
the number of pages we have available.
So, let's extract it from our meta
data with the following.
1 const pageCount = meta?.pagination?.pageCount;
And finally, let's use our PaginationComponent
with the following.
1<PaginationComponent pageCount={pageCount} />
The completed code should look like the following.
1import Link from "next/link";
2import { getSummaries } from "@/data/loaders";
3import ReactMarkdown from "react-markdown";
4
5import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
6import { Search } from "@/components/custom/search";
7import { PaginationComponent } from "@/components/custom/pagination-component";
8
9interface LinkCardProps {
10 documentId: string;
11 title: string;
12 summary: string;
13}
14
15function LinkCard({ documentId, title, summary }: Readonly<LinkCardProps>) {
16 return (
17 <Link href={`/dashboard/summaries/${documentId}`}>
18 <Card className="relative">
19 <CardHeader>
20 <CardTitle className="leading-8 text-pink-500">
21 {title || "Video Summary"}
22 </CardTitle>
23 </CardHeader>
24 <CardContent>
25 <ReactMarkdown
26 className="card-markdown prose prose-sm max-w-none
27 prose-headings:text-gray-900 prose-headings:font-semibold
28 prose-p:text-gray-600 prose-p:leading-relaxed
29 prose-a:text-pink-500 prose-a:no-underline hover:prose-a:underline
30 prose-strong:text-gray-900 prose-strong:font-semibold
31 prose-ul:list-disc prose-ul:pl-4
32 prose-ol:list-decimal prose-ol:pl-4"
33 >
34 {summary.slice(0, 164) + " [read more]"}
35 </ReactMarkdown>
36 </CardContent>
37 </Card>
38 </Link>
39 );
40}
41
42interface SearchParamsProps {
43 searchParams?: {
44 page?: string;
45 query?: string;
46 };
47}
48
49
50export default async function SummariesRoute({ searchParams }: SearchParamsProps) {
51 const search = await searchParams;
52 const query = search?.query ?? "";
53 const currentPage = Number(search?.page) || 1;
54
55 const { data, meta } = await getSummaries(query, currentPage);
56 const pageCount = meta?.pagination?.pageCount;
57
58 console.log(meta);
59
60 if (!data) return null;
61 return (
62 <div className="grid grid-cols-1 gap-4 p-4">
63 <Search />
64 <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
65 {data.map((item: LinkCardProps) => (
66 <LinkCard key={item.documentId} {...item} />
67 ))}
68 </div>
69 <PaginationComponent pageCount={pageCount} />
70 </div>
71 );
72}
Now, the moment of truth: Let's see if it works. Make sure you add more summaries since our page size is currently set to 4 items. You need to have at least 5 items.
Excellent, it is working.
This Next.js with Strapi CMS tutorial covered how to implement search and pagination functionalities. Hope you are enjoying this tutorial.
We are almost done. We have two more sections to go, including deploying our project to Strapi Cloud and Vercel.
See you in the next post.
This project has been updated to use Next.js 15 and Strapi 5.
If you have any questions, feel free to stop by at our Discord Community for our daily "open office hours" from 12:30 PM CST to 1:30 PM CST.
If you have a suggestion or find a mistake in the post, please open an issue on the GitHub repository.
You can also find the blog post content in the Strapi Blog.
Feel free to make PRs to fix any issues you find in the project, or let me know if you have any questions.
Happy coding!