Loam — MTB Build & Tune
A Next.js web app and native Android client for mountain bike riders to configure builds, get manufacturer-verified suspension and tire setup starting points, save post-ride reviews, and receive AI-powered tuning recommendations based on personal preference history.
The backend is built with Next.js App Router, Prisma/PostgreSQL, and Ollama for local LLM inference. The Android client is built with Kotlin, Jetpack Compose, Ktor, and Koin against the same JSON API.
Tech stack
| Layer | Tech |
|---|---|
| Framework | Next.js 16 (App Router) |
| Language | TypeScript |
| Database | PostgreSQL |
| ORM | Prisma |
| Auth | bcryptjs + jose (JWT in HTTP-only cookies) |
| Images | sharp (layer compositing) |
| AI | Ollama (local LLM) |
| Validation | Zod |
| Android | Kotlin, Jetpack Compose, Ktor, Koin |
Features
- Catalog — Curated frames, forks, shocks, and tires with real-world setup ranges
- Setup verification — Every product carries a research audit trail (manufacturer docs, community sources, confidence levels)
- Build composer — Layered bike image generation from selected components
- Per-user learning — Builds, reviews, and learned preferences are scoped to authenticated users
- AI recommendations — Ollama-powered tuning advice that incorporates the rider's own review history
- Post-ride reviews — Rate suspension feel, grip, rolling speed, and report issues (bottom-outs, harsh hits, wallow, small-bump harshness)
Getting started
Prerequisites
- Node.js 20+
- PostgreSQL running locally (or a remote instance)
- Ollama installed and running (optional, for AI recommendations)
1. Install dependencies
npm install
2. Configure environment variables
cp .env.example .env.local
Edit .env.local:
DATABASE_URL=postgresql://user:password@localhost:5432/loam
JWT_SECRET=change-this-to-a-long-random-string-in-production
# Optional — recommended local model shown
OLLAMA_URL=http://localhost:11434
OLLAMA_MODEL=qwen3:32b
3. Set up the database
# Create and apply migrations
npm run db:migrate
# Seed product catalog
npm run db:seed
4. Pull an Ollama model (optional)
ollama pull qwen3:32b
qwen3:32b is the recommended local model for the recommendation endpoint on a 32GB VRAM GPU such as the AMD Radeon AI PRO R9700. Use qwen3:14b if you want faster responses or need a smaller memory footprint.
5. Run the dev server
npm run dev
Open http://localhost:3000.
Docker
Build and run the web app plus PostgreSQL:
docker compose up --build
The app is exposed at http://localhost:3333. The Compose stack provides DATABASE_URL and JWT_SECRET for the app container.
The Docker image installs dependencies with npm ci --ignore-scripts; Prisma generation runs later in the builder stage after prisma/schema.prisma has been copied into the image. The base image also installs OpenSSL so Prisma's native query engine works on Alpine.
Ollama in a Separate Container
The app expects Ollama at OLLAMA_URL. If Ollama runs in another container, expose port 11434 and point the app at that service or host address.
For an AMD GPU with ROCm, a typical Ollama container launch is:
docker run --rm -it \
--device=/dev/kfd \
--device=/dev/dri \
--group-add video \
--group-add render \
-v ollama:/root/.ollama \
-p 11434:11434 \
ollama/ollama:rocm
Then pull the recommended model:
ollama pull qwen3:32b
If the app runs in Docker Compose and Ollama is bound to the host on Linux, set:
environment:
OLLAMA_URL: http://host.docker.internal:11434
OLLAMA_MODEL: qwen3:32b
extra_hosts:
- "host.docker.internal:host-gateway"
Project structure
prisma/
schema.prisma # User, BikeBuild, RideReview, RiderPreference, Product
seed.ts # Seeds catalog into Product table
src/
app/
api/
auth/ # register, login, me, logout
builds/ # list / create builds (scoped to user)
reviews/ # create reviews (scoped to user + build ownership)
ai/recommend # Ollama recommendation prompt
images/compose # sharp layer compositing
catalog/ # public catalog + setupVerification
setup-audit/ # research audit endpoint
components/
BikeBuilderApp.tsx
lib/
auth.ts # bcrypt, JWT cookie session, requireAuth
db.ts # per-user Prisma queries
catalog.ts # static product data with citations
recommendations.ts # setup math by rider profile
setupVerification.ts # research audit metadata
types.ts # shared TypeScript types
public/
images/ # product assets + user-generated builds
android/
app/src/main/java/com/bikeloam/app/
data/ # DTOs, Ktor API client, repository
navigation/ # Compose navigation graph
ui/ # Compose screens, theme, ViewModels
app/src/test/ # JVM unit tests for repository + ViewModels
API routes
| Route | Method | Auth | Description |
|---|---|---|---|
/api/auth/register |
POST | No | Create account, set session cookie |
/api/auth/login |
POST | No | Log in, set session cookie |
/api/auth/me |
GET | No | Return current session user |
/api/auth/logout |
POST | No | Clear session cookie |
/api/builds |
GET | Yes | List current user's builds |
/api/builds |
POST | Yes | Save a new build |
/api/reviews |
POST | Yes | Review a build (must own it) |
/api/ai/recommend |
POST | Yes | AI tuning advice with user preferences |
/api/images/compose |
POST | No | Compose build image from layers |
/api/catalog |
GET | No | Full catalog + setupVerification |
/api/setup-audit |
GET | No | Research audit report |
Auth system
- Passwords hashed with bcrypt (12 rounds)
- Sessions are JWTs stored in HTTP-only
loam-sessioncookies (7-day expiry) requireAuth()guards data-modifying routes- All builds, reviews, and preferences are scoped to the authenticated
userId
Data model overview
- User — email (unique), passwordHash, name
- BikeBuild — belongs to User; stores component IDs, rider profile, setup JSON, image path
- RideReview — belongs to User + BikeBuild; ratings and issue flags
- RiderPreference — one per User; aggregated from review history (harsh/soft tendency, grip preference)
- Product — seeded catalog of frames, forks, shocks, tires with specs and sources JSON
Research & citations
Every product entry includes a sources array and a SetupVerification record:
manufacturer— official setup guides, owner manuals, product pagescommunity— Pinkbike reviews, Reddit discussions, Blister/MTBR test notes- Status levels:
verified→manufacturer-guided→community-informed→estimated
The /api/setup-audit endpoint returns a full breakdown of confidence levels and missing sources.
Android client
The native Android app lives in android/. It uses the same plain JSON API over HTTP with cookie-based auth:
- Implementing cookie jar storage for the
loam-sessioncookie - Calling
/api/auth/loginor/api/auth/registerto obtain a session - Reusing
/api/builds,/api/reviews,/api/ai/recommend, and/api/catalogas-is
For local emulator development, the debug build points at http://10.0.2.2:3000. Start the web backend with npm run dev, then build or test the app from the Android project:
cd android
./gradlew assembleDebug
./gradlew testDebugUnitTest
The image composition endpoint (/api/images/compose) returns a PNG path; mobile clients can either request server-side composition or replicate the layer-stacking logic locally.
Scripts
| Command | Purpose |
|---|---|
npm run dev |
Start Next.js dev server |
npm run build |
Production build |
npm run db:migrate |
Run Prisma migrations |
npm run db:seed |
Seed product catalog |
npm run db:studio |
Open Prisma Studio |
npm run lint |
Run ESLint |
Android commands are run from android/:
| Command | Purpose |
|---|---|
./gradlew assembleDebug |
Build the debug APK |
./gradlew installDebug |
Install debug build on a connected device or emulator |
./gradlew testDebugUnitTest |
Run JVM unit tests |
./gradlew connectedDebugAndroidTest |
Run instrumented Android tests |
Environment variables
| Variable | Required | Default | Description |
|---|---|---|---|
DATABASE_URL |
Yes | — | PostgreSQL connection string |
JWT_SECRET |
Yes | — | Secret for signing session JWTs |
OLLAMA_URL |
No | http://localhost:11434 |
Ollama server base URL |
OLLAMA_MODEL |
No | llama3.1:8b |
Model name for recommendations. qwen3:32b is recommended for a 32GB VRAM local GPU. |