DEV Community

Cover image for Next.js API Management: Implement Search, Pagination, Filter, Sort, and Limit Features
Rifky Alfarez
Rifky Alfarez

Posted on • Originally published at Medium

Next.js API Management: Implement Search, Pagination, Filter, Sort, and Limit Features

In this article, I want to share how I handle APIs in a Next.js application, including fetching data, displaying it, and implementing features such as searching, pagination, filtering, sorting, and limiting results. Let's dive straight into the details.

Requirements:

  • Next.js 14.2.18
  • Axios 1.7.8
  • Use debounce 10.0.4
  • API, Make sure the API you use supports features like searching, pagination, filtering, sorting, and limiting data. In this article, I'll demonstrate using the Jikan API, which provides data for anime and related content.

Project Setup

  1. Install Next.js and Required Packages
  2. Create the File and Folder Structure Organize your project with the following structure:

project structure

Fetch and Display Data

//src/lib/actions.ts

import axios from 'axios';

const axiosInstance = axios.create({
  baseURL: 'https://api.jikan.moe/v4',
});

export const getAllAnime = async (
  query?: string,
  currentPage?: number,
  orderBy?: string,
  type?: string,
  status?: string,
  sort?: string,
  limit?: number
) => {
  try {
    const res = await axiosInstance.get(
      `/anime?q=${query}&page=${currentPage}&order_by=${orderBy}&type=${type}&status=${status}&sort=${sort}&limit=${limit}`
    );

    const totalPages = res.data.pagination.last_visible_page;

    return {
      data: res.data.data,
      totalPages,
    };
  } catch (error) {
    console.log(error);
    return null;
  }
};
Enter fullscreen mode Exit fullscreen mode
//src/components/AnimeList.tsx

import { getAllAnime } from '@/lib/actions';
import Image from 'next/image';

type AnimeProps = {
  mal_id: number;
  images: {
    webp: {
      image_url: string;
    };
  };
  title: string;
};

