the idea
anilist's api is a graphql api, which works a little differently from the rest style apis you might be used to, like the sheets and notion endpoints from the databases tutorial. with a rest api, you usually hit a different url for each kind of request, one for a single item, another for a list, another for search. with graphql, there's only one url for everything, and instead you describe exactly what shape of data you want inside the request itself, using a small query language that looks a bit like json without the values.
that one url is always the same, no matter what you're asking for:
https://graphql.anilist.co
every request to it is a POST request (not the plain GET you might be used to from a normal link), carrying two things in its body, a query string describing what fields you want back, and an optional set of variables, the actual values that fill in any blanks in that query, like an id or a username. the overall pattern looks like this:
fetch("https://graphql.anilist.co", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
},
body: JSON.stringify({ query, variables })
})
.then(response => response.json())
.then(result => {
console.log(result.data);
})
.catch(error => console.error("couldn't reach anilist:", error));
method: "POST": graphql apis almost always use post, even for requests that only read data, since the query itself can be long and needs to travel in the request body rather than crammed into a urlheaders: tells anilist's server that the body you're sending is json, and that you'd like json back in return. skipping these can make the request fail or come back in a shape you don't expectbody: JSON.stringify({ query, variables }): this is shorthand for{ query: query, variables: variables }, javascript lets you drop the repeated name when the variable and the key are spelled the same way.queryholds the graphql string itself,variablesholds an object of any values that string referencesresult.data: unlike a typical rest response, graphql always wraps whatever you asked for inside a top leveldataobject, so you'll unwrap that one extra layer in every example below
the only thing that changes between the two sections coming up is what you put inside that query string, and what variables you send alongside it. read on for two concrete, ready to use examples of both, or check anilist's own graphql guide for the full list of fields and types available beyond what's covered here.
looking up a title
say you want to show a little card for a specific title on your site, its name, cover image, and a short description, without typing all of that out by hand. anilist's Media query can look up a single entry and hand back exactly the fields you ask for. the easiest way to grab one is straight off its anilist url, the number sitting in the middle of the link is the id you'll query with:
https://anilist.co/manga/188558/Melting-Point/
└── id ──┘
that same pattern holds for anime too, just with /anime/ in the url instead of /manga/:
https://anilist.co/anime/5680/K-ON/
└ id ─┘
step 1 — write the query
a graphql query is just a string, usually written with backticks so it can span multiple lines. this one asks for a single Media entry, matching whatever id gets passed in:
const query = `
query ($id: Int, $type: MediaType) {
Media(id: $id, type: $type) {
id
title {
romaji
english
}
coverImage {
large
}
description(asHtml: false)
siteUrl
}
}
`;
breaking down the pieces of this query specifically:
query ($id: Int, $type: MediaType) { ... }: declares this as a query (as opposed to a "mutation," which would change data instead of reading it), and defines two variables it expects to receive,$idtyped as anInt, and$typetyped as anilist's ownMediaType. any dollar sign prefixed name inside a graphql query is a placeholder that gets filled in by thevariablesobject you send alongside itMedia(id: $id, type: $type): this is the actual field being requested, anilist's built inMedialookup. passing bothidandtypetogether looks up one exact entry, rather than searching by name, which matters since a manga and an anime can technically share the same id number, andtypeis what tells anilist which one you actually mean- everything inside the curly braces after
Media(...)is the shape of the single result you want back. graphql never sends you fields you didn't ask for, if you don't listdescriptionhere, it simply won't be in the response at all, which keeps the payload small and predictable title { romaji english }: anilist stores titles in multiple languages under one nested object, this asks for both the romaji reading and the official english title, in case one of them is missing for a given entrycoverImage { large }: cover art also comes as a nested object with a few different size options,largeis a good middle ground for a card sized image on a pagedescription(asHtml: false): anilist descriptions can include basic html markup by default, passingasHtml: falsehere asks for plain text instead, which is usually easier to drop straight into a page without stray tags showing up
step 2 — send the request with an id
with the query written, the actual fetch call looks like this, filled in and ready to run, looking up the manga from the url above:
const variables = { id: 188558, type: "MANGA" };
fetch("https://graphql.anilist.co", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
},
body: JSON.stringify({ query, variables })
})
.then(response => response.json())
.then(result => {
const media = result.data.Media;
console.log(media.title.english || media.title.romaji);
})
.catch(error => console.error("couldn't reach anilist:", error));
a couple of things worth calling out here, since they trip people up the first time:
variables = { id: 188558, type: "MANGA" }: the keys here,idandtype, have to match the$idand$typenames used inside the query string exactly, minus the dollar signs. this is how the values actually reach the placeholdersresult.data.Media: remember every graphql response is wrapped in a top leveldataobject first, then inside that sits a key matching whatever field you queried,Mediahere, capitalized exactly as it appears in the querymedia.title.english || media.title.romaji: not every entry has an english title filled in, some only have the romaji reading, so falling back with||avoids ending up withundefinedon the page for those
and here's roughly what result.data.Media looks like once it arrives, trimmed down to the fields this query actually asked for:
{
"id": 188558,
"title": { "romaji": "Melting Point", "english": "Melting Point" },
"coverImage": { "large": "https://s4.anilist.co/file/anilistcdn/media/manga/cover/large/..." },
"description": "a plain text synopsis, with the html stripped out...",
"siteUrl": "https://anilist.co/manga/188558"
}
swapping the manga for an anime is the same query and the same code, just different variables, for the k-on example above that would be { id: 5680, type: "ANIME" } instead. that's the whole benefit of querying by id and type rather than by name, one query shape covers both without any extra branching in your code.
live example: this card isn't a mockup, it's built by the exact code above, fetching the manga in real time from anilist's api the moment this page loaded:
loading from anilist…
fetching a user's list
the other common use case is pulling an entire list off a profile, everything someone is currently reading, has completed, or has planned, straight from their public anilist account. this uses a different built in field, MediaListCollection, which groups entries by list name automatically.
step 1 — write the query
const listQuery = `
query ($username: String) {
MediaListCollection(userName: $username, type: MANGA) {
lists {
name
entries {
status
progress
media {
title {
romaji
}
coverImage {
medium
}
siteUrl
}
}
}
}
}
`;
a few differences from the id lookup above worth pointing out:
MediaListCollection(userName: $username, type: MANGA): instead of looking up one title, this pulls every entry a specific user has added to their manga lists.$usernameis a placeholder filled in throughvariables, the same way$idwas beforelists { name entries { ... } }: anilist groups a user's entries into named lists, "reading," "completed," "planning," and so on, matching whatever custom lists that person has set up.namegives you that list's label,entriesgives you the array of books inside itstatusandprogress: these live on the entry itself rather than on the manga, since they describe that specific user's relationship to the title, not a fact about the book.statuscomes back as one of anilist's fixed values likeCURRENT,COMPLETED, orPLANNINGmedia { ... }: nested one level deeper, this is where the actual book details live, its title, cover, and anilist page, structured the same way as in the lookup example above
step 2 — send the request with a username
const listVariables = { username: "coeur" };
fetch("https://graphql.anilist.co", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json"
},
body: JSON.stringify({ query: listQuery, variables: listVariables })
})
.then(response => response.json())
.then(result => {
const lists = result.data.MediaListCollection.lists;
lists.forEach(list => {
console.log(list.name);
list.entries.forEach(entry => {
console.log(" ", entry.media.title.romaji, "-", entry.status);
});
});
})
.catch(error => console.error("couldn't reach anilist:", error));
the nested loop here mirrors the shape of the data exactly, an outer .forEach() over each named list, and an inner .forEach() over that list's entries, since the query itself asked for entries nested inside lists rather than one flat array of books. if you'd rather work with a single flat list instead of one grouped by status, you can flatten it in one line once the response arrives:
const allEntries = lists.flatMap(list => list.entries);
.flatMap() runs a function over every list and merges all the returned arrays into one, so instead of an array of lists each holding entries, you end up with a single array of every entry across all of them, ready to loop over just once.
rate limits & errors
a couple of practical things worth knowing before wiring this into a real page, things that don't come up until you've already built something and it starts behaving strangely.
- rate limits: anilist limits how many requests you can send in a short window, and returns a
429status code if you go over it. this mostly matters if you're firing off many searches at once, like looping a fetch call over a long list of titles, a single request for a page load is very unlikely to hit it - graphql errors still return a 200: if your query has a typo, or you look up an id that doesn't exist, anilist often still responds with a normal successful status, but includes an
errorsarray in the response body instead of thedatayou expected. it's worth checking for that explicitly rather than assuming a response arriving means it worked - private accounts and lists: if someone has set their anilist profile or list to private,
MediaListCollectionwill simply come back empty for them rather than throwing an error, so an empty result isn't always a bug in your code
checking for that errors array before touching result.data is a one line addition that saves a lot of confusion later:
.then(result => {
if (result.errors) {
console.error("anilist returned an error:", result.errors[0].message);
return;
}
// safe to use result.data here
});
result
with just those two query shapes, one for a single title, one for a whole user list, you can build a surprising amount, a "currently reading" widget pulled live from your profile, a small lookup card for a manga or anime's cover and synopsis, or a full catalog page generated straight from your anilist account instead of being typed out by hand. and since it's all one endpoint, adding a third kind of query later, ratings, favorites, whatever anilist exposes, is just a matter of writing a new query string, the fetch call around it never has to change.