DEV Community

Cover image for Epic Next JS 15 Tutorial Part 8: Search and Pagination in Next.js
Paul Bratslavsky for Strapi

Posted on • Originally published at strapi.io

Epic Next JS 15 Tutorial Part 8: Search and Pagination in Next.js

We are making amazing progress. We are now in the final stretch. In this section, we will look at Search and Pagination.

001-search-pagination.png

How To Handle Search In Next.js

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.

"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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now, let's look at the flow of our Search component's work by looking at the following.

return (
  <div>
    <Input
      type="text"
      placeholder="Search"
      onChange={(e) => handleSearch(e.target.value)}
      defaultValue={searchParams.get("query")?.toString()}
    />
  </div>
);
Enter fullscreen mode Exit fullscreen mode

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.

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);
Enter fullscreen mode Exit fullscreen mode

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.

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

002-search-component.gif

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.

export async function getSummaries() {
  const url = new URL("/api/summaries", baseUrl);
  return fetchData(url.href);
}
Enter fullscreen mode Exit fullscreen mode

Here are the changes we are going to make.

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);
}
Enter fullscreen mode Exit fullscreen mode

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.

const { data } = await getSummaries(query);
Enter fullscreen mode Exit fullscreen mode

Now, let's see if our search is working.

003-testing-search.gif

Great. Now that our search is working, we can simply move forward with our pagination.

How To Handle Pagination In Your Next.js Project

Pagination Basics

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
Enter fullscreen mode Exit fullscreen mode

And add the following code to your components/custom folder file called pagination-component.tsx.

"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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

import { PaginationComponent } from "@/components/custom/pagination-component";
Enter fullscreen mode Exit fullscreen mode

Now update our SearchParamsProps interface to the following.

interface SearchParamsProps {
  searchParams?: {
    page?: string;
    query?: string;
  };
}
Enter fullscreen mode Exit fullscreen mode

Now, let's create a currentPage variable to store the current page we get from our URL parameters.

const currentPage = Number(searchParams?.page) || 1;
Enter fullscreen mode Exit fullscreen mode

Now, we can pass our currentPage to our getSummaries function.

const { data } = await getSummaries(query, currentPage);
Enter fullscreen mode Exit fullscreen mode

Now, let's go back to our loaders. tsx file and update the getSummaries with the following code to utilize our pagination.

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);
}
Enter fullscreen mode Exit fullscreen mode

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.

const { data, meta } = await getSummaries(query, currentPage);
console.log(meta);
Enter fullscreen mode Exit fullscreen mode

We will see the following data if we console log our meta field.

{ pagination: { page: 1, pageSize: 4, pageCount: 1, total: 4 } }
Enter fullscreen mode Exit fullscreen mode

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.

  const pageCount = meta?.pagination?.pageCount;
Enter fullscreen mode Exit fullscreen mode

And finally, let's use our PaginationComponent with the following.

<PaginationComponent pageCount={pageCount} />
Enter fullscreen mode Exit fullscreen mode

The completed code should look like the following.

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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.

004-pagination.gif

Excellent, it is working.

Conclusion

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.

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

Top comments (0)