From a Bookmark Pile to a Private Read-Later Reader: A Build Journal

learninglearningnextjssupabasedocs

Table of Contents

  1. The Starting Point
  2. The Vision and Its Decisions
  3. Saving and Reading Articles
  4. The Isolation Trap
  5. Highlights, Trash and Saving from Anywhere
  6. Collections, Kinds and Bulk Actions
  7. Importing from Raindrop
  8. Following a Few Friends
  9. Sharing, Sending and Exploring
  10. RSS Feeds Without a Queue
  11. Gotchas and Lessons Learned
  12. Where We Are Now

1. The Starting Point

My bookmarks were a pile. Links in the browser, links in Raindrop, tabs I meant to read "later", and a read-it-later service whose maker had stopped developing it. I wanted three things: save a page and read it again in peace, find it again by what it says, and pass the good ones to a few people I know.

The open-source project Omnivore does most of this. I tried to run it locally and gave up: it needs many services, pre-built images and a long list of environment variables. It is also AGPL-licensed, and my repositories are public, so copying code was never an option. I took the ideas (reader view, highlights with notes, labels, saved filters, full-text search) and wrote everything myself.

The place to build it already existed. My private e-book reader, cognoscimus, has invitation-only accounts, row-level security, a storage quota and a way to share books. A second app would have rebuilt all of that.

TechnologyRole
Next.js 16 (App Router)One app, routes /library and /later
SupabaseAccounts, Postgres, row-level security
linkedom + ReadabilityTurning a web page into readable text
fast-xml-parserReading RSS and Atom feeds

2. The Vision and Its Decisions

The shape I wanted: one account, two parts. The library for books, Later for articles and links. Later should stay small: no queue, no GraphQL, no AI extras until I miss them.

QuestionDecisionWhy
New app or part of cognoscimus?Part of itAccounts, quota and sharing already exist
Where does the code live?Own folders (app/later, libs/later)A later split stays possible
Who may write article text?Only the serverA client must not put its own HTML into the database
Headless browser for fetching?No, linkedom + ReadabilityLight, no queue. Pages built only by JavaScript are missed
Sharing an article?A copyWeb content is not a loan like a book
Who can see what I save?Nobody, unless I shareThe repository is public, so no public links either
Browser ──► /later (Next.js)
               │ saves a link          reads, marks, orders
               ▼                              ▲
        /api/later/save ──► fetch ──► extract ──► Postgres (RLS)
               (checks the address)    (text only)

3. Saving and Reading Articles

The first version could do four things: save a link, show it in a reader, archive it, and find it again.

Saving is a server route. It downloads the page, cuts out the article with Mozilla's Readability, sanitises the HTML and stores it. Fetching other people's URLs from a server is dangerous (a link to http://169.254.169.254 asks the cloud provider for secrets), so every hop is checked:

for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
  await assertPublicHttpUrl(url);   // refuses private and internal addresses
  const response = await fetch(url, {
    redirect: 'manual',             // follow redirects by hand
    signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
  });
  if (response.status >= 300 && response.status < 400) {
    url = new URL(response.headers.get('location')!, url);
    continue;                       // the next hop is checked again
  }
  // ...
}

📖 Server-side request forgery

The table later_items holds the text. The columns a browser may change are limited to reading state and progress with a column-level grant, so even a signed-in user cannot write their own HTML into it. Search is Postgres full-text search over a generated tsvector column. Labels disappear on their own when the last article using them is gone (a trigger).

📖 PostgreSQL column privileges


4. The Isolation Trap

The e-book reader needs cross-origin isolation (a browser mode for WASM), and the server sends Cross-Origin-Embedder-Policy: require-corp for every page. In that mode the browser blocks every image from another site that does not opt in. Articles are full of such images.

SymptomCauseFix
Article pictures missingrequire-corp on every documentServe /later without it
Clicking from Later to the library breaks WASMIsolation is decided per documentUse full page loads (<a href>) between the two parts

The second row cost me time: client-side routing keeps the old document, so the library opened without isolation. Between /later and the rest the app now always loads a fresh page.

📖 Cross-Origin-Embedder-Policy on MDN


5. Highlights, Trash and Saving from Anywhere

Highlights need an anchor that survives a reload. I store the quoted text plus a little text before and after it (prefix and suffix) and search for that combination when the article opens, so the highlight finds its place again without depending on exact character offsets.

Trash. Deleting an article sets deleted_at. It can be restored, and the next save sweeps out whatever has been in the trash for more than 30 days. A mistaken click no longer costs an article.

Saving from anywhere. On a phone the share sheet hands over a link, but Android often puts it in the text field, not url. A small function picks the first web address from url, text or title, and the same page serves a bookmarklet for desktop browsers.


6. Collections, Kinds and Bulk Actions

Collections nest like folders, an article is in exactly one, and labels stay as the cross-cutting order. Moving a collection into its own child would close a loop, so a database trigger walks up the parents and refuses it:

IF EXISTS (
  WITH RECURSIVE ancestors AS (
    SELECT id, parent_id FROM later_collections WHERE id = NEW.parent_id
    UNION
    SELECT c.id, c.parent_id FROM later_collections c
      JOIN ancestors a ON c.id = a.parent_id
  )
  SELECT 1 FROM ancestors WHERE id = NEW.id
) THEN
  RAISE EXCEPTION 'a collection cannot move into itself';
END IF;

📖 Recursive queries (WITH RECURSIVE)

Kinds. Treating every page as an article produced garbage for shop pages and GitHub repositories, or the error "no readable content". Each item now has a kind: article, link, video or document. The decision runs in this order, and the first match wins:

  1. The address (a PDF ending, YouTube, GitHub, a shop)
  2. The page's og:type (video, product)
  3. Readability finds nothing, or fewer than 120 words: it is a link

