Several of you have asked how the What we are reading page works, and whether it is hard to build one for your own blog. It is not. The whole thing is a short list, a couple of helper functions and a stylesheet, and most of what we learned along the way fits in a paragraph each.
So here are five small tips, in the order we needed them. Every snippet runs as shown. If you have never written a line of code, skim the headings and read the tip boxes: they are the part we wish somebody had told us first.
test ABC 123
1. Keep the reading list as data
Our first version had the books written into the page, one paragraph each. It lasted until the second month, when somebody wanted them sorted by author. The fix was to separate the facts from the page: a list of plain objects, and a few functions that read it.
export type Book = {
title: string;
author: string;
pages: number;
/** The month the club read it, as "2026-06". */
month: string;
};
export const readingList: Book[] = [
{ title: 'Hamnet', author: 'Maggie O’Farrell', pages: 305, month: '2026-06' },
{ title: 'Jane Eyre', author: 'Charlotte Brontë', pages: 532, month: '2026-07' },
{ title: 'H is for Hawk', author: 'Helen Macdonald', pages: 300, month: '2026-08' },
];
export const totalPages = readingList.reduce((sum, book) => sum + book.pages, 0);
export function monthName(month: string): string {
const [year, number] = month.split('-').map(Number) as [number, number];
return new Intl.DateTimeFormat('en-GB', { month: 'long', year: 'numeric', timeZone: 'UTC' }).format(
new Date(Date.UTC(year, number - 1, 1)),
);
}The highlighted lines are the whole data set and the one sum we show at the top of the page. monthName("2026-06") gives June 2026, in British spelling, with no library and no time zone surprises: the timeZone: 'UTC' option stops a book read on the first of the month from sliding back into the previous one.
Where should the list live?
A TypeScript file is only one answer. We tried four before settling.
| Where it lives | Who can edit it | Watch out for |
|---|---|---|
| A TypeScript file | Whoever can code | Every change is a code change |
| A JSON file | Anyone with a text editor | No comments, no trailing commas |
| A CSV export | Anyone with a spreadsheet | Titles with commas need quoting |
| A database table | The site, through a form | One more thing to back up |
Tip: write the boring version first
Start with the plain list in one file. You can move it to a spreadsheet or a database later, and because the page only ever reads readingList, nothing else has to change.
2. Let the shell do the small sums
Before you write a function to add up the pages, ask whether the terminal can. The club keeps a spreadsheet, and exporting it as a CSV file takes ten seconds. From there, awk does the adding. Everything below runs in any bash shell.
cat > books.csv <<'EOF'
title,pages
Hamnet,305
Jane Eyre,532
H is for Hawk,300
EOF
# Skip the header row (NR > 1) and add up the second column.
awk -F, 'NR > 1 { total += $2 } END { print total " pages" }' books.csvIt prints 1137 pages. The same trick works on the web. Open Library has a free search API, and one long line gets you a book’s title, author and typical page count. You need curl and jq installed, and the line is long on purpose: turn on Wrap in the block’s settings and it reads without a scrollbar.
curl --silent --fail --get "https://openlibrary.org/search.json" --data-urlencode "title=The Remains of the Day" --data-urlencode "fields=title,author_name,number_of_pages_median" --data-urlencode "limit=1" | jq '.docs[0]'3. Ask the database, do not loop
When the list grew past a few dozen books we moved it into a table, and the question which author have we read the most of? turned into one query instead of a loop and a counter. This works as written in SQLite and in Postgres.
CREATE TABLE books (
title TEXT NOT NULL,
author TEXT NOT NULL,
pages INTEGER NOT NULL
);
INSERT INTO books (title, author, pages) VALUES
('Wolf Hall', 'Hilary Mantel', 672),
('Bring Up the Bodies', 'Hilary Mantel', 432),
('Hamnet', 'Maggie O’Farrell', 305);
SELECT author, COUNT(*) AS books, SUM(pages) AS pages
FROM books
GROUP BY author
HAVING COUNT(*) > 1
ORDER BY pages DESC;The result is one row: Hilary Mantel, two books, 1104 pages. Add a fourth book by her and the answer updates itself.
4. Give long text a comfortable measure
The biggest improvement to the page was not code at all. It was one rule: lines of about 65 characters, with room between them. In CSS you can say that directly with the ch unit, and let the browser balance the headings.
.book-notes {
max-inline-size: 65ch;
margin-inline: auto;
font-size: clamp(1.05rem, 1rem + 0.3vw, 1.2rem);
line-height: 1.7;
text-wrap: pretty;
}
.book-notes :is(h2, h3) {
text-wrap: balance;
}
@media (prefers-reduced-motion: no-preference) {
html {
scroll-behavior: smooth;
}
}Tip: respect the reader’s settings
The last rule only smooths the scrolling for people who have not asked their device to reduce motion. It costs three lines and nobody has ever noticed it, which is how good defaults should feel.
5. Check your settings before you trust them
The club’s rules live in a small club.json file. JSON is easy to break with one stray comma, and the site would only tell you when a member visited. Make the failure loud and early: parse the file as part of your build, or by hand as below.
{
"name": "The Playground Book Club",
"meets": "first Thursday of the month",
"shortlist": 4,
"ballots": 2,
"tieBreak": "shorter book"
}node -e "JSON.parse(require('node:fs').readFileSync('club.json', 'utf8')); console.log('club.json is valid JSON')"If it prints club.json is valid JSON, you are done. If it points at a line and a column, that is the comma. Fix it, and run it again.
Want to build your own?
None of this needs a framework, and all of it can start on a train. Bring a laptop to the next meeting and we will build a reading list page together, just ask.



