A calm, self-hosted RSS reader for people who miss the old web.
There is no algorithm here. No ranking, no "For You", no engagement bait. Just the sites you chose to follow, in the order they published, in a quiet interface that gets out of your way. It runs as a single container next to a Postgres database, and it is multi-user from the ground up, so your family, your club, or your team can read on the same instance without stepping on each other.
- Reading first. Three list layouts (cards, list, magazine), comfortable and compact densities, and three article views: a clean readability extraction, the feed's own content, or the full web page. Long-form text is set in a proper serif at a readable measure.
- Your keyboard works.
j/kthrough articles,sto star,ato mark a view read,/to search,?for the full shortcut overlay. The mouse is optional. - Fast, honest search. Postgres full-text search across everything you are subscribed to, with phrase and exclusion syntax, ranked sensibly. Save a search to the sidebar, and let rules mark read or star new articles for you.
- It respects your attention. Unread-only mode, mark-all-read that does what it says with Undo, per-feed controls (custom titles, Skim / Must read attention levels, hide from All Items), and folders and subfolders that drag and drop.
- It looks after itself. Feeds are discovered from a plain site URL, favicons fetched, HTML sanitized server-side before it ever reaches your browser, and articles deduplicated globally no matter how many users subscribe.
- It works offline. Installable PWA with cached articles, so the train tunnel does not end your morning read.
- It is part of the open web. Share what you read on a public page that is itself a feed, publish your blogroll, get posts in seconds from feeds with a WebSub hub, and follow Mastodon and Bluesky people like any blog. A star keeps a readable copy, so a dead link does not take the article with it.
- It is yours. OPML import and export (leave whenever you like), eight named themes from paper-warm to void-black, sessions in your own database, and not a single external service in the serving path.
- Built to be read by screen readers too. Live-region announcements, focus-managed dialogs, skip links, labeled controls.
The first account to register becomes the admin; after that, open registration, invites, or closed, your call.
| Layer | Choice |
|---|---|
| Language | TypeScript everywhere |
| Web | React 19 + Vite, React Router, TanStack Query |
| Styling | Tailwind CSS v4 + shadcn/ui (Radix) |
| API | Fastify 5 |
| Worker | Node process polling feeds on an interval |
| Database | PostgreSQL via Drizzle ORM |
| Auth | Self-rolled session cookies (argon2id hashing) |
| Monorepo | pnpm workspaces + Turborepo |
apps/
web/ React SPA (Vite)
api/ Fastify API + feed-polling worker + Drizzle schema
packages/
shared/ Zod schemas + types shared by web and api
docker/ Dockerfile + compose files
.github/ CI (lint/typecheck/test/build) + GHCR image publish
The API serves the built SPA from the same origin in production, so the whole app is one container image (plus Postgres).
Prerequisites: Node 22+, pnpm, Docker.
cp .env.example .env # then edit SESSION_SECRET at minimum
pnpm install
# Start Postgres only; run the app on the host for hot reload.
pnpm docker:dev
pnpm db:migrate # apply the migrations committed in apps/api/drizzle
pnpm dev # web on :5173, api on :3000 (proxied)Register the first account through the UI. The first user to register becomes the admin.
To run the feed worker locally:
pnpm --filter @rss/api dev:workercp .env.example .env # set a strong SESSION_SECRET and DB password
docker compose -f docker/docker-compose.yml up -d --buildThis starts Postgres, runs migrations once, then launches the API (serving the
SPA on :3000) and the feed worker.
| Command | What it does |
|---|---|
pnpm dev |
Run web + api in watch mode |
pnpm build |
Build all packages |
pnpm lint |
ESLint across the workspace |
pnpm typecheck |
tsc --noEmit across the workspace |
pnpm test |
Run tests (Vitest) |
pnpm db:generate |
Generate Drizzle migrations from the schema |
pnpm db:migrate |
Apply migrations |
pnpm db:studio |
Open Drizzle Studio |
Feeds and articles are stored once, globally, and deduplicated by URL / guid.
Per-user data lives in subscriptions (which feeds a user follows) and
article_states (read / starred). This keeps storage flat as users grow and lets
the worker fetch each feed a single time regardless of how many people subscribe.
- Feed discovery from a site URL + favicon fetching
- OPML import / export
- Reading views: cards, list, magazine (+ comfortable/compact density)
- Article views: Feed (the feed's own content), Extracted (readability), and Web (the full page)
- Full-text search (Postgres
tsvector) - Keyboard-driven navigation (j/k, mark read, star)
- Mark-all-read, filters (unread / starred / by folder)
- Keyset pagination + infinite scroll
- HTML sanitization of article content
- PWA / offline support
- Per-user settings + themes
- Admin, invites, and multi-user management
The phase that leans into what made the old web good. Each item has its
spec in docs/design-specs.
- Sharing and shared items (SPEC-019):
a proper share button, Google-Reader-style shared items with notes, an
opt-in public linkblog at
/u/youthat is itself an Atom + JSON feed others can subscribe to, and a Community view of what others on your instance share. - Public blogrolls (SPEC-020): a "who I read" page with an importable OPML twin.
- Realtime delivery via WebSub (SPEC-021): new posts arrive in seconds when a feed offers a hub; polite polling otherwise.
- Attention levels (SPEC-022): mark a feed Skim (no unread guilt, ever) or Must read (never miss a post).
- Follow the new social web too (SPEC-023):
paste a Mastodon, Bluesky or Medium profile, or a
@user@instancehandle, and it just subscribes; their pages were feeds all along. - Link-rot armor (SPEC-024): starring keeps a readable copy of the page, every article has a Wayback Machine link for pages that already died, and admins can set how long to keep old articles (starred and shared ones are always kept).
- Saved searches and rules (SPEC-025): save a search, scope and all, to the sidebar, and set rules that mark read or star new articles as they arrive ("title contains sponsored: mark it read"), with a button to run a rule over older articles too.
- IndieWeb identity (SPEC-026):
add your website, a photo and your other profiles, and your public
pages carry a proper
h-cardwithrel="me"links (so Mastodon can show them as verified), shares marked up as bookmarks, and blogroll discovery.
Every spec in docs/design-specs is built. New work
starts as a new spec there.