Last month, I launched MenuGo.live — an AI-powered QR code menu generator for restaurants. Users upload a photo of their paper menu, and AI extracts everything automatically. In under 60 seconds, they have a beautiful digital menu with a QR code ready to print.
In this post, I'll break down the entire tech stack, share code snippets, and explain the decisions I made along the way.
The Problem I Wanted to Solve
I kept noticing restaurants struggling with their menus:
- Printing costs: £500-2000/year on reprints
- Outdated instantly: Prices change, but menus don't
- Manual data entry: Typing out entire menus is tedious
- Generic QR codes: Black and white squares that don't match the brand
I wanted to build something that solved all of these. The result is MenuGo.
The Tech Stack
Here's what powers MenuGo:
| Layer | Technology |
|---|---|
| Framework | Next.js 15 (App Router) |
| Database | Supabase (PostgreSQL + Auth + Storage) |
| AI | Google Gemini 1.5 Flash |
| Payments | Stripe (Subscriptions + Webhooks) |
| Styling | Tailwind CSS + DaisyUI |
| Deployment | Vercel |
| Analytics | Google Analytics 4 + Tag Manager |
Let me walk through each part.
1. Next.js 15 App Router
I chose Next.js 15 with the App Router for its:
- Server Components: Faster initial loads, less client-side JS
- Server Actions: No need for separate API routes for mutations
- Parallel Routes: Great for the dashboard layout
- Built-in caching: Automatic request deduplication
Project Structure
src/
├── app/
│ ├── (auth)/
│ │ ├── login/
│ │ └── signup/
│ ├── (dashboard)/
│ │ ├── layout.tsx
│ │ ├── menus/
│ │ ├── restaurants/
│ │ └── settings/
│ ├── (marketing)/
│ │ ├── page.tsx
│ │ ├── pricing/
│ │ └── blog/
│ ├── api/
│ │ ├── webhooks/stripe/
│ │ └── ai/extract-menu/
│ └── m/[slug]/ # Public menu pages
├── components/
├── lib/
│ ├── supabase/
│ ├── stripe.ts
│ └── gemini.ts
└── types/
Server Actions Example
Instead of creating API routes for everything, I use Server Actions for mutations:
// app/actions/menu.ts
'use server'
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
export async function updateMenuItemPrice(
itemId: string,
newPrice: number
) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) throw new Error('Unauthorized')
const { error } = await supabase
.from('menu_items')
.update({ price: newPrice })
.eq('id', itemId)
if (error) throw error
revalidatePath('/dashboard/menus')
return { success: true }
}
Using it in a component:
// components/PriceEditor.tsx
'use client'
import { updateMenuItemPrice } from '@/app/actions/menu'
import { useTransition } from 'react'
export function PriceEditor({ itemId, currentPrice }) {
const [isPending, startTransition] = useTransition()
const handleSubmit = (formData: FormData) => {
const price = parseFloat(formData.get('price') as string)
startTransition(() => updateMenuItemPrice(itemId, price))
}
return (
<form action={handleSubmit}>
<input
name="price"
type="number"
step="0.01"
defaultValue={currentPrice}
disabled={isPending}
/>
<button type="submit" disabled={isPending}>
{isPending ? 'Saving...' : 'Save'}
</button>
</form>
)
}
2. Supabase - The Backend Powerhouse
Supabase handles authentication, database, and file storage. It's like Firebase but with PostgreSQL, which means real SQL power.
Database Schema
Here's a simplified version of the core tables:
-- Restaurants
CREATE TABLE restaurants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) UNIQUE NOT NULL,
logo_url TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Menus
CREATE TABLE menus (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
restaurant_id UUID REFERENCES restaurants(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL,
is_active BOOLEAN DEFAULT true,
theme_config JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Menu Categories
CREATE TABLE menu_categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
menu_id UUID REFERENCES menus(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
sort_order INTEGER DEFAULT 0
);
-- Menu Items
CREATE TABLE menu_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
category_id UUID REFERENCES menu_categories(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
description TEXT,
price DECIMAL(10,2),
image_url TEXT,
is_available BOOLEAN DEFAULT true,
dietary_info TEXT[], -- ['vegetarian', 'gluten-free']
sort_order INTEGER DEFAULT 0
);
Row Level Security (RLS)
This is where Supabase shines. RLS ensures users can only access their own data:
-- Users can only see their own restaurants
CREATE POLICY "Users can view own restaurants" ON restaurants
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Users can insert own restaurants" ON restaurants
FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can update own restaurants" ON restaurants
FOR UPDATE USING (auth.uid() = user_id);
CREATE POLICY "Users can delete own restaurants" ON restaurants
FOR DELETE USING (auth.uid() = user_id);
-- Public can view active menus (for the customer-facing pages)
CREATE POLICY "Public can view active menus" ON menus
FOR SELECT USING (is_active = true);
Supabase Client Setup
I have two clients — one for server components and one for client components:
// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
},
},
}
)
}
// lib/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
)
}
3. Google Gemini AI - The Magic
This is the feature that makes MenuGo special. Users upload a photo of their paper menu, and Gemini extracts all the items, descriptions, and prices.
Why Gemini?
I tested several AI providers:
| Provider | Pros | Cons |
|---|---|---|
| OpenAI GPT-4V | Most accurate | Expensive, slow |
| Claude 3 | Great reasoning | No direct image API at the time |
| Gemini 1.5 Flash | Fast, cheap, accurate enough | Occasional formatting issues |
Gemini 1.5 Flash hit the sweet spot: fast enough for a good UX, cheap enough to offer on free plans, and accurate enough for menu extraction.
Implementation
// lib/gemini.ts
import { GoogleGenerativeAI } from '@google/generative-ai'
const genAI = new GoogleGenerativeAI(process.env.GOOGLE_AI_API_KEY!)
export async function extractMenuFromImage(
imageBase64: string,
mimeType: string
): Promise<ExtractedMenu> {
const model = genAI.getGenerativeModel({ model: 'gemini-1.5-flash' })
const prompt = `You are a menu extraction assistant. Analyze this restaurant menu image and extract all items.
Return a JSON object with this exact structure:
{
"categories": [
{
"name": "Category Name",
"items": [
{
"name": "Item Name",
"description": "Item description if visible",
"price": 12.99,
"dietary": ["vegetarian", "gluten-free"]
}
]
}
]
}
Rules:
- Extract ALL items visible in the menu
- Use null for price if not visible
- Use empty string for description if not visible
- Only include dietary info if explicitly marked (V, VG, GF, etc.)
- Maintain the category structure from the original menu
- Return ONLY valid JSON, no markdown or explanation`
const result = await model.generateContent([
{ text: prompt },
{
inlineData: {
mimeType,
data: imageBase64,
},
},
])
const response = result.response.text()
// Clean up response (Gemini sometimes wraps in markdown)
const jsonString = response
.replace(/```
{% endraw %}
json\n?/g, '')
.replace(/
{% raw %}
```\n?/g, '')
.trim()
return JSON.parse(jsonString)
}
The API Route
// app/api/ai/extract-menu/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { extractMenuFromImage } from '@/lib/gemini'
export async function POST(request: NextRequest) {
const supabase = await createClient()
// Check authentication
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Check rate limits (free users: 5/day, pro: unlimited)
const { data: profile } = await supabase
.from('profiles')
.select('subscription_tier, ai_extractions_today')
.eq('id', user.id)
.single()
if (profile?.subscription_tier === 'free' && profile.ai_extractions_today >= 5) {
return NextResponse.json(
{ error: 'Daily limit reached. Upgrade to Pro for unlimited extractions.' },
{ status: 429 }
)
}
try {
const formData = await request.formData()
const file = formData.get('image') as File
if (!file) {
return NextResponse.json({ error: 'No image provided' }, { status: 400 })
}
// Convert to base64
const bytes = await file.arrayBuffer()
const base64 = Buffer.from(bytes).toString('base64')
// Extract menu using Gemini
const extractedMenu = await extractMenuFromImage(base64, file.type)
// Increment usage counter
await supabase
.from('profiles')
.update({
ai_extractions_today: (profile?.ai_extractions_today || 0) + 1
})
.eq('id', user.id)
return NextResponse.json({ menu: extractedMenu })
} catch (error) {
console.error('Menu extraction failed:', error)
return NextResponse.json(
{ error: 'Failed to extract menu. Please try again.' },
{ status: 500 }
)
}
}
Client-Side Usage
// components/MenuUploader.tsx
'use client'
import { useState } from 'react'
import { Upload, Loader2, Sparkles } from 'lucide-react'
export function MenuUploader({ onExtracted }) {
const [isExtracting, setIsExtracting] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setIsExtracting(true)
setError(null)
try {
const formData = new FormData()
formData.append('image', file)
const response = await fetch('/api/ai/extract-menu', {
method: 'POST',
body: formData,
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Extraction failed')
}
const { menu } = await response.json()
onExtracted(menu)
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong')
} finally {
setIsExtracting(false)
}
}
return (
<div className="border-2 border-dashed border-gray-300 rounded-xl p-8 text-center">
{isExtracting ? (
<div className="flex flex-col items-center gap-4">
<Loader2 className="w-12 h-12 animate-spin text-primary" />
<p className="text-lg font-medium">AI is extracting your menu...</p>
<p className="text-sm text-gray-500">This usually takes 10-30 seconds</p>
</div>
) : (
<label className="cursor-pointer flex flex-col items-center gap-4">
<div className="w-16 h-16 bg-primary/10 rounded-full flex items-center justify-center">
<Sparkles className="w-8 h-8 text-primary" />
</div>
<div>
<p className="text-lg font-medium">Upload your menu</p>
<p className="text-sm text-gray-500">
Take a photo or upload an image. AI will do the rest.
</p>
</div>
<input
type="file"
accept="image/*"
onChange={handleUpload}
className="hidden"
/>
<span className="px-6 py-3 bg-primary text-white rounded-lg font-medium">
Choose Image
</span>
</label>
)}
{error && (
<p className="mt-4 text-red-600 text-sm">{error}</p>
)}
</div>
)
}
4. Stripe - Subscriptions & Payments
MenuGo has a freemium model:
| Plan | Price | Features |
|---|---|---|
| Free | £0 | 1 restaurant, 1 menu, 5 AI extractions/day |
| Pro | £9.99/mo | 5 restaurants, unlimited menus, unlimited AI |
| Business | £29/mo | Unlimited everything, custom domain, API |
Stripe Setup
// lib/stripe.ts
import Stripe from 'stripe'
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: '2024-12-18.acacia',
})
// Price IDs from Stripe Dashboard
export const STRIPE_PLANS = {
free: {
monthly: null,
yearly: null,
},
pro: {
monthly: 'price_xxxxxxxxxxxxx',
yearly: 'price_xxxxxxxxxxxxx',
},
business: {
monthly: 'price_xxxxxxxxxxxxx',
yearly: 'price_xxxxxxxxxxxxx',
},
}
Creating Checkout Sessions
// app/api/stripe/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { stripe, STRIPE_PLANS } from '@/lib/stripe'
import { createClient } from '@/lib/supabase/server'
export async function POST(request: NextRequest) {
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { priceId } = await request.json()
// Get or create Stripe customer
const { data: profile } = await supabase
.from('profiles')
.select('stripe_customer_id')
.eq('id', user.id)
.single()
let customerId = profile?.stripe_customer_id
if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: { userId: user.id },
})
customerId = customer.id
await supabase
.from('profiles')
.update({ stripe_customer_id: customerId })
.eq('id', user.id)
}
// Create checkout session
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?upgraded=true`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/pricing`,
metadata: { userId: user.id },
subscription_data: {
metadata: { userId: user.id },
},
})
return NextResponse.json({ url: session.url })
}
Webhook Handler
This was the trickiest part. Stripe webhooks need to update your database when subscriptions change:
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { headers } from 'next/headers'
import { stripe } from '@/lib/stripe'
import { createClient } from '@supabase/supabase-js'
import Stripe from 'stripe'
// Use service role for webhooks (bypasses RLS)
const supabase = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!
)
export async function POST(request: NextRequest) {
const body = await request.text()
const headersList = await headers()
const signature = headersList.get('Stripe-Signature')!
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
console.error('Webhook signature verification failed')
return NextResponse.json({ error: 'Invalid signature' }, { status: 400 })
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session
const userId = session.metadata?.userId
if (userId && session.subscription) {
const subscription = await stripe.subscriptions.retrieve(
session.subscription as string
)
await supabase
.from('profiles')
.update({
stripe_subscription_id: subscription.id,
subscription_status: subscription.status,
subscription_tier: getTierFromPriceId(
subscription.items.data[0].price.id
),
})
.eq('id', userId)
}
break
}
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
const subscription = event.data.object as Stripe.Subscription
const userId = subscription.metadata?.userId
if (userId) {
await supabase
.from('profiles')
.update({
subscription_status: subscription.status,
subscription_tier:
subscription.status === 'active'
? getTierFromPriceId(subscription.items.data[0].price.id)
: 'free',
})
.eq('id', userId)
}
break
}
}
return NextResponse.json({ received: true })
}
function getTierFromPriceId(priceId: string): string {
// Map your price IDs to tiers
const priceToTier: Record<string, string> = {
'price_pro_monthly': 'pro',
'price_pro_yearly': 'pro',
'price_business_monthly': 'business',
'price_business_yearly': 'business',
}
return priceToTier[priceId] || 'free'
}
5. Challenges & Lessons Learned
Challenge 1: AI Extraction Accuracy
Gemini sometimes returns inconsistent formats or misses items. My solutions:
- Detailed prompts: The more specific the prompt, the better the output
- JSON validation: Always validate and sanitize the AI response
- Manual editing: Let users easily correct any mistakes
- Retry logic: If extraction fails, offer a retry button
Challenge 2: Stripe Webhook Reliability
My webhooks were returning 200 OK but the database wasn't updating. The issue? The timestamp conversion was failing silently.
// Before (broke in production)
new Date(subscription.current_period_end * 1000).toISOString()
// After (handles edge cases)
const periodEnd = subscription.items.data[0]?.current_period_end
const periodEndISO = periodEnd
? new Date(periodEnd * 1000).toISOString()
: null
Challenge 3: Image Processing Performance
Large menu images were timing out. Solutions:
-
Client-side compression before upload using
browser-image-compression - Streaming responses for long AI operations
- Progress indicators so users know it's working
Challenge 4: Supabase RLS Complexity
RLS policies can get complex with nested data. I learned to:
- Start with simple policies and add complexity as needed
- Use the Supabase dashboard to test policies before deploying
- Create database functions for complex permission checks
6. Results & What's Next
Launch Stats (First Month)
- Users: 150+ signups
- Menus created: 80+
- AI extractions: 200+
- Paid conversions: 5 (early days!)
What's Next
- Design Studio: Theme editor with 30+ templates ✅ (just launched)
- QR Code Studio: Branded QR templates ✅ (just launched)
- Multi-language: Auto-translate menus
- Analytics: What items are customers viewing most?
- Integrations: Connect with POS systems
Try It Yourself
If you're building a SaaS, this stack is incredibly powerful:
- Next.js App Router: Modern React with great DX
- Supabase: Auth + DB + Storage in one place
- Gemini AI: Affordable and fast for vision tasks
- Stripe: Battle-tested payments
- Vercel: Deploy with zero config
Check out MenuGo.live to see it in action. You can create a free menu in 60 seconds.
Resources
Thanks for reading! If you found this helpful, consider:
- ⭐ Giving this post a like
- 🔄 Sharing with others building SaaS
- 💬 Leaving a comment with your questions
Building something similar? I'd love to hear about it!
Top comments (0)