I wanted to collect birthday memories from a group of friends and send them as one cohesive gift. Nothing I found did this cleanly — Kudoboard costs money beyond basic use, Google Slides requires manual design work, WhatsApp threads are chaos.
So I built WishBloom: a free collaborative birthday memory book creator.
Here's how it's built and what I learned.
The Problem It Solves
You create a WishBloom for someone. You write the first memory card yourself, then share a contribution link with friends. Each person adds their own card — a date, a story, an optional photo, a mood tag. Nobody sees anyone else's contribution until the birthday person opens the finished book via a single shared link.
The output isn't a PDF or a document. It's a full interactive experience with a pressed flower botanical aesthetic. The last thing that happens: digital candles appear, and the birthday person blows them out using their microphone.
Architecture Overview
Next.js App Router (frontend + API routes)
↓
MongoDB Atlas (book and memory card storage)
↓
Cloudinary (photo uploads and transformations)
↓
Vercel (hosting and edge deployment)
I chose Next.js App Router for the ability to mix Server and Client Components cleanly — the book viewer is mostly server-rendered for SEO and performance, while the creation wizard is a Client Component for interactivity.
MongoDB was the right call for a document-oriented data model where each "book" contains an array of memory cards with variable structure.
Cloudinary handles image uploads so I never touch image processing myself.
The 5-Step Creation Wizard
The wizard is the most complex piece of the frontend. Step 1 collects recipient details, Steps 2–4 collect memories, letters, and wishes, Step 5 is a preview before publishing.
The challenge: photos upload to Cloudinary asynchronously while the user is still filling out the form. I needed the Cloudinary URL available before the step data saves to MongoDB.
My solution — upload on file selection, not on form submit:
const handlePhotoUpload = async (file: File) => {
setUploadStatus('uploading')
const formData = new FormData()
formData.append('file', file)
const response = await fetch('/api/upload', {
method: 'POST',
body: formData,
})
const { url } = await response.json()
setMemoryCard(prev => ({ ...prev, photoUrl: url }))
setUploadStatus('complete')
}
This means by the time they hit "Save Memory", the URL is already in state. No race conditions.
Cloudinary Integration
The upload API route is straightforward:
// app/api/upload/route.ts
import { v2 as cloudinary } from 'cloudinary'
export async function POST(request: Request) {
const formData = await request.formData()
const file = formData.get('file') as File
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const result = await new Promise((resolve, reject) => {
cloudinary.uploader
.upload_stream(
{
folder: 'wishbloom',
transformation: [
{ quality: 'auto', fetch_format: 'auto' },
],
},
(error, result) =>
error ? reject(error) : resolve(result)
)
.end(buffer)
})
return Response.json({
url: (result as any).secure_url,
})
}
quality: 'auto' and fetch_format: 'auto' mean Cloudinary automatically serves WebP to browsers that support it and compresses intelligently. My LCP stays under 2.5s even with multiple Cloudinary images on the page.
The Microphone Candle Feature 🎂🕯️
This is the part people find surprising. When the birthday person reaches the end of their book, digital candles appear. They blow them out by speaking into their microphone.
The implementation uses the Web Audio API:
const startListening = async () => {
const stream = await navigator.mediaDevices.getUserMedia({
audio: true,
})
const audioContext = new AudioContext()
const analyser = audioContext.createAnalyser()
const source =
audioContext.createMediaStreamSource(stream)
analyser.fftSize = 256
source.connect(analyser)
const dataArray = new Uint8Array(
analyser.frequencyBinCount
)
const detect = () => {
analyser.getByteFrequencyData(dataArray)
const average =
dataArray.reduce((a, b) => a + b) /
dataArray.length
if (average > BREATH_THRESHOLD) {
extinguishNextCandle()
}
if (candlesRemaining > 0)
requestAnimationFrame(detect)
}
detect()
}
BREATH_THRESHOLD took the most iteration. Too low and ambient noise triggers it. Too high and you have to blow hard enough to knock over a real cake. I landed on 18 after testing on six different devices including cheap Android phones.
There's also a manual button for people who don't want to use the mic.
Multi-step Form State Management
I store the wizard state in localStorage with a debounced auto-save every 2 seconds. If someone closes the tab mid-creation, they get a "Continue where you left off?" prompt on their next visit.
const debouncedSave = useCallback(
debounce((state: WizardState) => {
localStorage.setItem(
'wishbloom_draft',
JSON.stringify(state)
)
}, 2000),
[]
)
useEffect(() => {
debouncedSave(wizardState)
}, [wizardState, debouncedSave])
This reduced drop-offs significantly after I added it.
Lessons Learned
Ship the weird feature. The microphone candle thing felt gimmicky when I built it. It's the thing everyone mentions first when they see it.
Cloudinary's transformation API is underrated. I initially planned to write my own image compression. Cloudinary's quality: auto does a better job than I ever would have.
Multi-step forms are harder than they look. Not because of the form logic — because of what happens at the edges: network failures mid-upload, navigation away mid-step, mobile keyboard pushing the viewport up and breaking layout.
MongoDB schema flexibility is genuinely useful here. Each memory card can have different fields depending on the step. A rigid SQL schema would have required multiple tables. One MongoDB document holds everything.
What I'd Do Differently
I'd implement optimistic UI updates for the memory card saves earlier. Currently there's a loading state between "Save Memory" and the card appearing in the list. With optimistic updates the card would appear instantly and revert only on error. Users hate waiting.
The Project
🌸 Live: wishblooms.in
💻 GitHub: github.com/NaveenAgarwal2004
I'm a BCA graduate from Jaipur, India. This is the first production ready application I've built that feels genuinely useful rather than just technically interesting.
If you're building something similar or have questions about any of the implementation, I'm happy to go deeper in the comments.
What do you think — does the microphone candle feature feel gimmicky or does it land? I genuinely can't tell anymore.
Top comments (0)