Blogrolls used to be everywhere. Back in the early web, people would keep a sidebar full of links to other blogs they liked. It was how you discovered things. Then social media happened, algorithms took over discovery, and blogrolls mostly disappeared.
The IndieWeb crowd has been pushing to bring them back, and I think they’re right. If you read someone’s blog and like it, why not tell your visitors about it? It costs nothing.
I wanted a blogroll for this site, but I didn’t want a boring <ul> of links. I wanted something with personality. So I went with a retro terminal/BBS look.
The data structure
Everything starts in constants.ts. I defined a BlogrollEntry type and an array of categories:
export const BLOGROLL_CATEGORIES = [ "indonesian", "tech", "indieweb", "personal", "tools",] as const;
export type BlogrollEntry = { name: string; url: string; feed?: string; description: string; category: (typeof BLOGROLL_CATEGORIES)[number];};The feed field is optional. Not every blog advertises their RSS URL in the same place, so I can override it when needed. Otherwise the OPML export falls back to appending /rss.xml to the URL.
Adding a new blog is just pushing an object onto the BLOGROLL array:
{ name: "Wayan Jimmy", url: "https://blog.wayanjimmy.xyz", description: "Code, life, and everything in between", category: "indonesian",}No CMS, no database. It’s an array in a TypeScript file.
The card component
BlogrollCard.astro is where the terminal aesthetic happens. Each card gets a few small visual details that make it feel like a BBS listing:
- A
›prefix before the blog name, like a terminal prompt //before the description, styled as a code comment- A
url:label showing the stripped domain - A pulsing green dot next to blogs that have an RSS feed
Here’s the feed indicator, which I’m pretty happy with:
{feed && ( <a href={feed} target="_blank" rel="noopener noreferrer" class="flex items-center gap-1.5 font-mono text-xs text-foreground/50 hover:text-accent" aria-label={`RSS feed for ${name} (opens in new tab)`}> <span class="relative flex h-2 w-2"> <span class="absolute inline-flex h-full w-full motion-safe:animate-ping rounded-full bg-accent/75 opacity-75" /> <span class="relative inline-flex h-2 w-2 rounded-full bg-accent" /> </span> feed </a>)}The motion-safe:animate-ping bit is worth noting. Tailwind’s motion-safe: variant means the pulsing animation only runs if the user hasn’t enabled “reduce motion” in their OS settings. People with vestibular disorders don’t need a bunch of blinking dots on the page.
Blog names are wrapped in <h3> tags. This wasn’t my first instinct (I originally used plain <a> tags), but it means screen reader users navigating by headings can jump between blogs. Small thing, big difference.
The page layout
blogroll.astro has an ASCII art header at the top:
┌──────────────────────────────────────┐│ BLOGROLL v1.0 :: NETWORK FEEDS ││ Status: ONLINE │└──────────────────────────────────────┘It’s hidden on mobile with hidden sm:block because ASCII art breaks completely on small screens. The narrow viewport just can’t fit the box characters without wrapping and turning the whole thing into garbage. Instead, mobile visitors get a plain-text fallback that reads BLOGROLL v1.0 :: NETWORK FEEDS // Status: ONLINE.
Below the header, blogs are grouped by category. Each group is a <section> with an aria-labelledby pointing to the category heading. The cards themselves sit in a <ul role="list"> with each card in an <li>. I know, it’s just a list of links, but the semantic structure means screen readers can announce “list, 9 items” when entering a category, which is actually useful.
<ul role="list" class="grid gap-3 sm:grid-cols-2"> {blogs.map(blog => ( <li><BlogrollCard {...blog} /></li> ))}</ul>Two columns on desktop, one column on mobile. Nothing fancy.
OPML export
This was probably the most practical feature. blogroll.opml.ts is an Astro API route that generates an OPML file, which is the standard format for exchanging RSS subscription lists.
export const GET: APIRoute = () => { const categories = [...new Set(BLOGROLL.map(b => b.category))];
const outlines = categories.map(category => { const blogs = BLOGROLL.filter(b => b.category === category); const blogOutlines = blogs.map(blog => { const feedUrl = blog.feed || `${blog.url.replace(/\/$/, "")}/rss.xml`; return `<outline type="rss" text="${escapeXml(blog.name)}" xmlUrl="${escapeXml(feedUrl)}" htmlUrl="${escapeXml(blog.url)}" />`; }).join("\n"); return `<outline text="${category}">\n${blogOutlines}\n</outline>`; }); // ... wrap in OPML boilerplate and return as XML};Blogs get grouped by category in the output, just like on the page. If a blog entry doesn’t have an explicit feed URL, it falls back to ${url}/rss.xml, which works for most blogs.
There’s an escapeXml helper that handles &, <, >, quotes, and apostrophes. XML is picky about these things, and I’d rather not ship a broken file because someone has an ampersand in their blog name.
The page has a download button at the bottom. Click it, save the file, and import it into Miniflux, NetNewsWire, Feedly, or whatever RSS reader you use.
Accessibility refinements
After the initial build, I went through the page with the keyboard and a screen reader. Found a few things I’d missed.
External links didn’t have ARIA labels. Clicking “Wayan Jimmy” opens a new tab, and sighted users can see that from context, but a screen reader just announces the link text. I added aria-label="Visit ${name} (opens in new tab)" to every blog link so the behavior is clear.
I also added group-focus-within states to the cards. When you tab into a card, the left border accent and background change appear, matching the hover effect. Keyboard navigation should feel as intentional as mouse navigation.
Closing
A blogroll is a small feature. It took maybe a day to build. But I like what it does: it points people toward things I think are worth reading, and it does it in a way that feels like it belongs on a personal website. Not a recommendation algorithm, just a list I maintain by hand.
This is part 2 of a series about redesigning this blog.