Vineet Ganti
Building this site: sections, series, an audio player and a tour of the code
All posts

Build Logs

·45 min read

Building this site: sections, series, an audio player and a tour of the code

What changed on this site in one round of work, with the code: blog sections and series, renamed posts and redirects, a listen-to-this-post audio player (with a follow-up fix for phones and diagram reading), and then a full tour of how the site's code works, from MDX files to static pages.

Next.jsReactMDXWeb Speech APIPortfolio

This site started as a flat list of posts. Then the posts piled up: two long series, a few essays, some systems notes, and an average read time that kept growing. So I added three things: sections and series to organise the blog, a Listen to this post button, and a clean-up of names, URLs and images to match.

This post is the build log for that work, in two parts. Part 1 explains each change and the code behind it. Part 2 is a tour of the whole codebase, so I (and anyone reading) can find their way around later. The code is on GitHub.

WhatWhyWhere it lives
Sections, series and tagsFind posts by topic, and read a series in ordersrc/lib/sections.ts, src/lib/blog.ts, two new routes
Renamed posts, redirects, image foldersClear names and URLs now that posts sit under a sectionnext.config.ts, public/images/
Audio playerPosts average a long read, so let people listensrc/components/AudioPlayer.tsx, src/lib/speech.ts
Audio fix: phones and diagramsPlayback failed on mobile browsers, and diagrams were skippedThe same two files, plus src/lib/blog.ts for the time estimate
AI podcast versionsA conversational, downloadable audio option made with NotebookLMcontent/audio.json, scripts/upload-audio.mjs, src/components/RecordedAudio.tsx, findAudio in src/lib/blog.ts
NotesA place for short, text-only thoughts that don't need a postcontent/notes/, src/lib/notes.ts, src/app/notes/, NoteList

#Part 1: What changed

#Why the blog needed structure

Before this change, /blog was one list sorted by date. That has three problems once there are a dozen posts:

  • No way to browse by topic. An essay about writing sat between two posts on transformer inference.
  • No reading order. The LLM Architectures posts and the Agentic AI posts are series, but that was only implied by titles and hand-written lists at the top of each post.
  • Inconsistent tags. Tags like "AI", "LLMs" and "AI Engineering" overlap, so they are poor for browsing.

#Three layers: section, series, tag

LayerQuestionHow manySet in frontmatter as
SectionWhich shelf is this on?One per post, from a short fixed listsection: "ai-llms"
SeriesIn what order should I read it?Zero or one per postseries: "agentic-ai" and seriesOrder: 5
TagWhat is it about, in detail?Any numbertags: ["MCP", "Strands"]

The reason for three layers and not one: each does a different job, and a post can sit on a shelf without being in a series. Tags stay as they were. Sections are few and change rarely. Series are as many as I like.

A post's frontmatter now looks like this:

yaml
---
title: "How agents talk: MCP for tools, A2A for other agents"
date: "2026-10-30"
section: "ai-llms"
series: "agentic-ai"
seriesOrder: 5
tags: ["Agentic AI", "LLMs", "Strands", "MCP"]
---

#One config file for the shelves

The sections and series are defined in one place, src/lib/sections.ts:

ts
export const SECTIONS: Section[] = [
  { slug: "ai-llms", title: "AI & LLMs", description: "How language models work, run and get used: ..." },
  { slug: "systems", title: "Systems & Engineering", description: "..." },
  { slug: "build-logs", title: "Build Logs", description: "..." },
  { slug: "essays", title: "Essays & Ideas", description: "..." },
];

/** Where a post lands when its `section` is missing or misspelled. */
export const DEFAULT_SECTION = "essays";

export const SERIES: Series[] = [
  { slug: "llm-architectures", title: "LLM Architectures", description: "..." },
  { slug: "agentic-ai", title: "Agentic AI", description: "..." },
  { slug: "mit-distributed-systems", title: "MIT Distributed Systems", description: "..." },
];

Adding a new area of writing is one entry in this list. The routes, the chips and the cards all read from it, so none of them change. A series that isn't listed still works: getSeriesInfo makes a title from the slug.

The fallback matters for the future. If I write about something I haven't thought of yet, the post lands in Essays & Ideas with a tag, and I only add a section once three or more posts need it.

#Reading the new fields: parsePost

blog.ts used to build a post object in two places, one for the list and one for a single post. I merged them into one helper, so a new field is added once:

ts
function parsePost(slug: string, fileContent: string): BlogPost {
  const { data, content } = matter(fileContent);
  const stats = readingTime(content);
  const order = Number(data.seriesOrder);

  return {
    slug,
    title: data.title || slug,
    description: data.description || "",
    date: data.date || new Date().toISOString(),
    tags: data.tags || [],
    section: getSection(data.section) ? data.section : DEFAULT_SECTION,
    series: data.series || undefined,
    seriesOrder: Number.isFinite(order) ? order : undefined,
    readingTime: stats.text,
    listenMinutes: estimateListenMinutes(content),
    toc: data.toc !== false,
    content,
  };
}

Three things to notice. A bad section never breaks a build: it falls back to the default. A missing seriesOrder becomes undefined and not NaN. And listenMinutes is new, for the audio player later in this post.