Links keep their description searchable, and a button turns one into an article later. PDFs are not downloaded at all.

Bulk actions and saved views. A "Select" mode adds checkboxes and a bar (archive, move, label, trash). The ids go to the database in chunks of 100 so the request address stays short. Filters can be saved as named views.


7. Importing from Raindrop

My own pile had to come in. The import reads Raindrop's HTML export. Tags become labels. Folders become real nested collections (the default), or labels like Coding/Python, or are ignored. Existing collections are matched by parent and lower-case name, and only newly added links are filed, so running the import twice changes nothing.

Nothing is fetched during the import. The links go in as pending entries, and the page loads their text afterwards, three at a time, deciding the kind as it goes.


8. Following a Few Friends

The audience is about ten people I know. That allowed a strict model:

  • A profile with a unique username. Profiles are private by default. They cannot be listed, only found by the exact name, and only if the owner turned "discoverable" on.
  • A follow is a request. The followed person accepts, declines or blocks.
  • "No such name" and "not discoverable" answer the same on purpose, so nobody can probe who has an account.

The rules sit in the database, not in the app. A follow is written only by a function, and the followed person may change nothing but status:

REVOKE INSERT, UPDATE ON public.follows FROM authenticated;
GRANT UPDATE (status) ON public.follows TO authenticated;

A blocked person sees no row at all, and cannot remove the block by deleting the follow. I tested this with throwaway users: a stranger sees only their own profile, a direct insert is denied, accepting your own request is denied.

The username moved to the account page once I noticed that the library will want to follow people too. One name for the whole app.


9. Sharing, Sending and Exploring

Sharing. An article is private or followers, and a collection can be shared with everything below it. Followers get no table access. They read through functions that return only what was shared, and only to accepted followers. Whether a collection is shared is decided by walking up its parents:

WITH RECURSIVE chain AS (
  SELECT id, parent_id, shared FROM later_collections WHERE id = p_id
  UNION
  SELECT c.id, c.parent_id, c.shared
  FROM later_collections c JOIN chain ON c.id = chain.parent_id
)
SELECT COALESCE(bool_or(shared), false) FROM chain;

Sending. I can send one article to a username. It waits as a snapshot until the recipient opens it and decides. Nothing reaches their library unasked, and the sender cannot tell whether the offer is still open.

Exploring. My first version showed followers a list of titles after two clicks, with no context. The production test said it all: you cannot decide what to read from a bare title. The second version is a feed of everything shared with me, with kind, source, excerpt and the owner's note. Each item can be opened, its source visited, and saved to my own library.

LessonWhat I changed
A title alone is not enoughKind, source, excerpt and note on every card
Links without context are uselessEvery item can carry a note
Receiving is not acceptingOpen first, then Save or Delete
Typing a username is clumsyFollowed people appear as suggestions

10. RSS Feeds Without a Queue

Feeds need something that runs on a schedule. The usual answer is a queue and a worker. For ten people that is too much. I use a timer on the server that calls a protected route every 15 minutes. The route fetches at most 20 due feeds and files the new entries.

New entries wait in their own stream instead of the inbox, so a busy feed cannot bury my reading list. Saving one turns it into a normal article.

Care went into the unfriendly cases:

  • Requests are conditional (ETag, If-Modified-Since), so unchanged feeds cost almost nothing.
  • Every redirect passes the same address check as saving an article.
  • A new feed offers only its newest 20 entries.
  • A failing feed waits twice as long after each failure, up to a day, and pauses itself after ten:
next_check_at: minutes(Math.min(feed.check_every_minutes * 2 ** errors, 1440)),
active: errors < FEED_MAX_ERRORS,

📖 HTTP conditional requests

I tested with real feeds, with an address that points to a page (the feed is found through its <link rel="alternate">), and with 127.0.0.1, which is refused.


11. Gotchas and Lessons Learned

AreaGotchaFix
HeadersPictures from other sites blocked by require-corp/later without isolation, full page loads between parts
SSRFA redirect can point inside the networkCheck every hop, redirect by hand
BulkA long in(...) list breaks the request addressChunks of 100
Bulk upsertRows with different columns become NULLEvery row carries the same columns
ImportsParent folders must exist before childrenResolve by parentId plus lower-case name
Privacy"Not found" vs "not discoverable" leaks accountsSame answer for both
LockfileA direct dependency clashed with a workspace overrideUse the same version range as the override
GitA stale index.lock blocked git addDelete it once no git process runs
Tests33 failures outside Later were already thereVerified against the committed state, reported, left alone

Two habits paid off. Rules live in the database (grants, row-level security, functions), so a mistake in the app cannot leak data. And each phase was tested with throwaway users before I trusted it, then used for real on production, where the feedback changed the design twice.


12. Where We Are Now

What's Done

PartStatusKey outcome
Save and readDoneReadable text, search, archive, labels
Highlights and trashDoneAnchors survive reloads, 30 days to restore
Collections and kindsDoneNested folders, article or link, bulk actions
Raindrop importDoneFolders become collections, safe to repeat
People and sharingDoneFollow requests, sharing, sending, explore feed
RSS feedsDoneScheduled fetch, own stream, OPML import

What's Next

  • A newsletter or mail inbox: email an article to myself
  • Notice when a saved article changes
  • Follow people in the library too, and send a whole collection
  • Browser extension, export and text to speech

Current Project Structure

cognoscimus/
  apps/web/src/
    app/later/          list, reader, people, explore, feeds
    app/api/later/      save, import, refetch, recheck, feeds
    libs/later/         fetching, extraction, kinds, social, feeds
  supabase/migrations/  0008 later ... 0020 later_feeds

The source is on GitHub, and the project page has the feature list.