# How to Add a Reading List Organizer to Your App — Copy-Paste Prompts

- Tool: App Features
- Last updated: July 2026

## TL;DR

A reading list organizer needs one Supabase table (reading_list), three-state status tabs (Want to Read / Reading / Finished), a bookmark toggle with optimistic UI, scroll-based read progress tracking, and bulk actions for finished items. With Lovable or V0 you can ship a working reading list in 2–4 hours for $0–$25/month. No external APIs are required for the core feature.

## What a Personal Reading List Organizer Actually Is

A reading list lets users save articles or links and track what they have read, are currently reading, or plan to read. It is one of the most requested productivity features for content-heavy apps: news readers, learning platforms, research tools, and blog aggregators all benefit from it. The core is a single Supabase table with a status enum and a percent_read integer. The UI is three status tabs filtering the same list. AI tools generate the tab UI and bookmark toggle quickly; the tricky parts are scroll progress tracking (which fires on every scroll event and must be debounced), the upsert-on-duplicate-URL logic (saving the same article twice should update rather than insert), and the bulk action bar for selecting multiple items at once.

## Anatomy of the Feature

Seven components build a complete reading list organizer. The scroll progress tracker and the upsert-on-duplicate logic are where first builds most often break.

- **Save Button / Bookmark Toggle** (ui): An icon button (bookmark-outline when unsaved, bookmark-filled when saved) embedded in content cards and article page headers. On click, triggers a Supabase INSERT into reading_list with status='want_to_read'. Optimistic toggle: local UI state flips immediately, with rollback if the Supabase call returns an error. Unsave triggers a DELETE on the reading_list row.
- **Reading List Sidebar / Page** (ui): A full-list view with three tabs at the top (Want to Read, Reading, Finished) implemented with shadcn/ui Tabs or React state. Switching a tab filters the Supabase query by status without a page reload. Each list item shows the article title, domain favicon, estimated read time, status badge, and a thumbnail. A sort dropdown above the list (newest, oldest, shortest, longest read time) updates the Supabase query ORDER BY clause.
- **Status Selector** (ui): A three-state toggle per item (react-aria ToggleGroup or shadcn/ui RadioGroup styled as a segmented control) showing Want to Read, Reading, Finished. Selecting a new status fires a Supabase UPDATE on the reading_list row and animates the item out of the current tab if the new status differs. Setting status to 'finished' also writes finished_at = now().
- **Reading Progress Tracker** (data): Stores the user's scroll position as percent_read integer (0–100) in the reading_list table. A scroll event listener on the article page fires a debounced (lodash debounce, 2 seconds) Supabase UPDATE on every scroll pause. Also fires on Page Visibility API visibilitychange (tab hidden) and beforeunload to flush the latest progress before the user leaves.
- **Estimated Read Time Calculator** (ui): A client-side calculation applied once at save time: (wordCount / 200) minutes, where wordCount is the number of words in the article body text. Stored in the read_time_minutes column. The word count is taken from the article body rendered in the current page, not from fetched HTML — avoids counting nav and footer text.
- **Reading List Table** (data): A single Supabase table with all the fields needed for the reading list: id, user_id, url, title, thumbnail_url, domain, read_time_minutes, status (want_to_read / reading / finished), percent_read, added_at, and finished_at. A UNIQUE constraint on (user_id, url) enables the upsert pattern. RLS restricts all operations to the owning user via auth.uid().
- **Bulk Actions Bar** (ui): A sticky bar that appears at the bottom of the reading list when one or more items have their checkboxes checked. Shows the count of selected items and two actions: 'Mark as Finished' (batch Supabase UPDATE setting status='finished' and finished_at=now() for all selected ids) and 'Delete Selected' (batch Supabase DELETE with a confirmation toast before execution). Clears selection after action completes.

## Data model

One table covers the complete reading list feature with all tracking fields. Run this in the Supabase SQL Editor — it is copy-paste ready.

