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
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
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.
- 1Move embedding generation to a background queue — synchronous calls during admin product writes briefly blocked the UI.
- 2Add a staging container that mirrors prod data shape so AI tuning doesn't happen against the live DB.
- 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)
| Service | Usage estimate | Cost 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 Embeddings | 50,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.