diff --git a/.dockerignore b/.dockerignore
index e81f3a6..c197e60 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -1,5 +1,4 @@
node_modules
-.next
.git
.gitignore
*.md
@@ -7,10 +6,10 @@ node_modules
*.db-*
.env
.env.*
-!.env.example
-data
.DS_Store
-.vercel
+data
coverage
.vscode
.idea
+.turbo
+.tanstack
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 64ea74a..48bcbff 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -41,7 +41,7 @@ jobs:
- name: Get app version
id: version
- run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT"
+ run: echo "version=$(jq -r .version apps/web/package.json)" >> "$GITHUB_OUTPUT"
- name: Docker metadata
id: meta
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 0d7c711..bd95614 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -18,11 +18,11 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile
- - name: Run tests with coverage
- run: bun test --coverage --coverage-reporter=lcov
+ - name: Lint
+ run: bunx turbo run lint
- # - name: Upload to Codecov
- # uses: codecov/codecov-action@v3
- # with:
- # file: ./coverage/lcov.info
- # fail_ci_if_error: true
+ - name: Type check
+ run: bunx turbo run check-types
+
+ - name: Run tests
+ run: bunx turbo run test -- --coverage --coverage-reporter=lcov
diff --git a/.gitignore b/.gitignore
index 2a4f035..f246a0e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,47 +1,56 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
-# dependencies
-/node_modules
-/.pnp
-.pnp.*
-.yarn/*
-!.yarn/patches
-!.yarn/plugins
-!.yarn/releases
-!.yarn/versions
+# Dependencies
+node_modules
+.pnp
+.pnp.js
-# testing
-/coverage
+# Build outputs
+dist
+build
+*.tsbuildinfo
+.tanstack
-# next.js
-/.next/
-/out/
+# Environment variables
+.env
+.env*.local
+!.env.example
-# production
-/build
-/data
-
-# misc
+# IDEs and editors
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+.idea
+*.swp
+*.swo
+*~
.DS_Store
-*.pem
-# sqlite
-*.db
-*.db-*
-
-# debug
+# Logs
+logs
+*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+lerna-debug.log*
.pnpm-debug.log*
-# env files (can opt-in for committing if needed)
-.env*
-!.env.example
+# Turbo
+.turbo
-# vercel
-.vercel
+# Testing
+coverage
+.nyc_output
-# typescript
-*.tsbuildinfo
-next-env.d.ts
+# Misc
+*.tgz
+.cache
+tmp
+temp
+
+# local data
+*.db
+*.db-*
+/data
diff --git a/.vscode/extensions.json b/.vscode/extensions.json
new file mode 100644
index 0000000..17a9ff2
--- /dev/null
+++ b/.vscode/extensions.json
@@ -0,0 +1,8 @@
+{
+ "recommendations": [
+ "biomejs.biome",
+ "bradlc.vscode-tailwindcss",
+ "vitest.explorer",
+ "vercel.turbo-vsc"
+ ]
+}
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..71c928b
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,41 @@
+{
+ "typescript.tsdk": "node_modules/typescript/lib",
+ "editor.defaultFormatter": "biomejs.biome",
+ "editor.formatOnSave": true,
+ "editor.formatOnPaste": true,
+ "editor.codeActionsOnSave": {
+ "source.fixAll.biome": "explicit",
+ "source.organizeImports.biome": "explicit"
+ },
+ "files.readonlyInclude": {
+ "**/routeTree.gen.ts": true
+ },
+ "files.watcherExclude": {
+ "**/routeTree.gen.ts": true
+ },
+ "search.exclude": {
+ "**/routeTree.gen.ts": true
+ },
+ "emmet.showExpandedAbbreviation": "never",
+ "[javascript]": {
+ "editor.defaultFormatter": "biomejs.biome"
+ },
+ "[typescript]": {
+ "editor.defaultFormatter": "biomejs.biome"
+ },
+ "[javascriptreact]": {
+ "editor.defaultFormatter": "biomejs.biome"
+ },
+ "[typescriptreact]": {
+ "editor.defaultFormatter": "biomejs.biome"
+ },
+ "[json]": {
+ "editor.defaultFormatter": "biomejs.biome"
+ },
+ "[jsonc]": {
+ "editor.defaultFormatter": "biomejs.biome"
+ },
+ "[css]": {
+ "editor.defaultFormatter": "biomejs.biome"
+ }
+}
diff --git a/CLAUDE.md b/CLAUDE.md
index 9487df0..6d4fe56 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -5,14 +5,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Commands
```bash
-bun run dev # Start Next.js dev server
-bun run build # Production build
+# Root commands (via Turborepo)
+bun run dev # Start API server + Vite dev server
+bun run build # Production build (both apps)
bun run lint # Biome lint check
bun run format # Biome format (auto-fix)
-bun run db:push # Push schema changes to SQLite database
-bun run db:generate # Generate Drizzle migration files
-bun run db:migrate # Run Drizzle migrations
-bun run db:studio # Open Drizzle Studio (visual DB browser)
+bun run check-types # TypeScript type check
+bun run test # Run tests
+
+# Database commands (run from packages/db/)
+cd packages/db && bun run db:push # Push schema changes to SQLite database
+cd packages/db && bun run db:generate # Generate Drizzle migration files
+cd packages/db && bun run db:migrate # Run Drizzle migrations
+cd packages/db && bun run db:studio # Open Drizzle Studio (visual DB browser)
```
IMPORTANT: Default to using Bun instead of Node.js:
@@ -39,67 +44,113 @@ bun run test
## Architecture
-**Sofa** is a self-hosted movie & TV tracking app (like Trakt/TVTime) built as a single Next.js 16 application with SQLite.
+**Sofa** is a self-hosted movie & TV tracking app (like Trakt/TVTime) built as a Turborepo monorepo with a standalone Hono API server, a Vite + TanStack Router SPA frontend, and shared packages.
+
+### Monorepo structure
+
+```
+couch-potato/
+├── apps/
+│ ├── server/ # @sofa/server — Hono API server (oRPC, auth, cron, webhooks)
+│ └── web/ # @sofa/web — Vite SPA (TanStack Router, TanStack Query)
+├── packages/
+│ ├── api/ # @sofa/api — oRPC contract + Zod schemas (shared)
+│ ├── auth/ # @sofa/auth — Better Auth server config
+│ ├── core/ # @sofa/core — Business logic services
+│ ├── db/ # @sofa/db — Database client, schema, migrations, constants, logger
+│ └── tmdb/ # @sofa/tmdb — TMDB API client + image URL helper
+├── turbo.json # Turborepo task configuration
+├── biome.json # Shared Biome config
+├── Dockerfile # Multi-stage Docker build (turbo prune, single process)
+└── package.json # Root workspace config
+```
+
+- **`@sofa/server`** (`apps/server/`) — Hono API server. Hosts oRPC procedures, Better Auth, webhook/image/backup routes, and cron jobs. Runs DB migrations on startup. In production, also serves the SPA static files on port 3000. In dev, runs on port 3001.
+- **`@sofa/web`** (`apps/web/`) — Vite SPA with TanStack Router (file-based routing). No SSR, no DB access, no services. All data fetched via oRPC client calls. Vite dev server proxies `/api/*` and `/rpc/*` to the API server.
+- **`@sofa/api`** (`packages/api/`) — JIT package with the oRPC contract and Zod schemas. No build step.
+- **`@sofa/db`** (`packages/db/`) — Database layer: Drizzle schema, client, migrations, constants, logger.
+- **`@sofa/tmdb`** (`packages/tmdb/`) — TMDB API client (openapi-fetch) and image URL construction.
+- **`@sofa/core`** (`packages/core/`) — All business logic services (metadata, tracking, discovery, etc.).
+- **`@sofa/auth`** (`packages/auth/`) — Better Auth server config + environment checks.
+
+All shared packages are JIT (raw TypeScript exports, no build step). Consumers transpile via their own bundlers.
### Stack
-- **Framework**: Next.js 16 (App Router), React 19, TypeScript
+- **Frontend**: Vite 7 SPA, React 19, TypeScript, TanStack Router (file-based routing)
+- **API Server**: Hono (standalone Bun server)
+- **Monorepo**: Turborepo with Bun workspaces
- **Database**: SQLite via bun:sqlite + Drizzle ORM (WAL mode, singleton via `globalThis`, sync queries, auto-migrations on startup)
- **Auth**: Better Auth with Drizzle adapter — email/password + optional OIDC/SSO via `genericOAuth` plugin
-- **Styling**: Tailwind CSS v4, shadcn components, dark cinema theme with warm primary accents
-- **Fonts**: DM Serif Display (display), DM Sans (body), Geist Mono (mono)
+- **Styling**: Tailwind CSS v4 (via `@tailwindcss/vite`), shadcn components, dark cinema theme with warm primary accents
+- **Fonts**: DM Serif Display (display), DM Sans (body), Geist Mono (mono) — self-hosted via `@fontsource`
- **API**: oRPC (contract-first, type-safe RPC) with `@orpc/tanstack-query` for TanStack Query integration
- **State**: Jotai (client), TanStack Query (data fetching & mutations via oRPC)
-- **Linting**: Biome (2-space indent, organized imports, React/Next.js recommended rules)
+- **Linting**: Biome (2-space indent, organized imports, React recommended rules)
- **External API**: TMDB (The Movie Database) with Bearer token auth
-### Path alias
+### Package imports
-`@/*` maps to project root (`./`), e.g. `@/lib/db/client` → `./lib/db/client`.
+Within `apps/web/`, `@/*` maps to `apps/web/src/`, e.g. `@/components/nav-bar` → `apps/web/src/components/nav-bar`.
+
+Cross-package imports use the package name:
+- `@sofa/api/contract`, `@sofa/api/schemas` — Contract and types
+- `@sofa/db/client`, `@sofa/db/schema`, `@sofa/db/constants`, `@sofa/db/logger` — Database layer
+- `@sofa/tmdb/client`, `@sofa/tmdb/image` — TMDB client
+- `@sofa/core/metadata`, `@sofa/core/tracking`, etc. — Business logic
+- `@sofa/auth/server`, `@sofa/auth/config` — Auth config
### Key directories
-- `lib/db/schema.ts` — Single file with all Drizzle table definitions (auth tables + app tables)
-- `lib/db/migrate.ts` — Auto-migration runner (executed on startup via instrumentation)
-- `lib/services/` — Business logic layer (metadata, tracking, discovery, availability, credits, person, webhooks, backup, settings, colors, update-check, system-health)
-- `lib/tmdb/` — TMDB API client and TypeScript types
-- `lib/auth/` — Better Auth server config (`server.ts`), client hooks (`client.ts`), cached session helper (`session.ts`)
-- `lib/config.ts` — Server-side environment checks (TMDB configured, OIDC configured, etc.)
-- `lib/logger.ts` — Structured logger with `LOG_LEVEL` support
-- `lib/types/` — Shared TypeScript types (e.g. `title.ts`)
-- `lib/cron.ts` — Background job scheduler (croner, `globalThis` singleton)
-- `lib/query-client.ts` — Singleton `QueryClient` with default options (30s stale time)
-- `lib/orpc/` — oRPC API layer (contract-first, type-safe RPC):
- - `contract.ts` — Full API contract definition (~30 procedures)
- - `schemas.ts` — Shared Zod input schemas used by contract
- - `context.ts` — Context type & `os` implementer
- - `middleware.ts` — Auth (`authed`) and admin (`admin`) middleware
- - `router.ts` — Assembled router implementing the contract
- - `handler.ts` — RPCHandler instance with error logging
- - `client.ts` — Client-side oRPC client (RPCLink)
- - `client.server.ts` — Server-side oRPC client (zero HTTP overhead for SSR)
- - `tanstack.ts` — `orpc` utils for TanStack Query (`orpc.*.queryOptions()`)
- - `procedures/` — Procedure implementations by domain (titles, episodes, seasons, people, dashboard, explore, search, discover, stats, status, integrations, admin, account, watchlist)
-- `app/rpc/[[...rest]]/route.ts` — Next.js catch-all route handler for oRPC
-- `app/api/` — Non-RPC routes (auth, images, avatars, backups, webhooks, lists, health)
-- `app/(pages)/` — Authenticated pages (dashboard, explore, titles/[id], people/[id], settings)
-- `app/(auth)/` — Auth pages (login, register, setup)
+**API server** (`apps/server/src/`):
+- `index.ts` — Hono app entry point, startup, CORS, route mounting, graceful shutdown
+- `cron.ts` — Background job scheduler (croner)
+- `orpc/` — oRPC server layer (context, middleware, router, handler, procedures/)
+- `routes/` — Non-RPC Hono routes (auth, images, avatars, backups, webhooks, lists, health)
+
+**Web app** (`apps/web/src/`):
+- `main.tsx` — React root: creates router + renders `
- The title you're looking for doesn't exist or may have been - removed from the database. -
-- An unexpected error occurred while loading this page. You can try - again or head back to the dashboard. -
-- Error ID: {error.digest} -
- )} - -- We hit a snag loading this page. This is usually temporary — - try refreshing or come back in a moment. -
- - {error.digest && ( -Error ID: {error.digest}
- )} - -- This page was left on the cutting room floor. It may have been - moved, removed, or never made it past the screenplay. -
-+ This page was left on the cutting room floor. It may have been + moved, removed, or never made it past the screenplay. +
++ An unexpected error occurred while loading this page. You can try + again or head back to the dashboard. +
++ Something went wrong while loading this title. Please try again. +
+ + Dashboard + ++ The title you're looking for doesn't exist or may have been + removed from the database. +
+