A fully typed, zero-dependency client for the AniList GraphQL API.
Supports Node.js, Bun, Deno, and modern browsers.
- Zero dependencies — uses the native
fetchAPI - Universal — Node.js ≥ 20, Bun, Deno, and modern browsers
- Dual format — ships ESM + CJS with full
.d.tsdeclarations - LRU cache with TTL, stale-while-revalidate, and hit/miss stats
- Rate-limit protection with exponential backoff, retries, and custom strategies
- Request deduplication — concurrent identical queries share a single in-flight request
- Batch queries — fetch up to 50 media, characters, or staff in a single API call
- Auto-pagination — async iterator that yields items across all pages
- AbortSignal support — cancel globally or per-request via
withSignal() - Injectable logger — plug in
console, pino, winston, or any compatible logger - Redis-ready — swap the in-memory cache with the built-in
RedisCacheadapter
npm install ani-client
# pnpm
pnpm add ani-client
# yarn
yarn add ani-clientimport { AniListClient, MediaType } from "ani-client";
const client = new AniListClient();
// Fetch an anime by AniList ID
const bebop = await client.getMedia(1);
console.log(bebop.title.romaji); // "Cowboy Bebop"
// Search with filters
const results = await client.searchMedia({
query: "Naruto",
type: MediaType.ANIME,
genres: ["Action"],
perPage: 10,
});
// Lookup by MyAnimeList ID
const fma = await client.getMediaByMalId(5114);The client caches every response in memory by default. You can tune TTL, capacity, and enable stale-while-revalidate:
const client = new AniListClient({
cache: {
ttl: 1000 * 60 * 5, // 5 min TTL
maxSize: 200, // LRU capacity
staleWhileRevalidateMs: 60_000, // serve stale for 1 min after expiry
},
});
console.log(client.cacheStats);
// { hits: 42, misses: 8, stales: 2, hitRate: 0.84 }For distributed setups, swap to the built-in Redis adapter:
import { AniListClient, RedisCache } from "ani-client";
import { createClient } from "redis";
const redis = createClient();
await redis.connect();
const client = new AniListClient({
cacheAdapter: new RedisCache(redis),
});The client is pre-configured to stay within AniList's 30 req/min limit. You can override the defaults and provide a custom retry strategy:
const client = new AniListClient({
rateLimit: {
maxRequests: 25,
windowMs: 60_000,
maxRetries: 3,
retryOnNetworkError: true,
retryStrategy: (attempt) => (attempt + 1) * 1000, // linear backoff
},
});
// Inspect the rate limit state after any request
console.log(client.rateLimitInfo);
// { remaining: 22, limit: 25, reset: 1741104000 }Fetch multiple entries in a single API call (chunks of 50):
const anime = await client.getMediaBatch([1, 5, 6, 20]);
const characters = await client.getCharacterBatch([1, 2, 3]);
const staff = await client.getStaffBatch([95269, 95270]);Use the built-in async iterator to walk through all pages automatically:
for await (const anime of client.paginate(
(page) => client.searchMedia({ query: "Gundam", page, perPage: 50 }),
5, // max 5 pages
)) {
console.log(anime.title.romaji);
}Scope any client instance to an AbortSignal for per-request cancellation:
const controller = new AbortController();
const scoped = client.withSignal(controller.signal);
setTimeout(() => controller.abort(), 3_000);
const anime = await scoped.getMedia(1); // cancelled after 3sPass any console-compatible logger to trace requests and cache events:
const client = new AniListClient({ logger: console });
// debug: "API request" { variables: { id: 1 } }
// debug: "Request complete" { durationMs: 120 }Paginated access to a media's characters and staff:
const characters = await client.getMediaCharacters(1, {
page: 1,
perPage: 25,
voiceActors: true,
});
const staff = await client.getMediaStaff(1, { page: 1, perPage: 25 });const user = await client.getUser("AniList");
const favs = await client.getUserFavorites("AniList", { perPage: 50 });
const char = await client.getCharacter(1, { voiceActors: true });
const studio = await client.getStudio(21, { media: { perPage: 50 } });
const schedule = await client.getWeeklySchedule();
const review = await client.getReview(760);All API errors throw an AniListError with a status code and the raw GraphQL errors array:
import { AniListError } from "ani-client";
try {
await client.getMedia(999999999);
} catch (e) {
if (e instanceof AniListError) {
console.error(e.message); // "Not Found"
console.error(e.status); // 404
console.error(e.errors); // raw GraphQL errors array
}
}Intercept requests, responses, cache events, and errors:
const client = new AniListClient({
hooks: {
onRequest: (query, variables) => console.log("→", variables),
onResponse: (query, durationMs, fromCache) => console.log(`← ${durationMs}ms`),
onCacheHit: (key) => console.log("cache hit", key),
onRateLimit: (retryAfterMs) => console.warn(`rate limited, retrying in ${retryAfterMs}ms`),
onError: (error) => console.error(error.message),
},
});| Runtime | Version |
|---|---|
| Node.js | ≥ 20 |
| Bun | ≥ 1.0 |
| Deno | ≥ 1.28 |
| Browsers | fetch + AbortController required |
Full API reference, configuration options, and guides (caching, pagination, hooks, Redis, etc.):
Contributions are welcome.
Before opening an issue or a pull request, please read:
This repository also includes GitHub issue templates and a pull request template to help keep reports and contributions consistent.