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:
- Part 1: Learn Next.js by building a website
- Part 2: Building Out The Hero Section of the homepage
- Part 3: Finishup up the homepage Features Section, TopNavigation and Footer
- Part 4: How to handle login and Authentification in Next.js
- Part 5: Building out the Dashboard page and upload file using NextJS server actions
- Part 6: Get Video Transcript with AI SDK
- Part 7: Strapi CRUD permissions
- Part 8: Search & pagination in Nextjs
- Part 9: Backend deployment to Strapi Cloud
- Part 10: Frontend deployment to Vercel
Why Search and Pagination Matter
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.
Building Smart Search in Next.js
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}
Understanding the Search Component
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 thereplace
method to update the URL without adding to browser historyusePathname
: Gets the current page path so we can build the new URL correctly
The Magic of Debouncing:
useDebouncedCallback
: Waits 300ms after the user stops typing before actually performing the search- This prevents overwhelming the server with requests while the user is still typing
How 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.
Making Search Actually Work
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}
Understanding the Search Logic
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 text- Conditional filtering: We only add filters when there's actually a search query
The 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.
Implementing Smart Pagination
When you have hundreds of summaries, loading them all at once becomes a performance nightmare. Pagination solves this by breaking content into manageable pages.
Understanding Pagination Benefits
- Better Performance: Load only what users need to see
- Improved User Experience: Easier to browse through organized chunks
- Reduced Server Load: Fewer resources used per request
- Faster Page Loading: Smaller data transfers mean quicker loading
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}
How Our Pagination Works
This component is smart about preserving search queries and other URL parameters:
- URL Preservation: When you go to the next page, it keeps your search query intact
- Disabled States: Previous button is disabled on page 1, next button is disabled on the last page
- Visual Feedback: Current page is clearly displayed, and the next button is highlighted in pink
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}
Understanding Strapi Pagination
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.
Adding a Professional Loading Experience
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.
What We've Accomplished
Congratulations! We've built a comprehensive search and pagination system that includes:
Search Features
- Real-time URL updates: Search queries are reflected in the URL
- Debounced requests: Smart waiting prevents server overload
- Multi-field search: Searches both title and content
- Case-insensitive matching: User-friendly search behavior
Pagination Features
- Efficient data loading: Only loads what users need to see
- URL-based navigation: Pages can be bookmarked and shared
- Search preservation: Moving between pages keeps search active
- Smart button states: Clear visual feedback for navigation
User Experience Enhancements
- Professional loading states: Skeleton screens during data fetching
- Responsive design: Works great on all device sizes
- Intuitive navigation: Clear previous/next buttons with visual distinction
Looking Ahead
Our video summary application is now feature-complete! Users can:
- Create summaries from YouTube videos
- Search through their content easily
- Navigate large collections efficiently
- Update and delete their own summaries securely
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.
Note about this project
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