```sql
create table public.reading_list (
  id uuid primary key default gen_random_uuid(),
  user_id uuid references auth.users(id) on delete cascade not null,
  url text not null,
  title text,
  thumbnail_url text,
  domain text,
  read_time_minutes int default 0,
  status text not null check (status in ('want_to_read', 'reading', 'finished')) default 'want_to_read',
  percent_read int not null default 0 check (percent_read >= 0 and percent_read <= 100),
  added_at timestamptz not null default now(),
  finished_at timestamptz,
  constraint reading_list_user_url_unique unique (user_id, url)
);

alter table public.reading_list enable row level security;

create policy "Users can view own reading list"
  on public.reading_list for select
  using (auth.uid() = user_id);

create policy "Users can insert own reading list"
  on public.reading_list for insert
  with check (auth.uid() = user_id);

create policy "Users can update own reading list"
  on public.reading_list for update
  using (auth.uid() = user_id);

create policy "Users can delete own reading list"
  on public.reading_list for delete
  using (auth.uid() = user_id);

create index reading_list_user_status_idx
  on public.reading_list (user_id, status, added_at desc);

create index reading_list_user_finished_idx
  on public.reading_list (user_id, finished_at desc);
```

The UNIQUE constraint on (user_id, url) enables the upsert pattern: INSERT ... ON CONFLICT (user_id, url) DO UPDATE SET title=EXCLUDED.title, thumbnail_url=EXCLUDED.thumbnail_url, status='want_to_read'. This prevents duplicate rows and refreshes stale metadata if the user saves the same URL twice.

## Build paths

### Lovable — fit 5/10, 2–3 hours

Lovable auto-wires the reading_list table, auth, and RLS via Lovable Cloud. Status tabs and bookmark toggle are common patterns Lovable handles in one or two prompts. Best all-round path for this feature.

1. Create a new Lovable project with Lovable Cloud enabled so the reading_list table and auth are provisioned; run the data model SQL in the Supabase SQL Editor to add the UNIQUE constraint and indexes
2. Paste the prompt below and let Agent Mode build the reading list page, status tabs, bookmark toggle, and scroll progress tracker
3. Test scroll progress tracking on the published URL — the scroll listener must be on the article body element, which behaves differently in the Lovable preview pane
4. Use Design Mode to refine the empty-state illustrations per tab and the bulk action bar appearance without spending credits
5. Click Publish and verify that saving the same URL twice triggers the upsert (no duplicate rows in the Supabase Table Editor)

Starter prompt:

```
Build a personal reading list organizer. Use Supabase table 'reading_list' with fields: id, user_id, url text, title text, thumbnail_url text, domain text, read_time_minutes int, status text CHECK(status IN ('want_to_read','reading','finished')) DEFAULT 'want_to_read', percent_read int DEFAULT 0, added_at timestamptz DEFAULT now(), finished_at timestamptz. Add UNIQUE(user_id, url). Build: (1) a bookmark toggle button on every content card — filled when saved, outlined when not — that calls INSERT ... ON CONFLICT (user_id, url) DO UPDATE SET title=EXCLUDED.title on save and DELETE on unsave, with optimistic UI and rollback on error, (2) a /reading-list page with three tabs (Want to Read, Reading, Finished) using shadcn/ui Tabs — switching tabs filters by status without a page reload, tab labels show item counts derived from the fetched list, (3) a status segmented control per item (Want to Read / Reading / Finished) that fires Supabase UPDATE on change and sets finished_at=now() when status changes to 'finished', animates item out of current tab with fade-out, (4) estimated read time calculated as wordCount / 200 at save time stored in read_time_minutes — show 'less than 1 min' for 0 minutes, (5) a scroll progress tracker on article pages using a debounced scroll event listener (lodash debounce 2000ms) that fires Supabase UPDATE on percent_read — also flush on Page Visibility API visibilitychange and on route change — read initial percent_read from DB on article mount and scroll to that position, (6) a progress bar on each list item card showing percent_read, (7) multi-select with checkboxes per item — bulk actions bar appears showing count selected with 'Mark as Finished' (batch UPDATE) and 'Delete Selected' (DELETE with confirmation toast) buttons — use WHERE id = ANY($1) array for batch operations, (8) sort dropdown above list (newest, oldest, shortest, longest) updating the query ORDER BY, (9) empty state per tab with distinct illustration and message.
```

