MyAnimeList is the largest anime database on the internet, with entries for over 25,000 anime series, 60,000 manga titles, and hundreds of thousands of characters. For years, accessing this data programmatically required scraping or reverse-engineering undocumented endpoints. Then Jikan arrived and changed everything. Jikan is an open-source, unofficial REST API that parses MyAnimeList pages and returns structured JSON, giving developers access to the entire MAL dataset without the legal and ethical complications of scraping.
In this article, we are building Anime Investigator, a client-side anime encyclopedia that combines Jikan's comprehensive data with AnimeChan's curated quote database. Users can search for anime, browse seasonal charts, explore character profiles, read quotes from their favorite series, and discover new shows through genre-based recommendations. The result is an experience that supplements MyAnimeList itself with features that MAL does not offer natively.
This is a project that will teach you about working with large, deeply nested API responses, handling aggressive rate limiting, building responsive image-heavy layouts, and creating navigation patterns for hierarchical data. It also happens to be the kind of project that gets you instant credibility in any anime fan community, which, depending on your social circle, might be the most valuable outcome of all.
Jikan v4 at https://api.jikan.moe/v4 is the current stable version of the API. It covers anime, manga, characters, people (voice actors, directors), seasons, schedules, genres, producers, and user profiles. The data fidelity is excellent because it mirrors what is on MyAnimeList, which is community-curated by millions of users.
The API is organized around resource types. Each resource has endpoints for searching, getting by ID, and fetching related resources. For anime, the key endpoints include:
// Jikan v4 Anime Endpoints
// Search anime
// GET https://api.jikan.moe/v4/anime?q=attack+on+titan&limit=10
{
"data": [
{
"mal_id": 16498,
"url": "https://myanimelist.net/anime/16498/Shingeki_no_Kyojin",
"images": {
"jpg": {
"image_url": "https://cdn.myanimelist.net/images/anime/10/47347.jpg",
"small_image_url": "...",
"large_image_url": "..."
},
"webp": { ... }
},
"title": "Shingeki no Kyojin",
"title_english": "Attack on Titan",
"title_japanese": "進撃の巨人",
"type": "TV",
"episodes": 25,
"status": "Finished Airing",
"airing": false,
"duration": "24 min per ep",
"rating": "R - 17+ (violence & profanity)",
"score": 8.54,
"scored_by": 2145678,
"rank": 89,
"popularity": 1,
"members": 3567890,
"synopsis": "...",
"season": "spring",
"year": 2013,
"genres": [
{ "mal_id": 1, "name": "Action" },
{ "mal_id": 8, "name": "Drama" }
],
"studios": [
{ "mal_id": 858, "name": "Wit Studio" }
]
}
],
"pagination": {
"last_visible_page": 1,
"has_next_page": false,
"current_page": 1
}
}
// Get anime by ID
// GET https://api.jikan.moe/v4/anime/16498
// Get anime characters
// GET https://api.jikan.moe/v4/anime/16498/characters
// Get seasonal anime
// GET https://api.jikan.moe/v4/seasons/2026/spring
// Get top anime
// GET https://api.jikan.moe/v4/top/anime?filter=airing&limit=25
// Get anime recommendations
// GET https://api.jikan.moe/v4/anime/16498/recommendations
Jikan's rate limiting is strict: three requests per second for the free tier. Exceeding this triggers a 429 response with a Retry-After header. This is the single biggest technical constraint of the project and shapes nearly every architectural decision we make.
AnimeChan at https://animechan.io/api/v1 provides anime quotes with attribution to both the anime title and the character who said it. The database is community-curated and covers popular series well, though niche shows may have few or no quotes. The API is simple but effective for our purposes.
// AnimeChan endpoints
// Random quotes
// GET https://animechan.io/api/v1/quotes/random
{
"status": "ok",
"data": {
"content": "People's lives don't end when they die. It ends when they lose faith.",
"anime": {
"id": 20,
"name": "Naruto"
},
"character": {
"id": 45,
"name": "Itachi Uchiha"
}
}
}
// Search quotes by anime
// GET https://animechan.io/api/v1/quotes?anime=naruto&page=1
// Random quotes (multiple)
// GET https://animechan.io/api/v1/quotes/random?limit=10
Anime Investigator uses a router-based single-page application pattern with five views: Search, Seasonal, Top Charts, Anime Detail, and Random Discovery. The router manages URL hash navigation, and each view is a self-contained component that fetches its own data.
Rate limiting is not just a constraint to work around; it is a fundamental design force that shapes how our application works. We need a request queue that guarantees we never exceed three requests per second, even when multiple components are making requests simultaneously.
class JikanClient {
constructor() {
this.baseUrl = 'https://api.jikan.moe/v4';
this.requestQueue = [];
this.isProcessing = false;
this.minInterval = 350; // ~3 requests per second
this.lastRequestTime = 0;
this.cache = new Map();
this.cacheTTL = 5 * 60 * 1000; // 5 minutes
}
async request(path, params = {}) {
const url = new URL(`${this.baseUrl}${path}`);
Object.entries(params).forEach(([key, value]) => {
if (value !== null && value !== undefined) {
url.searchParams.set(key, value);
}
});
const cacheKey = url.toString();
const cached = this.cache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < this.cacheTTL) {
return cached.data;
}
return new Promise((resolve, reject) => {
this.requestQueue.push({ url: cacheKey, resolve, reject });
this.processQueue();
});
}
async processQueue() {
if (this.isProcessing || this.requestQueue.length === 0) return;
this.isProcessing = true;
while (this.requestQueue.length > 0) {
const { url, resolve, reject } = this.requestQueue.shift();
// Enforce minimum interval
const elapsed = Date.now() - this.lastRequestTime;
if (elapsed < this.minInterval) {
await new Promise(r => setTimeout(r, this.minInterval - elapsed));
}
this.lastRequestTime = Date.now();
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(10000)
});
if (response.status === 429) {
const retryAfter = parseInt(response.headers.get('Retry-After') || '2');
await new Promise(r => setTimeout(r, retryAfter * 1000));
// Re-queue the request
this.requestQueue.unshift({ url, resolve, reject });
continue;
}
if (!response.ok) {
throw new Error(`Jikan error: ${response.status}`);
}
const data = await response.json();
this.cache.set(url, { data: data.data, timestamp: Date.now() });
resolve(data.data);
} catch (error) {
reject(error);
}
}
this.isProcessing = false;
}
// Anime endpoints
async searchAnime(query, page = 1, limit = 12) {
return this.request('/anime', { q: query, page, limit, sfw: true });
}
async getAnime(id) {
return this.request(`/anime/${id}`);
}
async getAnimeCharacters(id) {
return this.request(`/anime/${id}/characters`);
}
async getAnimeRecommendations(id) {
return this.request(`/anime/${id}/recommendations`);
}
async getSeasonalAnime(year, season, page = 1) {
return this.request(`/seasons/${year}/${season}`, { page, limit: 25 });
}
async getCurrentSeason() {
return this.request('/seasons/now', { limit: 25 });
}
async getTopAnime(filter = 'bypopularity', page = 1) {
return this.request('/top/anime', { filter, page, limit: 25 });
}
async getAnimeByGenre(genreId, page = 1) {
return this.request('/anime', { genres: genreId, page, limit: 25, order_by: 'score', sort: 'desc' });
}
async getGenres() {
return this.request('/genres/anime');
}
}
class AnimeChanClient {
constructor() {
this.baseUrl = 'https://animechan.io/api/v1';
this.cache = new Map();
}
async getRandomQuotes(count = 5) {
try {
const response = await fetch(
`${this.baseUrl}/quotes/random?limit=${count}`,
{ signal: AbortSignal.timeout(8000) }
);
if (!response.ok) throw new Error(`AnimeChan: ${response.status}`);
const data = await response.json();
return data.data || [];
} catch (error) {
console.error('AnimeChan error:', error);
return [];
}
}
async searchQuotesByAnime(animeName, page = 1) {
try {
const response = await fetch(
`${this.baseUrl}/quotes?anime=${encodeURIComponent(animeName)}&page=${page}`,
{ signal: AbortSignal.timeout(8000) }
);
if (!response.ok) return [];
const data = await response.json();
return data.data || [];
} catch {
return [];
}
}
}
class AnimeRouter {
constructor(rootElement) {
this.root = rootElement;
this.routes = {};
this.currentView = null;
window.addEventListener('hashchange', () => this.handleRoute());
}
register(hash, viewFactory) {
this.routes[hash] = viewFactory;
}
navigate(hash) {
window.location.hash = hash;
}
handleRoute() {
const hash = window.location.hash || '#search';
const [route, ...params] = hash.split('/');
const viewFactory = this.routes[route];
if (viewFactory) {
this.currentView = viewFactory(params);
this.currentView.render(this.root);
}
}
start() {
this.handleRoute();
}
}
class SearchView {
constructor(jikan) {
this.jikan = jikan;
this.searchResults = [];
this.debounceTimer = null;
}
render(container) {
container.innerHTML = `
Search Anime
Search the MyAnimeList database via Jikan
`;
const input = container.querySelector('#animeSearch');
input.addEventListener('input', () => {
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
if (input.value.trim().length >= 3) {
this.search(input.value.trim(), container);
}
}, 400);
});
}
async search(query, container) {
const resultsDiv = container.querySelector('#searchResults');
resultsDiv.innerHTML = 'Searching...
';
try {
const results = await this.jikan.searchAnime(query, 1, 12);
this.searchResults = Array.isArray(results) ? results : [];
if (this.searchResults.length === 0) {
resultsDiv.innerHTML = 'No results found.
';
return;
}
resultsDiv.innerHTML = `
${this.searchResults.map(anime => this.renderAnimeCard(anime)).join('')}
`;
resultsDiv.querySelectorAll('.anime-card').forEach(card => {
card.addEventListener('click', () => {
window.location.hash = `#anime/${card.dataset.id}`;
});
});
} catch (error) {
resultsDiv.innerHTML = `Search failed: ${error.message}
`;
}
}
renderAnimeCard(anime) {
const imageUrl = anime.images?.jpg?.image_url || '';
const score = anime.score || 'N/A';
const episodes = anime.episodes || '?';
return `
`;
}
}
class AnimeDetailView {
constructor(jikan, animeChan) {
this.jikan = jikan;
this.animeChan = animeChan;
this.animeId = null;
}
setAnimeId(id) {
this.animeId = id;
}
async render(container) {
if (!this.animeId) return;
container.innerHTML = 'Loading anime details...
';
try {
// Fetch anime data, characters, and quotes in sequence
// (respecting rate limits prevents parallel requests)
const anime = await this.jikan.getAnime(this.animeId);
const characters = await this.jikan.getAnimeCharacters(this.animeId);
// AnimeChan is a different API, so this can be parallel
const titleForQuotes = anime.title_english || anime.title;
const quotes = await this.animeChan.searchQuotesByAnime(titleForQuotes);
container.innerHTML = `
${anime.title_english || anime.title}
${anime.title_japanese || ''}
${anime.score || 'N/A'}
${anime.scored_by ? anime.scored_by.toLocaleString() + ' votes' : ''}
#${anime.rank || '?'}
Rank
#${anime.popularity || '?'}
Popularity
${(anime.genres || []).map(g =>
`${g.name}`
).join('')}
Type: ${anime.type || 'Unknown'}
Episodes: ${anime.episodes || '?'}
Status: ${anime.status || 'Unknown'}
Duration: ${anime.duration || '?'}
Season: ${anime.season ? `${anime.season} ${anime.year}` : 'N/A'}
Rating: ${anime.rating || 'N/A'}
Studios:
${(anime.studios || []).map(s => s.name).join(', ') || 'Unknown'}
${anime.synopsis ? `
Synopsis
${anime.synopsis}
` : ''}
${characters && characters.length > 0 ? `
Characters
${characters.slice(0, 12).map(c => `
${c.character?.name || 'Unknown'}
${c.role || ''}
${c.voice_actors?.length > 0
? ` · VA: ${c.voice_actors[0].person?.name || ''}`
: ''
}
`).join('')}
` : ''}
${quotes.length > 0 ? `
Quotes from this Anime
${quotes.slice(0, 8).map(q => `
"${q.content}"
— ${q.character?.name || 'Unknown Character'}
`).join('')}
` : ''}
`;
container.querySelector('#backBtn').addEventListener('click', () => {
window.location.hash = '#search';
});
} catch (error) {
container.innerHTML = `
Failed to load anime details: ${error.message}
`;
}
}
}
class SeasonalView {
constructor(jikan) {
this.jikan = jikan;
}
async render(container) {
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
let season;
if (month <= 3) season = 'winter';
else if (month <= 6) season = 'spring';
else if (month <= 9) season = 'summer';
else season = 'fall';
container.innerHTML = `
Seasonal Anime
${season.charAt(0).toUpperCase() + season.slice(1)} ${year}
${['winter', 'spring', 'summer', 'fall'].map(s => `
`).join('')}
Loading seasonal anime...
`;
await this.loadSeason(container, year, season);
container.querySelectorAll('.season-btn').forEach(btn => {
btn.addEventListener('click', () => {
this.loadSeason(container, year, btn.dataset.season);
});
});
}
async loadSeason(container, year, season) {
const grid = container.querySelector('#seasonalGrid');
grid.innerHTML = 'Loading...
';
try {
const anime = await this.jikan.getSeasonalAnime(year, season);
const animeList = Array.isArray(anime) ? anime : [];
if (animeList.length === 0) {
grid.innerHTML = 'No anime found for this season.
';
return;
}
grid.innerHTML = animeList.map(a => {
const img = a.images?.jpg?.image_url || '';
return `
`;
}).join('');
grid.querySelectorAll('.anime-card').forEach(card => {
card.addEventListener('click', () => {
window.location.hash = `#anime/${card.dataset.id}`;
});
});
} catch (error) {
grid.innerHTML = `
Failed to load season: ${error.message}
`;
}
}
}
class AnimeInvestigator {
constructor(rootId) {
this.root = document.getElementById(rootId);
this.jikan = new JikanClient();
this.animeChan = new AnimeChanClient();
this.router = new AnimeRouter(document.getElementById('viewContainer'));
this.setupNavigation();
this.setupRoutes();
this.router.start();
}
setupNavigation() {
this.root.innerHTML = `
`;
}
setupRoutes() {
this.router.register('#search', () => new SearchView(this.jikan));
this.router.register('#seasonal', () => new SeasonalView(this.jikan));
this.router.register('#anime', (params) => {
const view = new AnimeDetailView(this.jikan, this.animeChan);
view.setAnimeId(params[0]);
return view;
});
this.router.register('#top', () => ({
render: async (container) => {
container.innerHTML = 'Loading top anime...
';
try {
const anime = await this.jikan.getTopAnime('bypopularity');
const list = Array.isArray(anime) ? anime : [];
container.innerHTML = `
Top Anime by Popularity
${list.map((a, i) => `
${i + 1}
${a.title_english || a.title}
${a.type} · Score: ${a.score || 'N/A'}
· ${(a.members || 0).toLocaleString()} members
`).join('')}
`;
container.querySelectorAll('.anime-card').forEach(card => {
card.addEventListener('click', () => {
window.location.hash = `#anime/${card.dataset.id}`;
});
});
} catch (error) {
container.innerHTML = `Failed: ${error.message}
`;
}
}
}));
this.router.register('#quotes', () => ({
render: async (container) => {
container.innerHTML = 'Loading quotes...
';
const quotes = await this.animeChan.getRandomQuotes(10);
container.innerHTML = `
Anime Quotes
${quotes.map(q => `
"${q.content}"
— ${q.character?.name || 'Unknown'},
${q.anime?.name || 'Unknown Anime'}
`).join('')}
`;
container.querySelector('#moreQuotes')?.addEventListener('click', () => {
this.router.routes['#quotes']().render(container);
});
}
}));
}
}
// Initialize
document.addEventListener('DOMContentLoaded', () => {
new AnimeInvestigator('app-root');
});
Jikan's three-requests-per-second limit is the most impactful constraint in this project. When loading an anime detail page, we need the anime data, then the character list, then recommendations. At minimum, that is three sequential requests, which takes at least one second. Add AnimeChan quotes and the page takes two seconds to fully populate, which is acceptable but requires clear loading states so users know data is coming.
The temptation is to make all requests in parallel, but that burns your entire rate limit budget on one page load and leaves nothing for other user interactions. Sequential requests with visual progressive loading are the correct pattern here.
MyAnimeList CDN images referenced in Jikan responses work in <img> tags because browsers do not enforce same-origin on image display. However, if you try to draw these images to a <canvas> element for processing, you will hit a tainted canvas error. For display purposes, this is not an issue. For generating thumbnails or image composites, you would need to proxy the images through your own server.
AnimeChan has a solid quotes database for popular anime (Naruto, Attack on Titan, Death Note, etc.) but thin coverage for less popular or recent series. When building the detail view, always handle the case where no quotes are returned. Display the quotes section only when data exists; do not show an empty section with a "no quotes found" message, as it feels like a broken feature rather than an absent one.
Jikan returns titles in multiple languages: title (usually romaji), title_english, and title_japanese. Not all anime have English titles, and some have multiple alternative titles. Our display logic prioritizes English titles for accessibility but shows Japanese titles as supplementary information for fans who prefer them.
Jikan responses for popular anime can be large. A single anime detail response might be 10KB of JSON. Character lists can be even larger if the anime has hundreds of characters. Always implement pagination for character lists and limit the initial display to twelve or twenty characters with a "show more" option.
Jikan's genre data and recommendation endpoints enable building personalized recommendation systems. Track which anime a user views, extract their genre preferences, and suggest new shows that match their profile. The scoring and popularity data helps rank recommendations by quality.
The seasonal view helps anime fans plan their watching schedule for upcoming seasons. By showing scores, genres, and studio information, users can quickly identify which new anime align with their preferences. Adding a personal watchlist feature (backed by localStorage) would make this a complete seasonal planning tool.
Content creators can use the detailed anime data to quickly research topics for blog posts, videos, or podcasts. Character lists, voice actor information, and studio details provide the factual backbone for content creation without manual research on MyAnimeList.
Anime Discord servers and subreddits often need bots or widgets that display anime information. The Jikan client we built could be adapted into a Discord bot that responds to commands with formatted anime details, seasonal charts, or random quotes.
Jikan provides multiple image sizes. Use small_image_url for grid thumbnails and large_image_url only for detail views. This can reduce image bandwidth by 70% on grid pages. Always set loading="lazy" on images below the fold.
Not all data on a detail page is equally important. Load the main anime data first (it populates the hero section), then characters (they populate below the fold), then quotes (they are supplementary). This progressive loading strategy ensures users see the most important information first.
When displaying a grid of anime cards, each card links to a detail page. When the user hovers over a card, preload the anime detail data into the cache. This makes the transition to the detail page feel instantaneous because the data is already available when the user clicks.
// Preload on hover
card.addEventListener('mouseenter', () => {
// Start loading but don't block on it
jikan.getAnime(card.dataset.id).catch(() => {});
});
Add localStorage-backed watchlist management. Users can mark anime as "watching," "completed," "plan to watch," or "dropped." Display watchlist statistics and completion rates. This mirrors MAL's core feature without requiring account creation.
Jikan provides voice actor data for each character. Build a voice actor view that shows all anime a particular voice actor has appeared in. This is a feature that even MyAnimeList does not make particularly easy to navigate, so it represents genuine added value.
Jikan covers manga as well as anime. Add a view that shows the source manga for any anime adaptation, including how far the anime has adapted and what chapters remain. This helps users who want to continue a story in manga form after the anime ends.
Generate shareable cards with anime details and personal ratings. Users could share their seasonal chart picks or their top-ten lists as images on social media. This combines the anime data with canvas rendering to create a viral sharing mechanism.
Anime Investigator proves that fan tools built with free APIs can rival commercial products in utility if not in polish. The Jikan API opens MyAnimeList's vast dataset to any developer willing to respect its rate limits, and AnimeChan adds the cultural texture of quotes that make anime memorable. Together, they enable an encyclopedia that is not just a data browser but a discovery tool.
The critical technical takeaway is rate limit management. Jikan's three-requests-per-second cap forces you to think carefully about request priority, caching strategy, and progressive loading. These are the same skills you need when working with any production API, whether it costs nothing or a thousand dollars a month. Rate limits are a universal reality of API consumption, and learning to work within them elegantly is a skill that pays dividends across every project.
The design takeaway is about understanding your audience. Anime fans are knowledgeable, opinionated, and detail-oriented. They notice when genres are missing, when scores are outdated, or when images fail to load. Building for this audience demands a level of data completeness and presentation quality that casual users would not require. Meet that standard, and you earn loyalty that lasts.
Go build something that makes your favorite anime easier to share with someone who has never seen it. The data is there. The APIs are free. The community is waiting.