This website wasn’t truly “me” unless I had a Currently Reading list. I wanted to create a page that pulls that data directly from my Goodreads bookshelf, without any need for manual updates.
Why?
Because, honestly, I’d rather spend that time reading.
But there’s no API
Unfortunately, Goodreads shut down its public API to new developers back in 2020. What’s left is an unauthenticated, legacy RSS feed per shelf, but that will fortunately still work for this project:
https://www.goodreads.com/review/list_rss/<user_id>?shelf=currently-readingGoodreads requires your numeric user ID, not your username, which meant a trip to my profile page to dig it out from the URL. Using https://goodreads.com/username redirected me to the exact URL I needed. (If you’re using the app, you can go to your profile, click the share button, and copy your full profile URL from there to grab the ID.)
PS: you can also swap currently-reading for to-read, read, or any custom shelf name, and the same format works for any of them.
Cloudflare Workers
As a static site, the obvious move was to use a build-time fetch, pulling the RSS at deploy time and then baking it into the HTML. But doing this meant the list only updated when the site is rebuilt, and I wanted it to reflect actual reality without having to push a commit every time I picked up a new book. So I created a serverless function and fetched it client-side on page load.
It was built as a Cloudflare Worker, configured via wrangler.jsonc’s main field pointing at the script instead of relying on file-based routing:
{
"name": "your-site",
"compatibility_date": "2026-07-20",
"main": "worker/index.js",
"assets": {
"directory": "./dist",
"binding": "ASSETS"
}
}And here’s the Worker script itself. There’s only one route handled, with everything else falling through to the actual static site:
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
Fun Fact: Goodreads blocks Cloudflare Workers’ default User-Agent with a 403 Forbidden, but fortunately, a real browser UA gets through just fine. So, I needed to send a proper UA header, which was easy enough. I then parsed the feed with plain regex instead of pulling in an XML library (overkill), and cached the result at the edge so we’re not hammering Goodreads on every page load:
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(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, '"')
.replace(/'/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 itself is just a plain fetch on load, rendered with createElement/textContent rather than an HTML string. The Goodreads feed is effectively trusted content, but there’s not really a good reason to pipe third-party text straight into the DOM as markup when the safe version costs nothing extra, and you can control the styling:
<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;
}
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) : []));
});
}
</script>Testing with preview:worker
None of this is testable with astro dev. The problem is that Worker code doesn’t run there, as it’s all pure Vite. I ended up needing a second script that builds the site and runs it through wrangler dev, the actual runtime, so I could actually test the functionality:
"preview:worker": "astro build && wrangler dev"And it worked! I have a simple list that 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. You should check for a legacy or read-only path before assuming a data source is gone entirely.
- Third-party services can block on things you don’t control by default: The 403 isn’t a code bug. It is Goodreads’ edge rejecting a generic Workers User-Agent. A real browser never sees this class of failure, which is exactly why it’s easy to miss until you test against the real deployment.
- Cache anything you don’t own the rate limit on: Thirty minutes via the Workers Cache API means the function isn’t hammering Goodreads on every page view, it adds zero complexity, and it’s polite.
- Test against the runtime you’re actually shipping to:
astro devnever executes Worker code.preview:worker(astro build && wrangler dev) does, and it’s the only way I could verify that any of this worked.