Limitations:

- Scroll progress tracking requires a persistent scroll listener on the article body element — test on the published URL where the article page renders at full height rather than inside the Lovable preview pane
- V0-quality polished UI achievable through Design Mode iteration, but the initial Lovable output may need typography and spacing refinement
- Lovable does not auto-generate the upsert ON CONFLICT clause by default — verify in Supabase Table Editor that saving the same URL twice updates rather than creates a duplicate row

### V0 — fit 4/10, 2–4 hours

V0 produces clean typographic reading list UI with Next.js App Router. Server Actions handle status updates. shadcn/ui Tabs and Checkbox components accelerate the multi-select bulk action UX. Best path when the reading list is part of a larger Next.js content app.

1. Prompt V0 with the spec below; it will generate the ReadingList page, BookmarkToggle, StatusSelector, and BulkActionsBar components
2. Add Supabase credentials in the Vars panel (NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY) and run the data model SQL in your Supabase SQL Editor
3. Verify there are no localStorage references in the generated code — V0 occasionally adds localStorage for reading list state which causes SSR errors in Next.js; Supabase should be the single source of truth
4. Deploy to Vercel and test scroll progress tracking on the live URL with a real article page
5. Confirm the upsert behavior by saving the same article URL twice and checking that only one row appears in the reading_list table

Starter prompt:

```
Build a personal reading list organizer in Next.js App Router. Use Supabase table 'reading_list' (id uuid, user_id uuid, url text, title text, thumbnail_url text, domain text, read_time_minutes int, status text CHECK(status IN ('want_to_read','reading','finished')) DEFAULT 'want_to_read', percent_read int DEFAULT 0, added_at timestamptz, finished_at timestamptz, UNIQUE(user_id, url)). Build: (1) a BookmarkToggle client component — bookmark-outline icon when unsaved, bookmark-filled when saved, that calls a Server Action 'toggleBookmark' doing INSERT ... ON CONFLICT (user_id, url) DO UPDATE SET title=EXCLUDED.title on save and DELETE on unsave, with optimistic state via useOptimistic, (2) /reading-list page with shadcn/ui Tabs for Want to Read / Reading / Finished — tabs filter Supabase query by status, labels show counts computed from the result array, (3) StatusSelector — shadcn/ui RadioGroup per item calling a 'updateStatus' Server Action; animate item out of current tab with Framer Motion fade when status changes, (4) read_time_minutes stored at save time as wordCount / 200 from the article body element's innerText; show 'less than 1 min' for 0, (5) ScrollProgressTracker — client component on article pages using useEffect scroll listener throttled with lodash debounce at 2000ms calling 'updateProgress' Server Action; also flush on visibilitychange and Next.js router events; on mount, read percent_read from Supabase and call window.scrollTo to restore position, (6) progress bar on each list card, (7) bulk select with Checkbox per item; BulkActionsBar appears at bottom when selection > 0 showing 'Mark as Finished' (batch UPDATE via Server Action) and 'Delete Selected' (DELETE with confirmation dialog) using WHERE id = ANY($1), (8) sort dropdown (newest, oldest, shortest, longest read time) passing ORDER BY to the query, (9) empty state per tab with illustration. Do not use localStorage for any reading list state — Supabase is the single source of truth.
```

Limitations:

- V0 does not auto-provision Supabase; set NEXT_PUBLIC_SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in Vercel Vars panel before deploying
- V0 occasionally generates localStorage calls for reading list state causing SSR errors — the prompt explicitly prevents this but review the generated code and remove any localStorage references
- Scroll progress tracking on a deployed Next.js App Router page with nested layouts may require attaching the listener to a specific scrollable element rather than document — test on the live URL

