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
- Install Next.js and Required Packages
- Create the File and Folder Structure Organize your project with the following 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;
}
};
//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>
</>
);
}
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>
);
}
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 {currentPage} of {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>
</>
);
}
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>
</>
);
}
//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>
</>
);
}
//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>
</>
);
}
//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>
</>
);
}
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>
);
}
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>
);
}
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" />;
}
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
, andpagination
changes.
AnimeList Component and Data Fetching
The AnimeList component handles:
- Asynchronous data fetching from the Jikan API (
getAllAnime()
).
Reusable FilterSelect Component
The FilterSelect
component:
- Abstracts dropdown functionality for filters like
status
,type
,order_by
, andsort
. - Simplifies code by reducing redundancy across
filter
andsort
components.
Pagination Handling
The Pagination component:
- Dynamically generates navigation links (
Prev
andNext
) based oncurrentPage
andtotalPages
. - 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:
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)
Really helpful, thank you Rifky🤗
Of course, happy to help, Ronel.
i learned so much from this post😍 good job rifky!!!🙌🥰❤🔥
Appreciate it, Difani! 🙌 If you have any questions, feel free to ask. Happy to help! 😊❤️🔥