Simply copy and paste the following command line in your terminal to create your first Strapi project.
npx create-strapi-app
my-project
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useDebouncedCallback } from "use-debounce";
import { Input } from "@/components/ui/input";
export function Search() {
const searchParams = useSearchParams();
const { replace } = useRouter();
const pathname = usePathname();
const handleSearch = useDebouncedCallback((term: string) => {
console.log(`Searching... ${term}`);
const params = new URLSearchParams(searchParams);
params.set("page", "1");
if (term) {
params.set("query", term);
} else {
params.delete("query");
}
replace(`${pathname}?${params.toString()}`);
}, 300);
return (
<div>
<Input
type="text"
placeholder="Search"
onChange={(e) => handleSearch(e.target.value)}
defaultValue={searchParams.get("query")?.toString()}
/>
</div>
);
}
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.
1
2
3
4
5
6
7
8
9
10
return (
<div>
<Input
type="text"
placeholder="Search"
onChange={(e) => handleSearch(e.target.value)}
defaultValue={searchParams.get("query")?.toString()}
/>
</div>
);
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.
1
2
3
4
5
6
7
8
9
10
11
12
13
const handleSearch = useDebouncedCallback((term: string) => {
console.log(`Searching... ${term}`);
const params = new URLSearchParams(searchParams);
params.set("page", "1");
if (term) {
params.set("query", term);
} else {
params.delete("query");
}
replace(`${pathname}?${params.toString()}`);
}, 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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import Link from "next/link";
import { getSummaries } from "@/data/loaders";
import ReactMarkdown from "react-markdown";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Search } from "@/components/custom/search";
interface LinkCardProps {
documentId: string;
title: string;
summary: string;
}
function LinkCard({ documentId, title, summary }: Readonly<LinkCardProps>) {
return (
<Link href={`/dashboard/summaries/${documentId}`}>
<Card className="relative">
<CardHeader>
<CardTitle className="leading-8 text-pink-500">
{title || "Video Summary"}
</CardTitle>
</CardHeader>
<CardContent>
<ReactMarkdown
className="card-markdown prose prose-sm max-w-none
prose-headings:text-gray-900 prose-headings:font-semibold
prose-p:text-gray-600 prose-p:leading-relaxed
prose-a:text-pink-500 prose-a:no-underline hover:prose-a:underline
prose-strong:text-gray-900 prose-strong:font-semibold
prose-ul:list-disc prose-ul:pl-4
prose-ol:list-decimal prose-ol:pl-4"
>
{summary.slice(0, 164) + " [read more]"}
</ReactMarkdown>
</CardContent>
</Card>
</Link>
);
}
interface SearchParamsProps {
searchParams?: {
query?: string;
};
}
export default async function SummariesRoute({
searchParams,
}: SearchParamsProps) {
const search = await searchParams;
const query = search?.query ?? "";
console.log(query);
const { data } = await getSummaries();
if (!data) return null;
return (
<div className="grid grid-cols-1 gap-4 p-4">
<Search />
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{data.map((item: LinkCardProps) => (
<LinkCard key={item.documentId} {...item} />
))}
</div>
</div>
);
}
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.
1
2
3
4
export async function getSummaries() {
const url = new URL("/api/summaries", baseUrl);
return fetchData(url.href);
}
Here are the changes we are going to make.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
export async function getSummaries(queryString: string) {
const query = qs.stringify({
sort: ["createdAt:desc"],
filters: {
$or: [
{ title: { $containsi: queryString } },
{ summary: { $containsi: queryString } },
],
},
});
const url = new URL("/api/summaries", baseUrl);
url.search = query;
return fetchData(url.href);
}
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.
1
const { 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
"use client";
import { FC } from "react";
import { usePathname, useSearchParams, useRouter } from "next/navigation";
import {
Pagination,
PaginationContent,
PaginationItem,
} from "@/components/ui/pagination";
import { Button } from "@/components/ui/button";
interface PaginationProps {
pageCount: number;
}
interface PaginationArrowProps {
direction: "left" | "right";
href: string;
isDisabled: boolean;
}
const PaginationArrow: FC<PaginationArrowProps> = ({
direction,
href,
isDisabled,
}) => {
const router = useRouter();
const isLeft = direction === "left";
const disabledClassName = isDisabled ? "opacity-50 cursor-not-allowed" : "";
return (
<Button
onClick={() => router.push(href)}
className={`bg-gray-100 text-gray-500 hover:bg-gray-200 ${disabledClassName}`}
aria-disabled={isDisabled}
disabled={isDisabled}
>
{isLeft ? "«" : "»"}
</Button>
);
};
export function PaginationComponent({ pageCount }: Readonly<PaginationProps>) {
const pathname = usePathname();
const searchParams = useSearchParams();
const currentPage = Number(searchParams.get("page")) || 1;
const createPageURL = (pageNumber: number | string) => {
const params = new URLSearchParams(searchParams);
params.set("page", pageNumber.toString());
return `${pathname}?${params.toString()}`;
};
return (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationArrow
direction="left"
href={createPageURL(currentPage - 1)}
isDisabled={currentPage <= 1}
/>
</PaginationItem>
<PaginationItem>
<span className="p-2 font-semibold text-gray-500">
Page {currentPage}
</span>
</PaginationItem>
<PaginationItem>
<PaginationArrow
direction="right"
href={createPageURL(currentPage + 1)}
isDisabled={currentPage >= pageCount}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
);
}
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.
1
import { PaginationComponent } from "@/components/custom/pagination-component";
Now update our SearchParamsProps
interface to the following.
1
2
3
4
5
6
interface SearchParamsProps {
searchParams?: {
page?: string;
query?: string;
};
}
Now, let's create a currentPage
variable to store the current page we get from our URL parameters.
1
const currentPage = Number(searchParams?.page) || 1;
Now, we can pass our currentPage
to our getSummaries
function.
1
const { 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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export async function getSummaries(queryString: string, currentPage: number) {
const PAGE_SIZE = 4;
const query = qs.stringify({
sort: ["createdAt:desc"],
filters: {
$or: [
{ title: { $containsi: queryString } },
{ summary: { $containsi: queryString } },
],
},
pagination: {
pageSize: PAGE_SIZE,
page: currentPage,
},
});
const url = new URL("/api/summaries", baseUrl);
url.search = query;
return fetchData(url.href);
}
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.
1
2
const { data, meta } = await getSummaries(query, currentPage);
console.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.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
import Link from "next/link";
import { getSummaries } from "@/data/loaders";
import ReactMarkdown from "react-markdown";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Search } from "@/components/custom/search";
import { PaginationComponent } from "@/components/custom/pagination-component";
interface LinkCardProps {
documentId: string;
title: string;
summary: string;
}
function LinkCard({ documentId, title, summary }: Readonly<LinkCardProps>) {
return (
<Link href={`/dashboard/summaries/${documentId}`}>
<Card className="relative">
<CardHeader>
<CardTitle className="leading-8 text-pink-500">
{title || "Video Summary"}
</CardTitle>
</CardHeader>
<CardContent>
<ReactMarkdown
className="card-markdown prose prose-sm max-w-none
prose-headings:text-gray-900 prose-headings:font-semibold
prose-p:text-gray-600 prose-p:leading-relaxed
prose-a:text-pink-500 prose-a:no-underline hover:prose-a:underline
prose-strong:text-gray-900 prose-strong:font-semibold
prose-ul:list-disc prose-ul:pl-4
prose-ol:list-decimal prose-ol:pl-4"
>
{summary.slice(0, 164) + " [read more]"}
</ReactMarkdown>
</CardContent>
</Card>
</Link>
);
}
interface SearchParamsProps {
searchParams?: {
page?: string;
query?: string;
};
}
export default async function SummariesRoute({ searchParams }: SearchParamsProps) {
const search = await searchParams;
const query = search?.query ?? "";
const currentPage = Number(search?.page) || 1;
const { data, meta } = await getSummaries(query, currentPage);
const pageCount = meta?.pagination?.pageCount;
console.log(meta);
if (!data) return null;
return (
<div className="grid grid-cols-1 gap-4 p-4">
<Search />
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{data.map((item: LinkCardProps) => (
<LinkCard key={item.documentId} {...item} />
))}
</div>
<PaginationComponent pageCount={pageCount} />
</div>
);
}
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!