### Custom — fit 3/10, 2–5 days

Custom development is necessary for a browser extension that saves articles from any site outside the app, AI-powered summarization on save, or a social reading list where users follow each other's public lists.

1. Build the core reading list page and Supabase schema (identical to the AI path) — this is the same regardless of whether it is custom or AI-built
2. Add a browser extension (Chrome Manifest v3) with a content script that extracts the article title, domain, and word count from the current page and calls your app's API to save the URL to the reading list
3. Implement AI summarization on save: a Supabase Edge Function that calls the Anthropic claude-haiku-4-5 model with the article body text to generate a 2–3 sentence summary stored in a summary column
4. Add a social layer: a public_reading_lists boolean column, follow relationships (reading_list_follows table), and a Following feed showing friends' recently finished items

Limitations:

- Browser extension requires a separate Manifest v3 project, app store submission, and API key management — adds 2–3 days beyond the reading list itself
- AI summarization via Anthropic claude-haiku-4-5 adds ~$0.80/1M input tokens (approx) — budget for this in the running cost estimate and cache summaries in the DB to avoid re-summarizing the same article

## Gotchas

- **Scroll progress resets to 0 every time the article page re-mounts** — AI tools typically store percent_read in React state, which is reset to 0 every time the component unmounts. On mobile, switching browser tabs or navigating to another page and returning causes the article to re-mount, losing all scroll progress before the Supabase UPDATE fires. The user sees the progress bar reset to empty on every return visit. Fix: Read the initial percent_read value from Supabase on article mount and scroll the window to that position. Flush the current percent_read to Supabase on the Page Visibility API visibilitychange event (document.addEventListener('visibilitychange', flush)) and on any route-change event. Do not rely solely on the scroll event debounce to persist progress — the component may unmount before the debounce fires.
- **V0 generates localStorage calls causing SSR errors in Next.js** — V0's common pattern for persisting reading list state uses localStorage to cache items client-side. In Next.js App Router, any code that accesses window.localStorage during server render throws 'ReferenceError: window is not defined' and breaks the page, including items already saved to Supabase that should render server-side. Fix: Wrap any localStorage access in a typeof window !== 'undefined' guard, or remove localStorage entirely and use Supabase as the single source of truth. Server Components can fetch the reading list from Supabase directly without touching the browser environment. Include 'Do not use localStorage for any reading list state' in your V0 prompt.
- **Saving the same URL twice creates duplicate entries** — AI tools typically generate a plain INSERT without conflict handling. When a user saves an article, unsaves it, and saves it again — or when a content card's bookmark toggle fires twice due to a double-tap — a second INSERT creates a duplicate row with a different added_at timestamp. The user sees two copies of the same article in their list. Fix: Add the UNIQUE constraint on (user_id, url) at the database level. Use INSERT ... ON CONFLICT (user_id, url) DO UPDATE SET title=EXCLUDED.title, thumbnail_url=EXCLUDED.thumbnail_url for every save operation. This upserts the metadata if it has changed and prevents duplicate rows. Always specify this explicitly in the prompt — generic INSERT is the default AI behavior.
- **Status tab count badges show stale numbers after bulk actions** — Tab badges showing 'Want to Read (12)' are often computed from a separate aggregation query run at page load. After the user bulk-marks 5 items as Finished, the Want to Read badge still shows 12 instead of 7 — because the count query was not invalidated after the batch UPDATE. Users think the action failed. Fix: Compute tab counts client-side from the already-fetched list array rather than a separate aggregation query. After any status update or bulk action, update the local array state and recompute counts from it. If using React Query, invalidate the reading_list query key after every mutation so counts recompute from fresh data.
- **Estimated read time shows 0 minutes for paywalled or JavaScript-heavy articles** — Word count is often calculated from the fetched HTML of an external article. For paywalled pages, the HTML contains only the teaser (few words). For JavaScript-heavy pages, the server-fetched HTML is empty — all content loads after JS execution. The result is read_time_minutes stored as 0 or 1 minute for long articles. Fix: Calculate word count from the article body element that is already rendered in the current page (document.querySelector('article').innerText or similar) rather than fetching the external URL again. For truly unknown content, use a fallback estimate (5 minutes) and display it with a tilde (~5 min) to signal it is an approximation.

