fetching data from anilist

pull manga and anime data straight from anilist's api and build it into your own page.

intermediate javascript graphql
anilist is a free anime and manga tracking site, and it also happens to have one of the friendliest public apis around, no api key, no signup, and it even allows requests straight from a browser. that makes it a great source for things like a "currently reading" widget, a favorites shelf, or a full catalog page pulled live from your own anilist account. this tutorial covers looking up a single title by id, and pulling an entire list off a user's profile.

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 url
  • headers: 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 expect
  • body: 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. query holds the graphql string itself, variables holds an object of any values that string references
  • result.data: unlike a typical rest response, graphql always wraps whatever you asked for inside a top level data object, 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.

neocities heads up: if you're hosting on neocities, plain fetch calls to outside apis like this one are currently blocked on newer free accounts as part of neocities' updated free tier policy, they only allow it once your account is either a supporter account, or a free account old enough (a few years, at the time of writing). if a fetch that works fine when you preview it locally suddenly does nothing once uploaded, this restriction is almost always why, not a bug in your code.

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, $id typed as an Int, and $type typed as anilist's own MediaType. any dollar sign prefixed name inside a graphql query is a placeholder that gets filled in by the variables object you send alongside it
  • Media(id: $id, type: $type): this is the actual field being requested, anilist's built in Media lookup. passing both id and type together 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, and type is 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 list description here, 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 entry
  • coverImage { large }: cover art also comes as a nested object with a few different size options, large is a good middle ground for a card sized image on a page
  • description(asHtml: false): anilist descriptions can include basic html markup by default, passing asHtml: false here 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, id and type, have to match the $id and $type names used inside the query string exactly, minus the dollar signs. this is how the values actually reach the placeholders
  • result.data.Media: remember every graphql response is wrapped in a top level data object first, then inside that sits a key matching whatever field you queried, Media here, capitalized exactly as it appears in the query
  • media.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 with undefined on 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. $username is a placeholder filled in through variables, the same way $id was before
  • lists { 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. name gives you that list's label, entries gives you the array of books inside it
  • status and progress: 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. status comes back as one of anilist's fixed values like CURRENT, COMPLETED, or PLANNING
  • media { ... }: 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 429 status 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 errors array in the response body instead of the data you 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, MediaListCollection will 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.