DEV Community

Cover image for I Built an AI-Powered QR Menu Generator in 2 Weeks - Here's How
Mayuresh Smita Suresh
Mayuresh Smita Suresh Subscriber

Posted on • Originally published at menugo.live

I Built an AI-Powered QR Menu Generator in 2 Weeks - Here's How

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/
Enter fullscreen mode Exit fullscreen mode

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 }
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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)
          )
        },
      },
    }
  )
}
Enter fullscreen mode Exit fullscreen mode
// 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!
  )
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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 }
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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',
  },
}
Enter fullscreen mode Exit fullscreen mode

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 })
}
Enter fullscreen mode Exit fullscreen mode

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'
}
Enter fullscreen mode Exit fullscreen mode

5. Challenges & Lessons Learned

Challenge 1: AI Extraction Accuracy

Gemini sometimes returns inconsistent formats or misses items. My solutions:

  1. Detailed prompts: The more specific the prompt, the better the output
  2. JSON validation: Always validate and sanitize the AI response
  3. Manual editing: Let users easily correct any mistakes
  4. 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
Enter fullscreen mode Exit fullscreen mode

Challenge 3: Image Processing Performance

Large menu images were timing out. Solutions:

  1. Client-side compression before upload using browser-image-compression
  2. Streaming responses for long AI operations
  3. Progress indicators so users know it's working

Challenge 4: Supabase RLS Complexity

RLS policies can get complex with nested data. I learned to:

  1. Start with simple policies and add complexity as needed
  2. Use the Supabase dashboard to test policies before deploying
  3. 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)