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.
07 · Subscription System
#Plans
| Plan | Price | Weight | XP Bonus | Key Feature |
|---|---|---|---|---|
| Starter | RON 89/mo | 250g | +100 XP/mo | 1 curated origin per month |
| Connoisseur | RON 159/mo | 500g | +250 XP/mo | 2 AI-matched origins + 10% off |
| Collective | RON 279/mo | 1kg | +500 XP/mo | 4 rare origins + 15% off |
#Stripe Integration
##Price IDs
Create these recurring prices in the Stripe Dashboard:
STRIPE_PRICE_STARTER = price_xxx (RON 89, monthly)
STRIPE_PRICE_CONNOISSEUR = price_xxx (RON 159, monthly)
STRIPE_PRICE_COLLECTIVE = price_xxx (RON 279, monthly)
##Subscription Creation Flow
1. User clicks "Subscribe" on plan
2. Frontend calls subscriptions.create tRPC procedure
3. Server:
a. Create Stripe Customer (or retrieve existing)
b. Create Stripe Checkout Session in subscription mode
c. Return session URL
4. Redirect to Stripe Checkout
5. User completes payment on Stripe
6. Stripe fires checkout.session.completed webhook
7. Server creates subscription record in DB
8. BullMQ dispatches welcome email
9. XP awarded (+250 for Connoisseur, etc.)
##Webhook Events Handled
checkout.session.completed → Create subscription in DB
customer.subscription.created → Upsert subscription record
customer.subscription.updated → Update plan/status
customer.subscription.deleted → Mark as cancelled
invoice.paid → Award monthly XP bonus, queue delivery
invoice.payment_failed → Update to PAST_DUE, send email
#Monthly Delivery Process
invoice.paid webhook received
↓
BullMQ: dispatch "subscription-delivery" job
↓
Worker:
1. Identify user's subscription plan
2. Query AI recommendations (2/4 products based on plan)
3. Create internal fulfilment order record
4. Send "Your box is being prepared" email
5. Award monthly XP bonus
6. Update subscription delivery log
↓
Manual fulfilment team notified via dashboard
#Cancellation
// Cancels at period end — user keeps access until renewal date
await stripe.subscriptions.update(stripeSubscriptionId, {
cancel_at_period_end: true,
});
// DB update
await db.subscription.update({
where: { stripeSubscriptionId },
data: { cancelAtPeriodEnd: true },
});
#Customer Portal
Stripe's Customer Portal handles:
- Plan upgrades / downgrades
- Payment method updates
- Invoice history
- Cancellation
const session = await stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: `${process.env.NEXT_PUBLIC_APP_URL}/profile`,
});
redirect(session.url);