- Add providerIds (google/goodreads/amazon/hardcover/openLibrary) to BookDetail type, DTO, service save/load - Expose provider ID fields in edit metadata form with editable inputs - Auto-save provider ID on metadata apply from search drawer - Polished metadata search: skeleton loaders, provider-colored pills/badges, result cards, diff panel - Fix: provider ID only injected in patch when user has selected fields
ProjectX
A book and library management app with Kobo device support. Multi-user, self-hostable.
Tech stack: NestJS 11 (Fastify) + Vue 3 + PostgreSQL + Drizzle ORM
Quick Start
Prerequisites
| Tool | Version | Install |
|---|---|---|
| Node.js | >= 24 | nodejs.org |
| pnpm | >= 9 | corepack enable && corepack prepare pnpm@latest --activate |
| Docker | latest | docker.com |
Setup
git clone <repo-url> projectx
cd projectx
docker compose up -d --wait # start Postgres
cp server/.env.example server/.env # create env config (edit if needed)
pnpm install # install all dependencies
cd server && pnpm db:migrate && cd .. # apply database migrations
Docker is only used for PostgreSQL. The server and client run directly on your machine.
Start developing
pnpm dev
This starts the NestJS server and Vue client concurrently:
- Client: http://localhost:5173
- API: http://localhost:3000/api
If you prefer separate terminals (cleaner logs):
# Terminal 1
cd server && pnpm start:dev
# Terminal 2
cd client && pnpm dev
Both must be running. The client proxies all /api and /socket.io requests to the server, so API calls will fail if only the client is running.
First steps after setup
- Log in - a default admin account is created automatically:
- Username:
admin - Password:
admin
- Username:
- Change password - you'll be prompted to set a new password on first login. Use
Admin123!for local dev. - Create a library - open Settings (gear icon) > Libraries > Create Library. Add a folder path pointing to some books on your machine, e.g. any directory containing
.epub,.pdf, or.cbzfiles. - Scan - the library scans automatically after creation. You should see books appear on the home page once the scan completes.
Where does data go?
BOOKS_PATHinserver/.envdefaults to../local/data(resolves to<project-root>/local/data). This is the app's data directory where extracted cover images and thumbnails are stored (local/data/covers/). It is not where your book files live - those paths are configured per-library in the UI. Thelocal/folder is gitignored.
Project Structure
projectx/
├── client/ Vue 3 frontend (Vite + Tailwind CSS v4)
├── server/ NestJS 11 backend (Fastify + Drizzle ORM)
├── packages/
│ └── types/ Shared TypeScript types (@projectx/types)
├── docker/
│ └── postgres/ Postgres init scripts (extensions)
├── local/ Local dev data (covers, docs) - gitignored
├── docker-compose.yml Dev: runs Postgres only (app runs natively)
├── Dockerfile Production multi-stage build
└── Dockerfile.dev Runs entire stack in Docker (not used for normal dev)
Monorepo layout
This is a pnpm workspace. The three packages are:
server/- NestJS API. Modules live insrc/modules/, one folder per feature. Database schema is insrc/db/schema/.client/- Vue 3 SPA. Feature code lives insrc/features/, shared components insrc/components/ui/.packages/types/- Shared types imported as@projectx/typesby both server and client. Add any type that crosses the API boundary here.
Common Commands
All commands run from the repo root unless noted otherwise.
Development
| Command | Description |
|---|---|
pnpm dev |
Start server + client (concurrently) |
cd server && pnpm start:dev |
Server only (watch mode) |
cd client && pnpm dev |
Client only (Vite dev server) |
Testing
| Command | Description |
|---|---|
cd server && pnpm test |
Server unit tests |
cd server && pnpm test -- --testPathPattern=scan |
Run matching server tests |
cd client && pnpm test:unit |
Client unit tests |
cd client && pnpm test:unit -- BookCover |
Run matching client tests |
cd server && pnpm test:e2e |
Server E2E tests |
Database
| Command | Description |
|---|---|
cd server && pnpm db:migrate |
Apply pending migrations |
cd server && pnpm db:generate <name> |
Generate a migration from schema changes |
cd server && pnpm db:studio |
Open Drizzle Studio (DB browser) |
Linting & formatting
| Command | Description |
|---|---|
cd server && npx prettier --write . |
Format server code |
cd client && npx prettier --write . |
Format client code |
cd server && pnpm lint |
Lint server |
cd client && pnpm lint |
Lint client |
Development Workflow
Once pnpm dev is running, here's how the feedback loop works for each type of change:
Frontend (client/)
Save a file → Vite HMR hot-swaps the module → browser updates instantly. No restart needed.
Backend (server/)
Save a file → NestJS watch mode (SWC) recompiles and restarts the server automatically. Takes about a second.
Shared types (packages/types/)
The types package points directly at .ts source files. Edit a type → both client and server pick it up on their next rebuild cycle. No build step needed.
Database schema
Schema changes are the one thing that isn't automatic. See Database Workflow for the generate + migrate steps.
Environment variables
The start:dev script reads server/.env once at startup. If you change a value in .env, you must restart pnpm dev for it to take effect.
After git pull
If other people have pushed changes, you may need to sync dependencies and migrations:
pnpm install # pick up lockfile changes
cd server && pnpm db:migrate # apply any new migrations
Adding a dependency
Always install into the correct workspace, not the root:
cd server && pnpm add <pkg> # backend dependency
cd client && pnpm add <pkg> # frontend dependency
Adding a new feature
The fastest way to learn the patterns is to follow an existing module:
- Backend: Look at
server/src/modules/bookmark/for a minimal example (controller, service, repository, DTO, module). Copy it, rename, and adapt. - Frontend: Look at
client/src/features/collection/for a full feature (composables for state/API, components for UI). - Shared types: If the feature adds API request/response shapes, add them in
packages/types/src/and import via@projectx/types.
Environment Variables
Server environment is configured in server/.env (created from .env.example during setup).
| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
postgres://projectx:projectx@localhost:5432/projectx |
PostgreSQL connection string |
PORT |
3000 |
Server port |
NODE_ENV |
development |
Environment mode |
JWT_SECRET |
change-me-in-production |
JWT signing secret |
JWT_EXPIRES_IN |
15m |
Access token lifetime |
JWT_REFRESH_EXPIRES_IN |
7d |
Refresh token lifetime |
BOOKS_PATH |
../local/data |
App data directory for cover images (not where books live) |
SMTP_* |
(empty) | Optional SMTP config for password-reset emails |
APP_URL |
http://localhost:5173 |
Client URL (used in emails) |
Database Workflow
The database schema is defined in server/src/db/schema/ using Drizzle ORM. Each domain has its own schema file (e.g. books.ts, libraries.ts, auth.ts).
Making schema changes:
- Edit the relevant file in
server/src/db/schema/ - Generate a migration:
cd server && pnpm db:generate describe_your_change - Apply it:
cd server && pnpm db:migrate
Never hand-write migration SQL. Always generate from schema diffs.
Resetting the database from scratch
# 1. Drop and recreate the public schema
docker exec $(docker ps --filter "name=postgres" --format "{{.Names}}" | head -1) \
psql -U projectx -d projectx -c "DROP SCHEMA public CASCADE; CREATE SCHEMA public;"
# 2. Clear Drizzle's migration tracking (it lives in a separate schema)
docker exec $(docker ps --filter "name=postgres" --format "{{.Names}}" | head -1) \
psql -U projectx -d projectx -c "DELETE FROM drizzle.__drizzle_migrations;"
# 3. Re-apply all migrations
cd server && pnpm db:migrate
# 4. (Optional) Wipe cover images
rm -rf local/data/covers
Step 2 is necessary because Drizzle tracks applied migrations in drizzle.__drizzle_migrations, which lives outside the public schema and survives the drop.
Architecture Overview
┌──────────────┐ ┌──────────────────────┐ ┌────────────┐
│ Vue 3 SPA │──────>│ NestJS API (/api) │──────>│ PostgreSQL │
│ port 5173 │<──────│ port 3000 │<──────│ port 5432 │
└──────────────┘ └──────────────────────┘ └────────────┘
│ │
│ WebSocket │ Drizzle ORM
│ (socket.io) │ (schema in src/db/schema/)
└────────────────────────┘
- Backend: NestJS 11 on Fastify. Global prefix
/api. Auth via JWT (access + refresh tokens in httpOnly cookies). RBAC with permissions system. - Frontend: Vue 3 Composition API (
<script setup>). Tailwind CSS v4 for styling. Feature-local state in composables, not global stores. - Database: PostgreSQL 16 via Drizzle ORM. Migrations generated by Drizzle Kit.
- Shared types:
@projectx/typespackage ensures API contracts stay in sync.
Troubleshooting
Port 5432 already in use
Another PostgreSQL instance is running. Either stop it or change the port mapping in docker-compose.yml:
ports:
- "5433:5432" # use 5433 on host
Then update DATABASE_URL in server/.env to use port 5433.
Port 3000 or 5173 already in use
Kill the process occupying the port:
lsof -ti:3000 | xargs kill -9 # server port
lsof -ti:5173 | xargs kill -9 # client port
pnpm db:migrate does nothing after a schema change
You need to generate a migration first:
cd server && pnpm db:generate describe_your_change
cd server && pnpm db:migrate
pnpm db:generate fails with a connection error
Drizzle Kit auto-loads server/.env for the DATABASE_URL. If the file is missing or the URL is wrong, generation fails. Verify server/.env exists and that PostgreSQL is running (docker compose ps).
The fallback URL in drizzle.config.ts uses postgres:5432 (the Docker service hostname), which only works inside Docker, not from the host machine.
Docker container won't start
docker compose down
docker compose up -d --wait
If the volume is corrupted, wipe it (destroys all data):
docker compose down -v
docker compose up -d --wait
cd server && pnpm db:migrate
Cannot find module '@projectx/types'
The shared types package needs to be built or the workspace link is broken:
pnpm install
CORS errors in the browser
Make sure you're accessing the client at http://localhost:5173 (not port 3000). The Vite dev server proxies API requests to the backend automatically.
Further Reading
| Doc | What it covers |
|---|---|
server/README.md |
Backend module map, DB commands, NestJS conventions |
client/README.md |
Frontend project layout, IDE setup, Vue/Tailwind conventions |
packages/types/ |
Shared type definitions between server and client |