A local-first study companion built with Next.js. Features flashcard review with FSRS spaced repetition, AI-generated cards, exams, and P2P exchange via WebRTC. All data lives in an in-memory SQLite database (sql.js) persisted to IndexedDB — no backend required for core functionality.
| What | Why |
|---|---|
| Next.js (App Router) | React framework |
| Drizzle ORM | Type-safe SQL query builder |
| sql.js | SQLite compiled to WebAssembly — runs in the browser |
| IndexedDB | Stores the sql.js database snapshot (Uint8Array) so data survives page reloads |
| Tailwind CSS v4 | Styling |
| shadcn/ui | UI primitives |
| simple-peer-light | WebRTC data channel wrapper |
| coder/websocket | Go WebSocket signaling library |
┌─────────────────────────────────┐
│ Next.js (React) │
│ ┌──────────────────┐ │
│ │ page.tsx │ │
│ │ (CRUD todo app) │ │
│ └────────┬─────────┘ │
│ │ │
│ ┌────────▼─────────┐ │
│ │ db/index.ts │ │
│ │ (Drizzle ORM) │ │
│ │ db.select()... │ │
│ │ db.insert()... │ │
│ │ db.update()... │ │
│ │ db.delete()... │ │
│ └────────┬─────────┘ │
│ │ │
│ ┌────────▼─────────┐ │
│ │ sql.js │ │
│ │ (SQLite in WASM) │ │
│ │ in-memory .db │ │
│ └──┬───────────┬────┘ │
│ │ │ │
│ export() load from │
│ Uint8Array Uint8Array │
│ │ │ │
│ ┌───────▼───────────▼────┐ │
│ │ storage.ts │ │
│ │ (IndexedDB wrapper) │ │
│ │ saveDatabase(data) │ │
│ │ loadDatabase() │ │
│ └────────────────────────┘ │
└─────────────────────────────────────┘
- On first load,
sql.jsspins up a SQLite instance in WebAssembly. - An in-memory database is created (empty or restored from IndexedDB).
drizzle-kit-generated migrations are applied to bring the schema up to date.- Drizzle ORM wraps the sql.js instance and exposes typed query builders.
- After every write (insert/update/delete),
persistNow()serializes the db viasqlDb.export()→Uint8Arrayand stores it in IndexedDB. - On subsequent page loads, the
Uint8Arrayis read from IndexedDB and fed back tonew SQL.Database(bytes)— data is restored. Migrations that were already applied are skipped (tracked by a__drizzle_migrationstable).
src/
├── app/
│ ├── layout.tsx # Root layout
│ ├── page.tsx # Todo UI (CRUD + query demos + Nuke DB button)
│ └── globals.css # Tailwind styles
├── db/
│ ├── schema.ts # Drizzle table definitions (todos)
│ ├── storage.ts # IndexedDB save/load/delete helpers
│ ├── index.ts # DB init — WASM load, restore, migrations, Drizzle wrap
│ └── migrations/ # drizzle-kit generated files + export.json
│ ├── *.sql # Migration SQL (one per change)
│ ├── export.json # Browser-importable bundle of all migrations
│ └── meta/ # Journal + schema snapshots
├── components/ui/ # shadcn/ui components
├── scripts/
│ └── export-migrations.ts # Build-time bundler: .sql → export.json
└── lib/utils.ts # Shared utilities
drizzle.config.ts # drizzle-kit config (sqlite)
pnpm install # installs deps + copies sql.js .wasm files to public/
pnpm dev # starts dev server at http://localhost:3000pnpm build # production build
pnpm start # run production buildSchema changes are managed through drizzle-kit migrations. The pipeline works entirely client-side — .sql files are generated during development, exported to a JSON bundle at build time, and applied in the browser at runtime.
-
Edit
src/db/schema.ts(add a table, change a column, etc.) -
Generate the migration SQL and re-export:
pnpm db:migrate
This runs
drizzle-kit generate(creates a new.sqlfile insrc/db/migrations/) thenscripts/export-migrations.ts(bundles all migrations intosrc/db/migrations/export.json). -
Restart the dev server — new schema is applied automatically on page load.
| Command | What it does |
|---|---|
pnpm db:generate |
Create a new .sql migration from schema diff |
pnpm db:export |
Re-bundle all .sql files into export.json (run after editing a migration manually) |
pnpm db:migrate |
Both of the above in one step |
If you need to wipe the database and re-run all migrations from scratch, click the Nuke DB button on the demo page (or delete the IndexedDB database from dev tools). On reload, migrations will run fresh.
drizzle-kit generate ──→ .sql files (src/db/migrations/*.sql)
↓
scripts/export-migrations.ts
(readMigrationFiles + JSON.stringify)
↓
export.json (bundled with the app)
↓
db.dialect.migrate() at runtime
(creates __drizzle_migrations tracking table,
skips already-applied migrations)
The __drizzle_migrations table records each migration's hash and timestamp. On subsequent loads, if the last applied migration's timestamp matches the latest .sql file's timestamp, nothing runs — the schema is already up to date.
sql.js ships two WebAssembly binaries (sql-wasm.wasm, sql-wasm-browser.wasm) that must be served as static files. A postinstall script (copy-wasm.mjs) copies them from node_modules/sql.js/dist/ to public/. They are gitignored — re-run pnpm install to get them.
src/db/schema.ts— Singletodostable withid,title,done(boolean mode),created_at.src/db/storage.ts— Two functions:saveDatabase(Uint8Array)andloadDatabase(), wrapping the native IndexedDB API.src/db/index.ts— Singleton DB init. CallsinitSqlJs()→ loads from IndexedDB or creates fresh → applies migrations viadialect.migrate()→ wraps withdrizzle(). Also exportsnukeDb()to reset the database.src/db/schema.ts— Drizzle table definitions; source of truth fordrizzle-kitmigrations.src/db/migrations/— Auto-generated.sqlfiles +meta/journal +export.json(the browser-importable bundle).drizzle.config.ts— drizzle-kit configuration (sqlite dialect, output folder, timestamp prefix).scripts/export-migrations.ts— Build-time script that bundles.sqlfiles intoexport.jsonusingdrizzle-orm/migrator'sreadMigrationFiles().src/app/page.tsx— Todo list UI with add/toggle/delete, search filter, demo buttons forCOUNT/LIKE/LIMIT, and a Nuke DB button to wipe and re-migrate.
StudyToolbox is built with a local-first, mobile-first approach:
- Safe-area aware: Respects iOS notches, Dynamic Islands, and home indicators via CSS
env(safe-area-inset-*) - Sticky footer: Footer always stays at the bottom using flexbox (
flex-1on main content) - Responsive: Optimized for mobile (375px), tablet (768px), and desktop (1024px+)
- Touch-friendly: All interactive elements meet 48px tap targets
- Dark mode: System-aware light/dark theme powered by
next-themes. Switch from the sun/moon toggle in the navbar (or inside the mobile menu) between Light, Dark, and System. Theme is persisted tolocalStorageand applied as aclasson<html>so it paints correctly before hydration.
Built on a consistent 4px/8px grid using Tailwind's spacing scale.
- Study Dome — Review cards, manage bundles & tags, take exams, track progress with FSRS.
- AI Factory — Generate flashcards from any content using OpenAI-compatible AI providers.
- Exchange Center — Share cards, bundles, and exams peer-to-peer via WebRTC. Uses a lightweight Go relay for initial signaling.
The Exchange Center requires a signaling relay to pair browsers. A standalone Go service lives in relay/:
cd relay
go run .See docs/relay-deployment.md for Docker and production deployment.
docs/architecture.md— System architecture and data flowdocs/exchange-center.md— How to use the Exchange Centerdocs/relay-deployment.md— Relay deployment guidedocs/responsive.md— Responsive design conventions, dark mode, and E2E coveragedocs/testing.md— Running and writing tests (unit, integration, E2E)
The project has 200+ Vitest unit/integration tests and 40+ Playwright E2E tests covering all lib/** and lib/services/** modules plus major user flows.
pnpm test # unit + integration tests
pnpm test:watch # unit tests in watch mode
pnpm test:coverage # unit tests with coverage report
pnpm test:e2e # Playwright E2E suite (auto-starts dev server)
pnpm test:e2e:headed # E2E in headed mode for debuggingSee docs/testing.md for the full guide, including how to add new tests.