On top of it sit small queries, each a filter over getAllPosts():

ts
export function getPostsBySection(section: string): BlogPost[] {
  return getAllPosts().filter((p) => p.section === section);
}

export function getPostsBySeries(series: string): BlogPost[] {
  return getAllPosts()
    .filter((p) => p.series === series)
    .sort((a, b) => (a.seriesOrder ?? 0) - (b.seriesOrder ?? 0));
}

export function getSeriesPosition(post: BlogPost): SeriesPosition | null {
  if (!post.series) return null;
  const posts = getPostsBySeries(post.series);
  const i = posts.findIndex((p) => p.slug === post.slug);
  if (i === -1) return null;
  return { position: i + 1, total: posts.length, prev: posts[i - 1], next: posts[i + 1] };
}

A series is ordered by seriesOrder, not by date. That matters because two posts in one series can be published on the same day, and a post's date can change.

#Two new routes

RouteFileWhat it shows
/blog/topic/[section]src/app/blog/topic/[section]/page.tsxOne section: chips, its series, its posts
/blog/series/[series]src/app/blog/series/[series]/page.tsxOne series, in reading order

Both are two path segments deep, so they can never collide with /blog/[slug], which is one. Each follows the same pattern as the post page:

tsx
export function generateStaticParams() {
  return SECTIONS.map((s) => ({ section: s.slug }));       // build these pages ahead of time
}

export default async function BlogSectionPage({ params }: { params: Promise<{ section: string }> }) {
  const { section: slug } = await params;                   // in this Next.js, params is a Promise
  const section = getSection(slug);
  if (!section) notFound();                                  // unknown section: a 404
  const posts = getPostsBySection(slug);
  // ...render the header, the chips, the series cards and the posts
}

I used real routes and not a ?section= filter on one page, so every section and series has a URL that can be shared, bookmarked and indexed, and every one is built as static HTML.

#The interface

The blog page: a row of section chips with counts, then series cards, then the latest posts, each labelled with its section and part number

ComponentJob
SectionNavThe chip row ("All 16", "AI & LLMs 11", ...). Counts come from the posts, and a section with no posts is hidden
SeriesStripCards for each series with its post count
SeriesNavPrevious and next links at the end of a series post
PostCardNow shows the section and "Series · Part N"
Post page headerThe section and "Part 5 of 5" as links above the title

A section page: the title and description of AI & LLMs, the chip row with that section highlighted, and the two series inside it

The top of a post: AI & LLMs / Agentic AI · Part 5 of 5 above the title, then the date, the title, the tags and the Listen to this post button

SectionNav takes one prop, the active section. It highlights the chip with aria-current="page", which also tells screen readers where they are.

#Renames, redirects and image folders

With sections in place, the old names stopped making sense. The six LLM posts were called mentiforce01 to mentiforce06, which is the name of the person I credit and not a description. They now have readable slugs, and their titles lost the LLM Architectures - 0N - prefix, because the section and the "Part N of 6" label say that already.

BeforeAfter
mentiforce01python-internals-for-llms
mentiforce02neural-network-fundamentals
mentiforce03transformer-architecture-principles
mentiforce04transformer-inference-optimization
mentiforce05vllm-inference-engine-design
mentiforce06batching-llm-requests
a MapReduce post with spaces in its file namedistributed-systems-mapreduce

Renaming a published URL breaks every link to it, so next.config.ts redirects the old slugs permanently:

ts
const RENAMED_POSTS: Record<string, string> = {
  mentiforce01: "python-internals-for-llms",
  // ...
  "mit-distributed%20systems%20-%20lecture1": "distributed-systems-mapreduce",
};

async redirects() {
  return Object.entries(RENAMED_POSTS).map(([from, to]) => ({
    source: `/blog/${from}`,
    destination: `/blog/${to}`,
    permanent: true,        // HTTP 308: browsers and search engines remember it
  }));
},

I checked this against a production build: /blog/mentiforce01 answered 308 with the new location, and so did the old MapReduce URL, spaces and all.

The images moved too. They used to sit in loose folders such as public/llm-vllm-engine/. They now follow the same shape as the blog:

text
public/images/<section>/<series>/<post-slug>/diagram.svg
public/images/ai-llms/agentic-ai/how-agents-talk/summary-map.svg
public/images/essays/ideal-chat-app/message_send_flow.png     (no series, so straight under the section)

I put them under images/ and not blog/ on purpose: a folder named public/blog/ would share the /blog/... URL space with the pages. In total, about 170 image references were rewritten.

#Checking the move

A migration like this is easy to get subtly wrong, so I wrote a throwaway script that compiles every post with the same MDX plugins as the site and checks:

  • every post compiles;
  • every section is a known one, and every series has seriesOrder values 1 to n with no gaps;
  • every image exists, and lives under its own section's folder;
  • every /blog/... link and #anchor points at something real;
  • no image in public/images is orphaned;
  • no post still mentions an old slug.

Then next build for the type-check and the prerendered pages, and a production server to check the redirects and routes over HTTP.

#The audio player

#Why the browser's own voice

