A small full-stack reading-list app. Save a link, tag it, search it, and highlight it once you've read it. Built because my "read later" plan was 90 open browser tabs and a bookmarks bar I never opened.
It's deliberately small and finished rather than large and half-done. The interesting parts to talk through are the tag system (a real many-to-many relationship), search, roll-your-own auth, and the test suite.
- Email + password accounts (passwords hashed, sessions are server-side)
- Save a link with an optional title, note, and tags — paste a bare domain and it adds
https:// - Tag links and filter by tag; tags are scoped per user
- Search across titles, notes, and URLs
- Filter by All / To read / Read, with live counts
- Mark links read/unread (read links get a highlighter swipe across the title)
- Edit and delete links
- Each user only ever sees their own data
- Responsive down to mobile; keyboard focus styles; reduced-motion respected
- Backend: Python + FastAPI
- Database: SQLite via Python's built-in
sqlite3module — no ORM, just SQL - Auth:
bcryptpassword hashing + server-side sessions in an httpOnly cookie - Frontend: vanilla JavaScript (no framework, no build step)
- Tests: pytest
The only runtime dependencies are fastapi, uvicorn, httpx, and bcrypt.
Requires Python 3.11 or newer.
pip install -r requirements.txt
python main.py
# open http://localhost:8000Create an account on the sign-in screen and start saving links. The database is
created automatically at data/tabkeep.db.
To run the tests:
pytestThere are 22 tests covering auth, validation, CRUD, tags, search, the read/unread filter, and — importantly — that one user can't read or modify another user's bookmarks.
main.py FastAPI app + all routes. create_app() builds the app without
starting it, so tests can run against an in-memory database.
database.py Opens SQLite, sets pragmas, and defines the schema.
auth.py Password hashing and session management.
public/ The frontend (index.html, styles.css, app.js).
test/ The API test suite.
users ──< bookmarks ──< bookmark_tags >── tags
└──────< sessions
bookmark_tags is the join table that makes the many-to-many relationship
between bookmarks and tags work. Tags are stored per user and pruned when they're
no longer attached to anything.
- Server-side sessions instead of JWTs. A session is a random token stored in the database; logging out deletes the row. That means a session can actually be revoked server-side, which a stateless JWT can't do without extra machinery. For an app like this that's the simpler, safer default.
- 404, not 403, for other people's bookmarks. Asking for a bookmark that isn't yours returns "not found" rather than "forbidden", so the API never even confirms that someone else's bookmark exists.
- Python's built-in
sqlite3. No native dependency, no ORM, no migration framework — just SQL and Python's standard library.check_same_thread=Falseand WAL mode give safe concurrent access without a connection pool. - No frontend framework. The app is small enough that plain DOM rendering is easy to read and has zero build tooling. If it grew, I'd reach for a framework.
- Search uses
LIKE. It's fine at this scale, but the obvious next step is SQLite's FTS5 full-text index for ranked results and better matching. bcryptblocks the thread. Fine for a personal-scale app; under real load I'd move hashing to a thread pool withasyncio.to_thread.- No password reset or email verification yet. The auth is intentionally minimal; these are the next things I'd add for a real product.
- No pagination. Every bookmark loads at once. I'd add cursor-based paging once a pile gets into the thousands.
The database is a file on disk, so deploy somewhere with a persistent disk
(Render, Railway, and Fly all offer this) and point DB_FILE at it. Set
ENV=production so the session cookie is marked Secure. A Procfile is
included.
This setup is not a fit for fully serverless/static hosts (e.g. Vercel), whose filesystem is read-only and ephemeral — the SQLite file wouldn't persist.