## Best practices

- Use INSERT ... ON CONFLICT (user_id, url) DO UPDATE for every save operation — duplicate URL rows are the single most common bug in reading list builds
- Flush percent_read to Supabase on visibilitychange and route-change events, not only on debounced scroll — users navigate away before debounces fire on mobile
- Compute tab item counts client-side from the fetched list array rather than separate count queries — avoids staleness and eliminates an extra database round trip
- Show 'less than 1 min' instead of '0 min' for very short articles — 0 minutes reads as a calculation error to users
- Implement the bulk action bar with a WHERE id = ANY($1) batch operation — never loop and fire one request per selected item; a loop with 20 items fires 20 separate database calls
- Restore scroll position on article mount from the stored percent_read value so users can resume reading exactly where they left off
- Add a UNIQUE index on (user_id, url) even if you add the UNIQUE constraint — the index is what makes the ON CONFLICT upsert performant at scale

## Frequently asked questions

### Can I save articles from outside the app?

Not with the standard build — the bookmark button only appears on content within your app. Saving external URLs requires either a browser extension (Chrome Manifest v3 with a popup that calls your API) or a share target registration (Web Share Target API) so users can share any page to your app from the mobile browser share sheet. Both are custom development requirements.

### Does read progress sync across devices?

Yes — percent_read is stored in Supabase and fetched on article mount on every device. When a user reads 60% of an article on their phone and opens it on their laptop, the article scrolls to the 60% position. The sync is as fast as the Supabase read on mount, typically under 200ms on a good connection.

### Can I export my reading list?

Not by default. Adding CSV export means a Supabase query returning all reading_list rows and a client-side CSV serialization using the browser's Blob and URL.createObjectURL APIs — no server needed. Include 'add a CSV export button to the reading list page' in your prompt if this is needed from the start.

### How is estimated read time calculated?

By dividing the article's word count by 200 — the average adult silent reading speed in words per minute. Word count is taken from the visible article body text at save time. For a 1,000-word article this gives 5 minutes. For articles shorter than 200 words the display shows 'less than 1 min'. The result is stored once in read_time_minutes and not recalculated on subsequent visits.

### Can I make my reading list public?

Not with the default build — RLS restricts all reading_list rows to the owning user. To add a public profile with shared reading history, add an is_public boolean column and a Supabase policy: CREATE POLICY 'Public profiles' ON reading_list FOR SELECT USING (is_public = true). Then create a /users/[username]/reading public route. This is a small but separate UI task — include it in your prompt if needed.

### What happens if I save the same article twice?

With the upsert pattern (INSERT ... ON CONFLICT DO UPDATE), saving the same URL a second time updates the title and thumbnail metadata rather than creating a duplicate row. The status and percent_read are not overwritten, so an article you are mid-way through reading stays at its current progress if you accidentally tap save again.

### Can I add notes to saved articles?

Not by default. Add a notes text column to the reading_list table and a notes textarea on the article view page with an auto-saving Supabase UPDATE (debounced 1 second). This pairs naturally with the Dynamic Notes or Digital Notebook feature if you want richer annotation — notes per article paragraph, highlights, and tags.

### How do I delete finished items in bulk?

The bulk actions bar handles this. Check the checkboxes on finished items (or switch to the Finished tab, select all), then tap 'Delete Selected'. The action fires a single Supabase DELETE WHERE id = ANY($1) with all selected IDs in one call — it does not loop. A confirmation toast appears before deletion to prevent accidental data loss.

---

Source: https://www.rapidevelopers.com/app-features/personal-reading-list-organizer
© RapidDev — https://www.rapidevelopers.com/app-features/personal-reading-list-organizer
