5 min read
Build Notes: Currently Reading
Table of Contents

This site wasn’t quite me without a Currently Reading page pulling live from my Goodreads shelf. Manual updates were never going to happen.

Why?

Because I’d rather spend that time reading.


But there’s no API

Unfortunately, Goodreads shut down its public API to new developers in 2020. What’s left is an unauthenticated, legacy RSS feed per shelf, which still works fine for this:

Text
https://www.goodreads.com/review/list_rss/<user_id>?shelf=currently-reading

Goodreads requires your numeric user ID, not your username. I had to dig it out of my profile page URL (going to https://goodreads.com/username redirects you to it). On the app, your profile’s share button gives you the same URL.

Swap currently-reading for to-read, read, or any custom shelf name, and the same format works for any of them.

Cloudflare Workers

The obvious approach for a static site is a build-time fetch: pull the RSS at deploy time and bake it into the HTML. But then the list only updates when the site rebuilds, and I didn’t want to push a commit every time I picked up a book. So instead I fetched it client-side, from a Worker.

wrangler.jsonc’s main field points at the script rather than relying on file-based routing:

JSONC
{
  "name": "your-site",
  "compatibility_date": "2026-07-20",
  "main": "worker/index.js",
  "assets": {
    "directory": "./dist",
    "binding": "ASSETS"
  }
}

The Worker itself handles one route and falls through to the static site for everything else:

JavaScript
import { handleReading } from "./reading.js";

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);

    if (url.pathname === "/api/reading" && request.method === "GET") {
      return handleReading(request, ctx);
    }

    return env.ASSETS.fetch(request);
  },
};

Avoiding 403 Forbidden

Goodreads blocks the default Workers User-Agent with a 403. A real browser UA gets through fine. I parsed the feed with plain regex instead of pulling in an XML library, and cached the result at the edge so I’m not hammering Goodreads on every page load:

JavaScript
const GOODREADS_USER_ID = "17276318"; // This is my ID. You can certainly pull my incredible list of books, but consider using your own.

const FEED_URL = `https://www.goodreads.com/review/list_rss/${GOODREADS_USER_ID}?shelf=currently-reading`;

function decodeEntities(str) {
  return str
    .replace(/&amp;/g, "&")
    .replace(/&lt;/g, "<")
    .replace(/&gt;/g, ">")
    .replace(/&quot;/g, '"')
    .replace(/&#39;/g, "'");
}

function field(item, tag) {
  const re = new RegExp(
    `<${tag}[^>]*>\\s*(?:<!\\[CDATA\\[([\\s\\S]*?)\\]\\]>|([\\s\\S]*?))\\s*<\\/${tag}>`
  );
  const match = item.match(re);
  if (!match) return "";
  return decodeEntities((match[1] ?? match[2] ?? "").trim());
}

function parseBooks(xml) {
  const items = xml.match(/<item>[\s\S]*?<\/item>/g) ?? [];
  return items.map((item) => ({
    title: field(item, "title"),
    author: field(item, "author_name"),
    link: field(item, "link"),
    image: field(item, "book_large_image_url"),
  }));
}

export async function handleReading(request, ctx) {
  const cache = caches.default;
  const cacheKey = new Request(request.url, request);

  const cached = await cache.match(cacheKey);
  if (cached) return cached;

  const feedResponse = await fetch(FEED_URL, {
    headers: {
      "user-agent": "Mozilla/5.0 (compatible; YourSite/1.0; +https://yoursite.com)",
    },
  });

  if (!feedResponse.ok) {
    return new Response(JSON.stringify({ books: [] }), {
      status: 502,
      headers: { "content-type": "application/json" },
    });
  }

  const xml = await feedResponse.text();
  const books = parseBooks(xml);

  const response = new Response(JSON.stringify({ books }), {
    headers: {
      "content-type": "application/json",
      "cache-control": "public, max-age=1800",
    },
  });

  ctx.waitUntil(cache.put(cacheKey, response.clone()));
  return response;
}

Using createElement/textContent

The page fetches on load and renders with createElement/textContent instead of an HTML string. The Goodreads feed is trusted content, but there’s no reason to pipe third-party text into the DOM as markup when the safe version costs nothing:

Astro
<div id="reading-list"><p class="text-sm opacity-75">Loading…</p></div>

<script>
  type Book = { title: string; author: string; link: string; image: string };

  function renderBook(book: Book) {
    const a = document.createElement("a");
    a.href = book.link;
    a.target = "_blank";
    a.rel = "noopener noreferrer";
    a.title = `${book.title} on Goodreads`;

    const img = document.createElement("img");
    img.src = book.image;
    img.alt = "";

    const title = document.createElement("p");
    title.textContent = book.title;

    const author = document.createElement("p");
    author.textContent = book.author;

    const text = document.createElement("div");
    text.append(title, author);

    a.append(img, text);
    return a;
  }

  function loadReading() {
    const el = document.getElementById("reading-list");
    if (el) {
      fetch("/api/reading")
        .then((res) => res.json())
        .then(({ books }: { books: Book[] }) => {
          el.replaceChildren(...(books.length ? books.map(renderBook) : []));
        });
    }
  }

  document.addEventListener("astro:page-load", loadReading);
</script>

Testing with preview:worker

None of this is testable with astro dev, since Worker code doesn’t run there (it’s pure Vite). I needed a second script that builds the site and runs it through wrangler dev, the real runtime:

JSON
"preview:worker": "astro build && wrangler dev"

It worked. The page now shows exactly what I’m reading at any given time, and here is the result:


Lessons Learned

  • A dead API often has a side door. Goodreads killed its public API, but not its RSS feeds. Check for a legacy or read-only path before deciding a data source is gone.
  • Third-party services can block things outside your control. The 403 wasn’t a code bug, it was Goodreads’ edge rejecting a generic Workers User-Agent. A real browser never sees that failure, which is why it’s easy to miss until you test against the actual deployment.
  • Cache anything you don’t own the rate limit on. Thirty minutes through the Workers Cache API keeps the function from hammering Goodreads on every page view. It adds zero complexity, and it’s polite.
  • Test against the runtime you’re actually shipping to. astro dev never executes Worker code. preview:worker does, and it’s the only way I could verify that any of this worked.

Looking for something more robust to share your Goodreads library? Check out @sadmanca’s astro-loader-goodreads.