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 (4)

Collapse
 
chayan-1906 profile image
PADMANABHA DAS

I also use Gemini for my news app, which features 11 AI tools. JSON inconsistency was a pain for me as well. I ended up building a fallback system (2.5 Flash → 2.0 Flash → 1.5 Flash) so if one model fails/returns bad JSON, it retries with the next. Did you consider something similar, or does the retry button handle it well enough for your users?

Collapse
 
mayu2008 profile image
Mayuresh Smita Suresh

I have fallback strategy which barely gets triggered, usually when you use older models like 1.5 this happens, always go with 2.5 flash and above

Collapse
 
chayan-1906 profile image
PADMANABHA DAS

Interesting — your article mentioned 1.5 Flash. Sounds like you upgraded since then?

Thread Thread
 
mayu2008 profile image
Mayuresh Smita Suresh

yes