15 January 2024




Book Tracking with Literal.Club

Everyone and their mothers always makes a goal to read more books at the start of every given year. Last year, I read more than I hoped, but on the previous version of my site had a tracker that was pretty tedious to use.

I want to display my book reading superiority so that everyone can see (a not-so-humble brag, if you will).

In the past I’ve used Goodreads to track all of my books. However, Goodreads deprecated their developer program a while back so if you don’t have an existing token you’re shit out of luck.

In my search for the perfect book tracker, I stumbled upon Literal. It’s like Goodreads, but their UI is from this century and they have an open GraphQL API. It’s like they knew I wanted to show off.

So after signing up, I imported my Goodreads library (looks like they’re one of the lucky ones with a live developer token 🤨) and got to work displaying my current read and favorites shelf on my site.

I’m using a Next.JS 13 app directory project, though I am sure most of the basic principles can slide over to any library.

Client Components

I am using Apollo to query Literal’s API for client-sided queries. The first step to getting this up and running is to create a React Context that provides the Apollo client to the rest of the application.

"use client";
import { ApolloLink, HttpLink } from "@apollo/client";
import { setContext } from "@apollo/client/link/context";
import {
} from "@apollo/experimental-nextjs-app-support/ssr";
export const getClient = () => {
  const authLink = setContext((_, { headers }) => {
    const token = process.env.NEXT_PUBLIC_LITERAL_TOKEN;
    return {
      headers: {
        authorization: token ? `Bearer ${token}` : "",
  const link = new HttpLink({
    uri: "https://literal.club/graphql/",
    fetchOptions: {
      cache: "no-store",
  return new NextSSRApolloClient({
    cache: new NextSSRInMemoryCache(),
      typeof window === "undefined"
        ? ApolloLink.from([
            new SSRMultipartLink({
              stripDefer: true,
        : ApolloLink.from([authLink, link]),
export function LiteralContext({
}: React.PropsWithChildren) {
  return (
    <ApolloNextAppProvider makeClient={getClient}>

Then, wrap it around your content in layout.tsx.

import { LiteralContext } from "@/context/literal";
// ....
export default function Root({ 
}: { children; React.ReactNode }) {
 // ...
 return (
     { /* any content here */ }

You may have noticed that I have a LITERAL_TOKEN and LITERAL_PROFILE_ID loading from my environment. To retrieve these, just POST to https://literal.club/graphql.

POST /graphql/ HTTP/1.1
Content-Length: 320
Content-Type: application/json
Host: literal.club
User-Agent: HTTPie
 "query":"mutation login($email: String!, $password: String!) {\n    login(email: $email, password: $password) {\n      token\n      profile {\n        id\n        handle\n        name\n        bio\n        image\n      }\n    }\n  }",
   "email": "<your username>",
   "password": "<your password>"

Grab the token and the profileId from the response and put those in your ENV file. To use client side in Next.JS, they need to be prefixed with NEXT_PUBLIC_.

For type info, you can define these models (and any other fields you want to use, full list here).

export type Author = {
  name: string;
  id?: string;
export type Book = {
  id?: string;
  title: string;
  subtitle?: string;
  description?: string;
  pageCount?: number;
  cover: string;
  authors?: Author[];

Once that is done, you can use useSuspenseQuery to query the API.

"use client";
import { useSuspenseQuery } from "@apollo/client";
import { Suspense, useEffect, useState } from "react";
import { gql } from "@apollo/client";
import { Book } from "@/lib/reading";
const query = gql`
  query myReadingStates {
    myReadingStates {
      book {
        authors {
export default function ReadingPreview {
 const [ book, setBook ] = useState({} as Book);
 const { data, error} = useSuspenseQuery(query);
 // filter when getting data to find book with status
 useEffect(() => {
  const current = (data as any)?
        .filter((s) => s.status === 'IS_READING')?
  setBook(current.book as Book);
 }, [data])
 return (
       <div className="grid grid-cols-3 gap-4">
           className="w-32 rounded-md col-span-1"
           alt={`${book.title} cover`}
         <div className="col-span-2">
           <div className="pb-2">
                className="font-serif font-semibold text-[#ffffff]">
                  book.authors?.map((a) => a.name).join(", ")
           <p>{book.description?.slice(0, 120).concat("...")}</p>
              className="flex justify-end text-[#7F7F7F] hover:underline hover:decoration-wavy hover:text-[#ffffff]">
             <Link href={"/books"}>read more</Link>

Server Side

On the Server Side of things, I decided to just use asynchronous fetch (partially because I am not a frontend person, and I had trouble trying to use the Apollo client server side lol).

Because the Literal API has you retrieve read dates by a different query, I used this method to retrieve all read books, and combine the object with the read dates.

async function getBooks() {
  const data = (
    (await fetch("https://literal.club/graphql/", {
      method: "POST",
      body: JSON.stringify({
        query: `query booksByReadingStateAndProfile(
        $limit: Int!
        $offset: Int!
        $readingStatus: ReadingStatus!
        $profileId: String!
      ) {
        booksByReadingStateAndProfile(limit: $limit offset: $offset readingStatus: $readingStatus profileId: $profileId) { id title authors { name } } }`,
        variables: {
          limit: 100,
          offset: 0,
          readingStatus: "FINISHED",
          profileId: process.env.NEXT_PUBLIC_LITERAL_PROFILE_ID,
      headers: {
        "Content-Type": "application/json",
        authorization: process.env.LITERAL_TOKEN ?? "",
    }).then((res) => res.json())) as any
  const books = new Map<number, any>();
  for (const book of data.booksByReadingStateAndProfile) {
    const dates = (
      (await fetch("https://literal.club/graphql/", {
        method: "POST",
        body: JSON.stringify({
          query: `query getReadDates($bookId: String!, $profileId: String!) {
            getReadDates(bookId: $bookId, profileId: $profileId) {
          variables: {
            bookId: book.id,
            profileId: process.env.NEXT_PUBLIC_LITERAL_PROFILE_ID,
        headers: {
          "Content-Type": "application/json",
          authorization: process.env.LITERAL_TOKEN ?? "",
      }).then((res) => res.json())) as any
    const date = dates?.getReadDates?.pop();
    const b = {
      started: date?.started,
      finished: date?.finished,
    if (b.finished != null) {
      const year = moment(b.finished).year();
      let read = books.get(year);
      if (read == null) {
        read = [b];
        books.set(year, read);
      } else {
  return books;

This returns a map, where the keys of the map are years and the content is an array of books.



Jan 15 2024