The posts here are long: some take well over half an hour to read. A Listen to this post button lets someone follow along while doing something else. There are two ways to build it:

Browser voice (Web Speech API)Pre-generated audio files
CostFreeA text-to-speech service, per post
New postsWork automaticallyNeed a generation step, and again after every edit
Voice qualityDepends on the reader's deviceConsistent and natural
Download, lock-screen playbackNoYes

I chose the free one, and kept the door open: a post could later point to an MP3 and the player could prefer it.

#The hard part: what to read

Posts here are full of code, tables and diagrams. Read aloud, code and tables are unlistenable. So the player reads the prose, reads each diagram from its alt text (the written description every image already carries), and for code, tables and the collapsed recall questions says what it skipped. The logic lives in src/lib/speech.ts, separate from React so it is easy to test.

Data

ts
const SKIPPED: Record<string, string> = {
  PRE: "Code example skipped.",
  TABLE: "Table skipped.",
  IMG: "Diagram skipped.",
  DETAILS: "Recall questions skipped.",
};

export function collectChunks(root: Element): SpeechChunk[] {
  // for each heading, paragraph, list item, code block, table, image and <details>:
  //   images            -> their alt text, spoken as "Image: ..." (a skip note if the alt is only a file name)
  //   code, tables, details -> one spoken note, once per run of the same kind
  //   text inside those -> ignored
  //   everything else   -> cleaned, then split into short chunks
}

