Fun with Hardcover's Book API: A quick reference

Emma Goto 🍙 - Oct 26 - - Dev Community

I'm a longtime Goodreads user, but have always wanted to try something better when it comes to tracking my books. So I've recently started using Hardcover, a competitor run by a small indie team. It's still in the early days, and I know it's hard to beat something as large as Goodreads, but I'm hopeful!

One of the great things about Hardcover is that, unlike Goodreads, it has an API that is free-to-use and lets you query for any data on the books that are stored in Hardcover. It also can be used to do basically anything you would be able to do yourself in the Hardcover UI, like update the status of books you are
reading, add books to lists and more.

How I'm using Hardcover's API

Right now I'm using the API to generate the book review section of my site. I fetched all my reviews from Hardcover, and am storing them in a JSON file. I then loop through the reviews in this list, and render them all on the page.

I wasn't too sure what to do about really short reviews, so for the moment I've settled on rendering the reviews as-is on the page if they are under 360 characters, and linking out to a separate page if they are more than that.

A screenshot of two reviews, one short and without a

I also added a search bar, which will search through all my reviews, and I have some text blurring set up if there are spoilers on the review.

A search bar with a filtered list of book reviews below it. One of the reviews is blurred so you cant read it.

In the future I'd like to add a way to filter by the star rating, and by genre as well.

Getting started with Hardcover's API

Hardcover's API is also still in an early-access mode, and there isn't any proper documentation yet, so in this post I'll go over some of the queries I found useful so far. Hardcover's Discord server is also a great place to get answers to your questions.

You'll first need to go to your settings page, and get your Hardcover API key. Then you can test out your queries in Hardcover's GraphQL console.

Getting all the books you have read

Starting off with a basic GraphQL query, we can filter using the status_id to get a list of titles of all the books you have marked as “read” in Hardcover:

{
  me {
    user_books(where: {status_id: {_eq: 3}}) {
      rating
      book {
        title
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

We've wrapped it in me which is what you can use to query on anything specific to your user.

The way the status_id value works is:

  • 1: A “want to read” book
  • 2: An “currently reading” book
  • 3: A “read” book
  • 5: “A did not finish” book

Getting the author of a book

If you search for cached_contributors, you'll get an array containing a list of “contributors” to a book. This will contain a predetermined set of data, like the contributor's name, ID, and image.

book {
  cached_contributors
}
Enter fullscreen mode Exit fullscreen mode

The reason they're “contributors” and not “authors” is that it can also contain the names of people who have translated the book. If there are multiple authors, they will also all be in the list. If you're querying on a regular fiction book with one author, using the first item in the list will usually be fine.

The cached version is faster to query, but if there was something specific you wanted, you can also query on the non-cached contributions:

book {
  title
    contributions {
      author {
        name
       }
    }
 }
Enter fullscreen mode Exit fullscreen mode

Getting a book's Hardcover URL

If you wanted to get the book's link on Hardcover, you can query for its slug:

book {
  slug
}
Enter fullscreen mode Exit fullscreen mode

A slug is the string after the domain name of the website, e.g. on emgoto.com/hardcover-book-api, the “hardcover-book-api” bit is the slug.

So once you get the slug, you'll just need to prepend https://hardcover.app/books/ to the beginning of it to create your Hardcover URL.

Getting the genre tags on a book

The genre tag system in Hardcover is user-generated. You can query on the cached_tags, which will return you the tags in order from most-tagged to least.

book {
  cached_tags
}
Enter fullscreen mode Exit fullscreen mode

Once you have the full list of tags, you can use cached_tags['Genre'] to get the genre-specific ones.

If a lot of people have tagged a particular book as fiction, that will be the first genre that shows up in the list. Interestingly people love to tag their books as fantasy, and so this genre often shows up before the fiction tag. People love tagging their books as fantasy so much, that sci-fi books like Dune even end up with both sci-fi and fantasy tags as well.

If you are going to use this data, I recommend doing a little bit of clean-up on it first. For example if the book has both fantasy and sci-fi as a genre tag, only use the one that comes first in the list and discard the other one, since that is more likely to be accurate.

Adding a book to your “to read" list

So far I've only touched on fetching data, but you can also use Hardcover's API to manipulate data - of course you can't touch anyone else's things, but anything that you could do on your own Hardcover account is fair game.

If you have a book's ID, you can add it to your “to read” list by setting its status_id to 1:

mutation addBook {
  insert_user_book(object: {book_id: 123, status_id: 1}) {
    id
  }
}
Enter fullscreen mode Exit fullscreen mode

Getting your book reviews

This is what I use to fetch all the reviews I have written in Hardcover:

{
  me {
    user_books(
      where: { _and: [ 
        {has_review: {_eq: true}},
        {status_id: {_eq: 3 }}
      ]}
      order_by: [
        { date_added: desc },
        { reviewed_at: desc }
      ]
    ) {
      reviewed_at
      date_added
      review_raw
      rating
      book {
        title
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Nearly all of my books and reviews are imported from Goodreads, and I think sometimes the data gets a little bit messed up in the import. I found that sorting it by both dated_added and reviewed_at was more accurate.

I used the review_raw value to get the review text, which is doesn't include any formatting like newlines. This unfortunately means if you have multiple paragraphs in your review, the API spits it out all as one long paragraph like this:

this is the end of one paragraph.And this is the start of the next
Enter fullscreen mode Exit fullscreen mode

With JavaScript, I got around this by doing a regex like the following:

const text = review_raw
    .replace(/(?<=[.!?])(?=[A-Z])/g, '\n\n');
Enter fullscreen mode Exit fullscreen mode

If there are any periods with no spaces after them, you can make a guess that this should be a new paragraph and add the double newline \n\n. Which creates a new paragraph.

Another downside is that spoiler tags are also missing, so you'll have to add these in manually.

There is also a review_html value, which I thought might be more useful, but unfortunately it always seems to be null for me. Similarly, if you have a review with spoilers, there is a review_has_spoilers value, but for all the books I've imported from Goodreads, this value is false so you may not be able to rely on it.

Searching for a book by title and/or author

Hardcover's book search is pretty accurate, but they don't have a 1-1 version of this in their API. There's a lot of ways you could try and mimic their search behaviour, but a quick way to get started would be to search on title, using _eq:

{
  books (
    order_by: {users_read_count: desc}
    where: {title: {_eq: "Dune"}}
    limit: 5
  ) {
    users_read_count
    title
  }
}
Enter fullscreen mode Exit fullscreen mode

I ordered the list of books by users_read_count, as the likelihood is that the book called Dune with the highest number of readers is going to be the Dune that you are looking for.

If you want to filter by title and author, the filter query will look like this:

where: {_and: [
  {title: {_eq: "Dune"}},
  {contributions: {author: {name: {_eq: "Frank Herbert"}}}}
]},
Enter fullscreen mode Exit fullscreen mode

If you didn't want to do exact string matching, you can use the _ilike query instead, which doesn't match on cases, so using lower case will still work:

{_ilike: "frank herbert"}
Enter fullscreen mode Exit fullscreen mode

You can also use % characters as wildcards with _ilike, so you can do

{_ilike: "%frank%"}
Enter fullscreen mode Exit fullscreen mode

Which will match any author that contains the word “frank”.

As a side note, since Hardcover uses Hasura, Googling for “how to do X in Hasura” will generally bring up how you can do these sort of more complicated queries.

Getting books in your Hardcover Lists

As well as the standard “want to read” and “read” lists, Hardcover also has a separate custom lists feature. To grab all of your lists, and the books inside of them, you can do the following:

{
  me {
    lists(order_by: {created_at: desc}) {
      id
      name
      slug
      list_books {
        book {
          title
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Adding a book to a list

If you wanted to add a book to a list, first you would need to grab the list ID and the book's ID. Then it's easy as the following:

mutation addBook {
  insert_list_book(object: {book_id: 123, list_id: 123}) {
    id
  }
}
Enter fullscreen mode Exit fullscreen mode

Making an API call with JavaScript

Once you want to move out of the GraphQL console, you can make API calls using fetch(). As a really quick example, here's me fetching all my reviews (I've abbrievated it down a bit):

const HARDCOVER_API_KEY = 'Bearer ...';

const query = `
{
  me {
    user_books(
      where: {has_review: {_eq: true}}
      order_by: [
        { date_added: desc },
        { reviewed_at: desc }
      ]
    ) {
      review_raw
      rating
      book {
        title
      }
    }
  }
}`;

const fetch('https://api.hardcover.app/v1/graphql', {
    headers: {
        'content-type': 'application/json',
        authorization: HARDCOVER_API_KEY,
    },
    body: JSON.stringify({ query }),
    method: 'POST',
})
  .then((response) => response.json())
  .then(({ data }) => {
    const reviews = data.me[0]['user_books'].map(review => {
      // Just as an example of what you could do
      const author = cached_contributors[0].author.name;
      console.log("author!", author);
    });
  });
Enter fullscreen mode Exit fullscreen mode

A programming pro-tip that I have to make constructing fetch calls easier is if you:

  • Go to Hardcover
  • Search for graphql in the Network tab
  • Find one that looks useful and is returning info
  • Right click the network call > Copy > Copy as fetch

It will copy for you a fetch call similar to the one I've pasted above, which you can then use in your own code.

A screenshot of a browser with the network tab open, with it clicking the Copy as fetch option

A note on rate limiting

Once you move out of the GraphQL console and start doing things in a script, you may run into errors or rate limiting issues if you try and do too many things at the same time.

For example, when adding a new book to a list, I find that trying to add two at the same time will error in the API, probably because it is trying to add two books to the same position in the list.

Similarly, if you tried to make 100 different calls to search for a book based on its title, some of these calls will time out. If you spread them out and do one every second like the following, then you shouldn't have any issues:

let delay = -1000;
const delayIncrement = 1000;

const addBooksToList = async () => {
  const promises = books.map((book) => {
    delay += delayIncrement;
    return new Promise((resolve) =>
      setTimeout(resolve, delay))
      .then(() => addBookToList(book.id))
  });

  const results = await Promise.all(promises);
};
Enter fullscreen mode Exit fullscreen mode

Also, if you grabbed the image URL of a book from Hardcover, and then attempted to load the images of 100 books at the same time on your page, the API will rate limit you, and some of the images will not load. I recommend putting a loading=lazy in your image tags like so:

<img
  loading="lazy"
  src={image.url}
/>
Enter fullscreen mode Exit fullscreen mode

This way, the images will only load when a user scrolls down to view them.

And that's it! I'm keen to see where Hardcover goes next - I'm hoping it does well, and finally we have a Goodreads-killer on our hands. If you'd like to follow me on Hardcover, I fortuitously have managed to snag the @emma handle.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player