Skip to content
Return to Projects
Case Study

Brewhaus

An AI-driven specialty coffee marketplace for home enthusiasts.

The Problem

Home coffee enthusiasts struggle to find beans matched to their taste. Brewhaus profiles flavor with AI and recommends roasts that fit each palate — turning discovery into an interactive experience.

My Role

Built solo with AI-assisted development — SSR storefront, vector search, AI integration, and Dockerized deployment.

Highlights

  • Next.js 15 SSR storefront with gamification and subscription billing
  • pgvector semantic search powering AI-driven flavor profiling
  • Claude AI integration for personalized recommendations
  • Containerized with Docker and served via Cloudflare Tunnels

Stack

Next.js 15TypeScriptPostgreSQLpgvectorClaude AIDocker

Constraints

  • Self-hosted on a home server — no cloud bill, but also no auto-scaling.
  • Solo build, so the stack had to favour boring, well-documented pieces over novelty.
  • AI cost must stay bounded — recommendations had to use embeddings, not a per-request LLM call.

System Architecture

Edge
Cloudflare Tunnel
Application
Next.js 15 SSR
Subscriptions
Gamification
Intelligence
Claude AI
Flavor Profiler
Data
PostgreSQL
pgvector (semantic search)
Runtime
Docker

Key Trade-offs

The decisions worth defending — what I chose, what I turned down, and why.

Recommendation engine

Chose

pgvector similarity on cached embeddings

Rejected

LLM call per recommendation

Vector search is millisecond-fast and effectively free once embeddings are computed; LLMs would have added cost and latency per page view.

Public ingress

Chose

Cloudflare Tunnel

Rejected

Public VPS with open ports

No exposed origin IP, free TLS, and DDoS protection inherited from Cloudflare — much smaller attack surface for a self-hosted box.

Rendering model

Chose

Next.js 15 SSR

Rejected

Pure client-rendered SPA

Product pages need to be indexable and shareable; SSR gives fast first paint and clean OG cards without a separate prerender step.

What I'd Do Differently

An honest retrospective — the stuff I'd change with more time, more users, or a second pass.

  1. 1Move embedding generation to a background queue — synchronous calls during admin product writes briefly blocked the UI.
  2. 2Add a staging container that mirrors prod data shape so AI tuning doesn't happen against the live DB.
  3. 3Track recommendation quality with a click-through metric instead of relying on subjective spot-checks.

Technical Deep-Dive

Architecture, specifications, and implementation details.

05 · AI Recommendation Engine

#Overview

The AI engine is a 6-step pipeline combining Claude (Anthropic) for language understanding and OpenAI for vector embeddings, all stored and searched within PostgreSQL via pgvector.


#Pipeline

Step 1: QUIZ
  User answers 5 questions
  Claude Haiku processes Q&A pairs
  → Returns structured taste profile JSON

Step 2: EMBED
  Taste profile text → OpenAI text-embedding-3-small
  → 1536-dimensional float vector

Step 3: STORE
  Vector saved to taste_profiles.embedding (pgvector column)
  Upserted on every quiz completion

Step 4: SEARCH
  pgvector cosine similarity:
  SELECT ... ORDER BY embedding <=> $userVector LIMIT 20

Step 5: RE-RANK
  Claude Sonnet receives top-20 candidates + context:
  - Current stock levels
  - Season / time of year
  - User subscription tier
  - Order history (avoid repeats)
  → Returns ranked list of 4-8 product IDs

Step 6: FEEDBACK
  User interactions feed back into profile:
  - Click-through → mild positive signal
  - Cart add → strong positive signal
  - Purchase → strongest signal
  - Review rating → direct quality signal
  Weekly BullMQ cron refreshes embeddings for active users

#Claude Haiku — Quiz Processing

Model: claude-haiku-4-5
Purpose: Parse quiz answers → structured taste profile
Latency target: < 800ms

##System Prompt

You are a specialty coffee taste profile analyser for Brewhaus.
Given a user's quiz answers, return a JSON taste profile.
Respond ONLY with valid JSON — no markdown, no explanation.

##User Prompt Template

Based on these coffee quiz answers, build a taste profile:

Q: How do you usually brew your coffee?
A: {answer}