It reads the rendered page (#post-content), not the Markdown source. That has two benefits: the paragraph being read can be highlighted, because each chunk keeps a reference to its element, and whatever the page shows is what gets spoken, including text that comes from components like Callout.

Three details make it sound right:

  • Symbols are rewritten. → becomes "to", × becomes "times", and dashes become commas, because otherwise a speech engine reads them out as "rightwards arrow".
  • Splitting avoids decimals. Sentences are split with a lookbehind, /(?<=[.!?])\s+/, which only cuts after punctuation that is followed by a space. So "1.5×" stays whole and doesn't turn into "1" and "5".
  • Chunks are capped at 180 characters. Some browsers cut off a long utterance after about 15 seconds, so each piece stays short. It also makes skipping forward and back precise.

#Diagrams are read, too

The posts rely on diagrams, so skipping them lost half the lesson. Every image already has alt text, which is the description a screen reader would read, so the player reads that after the words "Image:". The rule that decides whether an alt text is a real description is deliberately simple: at least 12 characters and at least one space. An alt like flow_chart is a file name, not a description, so that image gets the skip note instead.

ts
function imageDescription(el: Element): string {
  const alt = (el.getAttribute("alt") ?? "").trim();
  return alt.length >= 12 && /\s/.test(alt) ? alt : "";
}

Two consequences. First, good alt text now matters twice: it helps screen readers and it is what the player says, so the formatting guide says to write it as a description of what the picture shows. Second, there is a setting in the player to turn diagram reading off. Changing it mid-play rebuilds the chunk list and carries on from the same paragraph, and the choice is saved. The listening-time estimate counts the alt text too.

#The player: a small state machine

AudioPlayer.tsx is a client component. A few decisions are worth explaining.

Support and voices are read with useSyncExternalStore. Whether the browser can speak, and which voices it has, are facts about the outside world, not component state. Voices arrive late, on a voiceschanged event, so the component subscribes to that event. On the server, where nothing can speak, it returns nothing, so the page renders the same on server and client.

Pause is "cancel and remember". The Web Speech API's own pause() is unreliable in some browsers, so pausing cancels the current utterance, and resuming starts that chunk again. Changing the speed or the voice does the same thing: restart the current chunk with the new setting.

A generation counter stops stale callbacks. Cancelling an utterance can still fire its onend event, which would start the next chunk and undo the pause. Every call to speak takes a new number, and a callback only acts if its number is still the current one:

ts
const speak = (i: number) => {
  const gen = ++genRef.current;          // a new "generation" invalidates every older callback
  synth.cancel();
  // ...highlight chunk i, set state...
  const utterance = new SpeechSynthesisUtterance(chunks[i].text);
  utterance.rate = prefsRef.current.rate;
  utterance.onend = () => {
    if (genRef.current === gen) speakRef.current(i + 1);   // only if nothing newer has started
  };
  synth.speak(utterance);
};

Highlighting scrolls only when needed. The block being read gets a tts-active class. If it drifts under the sticky header or the player bar, it is scrolled to the centre, and with the browser's reduced-motion setting on, the scroll is instant.

Settings are remembered. Speed and voice are saved to localStorage inside try/catch, because storage can be blocked, and the player works without it.

The time estimate has two sources. The button shows a server-computed figure (listenMinutes, from the Markdown with code, tables and images stripped, at about 165 words a minute). Once playing, the bar recomputes the time left from the chunks and the chosen speed.

The player on desktop: a bottom bar with previous, pause and next buttons, "Playing", the time left, a 1 times speed button, a settings button and a progress bar, while the paragraph being read is highlighted above it

The player on a phone: the same bar, with a shorter label so nothing is cut off

#Making it work on phones

After the first release, audio didn't play on phones, in Safari or in Chrome. I couldn't hold a phone to the code, so I read the player for the failure points that are known to bite mobile browsers, and found four real ones:

Symptom on a phoneCause in the codeFix
Nothing is spokencancel() and speak() were called in the same instant. Mobile Safari and Chrome can drop speech queued like thatOnly cancel when something is actually speaking or queued, and when restarting, wait 80 ms before speaking
It speaks one sentence, then freezesThe utterance object wasn't kept anywhere, and Chrome can garbage-collect it mid-speech, after which its end event never firesKeep the current utterance in a ref
It stays silent after a pauseMobile Chrome can leave the engine in a stuck "paused" stateCall resume() before every speak()
"Paused" and no explanationA failed utterance was caught and ignoredShow the reason in the player

Two safety nets sit on top, because phone browsers are inconsistent:

  • A watchdog checks every half second. If speech started but the engine has gone idle without an end event, it moves on to the next chunk. If speech never started within 4.5 seconds, it stops and says so: "Nothing was spoken. Check the volume (and the silent switch on iPhone), then tap play again."
  • Plain messages for refusals: if the browser blocks the audio (its autoplay rule) the player says to tap play again, instead of failing quietly.

The first tap still calls speak() straight away inside the click handler, because phones only allow speech that starts from a tap. The bar also now sits above the iPhone's home indicator, using env(safe-area-inset-bottom).

#How I tested speech without listening to it

You can't hear a unit test, but you can check everything except the sound. A script drove a headless Chrome over the DevTools protocol, replaced the browser's speak with a stub that records each text and fires onend a moment later, pressed Listen and let a whole post play through in seconds.

PostChunks spokenWordsSkip notes
How agents talk264about 3,60019 code, 18 table, 1 recall (12 diagrams read)
Building my first AI agent144about 2,22010 code, 4 table, 1 recall (5 diagrams read)
MapReduce post16about 2301 code

Each run finished by itself. The phone failure modes were tested the same way, in an emulated phone. A second test, with a stub that never finishes, pressed next, previous, pause, play, speed, voice settings and stop, and checked that the highlight and the bar came and went. Screenshots in light, dark and mobile found one real issue: the label was cut off on a phone, so the part counter is now hidden on small screens.

What this does not prove is how it sounds. That depends on the voices installed in the browser. Edge and Chrome offer "Natural" or "Online" voices that are far better than the defaults, so the voice picker lists those first.

#Limits

  • No download, and some phones stop speech when the screen locks. (Posts with a recorded version, below, don't have this problem.)
  • Anything that exists only in code or a table is skipped, and a diagram is only as good as its alt text, so the important point has to be in the prose or the description.
  • Pronunciation of names and acronyms is whatever the voice decides.

#AI podcast versions: NotebookLM and the m4a player

The text-to-speech player is free and works on every post, but a browser voice reads like a screen reader. For the posts I most want people to finish, I wanted something closer to a podcast. So a post can now carry a recorded audio version, made with Google's NotebookLM, and the page shows a separate Listen to AI podcast version button for it.

#How the audio is made with NotebookLM

NotebookLM takes documents you give it as sources and can turn them into an Audio Overview: a spoken conversation between two AI hosts that explains and discusses the material. The workflow for a post is:

  1. Add the post as a source (the text of the article, or its published page).
  2. Generate an Audio Overview, and steer it with the optional focus prompt, for example "explain this to someone learning it for the first time and stick to the post's examples".
  3. Listen to the whole thing. If it drifts, misses a section or gets a detail wrong, generate it again with a sharper prompt. That is how the refined versions come about: the first take is rarely the one I publish.
  4. Download the audio as an m4a file and name it after the post's slug.

The result is a different kind of audio from the browser voice, so the two aren't redundant:

Browser voice (Listen to this post)AI podcast version
Made byThe reader's device, liveNotebookLM, once, before publishing
ContentThe post read word for wordA conversation about the post
Code and tablesSkipped with a spoken noteDescribed in conversation, if the hosts choose to
Cost per postNoneA few minutes of my time to generate and check
Works offlineNoYes, after a download

#Adding the file to a post: the walkthrough

The goal was that adding audio to a post is a couple of commands, with no frontmatter to edit and no component to wire up. The recordings are not stored in git: they are large, so they live in object storage (see Moving the audio out of git) and the site only keeps a list of which posts have one.

Step 1: name the file after the post. For the post at content/blog/hello-agent.mdx, the recording is hello-agent.m4a. It is kept locally in public/audio/, which git ignores.

Step 2: upload it. node --env-file=.env.local scripts/upload-audio.mjs sends every file in that folder to the storage bucket and adds the post's slug to content/audio.json, a small committed list:

json
{
  "hello-agent": "hello-agent.m4a"
}

Step 3: the build finds it. parsePost looks the slug up in that list and builds the address from a base URL:

ts
const AUDIO_BASE_URL = (process.env.AUDIO_BASE_URL || "/audio").replace(/\/+$/, "");

function findAudio(slug: string, declared: unknown): string | undefined {
  if (typeof declared === "string" && /^(\/|https:\/\/)/.test(declared)) return declared;
  const file = readAudioManifest()[slug];
  return file ? `${AUDIO_BASE_URL}/${file}` : undefined;
}

No entry in the list means no audio, and the post is unchanged. In production AUDIO_BASE_URL points at the storage bucket. When it isn't set, it falls back to /audio, so recordings in the local folder still play in development. A post can also name its own file with audio: in the frontmatter, as a path or a full https:// address. The result is stored on the post as audioSrc.

Step 4: the page chooses a player. In the post page, one conditional decides which button appears:

tsx
{post.audioSrc ? (
  <RecordedAudio src={post.audioSrc} title={post.title} />
) : (
  <AudioPlayer minutes={post.listenMinutes} />
)}

A post with a recording gets the podcast button; every other post keeps the text-to-speech one. They aren't shown together because the browser voice highlights paragraphs as it reads, and two voices at once would be a mess.

Step 5: the RecordedAudio component. It looks like the text-to-speech player on purpose: the same card-style button in the post header, and a player bar that opens fixed to the bottom of the screen with play and pause, back 15 seconds, forward 30, a seek bar with the time, a speed button and a download link. Three details are worth knowing:

  • The <audio> element is always on the page, hidden. Phones only allow audio that starts from a tap, so the button's click handler calls play() directly, which needs an element that already exists.
  • preload="none". The file isn't downloaded until someone presses the button, so a post with a 20 MB recording costs nothing to people who never listen.
  • Failures are shown. If play() is refused or the file can't load, the bar says so, in the same way the text-to-speech player does after the phone fix. The bar also sits above the iPhone home indicator. The download link adds ?download=1 for files on another address, because browsers ignore the download attribute across origins and would play the file instead.

Step 6: ship it. Commit content/audio.json with the post and push. The audio itself is already in storage.

#Moving the audio out of git

The first version of this feature committed each m4a straight into public/audio/. That worked, but the files turned out to be large: the NotebookLM exports are 257 kbps stereo, about 40 MB each for a 20-minute episode, 433 MB across eleven files. Git keeps every committed copy forever and GitHub warns at 50 MB a file, so audio would have made the repository heavy while everything else on this blog is text and small SVGs.

So the recordings moved to Vercel Blob, with the site holding only the list and a base URL. I compared it with Cloudflare R2 first:

Vercel Blob (Hobby)Cloudflare R2 (free tier)
Storage1 GB10 GB
Downloads10 GB a monthFree, unlimited
Over the limitNo charge, but Blob is blocked for 30 daysNo hard cap
Setup hereSame dashboard, no DNS changeNeeds a custom domain on Cloudflare, and this domain's DNS is elsewhere

Blob won on setup, and the code doesn't depend on it: moving to R2 later is copying the files and changing AUDIO_BASE_URL. On Vercel's paid plan the audio would cost cents; the $20 a month platform fee is the real price, so a paid plan only makes sense if the site needs it for other reasons.

#Shrinking the files first

Hobby's 1 GB would have gone quickly at 433 MB, so before the upload I re-encoded every recording with ffmpeg:

text
ffmpeg -i in.m4a -vn -ac 1 -c:a aac -b:a 64k -movflags +faststart out.m4a
  • -ac 1 makes it mono. The NotebookLM hosts are two voices, not music, so stereo adds almost nothing.
  • -b:a 64k is 64 kbps AAC, down from 257 kbps. Speech sits mostly below 8 kHz, so what is lost is the very top of the spectrum and a little "air" on s and t sounds.
  • -movflags +faststart puts the file's index at the front, so phones can start playing and seek without downloading the whole file first.

The eleven files went from 433 MB to 113 MB, about 10 MB for a 22-minute episode, with identical durations. That is 11% of Hobby's storage, and a full play of an episode costs about 10 MB of the 10 GB monthly transfer, roughly a thousand plays instead of 250.

#Limits

  • Vercel's Hobby plan allows 1 GB of storage and 10 GB of downloads a month. Past that, the audio stops working until the month rolls over, although the site itself keeps working. Smaller files are the main defence, along with preload="none".
  • Old copies of the five recordings that were committed before the move remain in git history.
  • NotebookLM output varies between runs, so a recording can drift from the article after the article is edited. Regenerate it when the post changes materially.
  • The recording is not part of the "About N min" estimate, which belongs to the browser voice.

#Notes: a text-only feed

Not everything is worth a blog post. A thought about a navigation app, a small annoyance, a one-line opinion: these don't need a title, a table of contents or a recall section. So there is now a second kind of content next to posts, called notes, and it works like a tweet wall or an RSS feed: short, plain text, newest first.

#How a note is written

A note is a small Markdown file in content/notes/. The front matter holds only a date, and everything below it is the text:

md
---
date: "2026-10-30T12:00"
---
The UX on navigation is better for Apple Maps on iPhone.

A blank line starts a new paragraph.

There are no titles, tags, images or links to render. Writing a note means creating one file. The first note is a short take on Apple Maps versus Google Maps on CarPlay.

#Reading and ordering

src/lib/notes.ts reads the folder, skips any file with no text or an unreadable date, and sorts by date, newest first. The date can include a time, which breaks ties between notes written on the same day:

ts
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());

Only the date is shown on screen. A time zone on a statically built page would be a guess, so the time is used for ordering and nothing else.

#Two places to read them

  • /notes is the full feed. Each date is a link to that note's anchor, so a single note can be linked to.
  • The home page has a Notes section with the same list inside an isolated box about 26 rem tall that scrolls on its own. overscroll-contain stops the page from scrolling when the end of the box is reached, and the box can be focused with the keyboard, so it works without a mouse.

Both use one component, NoteList, so the two views can't drift apart.

#An RSS feed

/notes/feed.xml is a route handler that returns the notes as an RSS 2.0 feed. Each item's title is the first line of the note, up to 80 characters, and its link is the anchor on /notes. The text is escaped for XML, and the feed address is the site's own URL, written once at the top of the file.

#Limits

  • The feed is for notes only. Blog posts don't have an RSS feed yet.
  • Notes are plain text, so they can't hold a link, a list or code.
  • I haven't opened the feed in a feed reader, only checked the generated XML.

#Part 2: A tour of the website's code

#The stack

LayerChoice
FrameworkNext.js 16 with the App Router, React 19, TypeScript
StylingTailwind CSS 4, with the typography plugin for post text
ContentMDX files in the repo, read with gray-matter and rendered by next-mdx-remote
Markdown pluginsremark-gfm (tables), rehype-slug (heading ids), rehype-highlight (code colours)
FontsBricolage Grotesque for headings, Atkinson Hyperlegible Next and Mono for body and code
Iconslucide-react
Dates, reading timedate-fns, reading-time
HostingVercel

#The folder map

text
content/
  blog/            one .mdx file per post (the filename is the URL)
  projects/        one .md file per project card
  notes/           one .md file per note (a date and plain text)
  audio.json       which posts have a recording (slug to file name)
docs/              the MDX formatting guide
public/images/     diagrams and screenshots, by section / series / post
public/audio/      local copies of the recordings (git-ignored; the real files live in Vercel Blob)
scripts/           upload-audio.mjs, which sends recordings to storage
src/
  app/
    layout.tsx     fonts, the theme script, header, footer
    page.tsx       the home page
    globals.css    theme tokens and all the post styling
    blog/          /blog, /blog/[slug], /blog/topic/[section], /blog/series/[series]
    notes/         /notes and /notes/feed.xml (the RSS feed)
    projects/      /projects
    about/         /about
  components/      Header, Footer, ThemeToggle, PostCard, ProjectCard, SectionNav,
                   SeriesStrip, SeriesNav, TableOfContents, MdxContent, Callout,
                   CopyButton, AudioPlayer, RecordedAudio, NoteList
  lib/
    blog.ts        reads posts, builds the table of contents, series queries
    sections.ts    the sections and series
    speech.ts      the audio player's text logic
    notes.ts       reads and sorts the notes
    projects.ts    reads project files
next.config.ts     redirects and framework options

#The content pipeline

DataMetadataRequest or acknowledgment

There is no database and no API. The repository is the CMS:

  1. Read. getAllPosts() lists content/blog/*.mdx, reads each with fs, and gray-matter splits the frontmatter from the body. parsePost turns that into a BlogPost and the list is sorted newest first.
  2. Ask. Every page calls a query (getAllPosts, getPostsBySection, getPostsBySeries, getPostBySlug) at build time.
  3. Render. A post page passes the body to MdxContent, which compiles the MDX on the server.

Projects work the same way, a step simpler: projects.ts reads content/projects/*.md and each file's frontmatter becomes a card.

#Rendering a post: MdxContent

tsx
<MDXRemote
  source={source}
  components={components}
  options={{ mdxOptions: { remarkPlugins: [remarkGfm], rehypePlugins: [rehypeSlug, rehypeHighlight] } }}
/>

next-mdx-remote/rsc compiles MDX inside a server component, so no MDX compiler is shipped to the browser. Four overrides turn plain Markdown into the site's design:

ElementWhat replaces it
h2, h3, h4The heading plus a hover # permalink, using the id rehype-slug made
preA code-block with a language label and a Copy button
tableThe table inside a scrollable wrapper, so wide tables don't break the page
<Callout>A tinted box with an icon, in five types: note, info, tip, analogy and warn

The copy button is a client component that reads the sibling pre and writes its text to the clipboard, and it handles a blocked clipboard silently.

#The table of contents

getHeadings() in blog.ts parses the post with unified and remark-parse, walks the headings with unist-util-visit, and creates ids with the same slugger that rehype-slug uses, in the same order. That is what keeps the contents entries and the page's real heading ids matching, even when two headings share a name. The shallowest heading level present becomes the top level, with one level of sub-entries.

TableOfContents is a client component that adds:

  • scrollspy: the current section is the last heading above a point near the top of the screen, and at the very bottom it selects the last entry, so short final sections still count;
  • a reading-progress bar under the header;
  • a mobile bar with a contents menu that closes on Escape;
  • scroll updates throttled to one per animation frame.

#Layout, fonts and the theme

layout.tsx loads the three fonts as CSS variables, sets the page title template (%s | Vineet Ganti), and puts a small inline script in the head:

ts
const themeScript = `try{var t=localStorage.getItem("theme");if(t==="light"||t==="dark")document.documentElement.setAttribute("data-theme",t)}catch(e){}`;

It runs before the first paint, so a saved theme is applied with no flash of the wrong colours.

The theme itself is a set of CSS variables in globals.css: the "vanilla and olive" palette (--paper, --card, --ink, --accent, and so on). The light values are on :root. Dark values apply in two cases: when the system prefers dark and no theme is chosen (:root:not([data-theme="light"])), and when the user picks dark (:root[data-theme="dark"]). ThemeToggle flips data-theme and saves the choice.

tailwind.config.mts then maps colour names such as ink, card, accent and border to those variables. A component writes text-ink or bg-card, and the same class is correct in both themes. No component has a dark: variant.

#The pages

RouteTypeNotes
/StaticHero, a scrollable notes feed, featured projects, the three latest posts
/notesStaticEvery note, newest first
/notes/feed.xmlStatic route handlerThe notes as an RSS 2.0 feed
/blogStaticSection chips, series cards, all posts
/blog/[slug]Static, one per postHeader, table of contents, content, audio player, series navigation
/blog/topic/[section]Static, one per sectionA section's series and posts
/blog/series/[series]Static, one per seriesA series in order
/projectsStaticAll project cards
/aboutStaticText and contact links

Dynamic routes list what to build with generateStaticParams, and the build prints each one. The config enables cacheComponents and partialPrefetching, and the post pages show up as partially prerendered. The header's active link is a simple prefix match on the path, so /blog/topic/... keeps "Blog" highlighted without any extra code.

#Guard rails

WhatHow
A writing guidedocs/mdx-formatting-guide.md: frontmatter, callouts, images, and MDX gotchas such as stray < and { in prose
The build is the testnext build type-checks and prerenders every page, so a broken post fails the build
Linteslint with the Next.js config
Content checksThe throwaway script described above, run after bulk edits
FallbacksAn unknown section falls back to Essays, a missing date to today, blocked storage is ignored

#Summary

The blog now has three layers: a section (the shelf), a series (the reading order) and tags (the detail). Sections and series live in one config file, parsePost validates the new frontmatter, small queries filter posts, and two new routes give every section and series a shareable, prerendered page. Posts got readable slugs with permanent redirects from the old ones, and images moved to public/images/<section>/<series>/<post>/. The audio player reads the rendered prose and each diagram's description with the browser's text-to-speech, says what it skips, highlights the current paragraph, and stays correct through pauses and skips thanks to a generation counter. Playback on phones needed four fixes and a watchdog, and it has been tested only in an emulated phone. The site underneath is simple on purpose: MDX files in, static pages out, with the theme driven by CSS variables.

#Concepts at a glance

ConceptIn one lineWhy it matters
SectionThe shelf a post sits on, one per postBrowsing by topic
SeriesAn ordered reading list with seriesOrderPart N of M, previous and next
TagFree-form detailFiner topics without a new section
sections.tsThe one place sections and series are definedAdding one needs no code changes elsewhere
parsePostTurns a file into a BlogPost, with fallbacksA bad value never breaks the build
generateStaticParamsLists the pages to prerenderEvery section and series is static HTML
308 redirectA permanent redirect from an old URLRenamed posts keep working links
Web Speech APIThe browser's built-in text-to-speechFree audio with no files
collectChunksProse in, short speakable chunks out, with skip notesCode and tables aren't read aloud
Generation counterEach speak call invalidates older callbacksPause and skip stay reliable
Alt-text readingDiagrams are spoken from their image descriptionPictures aren't lost, and alt text matters twice
WatchdogA timer that notices speech that never started or never endedPhones drop events, so it moves on or explains
Audio overviewA two-host spoken conversation NotebookLM generates from a sourceThe AI podcast version of a post, checked before publishing
Object storageLarge files live in a bucket, and the site keeps a list plus a base URL (AUDIO_BASE_URL)Git stays small, and changing provider is a copy plus one setting
NotesDated plain-text files in content/notes/, shown newest first on /notes, the home page and an RSS feedShort thoughts without the weight of a post
useSyncExternalStoreReads browser facts (support, voices)Server and client render the same
Theme tokensCSS variables mapped to Tailwind colour namesOne class works in light and dark
MdxContentCompiles MDX on the server with custom componentsCallouts, copy buttons and heading links

#Test yourself

Why are there three layers (section, series, tag) and not one?

Each does a different job. A section is a small, stable shelf. A series is an order. A tag is free-form detail. A post can be on a shelf without being in a series, and tags alone can't express order.

What happens to a post with a missing or misspelled section?

parsePost falls back to the default, Essays & Ideas, so the build doesn't break and the post still appears.

Why is a series ordered by seriesOrder and not by date?

Two posts in a series can share a date, and a date can change. An explicit order is stable.

Why did renaming the posts need redirects(), and why is it permanent?

Old URLs may be linked from elsewhere. A permanent (308) redirect sends visitors to the new slug, and browsers and search engines remember it.

Why does the audio player read the rendered page and not the Markdown?

So each spoken chunk can point at a real element to highlight, and so that whatever the page actually shows (including text from components) is what gets read.

What does the generation counter protect against?

A cancelled utterance can still fire its end event, which would start the next chunk and undo a pause. Each speak call takes a new number, and a callback only acts if its number is still current.

Why might speech work on a laptop but not on a phone?

Phone browsers are stricter: speech must start from a tap, cancelling and speaking in the same instant can drop the speech, an utterance can be garbage-collected so its end event never fires, and the engine can be left paused. The player avoids each of those and has a watchdog for what slips through.

How is a note different from a blog post?

A note is a dated plain-text file in content/notes/: no title, tags, images or page of its own. Notes appear newest first on /notes, in a scrollable box on the home page and in an RSS feed, while posts have their own pages, sections and series.

How does a post get an AI podcast version?

Generate an Audio Overview of the post in NotebookLM, check it, download the m4a and save it as <post-slug>.m4a in public/audio/. Run the upload script, which sends it to storage and lists it in content/audio.json. The build finds it by slug, and the post page shows the podcast button instead of the browser-voice one.

Why does the podcast player keep a hidden audio element on the page?

Phones only allow audio that starts from a tap. The button's click handler calls play() on an element that already exists, so playback counts as user-initiated.

How does the player read a diagram?

It reads the image's alt text after the words "Image:". Alt text counts only if it is a real description (at least 12 characters with a space), so a file name is skipped with a note.

How can the whole player be tested without hearing anything?

Replace the browser's speak with a stub that records the text and fires the end event, drive the page in a headless browser, and check what was spoken, what was skipped and that the controls behave.

How does one CSS class work in both light and dark mode?

Tailwind colour names point at CSS variables, and the dark theme only redefines those variables, so text-ink or bg-card stays the same in every component.

#Update log

Every change to the site is logged here under the day it was made, oldest day first. New updates go in a new dated heading at the bottom of this list.

#2026-10-29

TypeChange
FeatureSections, series and tags, with /blog/topic and /blog/series pages, "Part N of M" and previous/next links
ChangeLLM Architectures posts renamed to readable slugs, with permanent redirects. Images moved to public/images/<section>/<series>/<post>/
FeatureListen to this post audio player: browser text-to-speech, reads prose, skips code, tables and diagrams with a spoken note, highlights the paragraph, speed and voice controls
FixAudio did not play on mobile Safari and Chrome. Four causes fixed: cancel and speak in the same instant, an utterance discarded before it finished, a stuck paused engine, and failures hidden from the reader. Added a watchdog and visible messages, and lifted the bar above the iPhone home indicator
FeatureDiagram reading. The player now reads each image's alt text, with a setting to turn it off, and the listening-time estimate counts it

The phone fix was tested in an emulated phone with a stubbed speech engine, not on a physical device. If it still fails on yours, the player now shows a message saying why.

#2026-10-30

TypeChange
FeatureAI podcast versions. A post can carry an m4a made with NotebookLM: upload <slug>.m4a to storage and list it in content/audio.json, and the post shows a Listen to AI podcast version button and a bottom player bar, in place of the browser-voice button
FeatureNotes. Short, dated, plain-text entries in content/notes/, shown newest first on a new /notes page, in a separate scrollable box on the home page, and as an RSS feed at /notes/feed.xml. Added a Notes link to the header
ChangeThe first note (Apple Maps versus Google Maps) was first written as a blog post in a short-lived Notes blog section. It was moved into the notes feed and that section was removed
ChangeAudio moved out of git. The recordings were re-encoded from 433 MB to 113 MB (mono AAC, 64 kbps) and now live in Vercel Blob. The site keeps content/audio.json and an AUDIO_BASE_URL setting, and scripts/upload-audio.mjs uploads new files

#2026-11-03

TypeChange
FeatureDiagram motion. Diagrams below the fold fade their parts in, in drawing order, as they scroll into view, and dashed arrows drift on hover. It is one small client component plus CSS rules, applies to every inlined SVG, and is off with reduced motion, without JavaScript and in print
FeatureLive charts. Five charts in three posts are now interactive: the queueing knee, the naive batch fill wait, the Poisson arrivals, the decode latency against context length, and the weight memory by precision. Each has sliders, a sentence that restates the numbers, and starts at the values its picture showed. The charts that are sketches or measurements stayed as pictures, because moving them would invent data
ContentData Engineering SQL series. A new Data Engineering section with a six-post practice bank of 92 questions. The posts are generated by a Python script that loads each question's sample data into DuckDB, runs the answer, and writes the expected outputs from the real results, so no output is typed by hand; answers marked as safe to re-run are run twice and the build fails if anything changes
FeatureData traces. Each answer has a step-through component that shows the tables at each stage of the query, with inserted, removed, changed and wrong rows tinted and labelled in words, built from the same DuckDB runs

#What's next

  • Tags are still free-form and a little inconsistent. A small fixed list would make them a better filter.
  • The older essays haven't had the recall treatment the series posts got.
  • No sitemap, search or RSS feed for blog posts yet (the notes already have one).
  • Record podcast versions for the long series posts first, since those are the ones people most want to listen to.
  • If recordings pile up, move them out of the git repository into object storage.

#Resources