export default async function AnimeList({
  query,
  currentPage,
  orderBy,
  type,
  status,
  sort,
  limit,
}: {
  query?: string;
  currentPage?: number;
  orderBy?: string;
  type?: string;
  status?: string;
  sort?: string;
  limit?: number;
}) {
  const animes = await getAllAnime(
    query,
    currentPage,
    orderBy,
    type,
    status,
    sort,
    limit
  );

  return (
    <>
      <div className="grid grid-cols-6 gap-4 pt-4">
        {animes?.data.map((anime: AnimeProps) => (
          <div key={anime.mal_id}>
            <Image
              src={anime.images.webp.image_url}
              alt={anime.title}
              width={500}
              height={500}
              className="w-full h-[250px] object-cover rounded-md"
            />
            <h3>
              {anime.title.length > 15
                ? `${anime.title.slice(0, 15)}...`
                : anime.title}
            </h3>
          </div>
        ))}
      </div>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Implement Search

//src/components/Search.tsx

'use client';

import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useDebouncedCallback } from 'use-debounce';

export default function Search() {
  const pathname = usePathname();
  const { replace } = useRouter();
  const searchParams = useSearchParams();

  const handleSearch = useDebouncedCallback((term: string) => {
    const params = new URLSearchParams(searchParams);
    params.set('page', '1');
    if (term) {
      params.set('query', term);
    } else {
      params.delete('query');
    }
    replace(`${pathname}?${params.toString()}`);
  }, 500);

  return (
    <div className="w-[50%]">
      <input
        type="text"
        placeholder="Search..."
        className="w-full bg-neutral-200 px-2 py-1 rounded-md"
        onChange={(e) => {
          handleSearch(e.target.value);
        }}
        defaultValue={searchParams.get('query')?.toString()}
      />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Implement Pagination

//src/components/Pagination.tsx

'use client';

import { usePathname, useRouter, useSearchParams } from 'next/navigation';

export default function Pagination({ totalPages }: { totalPages: number }) {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const currentPage = Number(searchParams.get('page')) || 1;
  const router = useRouter();

  const createPageURL = (pageNumber: number | string) => {
    const params = new URLSearchParams(searchParams);
    params.set('page', pageNumber.toString());
    return `${pathname}?${params.toString()}`;
  };

  return (
    <>
      <div className="w-full flex items-center justify-center gap-x-8">
        <button
          onClick={() => router.push(createPageURL(currentPage - 1))}
          disabled={currentPage === 1}
          className="bg-neutral-600 px-2 py-1 rounded-md text-neutral-100 disabled:bg-neutral-400 disabled:cursor-not-allowed"
        >
          Prev
        </button>
        <p className="text-[1rem] text-neutral-700">
          Page&nbsp;{currentPage} of&nbsp;{totalPages}
        </p>
        <button
          onClick={() => router.push(createPageURL(currentPage + 1))}
          disabled={currentPage === totalPages}
          className="bg-neutral-600 px-2 py-1 rounded-md text-neutral-100 disabled:bg-neutral-400 disabled:cursor-not-allowed"
        >
          Next
        </button>
      </div>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Implement Filter and Sort

//src/components/FilterByType.tsx

'use client';

import { usePathname, useRouter, useSearchParams } from 'next/navigation';

type TypeProps = { value: string; name: string };

const TYPE = [
  { value: 'tv', name: 'TV' },
  { value: 'movie', name: 'Movie' },
  { value: 'ova', name: 'OVA' },
  { value: 'special', name: 'Special' },
  { value: 'ona', name: 'ONA' },
  { value: 'music', name: 'Music' },
  { value: 'cm', name: 'CM' },
  { value: 'pv', name: 'PV' },
  { value: 'tv_special', name: 'TV Special' },
];

export default function FilterByType() {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const router = useRouter();

  const handleFilterByType = (type: string) => {
    const params = new URLSearchParams(searchParams);
    params.set('page', '1');
    params.set('type', type);
    router.replace(`${pathname}?${params.toString()}`);
  };

  return (
    <>
      <form className="w-[10%] bg-neutral-400 px-2 py-1 rounded-md">
        <select
          className="bg-neutral-400 w-full"
          onChange={(e) => handleFilterByType(e.target.value)}
          defaultValue={searchParams.get('type')?.toString()}
        >
          <option value={''} defaultValue={''}>
            Type:
          </option>
          {TYPE.map((type: TypeProps, index) => (
            <option key={index} value={type.value}>
              {type.name}
            </option>
          ))}
        </select>
      </form>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode
//src/components/FilterByStatus.tsx

'use client';

import { usePathname, useRouter, useSearchParams } from 'next/navigation';

type StatusProps = { value: string; name: string };

const STATUS = [
  { value: 'airing', name: 'Airing' },
  { value: 'upcoming', name: 'Upcoming' },
  { value: 'complete', name: 'Complete' },
];

export default function FilterByStatus() {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const router = useRouter();

  const handleFilterByStatus = (status: string) => {
    const params = new URLSearchParams(searchParams);
    params.set('page', '1');
    params.set('status', status);
    router.replace(`${pathname}?${params.toString()}`);
  };
  return (
    <>
      <form className="w-[10%] bg-neutral-400 px-2 py-1 rounded-md">
        <select
          className="bg-neutral-400 w-full"
          onChange={(e) => handleFilterByStatus(e.target.value)}
          defaultValue={searchParams.get('status')?.toString()}
        >
          <option value={''} defaultValue={''}>
            Status:
          </option>
          {STATUS.map((status: StatusProps, index) => (
            <option key={index} value={status.value}>
              {status.name}
            </option>
          ))}
        </select>
      </form>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode
//src/components/SortByOrder

'use client';

import { usePathname, useRouter, useSearchParams } from 'next/navigation';

type OrderByProps = { value: string; name: string };

const ORDER_BY = [
  { value: 'mal_id', name: 'Mal id' },
  { value: 'title', name: 'Title' },
  { value: 'start_date', name: 'Start date' },
  { value: 'end_date', name: 'End date' },
  { value: 'episodes', name: 'Episodes' },
  { value: 'score', name: 'Score' },
  { value: 'scored_by', name: 'Scored by' },
  { value: 'rank', name: 'Rank' },
  { value: 'popularity', name: 'Popularity' },
  { value: 'members', name: 'Members' },
  { value: 'favorites', name: 'Favorites' },
];

export default function SortByOrder() {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const router = useRouter();

  const handleSortByOrder = (orderBy: string) => {
    const params = new URLSearchParams(searchParams);
    params.set('page', '1');
    params.set('order_by', orderBy);
    router.replace(`${pathname}?${params.toString()}`);
  };
  return (
    <>
      <form className="w-[10%] bg-neutral-400 px-2 py-1 rounded-md">
        <select
          className="bg-neutral-400 w-full"
          onChange={(e) => handleSortByOrder(e.target.value)}
          defaultValue={searchParams.get('order_by')?.toString()}
        >
          <option value={''} defaultValue={''}>
            Order by:
          </option>
          {ORDER_BY.map((orderBy: OrderByProps, index: number) => (
            <option key={index} value={orderBy.value}>
              {orderBy.name}
            </option>
          ))}
        </select>
      </form>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode
//src/components/SortDirection.tsx

'use client';

import { usePathname, useRouter, useSearchParams } from 'next/navigation';

type SortDirectionProps = { value: string; name: string };

const SORT_DIRECTION = [
  { value: 'asc', name: 'Ascending' },
  { value: 'desc', name: 'Descending' },
];

export default function SortDirection() {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const router = useRouter();

  const handleSortDirection = (sort: string) => {
    const params = new URLSearchParams(searchParams);
    params.set('page', '1');
    params.set('sort', sort);
    router.replace(`${pathname}?${params.toString()}`);
  };
  return (
    <>
      <form className="w-[10%] bg-neutral-400 px-2 py-1 rounded-md">
        <select
          className="bg-neutral-400 w-full"
          onChange={(e) => handleSortDirection(e.target.value)}
          defaultValue={searchParams.get('sort')?.toString()}
        >
          <option value={''} defaultValue={''}>
            Sort:
          </option>
          {SORT_DIRECTION.map((orderBy: SortDirectionProps, index: number) => (
            <option key={index} value={orderBy.value}>
              {orderBy.name}
            </option>
          ))}
        </select>
      </form>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Limiting Results

//src/components/Limit.tsx

'use client';

import { usePathname, useRouter, useSearchParams } from 'next/navigation';

export default function Limit() {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const router = useRouter();

  const handleLimitChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    const value = event.target.value;
    const params = new URLSearchParams(searchParams);

    if (!isNaN(Number(value)) && Number(value) > 0) {
      params.set('page', '1');
      params.set('limit', value);
      router.replace(`${pathname}?${params.toString()}`);
    }

    if (
      Number(value) === 0 ||
      isNaN(Number(value)) ||
      value === '' ||
      Number(value) > 25
    ) {
      params.delete('limit');
      router.replace(`${pathname}?${params.toString()}`);
    }
  };

  return (
    <form className="w-[10%] bg-neutral-400 px-2 py-1 rounded-md flex items-center gap-x-2">
      <label htmlFor="limit">Limit:</label>
      <input
        id="limit"
        type="string"
        className="bg-neutral-400 w-full placeholder-neutral-700"
        min="1"
        placeholder="1-25"
        defaultValue={searchParams.get('limit') || ''}
        onChange={handleLimitChange}
      />
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Home Page

//src/app/page

import AnimeList from '@/components/AnimeList';
import FilterByStatus from '@/components/FilterByStatus';
import FilterByType from '@/components/FilterByType';
import Limit from '@/components/Limit';
import Pagination from '@/components/Pagination';
import Search from '@/components/Search';
import SortByOrder from '@/components/SortByOrder';
import SortDirection from '@/components/SortDirection';
import { getAllAnime } from '@/lib/actions';
import { Suspense } from 'react';

export default async function Home({
  searchParams,
}: {
  searchParams?: {
    query?: string;
    page?: string;
    order_by?: string;
    type?: string;
    status?: string;
    sort?: string;
    limit?: string;
  };
}) {
  const query = searchParams?.query || '';
  const currentPage = Number(searchParams?.page) || 1;
  const orderBy = searchParams?.order_by || '';
  const type = searchParams?.type || '';
  const status = searchParams?.status || '';
  const sort = searchParams?.sort || '';
  const limit = Number(searchParams?.limit) || 25;

  const pages = await getAllAnime(
    query,
    currentPage,
    orderBy,
    type,
    status,
    sort,
    limit
  );
  const totalPages = pages?.totalPages;

  return (
    <main className="w-[1020px] mx-auto">
      <div className="flex items-center gap-x-4 py-4">
        <Search />
        <FilterByType />
        <FilterByStatus />
        <SortByOrder />
        <SortDirection />
        <Limit />
      </div>
      <Pagination totalPages={totalPages} />
      <Suspense
        key={query + currentPage + orderBy + type + status + sort + limit}
        fallback={<div>Loading...</div>}
      >
        <AnimeList
          query={query}
          currentPage={currentPage}
          orderBy={orderBy}
          type={type}
          status={status}
          sort={sort}
          limit={limit}
        />
      </Suspense>
    </main>
  );
}
Enter fullscreen mode Exit fullscreen mode

Refactor Components

To make the code more concise and reusable, we can refactor the filter and sort components to reduce redundancy. Use FilterSelect.tsx component that can handle both the filtering and sorting functionality. Here's how you can refactor your components:

//src/components/FilterSelect.tsx

'use client';

import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import React from 'react';

type FilterSelectProps = {
  options: { value: string; name: string }[];
  paramKey: string;
  label: string;
};

const FilterSelect: React.FC<FilterSelectProps> = ({
  options,
  paramKey,
  label,
}) => {
  const pathname = usePathname();
  const searchParams = useSearchParams();
  const router = useRouter();

  const handleChange = (value: string) => {
    const params = new URLSearchParams(searchParams);
    params.set('page', '1');
    params.set(paramKey, value);
    router.replace(`${pathname}?${params.toString()}`);
  };

  return (
    <form className="w-[10%] bg-neutral-400 px-2 py-1 rounded-md">
      <select
        className="bg-neutral-400 w-full"
        onChange={(e) => handleChange(e.target.value)}
        defaultValue={searchParams.get(paramKey) || ''}
      >
        <option value="">{label}:</option>
        {options.map((option, index) => (
          <option key={index} value={option.value}>
            {option.name}
          </option>
        ))}
      </select>
    </form>
  );
};

const STATUS = [
  { value: 'airing', name: 'Airing' },
  { value: 'upcoming', name: 'Upcoming' },
  { value: 'complete', name: 'Complete' },
];

const TYPE = [
  { value: 'tv', name: 'TV' },
  { value: 'movie', name: 'Movie' },
  { value: 'ova', name: 'OVA' },
  { value: 'special', name: 'Special' },
  { value: 'ona', name: 'ONA' },
  { value: 'music', name: 'Music' },
  { value: 'cm', name: 'CM' },
  { value: 'pv', name: 'PV' },
  { value: 'tv_special', name: 'TV Special' },
];

const ORDER_BY = [
  { value: 'mal_id', name: 'Mal id' },
  { value: 'title', name: 'Title' },
  { value: 'start_date', name: 'Start date' },
  { value: 'end_date', name: 'End date' },
  { value: 'episodes', name: 'Episodes' },
  { value: 'score', name: 'Score' },
  { value: 'scored_by', name: 'Scored by' },
  { value: 'rank', name: 'Rank' },
  { value: 'popularity', name: 'Popularity' },
  { value: 'members', name: 'Members' },
  { value: 'favorites', name: 'Favorites' },
];

const SORT_DIRECTION = [
  { value: 'asc', name: 'Ascending' },
  { value: 'desc', name: 'Descending' },
];

export function FilterByStatus() {
  return <FilterSelect options={STATUS} paramKey="status" label="Status" />;
}

export function FilterByType() {
  return <FilterSelect options={TYPE} paramKey="type" label="Type" />;
}

export function SortByOrder() {
  return (
    <FilterSelect options={ORDER_BY} paramKey="order_by" label="Order by" />
  );
}

export function SortDirection() {
  return <FilterSelect options={SORT_DIRECTION} paramKey="sort" label="Sort" />;
}
Enter fullscreen mode Exit fullscreen mode

Modify home page and import filter and sort components from FilterSelect

Explanation

usePathname, useSearchParams, and useRouterHooks
These hooks from next/navigation are essential for managing URL states:

  • usePathname: Retrieves the current path of the URL.
  • useSearchParams: Accesses and manipulates query parameters.
  • useRouter: Provides routing functions to programmatically navigate or update URLs (replace or push).

debouncedCallback in Search Component
The useDebouncedCallback function delays the execution of the search request:

  • Prevents excessive API calls by waiting for a specified duration (500ms).
  • Ensures better performance and smoother user experience.

Dynamic URL Updates
Dynamic updates to URL parameters ensure a smooth, client-side navigation:

  • replace() updates the URL without reloading the page.
  • Keeps the browser history clean while reflecting the filter, sort, and pagination changes.

AnimeList Component and Data Fetching
The AnimeList component handles:

  • Asynchronous data fetching from the Jikan API (getAllAnime()).

Reusable FilterSelect Component
The FilterSelectcomponent:

  • Abstracts dropdown functionality for filters like status, type, order_by, and sort.
  • Simplifies code by reducing redundancy across filterand sortcomponents.

Pagination Handling
The Pagination component:

  • Dynamically generates navigation links (Prev and Next) based on currentPage and totalPages.
  • Disables buttons when reaching the first or last page.

Limit Input Validation
The Limit component validates user input for limiting the number of results:

  • Ensures the value is between 1 and 25.
  • Updates the URL dynamically or resets if the input is invalid.

Suspense for Data Fetching
The Suspense component is used to handle asynchronous rendering:

  • Displays a fallback loading indicator while waiting for data.
  • Ensures the UI remains responsive during data fetches.

and this is how the app looks like:

app looks like 1

app looks like 2

Conclusion
I hope this article has provided a comprehensive guide on how to implement essential features like search, pagination, filtering, sorting, and result limiting in a Next.js application.

Feel free to explore, clone, or contribute to the project on github. I look forward to your feedback and hope this project helps you in your development journey!

References:
https://nextjs.org/learn/dashboard-app/adding-search-and-pagination
https://nextjs.org/docs/app/api-reference/functions/use-search-params
https://docs.api.jikan.moe/

Top comments (4)

Collapse
 
rxnel profile image
Ronel

Really helpful, thank you Rifky🤗

Collapse
 
rifkyalfarez profile image
Rifky Alfarez

Of course, happy to help, Ronel.

Collapse
 
difani_anjayani_ffbd2fd3c profile image
Difani Anjayani

i learned so much from this post😍 good job rifky!!!🙌🥰❤‍🔥

Collapse
 
rifkyalfarez profile image
Rifky Alfarez

Appreciate it, Difani! 🙌 If you have any questions, feel free to ask. Happy to help! 😊❤️‍🔥