AI Disclosure: This app was heavily vibe coded with Claude Code over a weekend to see what Claude was all about.
A self-hosted reading tracker with a Node.js/Express API, SQLite storage, and a React frontend.
- Year-based reading dashboard per user
- Sections for Finished, Currently Reading, and Want to Read
- Multi-user support with admin panel
- Public shareable profile pages (
/u/:username) - Reading goal + pace tracking
- Cover lookup from Google Books and Open Library
- Book stats (all-time totals, top authors, top genres)
- Drag-and-drop reordering of the Want to Read list
- SQLite persistence (
data/books.db)
- Backend: Node.js, Express, better-sqlite3, express-session, bcryptjs
- Frontend: React + Vite
- Database: SQLite
- Node.js 20+
- npm 10+
- Install dependencies:
npm install- Configure environment:
cp .env.example .env
# edit .env with your values- Start development mode (API + React dev server):
npm run dev- Open:
http://localhost:5173
The API runs on http://localhost:8000 in dev.
On first startup the server seeds an admin account from your .env values:
- Username: value of
USERNAME - Password: value of
APP_PASSWORD
Log in at the root URL (/). After the initial seed those env vars are no longer used for authentication — passwords are stored as bcrypt hashes in the database.
Admins can manage users at /admin:
- Add users — set a username, password, and optional admin flag
- Reset passwords — set a new password for any user
- Delete users — removes the user and all their books
Each user has a completely isolated reading list, goals, and stats.
Every user has a public, read-only profile at:
/u/:username
/u/:username/year/:year
Private books are hidden from public views. Copy your public link from the user menu (share icon) in the header.
- Build frontend:
npm run build- Start server:
npm run startYou can use a process manager like PM2, a native systemd service, or Docker to keep the app running in the background.
Option A: Docker
Requires Docker with the Compose plugin.
docker compose up --build -dTo redeploy after pulling changes:
docker compose up --build -dThe --build flag rebuilds the image (including the frontend build step) and restarts the container. The database is persisted via a bind mount to ./data/books.db on the host.
Option B: Using PM2
# Install PM2 globally
npm install pm2 -g
# Start the app with PM2 (uses ecosystem.config.js)
pm2 start
# Save the PM2 config
pm2 save
# Set PM2 to start on system reboot
pm2 startupOption C: Using systemd
Create a service file:
sudo nano /etc/systemd/system/reading-challenge.serviceAdd the following (adjust User and paths to match your setup):
[Unit]
Description=Reading Challenge App
After=network.target
[Service]
User=pi
WorkingDirectory=/home/user/reading-challenge
ExecStart=/usr/bin/node /home/user/reading-challenge/server/index.js
Environment=NODE_ENV=production
Restart=always
[Install]
WantedBy=multi-user.targetEnable and start the service:
sudo systemctl enable reading-challenge
sudo systemctl start reading-challenge- Set up a reverse proxy (optional)
To access your app over HTTPS with a domain name:
- Point a domain or subdomain (e.g.
books.yourdomain.com) to your server's IP. - If hosting at home, set up port forwarding on your router for ports 80 and 443.
- Use a reverse proxy such as Nginx Proxy Manager or Cloudflare Tunnels.
- Point incoming traffic for your domain to
http://<your-server-ip>:8000.
In production, Express serves the built React app from client/dist.
| Variable | Description | Required |
|---|---|---|
SECRET_KEY |
Session cookie signing key | Yes |
APP_PASSWORD |
Initial admin password (first-run seed only) | Yes (first run) |
USERNAME |
Initial admin username (first-run seed only) | Yes (first run) |
GOOGLE_BOOKS_API_KEY |
Google Books API key for cover lookup | No |
PORT |
Backend port (default 8000) |
No |
CLIENT_ORIGIN |
Dev CORS origin (default http://localhost:5173) |
No |
APP_PASSWORD and USERNAME are only used to seed the first admin account. Once the users table is populated they have no effect.
All reading data is in data/books.db:
cp data/books.db books-backup-$(date +%Y%m%d).db