Q: What flavours do you love most?
A: {answer}

Q: How do you take your coffee?
A: {answer}

Q: When do you drink your first coffee?
A: {answer}

Q: What's your adventure level with coffee?
A: {answer}

Return exactly:
{
  "flavorNotes":    ["note1", "note2", "note3"],
  "roastPreference": "light" | "medium" | "dark",
  "brewMethod":      "string",
  "adventureLevel":  "beginner" | "enthusiast" | "connoisseur",
  "summary":         "one sentence description"
}

##Output Schema

interface TasteProfile {
  flavorNotes:    string[]
  roastPreference: "light" | "medium" | "dark"
  brewMethod:     string
  adventureLevel: "beginner" | "enthusiast" | "connoisseur"
  summary:        string
}

#Claude Sonnet — Brew Guide Generation

Model: claude-sonnet-4-5
Purpose: Generate personalised brew recipes per product
Latency target: < 3 seconds (streaming response preferred)

##Prompt Template

Write a personalised brew guide for {productName} from {origin}.

Product details:
- Flavor notes: {flavorNotes}
- Process: {process}
- Roast level: {roastLevel}
- Altitude: {altitude}

User's preferred brew method: {brewMethod}

Requirements:
- Specific water temperature, dose, yield, and timing
- One pro tip specific to this origin
- Max 200 words
- Friendly expert tone — like a barista friend

#Claude Sonnet — Re-ranking

Model: claude-sonnet-4-5
Purpose: Intelligent re-ranking of 20 pgvector candidates
Latency target: < 1.5 seconds

##Prompt Template

You are ranking coffee products for a customer based on their profile.

Customer profile: {JSON.stringify(tasteProfile)}
Customer tier: {subscriptionPlan}
Previous purchases: {previousProductIds}
Current date: {month} (affects seasonal recommendations)

Candidate products (ordered by vector similarity):
{candidates.map(p => `- ${p.id}: ${p.name} (${p.origin}, ${p.roastLevel}, ${p.flavorNotes})`).join('\n')}

Stock status: {stockMap}

Return a JSON array of 6 product IDs in your recommended order.
Exclude any out-of-stock products.
Avoid products the customer recently purchased.
For Collective subscribers, prioritise rare and exclusive lots.
Respond ONLY with a JSON array: ["id1", "id2", ...]

#OpenAI Embeddings

Model: text-embedding-3-small
Dimensions: 1536
Purpose: Both taste profiles and products are embedded

##Product Embedding Text

{name}. {description}. {origin}. {process}. {roastLevel}. 
{varietal}. Flavor notes: {flavorNotes.join(', ')}.

##Profile Embedding Text

Flavor preferences: {flavorNotes.join(', ')}.
Roast preference: {roastPreference}.
Brew method: {brewMethod}.
Experience level: {adventureLevel}.
{summary}

##Cosine Similarity Query

-- <=> operator = cosine distance (0 = identical, 2 = opposite)
SELECT
  id,
  name,
  1 - (embedding <=> $1::vector) AS similarity
FROM products
WHERE
  in_stock = TRUE
  AND category = 'BEANS'
  AND embedding IS NOT NULL
ORDER BY embedding <=> $1::vector
LIMIT 20;

#Weekly Embedding Refresh (BullMQ Cron)

// Runs every Sunday at 2am
const embeddingRefreshQueue = new Queue("embedding-refresh", { connection });

embeddingRefreshQueue.add(
  "refresh-active-users",
  {},
  {
    repeat: { cron: "0 2 * * 0" },
  }
);

// Worker: re-embeds taste profiles for users active in last 30 days
// Picks up new reviews, purchase history, rating signals

#Cost Estimates (Monthly)

ServiceUsage estimateCost estimate
Claude Haiku (quiz)2,000 quiz completions~$0.50
Claude Sonnet (guides)5,000 guide generations~$15.00
Claude Sonnet (rerank)8,000 re-rank calls~$20.00
OpenAI Embeddings50,000 embed calls~$0.50
Total AI cost~$36/month

Based on Anthropic and OpenAI pricing as of May 2026. Actual costs scale with usage.


Next: 06 — Gamification System →

~ End of Document ~