Feature spec
BeginnerCategory
personalization-ux
Build with AI
2–4 hours with Lovable or V0
Custom build
2–5 days custom dev
Running cost
$0–25/mo up to 10K users
Works on
Everything it takes to ship a Personal Reading List Organizer — parts, prompts, and real costs.
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.
What users consider table stakes in 2026
- Save any URL or in-app article with one click — bookmark icon on every content card fills on save and empties on unsave
- Three-state status tracking (Want to Read, Reading, Finished) switchable per item without a page reload
- Estimated read time displayed per item, calculated from word count at save time
- Progress bar per item showing how far the user has read (0–100%) updated as they scroll
- Sort and filter by status, date added, or estimated read time with the filter visible above the list
- Bulk actions bar appearing on multi-select: 'Mark as Finished' and 'Delete Selected' with a confirmation step
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
UIAn 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.
Note: Use INSERT ... ON CONFLICT (user_id, url) DO UPDATE SET title=EXCLUDED.title, thumbnail_url=EXCLUDED.thumbnail_url for the save action — this prevents duplicate rows when users tap save twice or save an article they previously saved and then deleted.
Reading List Sidebar / Page
UIA 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.
Note: Compute the tab item counts client-side from the already-fetched list array to avoid a second count query. Invalidate counts after any status change or bulk action.
Status Selector
UIA 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().
Note: Animate the item removal from the current tab with a fade-out rather than an instant disappear — users need visual confirmation that the item moved correctly.
Reading Progress Tracker
DataStores 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.
Note: The scroll listener must also fire on route change (Next.js router events or React Router useLocation effect) to capture progress when the user clicks a link within the article. Reading initial percent_read from the DB on article mount lets users resume at the last saved position.
Estimated Read Time Calculator
UIA 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.
Note: For paywalled or JavaScript-heavy external articles where the body text is unavailable, fall back to a fixed estimate (e.g. 5 minutes) rather than showing 0. Display '< 1 min' for articles shorter than 1 minute rather than '0 min'.
Reading List Table
DataA 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().
Note: Index on (user_id, status, added_at desc) covers the three most common queries: all items by status sorted newest first. Add a separate index on (user_id, finished_at) if bulk-delete-finished is a common operation.
Bulk Actions Bar
UIA 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.
Note: Use an IN clause with all selected ids for the batch UPDATE/DELETE — do not loop and fire one request per item. Supabase supports WHERE id = ANY($1) with an array of UUIDs for batch operations.
The 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.
1create table public.reading_list (2 id uuid primary key default gen_random_uuid(),3 user_id uuid references auth.users(id) on delete cascade not null,4 url text not null,5 title text,6 thumbnail_url text,7 domain text,8 read_time_minutes int default 0,9 status text not null check (status in ('want_to_read', 'reading', 'finished')) default 'want_to_read',10 percent_read int not null default 0 check (percent_read >= 0 and percent_read <= 100),11 added_at timestamptz not null default now(),12 finished_at timestamptz,13 constraint reading_list_user_url_unique unique (user_id, url)14);1516alter table public.reading_list enable row level security;1718create policy "Users can view own reading list"19 on public.reading_list for select20 using (auth.uid() = user_id);2122create policy "Users can insert own reading list"23 on public.reading_list for insert24 with check (auth.uid() = user_id);2526create policy "Users can update own reading list"27 on public.reading_list for update28 using (auth.uid() = user_id);2930create policy "Users can delete own reading list"31 on public.reading_list for delete32 using (auth.uid() = user_id);3334create index reading_list_user_status_idx35 on public.reading_list (user_id, status, added_at desc);3637create index reading_list_user_finished_idx38 on public.reading_list (user_id, finished_at desc);Heads up: 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 it — pick your path
Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.
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.
Step by step
- 1Create 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
- 2Paste the prompt below and let Agent Mode build the reading list page, status tabs, bookmark toggle, and scroll progress tracker
- 3Test 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
- 4Use Design Mode to refine the empty-state illustrations per tab and the bulk action bar appearance without spending credits
- 5Click Publish and verify that saving the same URL twice triggers the upsert (no duplicate rows in the Supabase Table Editor)
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.Where this path bites
- 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
Third-party services you'll need
The reading list feature requires only Supabase. Optional AI summarization adds Anthropic API costs, but the core feature has no third-party dependencies.
| Service | What it does | Free tier | Paid from |
|---|---|---|---|
| Supabase | Stores reading list items, status, progress, and user auth | Free tier: 500MB DB, 2 projects | Pro $25/mo |
| lodash | Debounce utility for scroll progress event throttling | Open-source MIT license | Free |
| Anthropic API (optional) | Article summarization on save using claude-haiku-4-5 | No free tier; pay-per-use | ~$0.80/1M input tokens (approx) |
Swipe the table sideways to see pricing.
What it costs to run
Drag through the tiers to see how your monthly bill scales with users — no surprises later.
Estimated monthly running cost
Supabase free tier covers reading list storage and auth. No external APIs required for the core feature. Scroll progress and status updates are free Supabase operations.
Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.
What breaks when AI tools build this
The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.
Scroll progress resets to 0 every time the article page re-mounts
Symptom: 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
Symptom: 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
Symptom: 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
Symptom: 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
Symptom: 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
When You Need Custom Development
AI tools handle the core reading list with Supabase well. These use cases require custom work:
- Browser extension to save articles from any website outside the app — requires a separate Chrome Manifest v3 project, a content script for article extraction, and a separate app store submission
- AI summarization or auto-tagging on save using Anthropic or OpenAI API — requires an Edge Function, API key management, and cost per summary to factor into pricing
- Social reading list where users follow each other's public lists and see a friends' reading activity feed
- Offline reading mode with the full article body cached to device storage for reading without connectivity
RapidDev builds these features for production
Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.
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.
Need this feature production-ready?
RapidDev builds a personal reading list organizer into real apps — auth, database, payments — at $13K–$25K.
Book a free consultation30-min call. No commitment.