An anime search screen looks simple: one input, a grid of covers, and a detail page. The hard part is not the UI. It is keeping the query shape small, handling empty and error states, and avoiding a request on every keystroke.
This tutorial builds the data layer and a small Next.js UI with AniList's GraphQL API. It needs no API key, but it does need a deliberate request shape and caching.
What we are building
- a search box that accepts an anime title
- a server route that validates the query and calls AniList
- a results grid with cover, title, score, episodes, and status
- a detail view that requests one title by its AniList id
- loading, empty, and error states
The example uses Next.js App Router, but the API layer works in any Node.js server.
1. Test the query before building UI
On 2026-10-27, this keyless search: "naruto" query returned HTTP 200 with six results:
curl -s https://graphql.anilist.co \
-H "content-type: application/json" \
--data '{
"query": "query Search($search: String) { Page(perPage: 6) { media(type: ANIME, search: $search, sort: SEARCH_MATCH) { id title { romaji english } coverImage { large } averageScore episodes status } } }",
"variables": { "search": "naruto" }
}'The response shape we care about:
type AnimeSearchResponse = {
data: {
Page: {
media: Array<{
id: number;
title: { romaji: string | null; english: string | null };
coverImage: { large: string | null } | null;
averageScore: number | null;
episodes: number | null;
status: string;
}>;
};
};
errors?: Array<{ message: string }>;
};averageScore and episodes can be null; do not render them as 0. A missing score is not a zero score.
2. Put AniList behind a server route
Create a route such as app/api/anime/search/route.ts:
import { NextRequest, NextResponse } from "next/server";
const ANILIST_ENDPOINT = "https://graphql.anilist.co";
const SEARCH_QUERY = `
query Search($search: String) {
Page(perPage: 12) {
media(type: ANIME, search: $search, sort: SEARCH_MATCH) {
id
title { romaji english }
coverImage { large }
averageScore
episodes
status
}
}
}
`;
export async function GET(request: NextRequest) {
const search = request.nextUrl.searchParams.get("q")?.trim() ?? "";
if (search.length < 2 || search.length > 80) {
return NextResponse.json(
{ error: "Query must be between 2 and 80 characters." },
{ status: 400 },
);
}
const response = await fetch(ANILIST_ENDPOINT, {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json",
},
body: JSON.stringify({
query: SEARCH_QUERY,
variables: { search },
}),
next: { revalidate: 300 },
});
if (!response.ok) {
return NextResponse.json(
{ error: `AniList returned ${response.status}` },
{ status: 502 },
);
}
const payload = await response.json();
if (payload.errors?.length) {
return NextResponse.json(
{ error: payload.errors[0].message },
{ status: 502 },
);
}
return NextResponse.json(payload);
}Why a server route instead of calling AniList from the component?
- the query and field list stay in one place
- input validation happens before the upstream call
- you can cache responses without relying on browser memory
- you can add Jikan or MangaDex as a fallback later
Do not proxy arbitrary GraphQL text from the client. That turns your route into an open relay and makes rate-limit abuse easy. Accept a search string and send a fixed query.
3. Build the search box
"use client";
import { useEffect, useState } from "react";
type Anime = {
id: number;
title: { romaji: string | null; english: string | null };
coverImage: { large: string | null } | null;
averageScore: number | null;
episodes: number | null;
status: string;
};
export function AnimeSearch() {
const [query, setQuery] = useState("");
const [items, setItems] = useState<Anime[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const trimmed = query.trim();
if (trimmed.length < 2) {
setItems([]);
setError(null);
return;
}
const controller = new AbortController();
const timer = window.setTimeout(async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(
`/api/anime/search?q=${encodeURIComponent(trimmed)}`,
{ signal: controller.signal },
);
const payload = await response.json();
if (!response.ok) throw new Error(payload.error ?? "Search failed");
setItems(payload.data?.Page?.media ?? []);
} catch (requestError) {
if (requestError instanceof DOMException && requestError.name === "AbortError") {
return;
}
setError(requestError instanceof Error ? requestError.message : "Search failed");
setItems([]);
} finally {
setLoading(false);
}
}, 300);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [query]);
return (
<section>
<label htmlFor="anime-search">Search anime</label>
<input
id="anime-search"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Try: Frieren"
/>
{loading ? <p>Searching…</p> : null}
{error ? <p role="alert">{error}</p> : null}
{!loading && !error && query.trim().length >= 2 && items.length === 0 ? (
<p>No titles matched. Try a shorter title or the Japanese name.</p>
) : null}
<ul>
{items.map((anime) => (
<li key={anime.id}>
{anime.coverImage?.large ? (
<img
src={anime.coverImage.large}
alt=""
width={120}
height={170}
loading="lazy"
/>
) : null}
<h2>{anime.title.english ?? anime.title.romaji}</h2>
<p>
{anime.averageScore != null ? `${anime.averageScore}/100` : "No score"}
{anime.episodes != null ? ` · ${anime.episodes} episodes` : ""}
</p>
<p>{anime.status.replaceAll("_", " ").toLowerCase()}</p>
</li>
))}
</ul>
</section>
);
}The debounce and AbortController are not optional polish. Without them, a fast typist can trigger several upstream requests and render results out of order.
4. Add a detail query
For the detail page, request only the fields the page can render:
const DETAIL_QUERY = `
query Detail($id: Int) {
Media(id: $id, type: ANIME) {
id
title { romaji english }
description(asHtml: false)
coverImage { large }
bannerImage
averageScore
episodes
duration
status
genres
startDate { year month day }
}
}
`;Pass id as a number, reject non-integers, and handle a null Media as a 404 rather than rendering an empty shell.
5. Cache and rate limits
AniList is free and keyless, which makes it easy to abuse accidentally. Set a short cache on repeated searches, do not request on every keystroke, and back off when the API returns 429.
A simple policy:
- cache the same normalized query for 5 minutes
- debounce the input by 300 ms
- cap
perPageat 12–20 - log upstream status codes and query latency
- show a retry action instead of silently returning an empty list
Check AniList's current rate-limit documentation before launch; limits can change. The same discipline applies to the alternatives below.
Alternatives
- Jikan if you want REST and MyAnimeList-shaped
resources.
- MangaDex when the product is manga-first and
needs chapter/translation data.
- AniList remains the best fit when you want one flexible GraphQL request for
search and detail cards.
Before you ship
A search screen is a small product surface, so the operational details matter more than the query itself. Add a visible retry path for upstream failures, keep a request id in your logs so a bad result can be traced, and make the empty state distinguish “no match” from “the API failed.” Test with a title that has an English and a romaji name, and with a title that returns no rows.
Images are remote assets: use a stable image component, provide dimensions to avoid layout shift, and do not proxy or rehost covers without checking the provider's terms. If you add a detail page, cache it separately from search results; a detail response changes less often than a search query.
Finally, keep the search route behind your own validation and rate limit. A keyless upstream is not an invitation to let anonymous clients call it without bounds.
Related API Deposu entries
Sources
Frequently Asked Questions
›Does AniList require an API key?
No. AniList's public GraphQL API works without an API key for read queries. You still need to respect its rate limit and send well-formed queries.
›Should I call AniList directly from the browser?
For a small prototype it works, but a server-side route gives you caching, request validation, rate-limit protection, and one place to add a fallback. In Next.js, use a route handler or server action rather than exposing every query to the client.
›How do I search anime by title in AniList?
Use the Page query with media(type: ANIME, search: $search, sort: SEARCH_MATCH). Request only the fields your cards render: id, title, coverImage, averageScore, episodes, and status.
›Is AniList GraphQL better than Jikan REST?
AniList is more flexible when you want to choose exact fields and combine related data in one request. Jikan is easier if you prefer REST and MyAnimeList-shaped resources. Both are keyless; pick based on the data and query style you need.
Author and editorial review information
Mert Murat created and maintains API Deposu — verifying the catalog's 400+ public API records against provider documentation, running endpoints where the API supports it, and writing the guides on this blog. Corrections and suggestions: iletisim@apideposu.com
Review method: Core claims were checked against the linked provider documentation and relevant API Deposu catalog records. Read the editorial policy
How this was made: AI assistance may be used while drafting. Before publication an editor checks every claim against the official provider documentation linked in the article and against the catalog records; anything that cannot be verified is not published.


