Welcome back to our Epic Next.js tutorial series!
We're in the home stretch! In this tutorial, we'll add the final touches to our video summary app by implementing search and pagination features. These are essential features that make our app truly user-friendly when dealing with lots of content.
Previous Tutorials:
Imagine you've been using our app for months and have created dozens or even hundreds of video summaries. Finding that one specific summary about machine learning from three weeks ago becomes like finding a needle in a haystack without search functionality.
Similarly, loading hundreds of summaries at once would make your page slow and overwhelming. Pagination breaks content into manageable chunks, making the app faster and easier to navigate.
Let's build these features step by step.
We'll create a search component that updates the URL as you type, making searches shareable and bookmarkable. The search will be "smart" - it won't make API requests on every keystroke, thanks to debouncing.
First, let's create our search component. Create a new file at src/components/custom/search.tsx
:
1"use client";
2import { usePathname, useRouter, useSearchParams } from "next/navigation";
3import { useDebouncedCallback } from "use-debounce";
4import { Input } from "@/components/ui/input";
5import { cn } from "@/lib/utils";
6
7interface ISearchProps {
8 className?: string;
9}
10
11export function Search({ className }: ISearchProps) {
12 const searchParams = useSearchParams();
13 const { replace } = useRouter();
14 const pathname = usePathname();
15
16 const handleSearch = useDebouncedCallback((term: string) => {
17 console.log(`Searching... ${term}`);
18 const params = new URLSearchParams(searchParams);
19 params.set("page", "1");
20
21 if (term) {
22 params.set("query", term);
23 } else {
24 params.delete("query");
25 }
26
27 replace(`${pathname}?${params.toString()}`);
28 }, 300);
29
30 return (
31 <Input
32 type="text"
33 placeholder="Search"
34 onChange={(e) => handleSearch(e.target.value)}
35 defaultValue={searchParams.get("query")?.toString()}
36 className={cn("", className)}
37 />
38 );
39}
Let's break down what's happening here:
Key Next.js Hooks:
useSearchParams
: Reads the current URL's query parameters (like ?query=machine+learning
)useRouter
: Gives us the replace
method to update the URL without adding to browser historyusePathname
: Gets the current page path so we can build the new URL correctlyThe Magic of Debouncing:
useDebouncedCallback
: Waits 300ms after the user stops typing before actually performing the searchHow the Flow Works:
1. User types in the search box
2. After 300ms of no typing, handleSearch
runs
3. We create new URL parameters with the search term
4. We reset the page to 1 (since search results start fresh)
5. The URL updates, which triggers a new data fetch
Before we can use this component, we need to install the debounce library:
yarn add use-debounce
Now let's add our search component to the summaries page. Navigate to src/app/(protected)/dashboard/summaries/page.tsx
and update it:
1import { loaders } from "@/data/loaders";
2import { SummariesGrid } from "@/components/custom/summaries-grid";
3import { validateApiResponse } from "@/lib/error-handler";
4
5import { Search } from "@/components/custom/search";
6
7import { SearchParams } from "@/types";
8
9interface ISummariesRouteProps {
10 searchParams: SearchParams;
11}
12
13export default async function SummariesRoute({
14 searchParams,
15}: ISummariesRouteProps) {
16 const resolvedSearchParams = await searchParams;
17 const query = resolvedSearchParams?.query as string;
18
19 const data = await loaders.getSummaries();
20 const summaries = validateApiResponse(data, "summaries");
21
22 return (
23 <div className="flex flex-col min-h-[calc(100vh-80px)] p-4 gap-6">
24 <Search className="flex-shrink-0" />
25 <SummariesGrid summaries={summaries} className="flex-grow" />
26 </div>
27 );
28}
Great! Now we have a search box, but it doesn't actually search anything yet. Let's fix that.
Now we need to update our data loader to handle search queries. We'll search through both the title and content of summaries, and we'll make the search case-insensitive for better user experience.
Navigate to src/data/loaders.ts
and find the getSummaries
function. Currently it looks like this:
1async function getSummaries(): Promise<TStrapiResponse<TSummary[]>> {
2 const authToken = await actions.auth.getAuthTokenAction();
3 if (!authToken) throw new Error("You are not authorized");
4
5 const query = qs.stringify({
6 sort: ["createdAt:desc"],
7 });
8
9 const url = new URL("/api/summaries", baseUrl);
10 url.search = query;
11 return api.get<TSummary[]>(url.href, { authToken });
12}
Let's update it to handle search queries:
1async function getSummaries(queryString: string): Promise<TStrapiResponse<TSummary[]>> {
2 const authToken = await actions.auth.getAuthTokenAction();
3 if (!authToken) throw new Error("You are not authorized");
4
5 const query = qs.stringify({
6 sort: ["createdAt:desc"],
7 ...(queryString && {
8 filters: {
9 $or: [
10 { title: { $containsi: queryString } },
11 { content: { $containsi: queryString } },
12 ],
13 },
14 }),
15 });
16
17 const url = new URL("/api/summaries", baseUrl);
18 url.search = query;
19 return api.get<TSummary[]>(url.href, { authToken });
20}
Here's what's happening in our updated function:
sort: ["createdAt:desc"]
: Always show newest summaries first$or
: This is Strapi's "OR" operator - it means "match if EITHER condition is true"$containsi
: Case-insensitive search that matches partial textThe search will now find summaries where the query appears in either the title OR the content (or both).
Now we need to update our page to pass the search query to our loader. Back in src/app/(protected)/dashboard/summaries/page.tsx
, update this line:
1const data = await loaders.getSummaries(query);
Let's test our search functionality:
Perfect! Our search is working beautifully. Now let's add pagination to handle large numbers of summaries.
When you have hundreds of summaries, loading them all at once becomes a performance nightmare. Pagination solves this by breaking content into manageable pages.
Let's start by installing the pagination component from Shadcn UI:
npx shadcn@latest add pagination
Now create our custom pagination component at src/components/custom/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";
12import { cn } from "@/lib/utils";
13
14interface PaginationProps {
15 pageCount: number;
16 className?: string;
17}
18
19interface PaginationArrowProps {
20 direction: "left" | "right";
21 href: string;
22 isDisabled: boolean;
23}
24
25const PaginationArrow: FC<PaginationArrowProps> = ({
26 direction,
27 href,
28 isDisabled,
29}) => {
30 const router = useRouter();
31 const isLeft = direction === "left";
32 const disabledClassName = isDisabled ? "opacity-50 cursor-not-allowed" : "";
33
34 // Make next button (right arrow) more visible with pink styling
35 const buttonClassName = isLeft
36 ? `bg-gray-100 text-gray-500 hover:bg-gray-200 ${disabledClassName}`
37 : `bg-pink-500 text-white hover:bg-pink-600 ${disabledClassName}`;
38
39 return (
40 <Button
41 onClick={() => router.push(href)}
42 className={buttonClassName}
43 aria-disabled={isDisabled}
44 disabled={isDisabled}
45 >
46 {isLeft ? "«" : "»"}
47 </Button>
48 );
49};
50
51export function PaginationComponent({
52 pageCount,
53 className,
54}: Readonly<PaginationProps>) {
55 const pathname = usePathname();
56 const searchParams = useSearchParams();
57 const currentPage = Number(searchParams.get("page")) || 1;
58
59 const createPageURL = (pageNumber: number | string) => {
60 const params = new URLSearchParams(searchParams);
61 params.set("page", pageNumber.toString());
62 return `${pathname}?${params.toString()}`;
63 };
64
65 return (
66 <Pagination className={cn("", className)}>
67 <PaginationContent>
68 <PaginationItem>
69 <PaginationArrow
70 direction="left"
71 href={createPageURL(currentPage - 1)}
72 isDisabled={currentPage <= 1}
73 />
74 </PaginationItem>
75 <PaginationItem>
76 <span className="p-2 font-semibold text-pink-500">
77 Page {currentPage}
78 </span>
79 </PaginationItem>
80 <PaginationItem>
81 <PaginationArrow
82 direction="right"
83 href={createPageURL(currentPage + 1)}
84 isDisabled={currentPage >= pageCount}
85 />
86 </PaginationItem>
87 </PaginationContent>
88 </Pagination>
89 );
90}
This component is smart about preserving search queries and other URL parameters:
Now let's update our summaries page to use pagination. In src/app/(protected)/dashboard/summaries/page.tsx
, add the pagination import and extract the current page:
1import { PaginationComponent } from "@/components/custom/pagination-component";
Add this line to get the current page from URL parameters:
1const currentPage = Number(resolvedSearchParams?.page) || 1;
And update the data fetching to include the current page:
1const data = await loaders.getSummaries(query, currentPage);
Now we need to update our getSummaries
function to handle pagination. Back in src/data/loaders.ts
, update the function:
1async function getSummaries(
2 queryString: string,
3 page: number = 1
4): Promise<TStrapiResponse<TSummary[]>> {
5 const authToken = await actions.auth.getAuthTokenAction();
6 if (!authToken) throw new Error("You are not authorized");
7
8 const query = qs.stringify({
9 sort: ["createdAt:desc"],
10 ...(queryString && {
11 filters: {
12 $or: [
13 { title: { $containsi: queryString } },
14 { content: { $containsi: queryString } },
15 ],
16 },
17 }),
18 pagination: {
19 page: page,
20 pageSize: process.env.PAGE_SIZE || 4,
21 },
22 });
23
24 const url = new URL("/api/summaries", baseUrl);
25 url.search = query;
26 return api.get<TSummary[]>(url.href, { authToken });
27}
Strapi makes pagination easy with built-in parameters:
page
: Which page to retrieve (starting from 1)pageSize
: How many items per page (we're using 4 for testing, but you might want 10-20 in production)Strapi also returns helpful metadata in the response:
1{ pagination: { page: 1, pageSize: 4, pageCount: 3, total: 12 } }
We use pageCount
to know how many pages are available for our pagination component.
Back in our page component, let's extract the page count and add our pagination component. Here's the complete updated src/app/(protected)/dashboard/summaries/page.tsx
:
1import { loaders } from "@/data/loaders";
2import { SummariesGrid } from "@/components/custom/summaries-grid";
3import { validateApiResponse } from "@/lib/error-handler";
4
5import { Search } from "@/components/custom/search";
6import { PaginationComponent } from "@/components/custom/pagination-component";
7
8import { SearchParams } from "@/types";
9
10interface ISummariesRouteProps {
11 searchParams: SearchParams;
12}
13
14export default async function SummariesRoute({
15 searchParams,
16}: ISummariesRouteProps) {
17 const resolvedSearchParams = await searchParams;
18 const query = resolvedSearchParams?.query as string;
19 const currentPage = Number(resolvedSearchParams?.page) || 1;
20
21 const data = await loaders.getSummaries(query, currentPage);
22 const summaries = validateApiResponse(data, "summaries");
23 const pageCount = data?.meta?.pagination?.pageCount || 1;
24
25 return (
26 <div className="flex flex-col min-h-[calc(100vh-80px)] p-4 gap-6">
27 <Search className="flex-shrink-0" />
28 <SummariesGrid summaries={summaries} className="flex-grow" />
29 <PaginationComponent pageCount={pageCount} />
30 </div>
31 );
32}
Let's test our pagination! Make sure you have at least 5 summaries since our page size is set to 4:
Excellent! Both search and pagination are working perfectly together.
Let's add one final touch - a loading screen that shows while data is being fetched. This gives users immediate feedback that something is happening.
Create a new file at src/app/(protected)/dashboard/summaries/loading.tsx
:
1import { Skeleton } from "@/components/ui/skeleton";
2import { Card, CardContent, CardHeader } from "@/components/ui/card";
3
4const styles = {
5 container: "grid grid-cols-1 gap-4 p-4",
6 grid: "grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6",
7 card: "border border-gray-200",
8 cardHeader: "pb-3",
9 cardContent: "pt-0 space-y-2",
10 skeleton: "animate-pulse",
11 title: "h-6 w-3/4",
12 line: "h-3 w-full",
13 shortLine: "h-3 w-2/3",
14 readMore: "h-3 w-16",
15};
16
17function SummaryCardSkeleton() {
18 return (
19 <Card className={styles.card}>
20 <CardHeader className={styles.cardHeader}>
21 <Skeleton className={`${styles.skeleton} ${styles.title}`} />
22 </CardHeader>
23 <CardContent className={styles.cardContent}>
24 <Skeleton className={`${styles.skeleton} ${styles.line}`} />
25 <Skeleton className={`${styles.skeleton} ${styles.line}`} />
26 <Skeleton className={`${styles.skeleton} ${styles.shortLine}`} />
27 <div className="mt-3">
28 <Skeleton className={`${styles.skeleton} ${styles.readMore}`} />
29 </div>
30 </CardContent>
31 </Card>
32 );
33}
34
35export default function SummariesLoading() {
36 return (
37 <div className={styles.container}>
38 <div className={styles.grid}>
39 {Array.from({ length: 8 }).map((_, index) => (
40 <SummaryCardSkeleton key={index} />
41 ))}
42 </div>
43 </div>
44 );
45}
This loading component creates skeleton versions of our summary cards that pulse gently while data loads. It's a small detail that makes a big difference in perceived performance.
Let's see our loading screen in action:
Perfect! Now our app feels polished and professional.
Congratulations! We've built a comprehensive search and pagination system that includes:
Our video summary application is now feature-complete! Users can:
In our next tutorials, we'll deploy this application to production using Strapi Cloud for the backend and Vercel for the frontend.
You now have a solid foundation for building search and pagination in any Next.js application. These patterns can be applied to blogs, e-commerce sites, dashboards, or any app that deals with lists of content.
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!
Paul