# How to Add a Digital Menu Builder to Your App (Copy-Paste Prompts Included)

- Tool: App Features
- Last updated: July 2026

## TL;DR

A digital menu builder needs a customer-facing display (category tabs, item cards with photos and dietary icons), a staff CMS for price and availability editing, and Supabase Storage for images. With Lovable or V0 you can ship a working menu in 4–8 hours for $0–25/month at small scale. The tricky parts are JSONB dietary filter logic and image bucket permissions — both of which AI tools get wrong on the first try.

## What a Digital Menu Builder Actually Is

A digital menu builder turns your restaurant or café's item list into a live, filterable, photo-rich experience that customers access from any phone — no app download required. Staff update prices and availability in a protected admin panel; changes appear immediately on the customer-facing view. The core product decisions are where images are stored and served (Supabase Storage is the default choice when using Lovable), how dietary filtering works (AND logic, not OR), and whether the menu is view-only or part of an ordering flow. Most early builds are view-only with a QR code link printed on each table — online ordering is a separate feature that requires payment gateway integration.

## Anatomy of the Feature

Seven components. AI tools handle the card UI and CRUD well on the first prompt. The dietary filter query operator and image bucket policy are the two places builds break silently.

- **Category navigation** (ui): Horizontal tab bar or vertical sidebar letting customers jump to a menu section without scrolling. Built with shadcn/ui Tabs (web) or React Navigation BottomTabNavigator / Flutter TabBar (mobile). Each tab is backed by a menu_categories row and filters the item grid dynamically.
- **Menu item card grid** (ui): Responsive CSS Grid (web) or Flutter GridView.builder (mobile) rendering one card per menu_items row. Each card shows the photo from a Supabase Storage signed URL, name, description, price formatted with Intl.NumberFormat for locale-aware currency, and dietary tag icons. An 'unavailable' overlay (semi-transparent grey) renders when is_available = false.
- **Admin CMS panel** (backend): A protected route (/admin/menu) where authenticated staff can create, edit, and delete categories and items. Built with react-hook-form + zod for form validation. Supabase auth role check (e.g., checking a staff_role claim in the JWT) gates access. Staff can toggle is_available and update price without touching code.
- **Image upload layer** (service): Supabase Storage public bucket receives photo uploads from the admin panel. Images are referenced by image_url stored on menu_items. For on-the-fly resizing use imgix (from $10/mo approx) or Supabase Storage transformation URLs to serve thumbnails at the correct resolution instead of downloading full-size images for card grids.
- **Dietary filter engine** (data): A JSONB column dietary_tags on menu_items stores an array of tag strings (e.g., ["vegan", "gluten-free"]). The filter query uses the PostgreSQL @> containment operator so that selecting multiple tags returns only items tagged with ALL of them — AND logic. Supabase full-text search via to_tsvector on name + description powers the item search bar.
- **QR code link generator** (service): qrcode.react (web) or qr_flutter (Flutter) renders a scannable QR code pointing to the published customer menu URL. Displayed on a printable /qr page so restaurant owners can export it for table tents. The URL is hardcoded from an environment variable — never generated from window.location.href in a preview environment.
- **Search index** (backend): Supabase full-text search using to_tsvector('english', name || ' ' || description) for menus with hundreds of items. For smaller menus (under 200 items) a client-side ILIKE '%query%' query on the name column is simpler and equally fast.

## Data model

Two tables cover the full menu structure. Run this in the Supabase SQL editor — it creates the tables, RLS policies, and indexes in one pass.

```sql
create table public.menu_categories (
  id uuid primary key default gen_random_uuid(),
  name text not null,
  sort_order int not null default 0,
  is_active bool not null default true,
  created_at timestamptz not null default now()
);

create table public.menu_items (
  id uuid primary key default gen_random_uuid(),
  category_id uuid references public.menu_categories(id) on delete cascade not null,
  name text not null,
  description text,
  price decimal(10,2) not null,
  image_url text,
  dietary_tags jsonb not null default '[]',
  is_available bool not null default true,
  sort_order int not null default 0,
  created_at timestamptz not null default now()
);

-- Enable RLS
alter table public.menu_categories enable row level security;
alter table public.menu_items enable row level security;

-- Public can read active categories and available items
create policy "Public can view active categories"
  on public.menu_categories for select
  using (is_active = true);

create policy "Public can view available items"
  on public.menu_items for select
  using (is_available = true);

-- Staff (authenticated users) can manage all records
create policy "Staff can manage categories"
  on public.menu_categories for all
  using (auth.role() = 'authenticated')
  with check (auth.role() = 'authenticated');

create policy "Staff can manage items"
  on public.menu_items for all
  using (auth.role() = 'authenticated')
  with check (auth.role() = 'authenticated');

-- Indexes for performance
create index menu_items_category_idx on public.menu_items (category_id, sort_order);
create index menu_items_dietary_tags_idx on public.menu_items using gin (dietary_tags);
create index menu_categories_sort_idx on public.menu_categories (sort_order);
```

The GIN index on dietary_tags makes the @> containment filter fast even with 1,000+ menu items. The staff-only write policies use auth.role() = 'authenticated' — if you need finer-grained role separation (e.g., servers mark unavailable but only managers change prices), add a profiles table with a role column and extend the policies.

## Build paths

### Lovable — fit 4/10, 4-6 hours

Best overall path — Lovable auto-wires Supabase for the item CRUD and renders category tabs plus item cards in one session. Image upload to Supabase Storage and the admin CMS are covered, though the bucket policy almost always needs a manual fix.

1. Create a new Lovable project and connect Lovable Cloud to provision Supabase auth and the database automatically
2. Paste the prompt below into Agent Mode and let it build the customer-facing menu, admin panel, and QR code page in one run
3. Open Supabase Dashboard → Storage → select your menu-photos bucket → Policies → add a SELECT policy for the anon role so public images load in production
4. Run the SQL data model from this page in the Supabase SQL editor to set up dietary tag indexes and RLS policies
5. Click Publish, open the live URL on your phone, and verify filtering, image load, and the QR code URL all point to the production domain

Starter prompt:

```
Build a digital menu builder app. Create two Supabase tables: menu_categories (id, name, sort_order, is_active) and menu_items (id, category_id, name, description, price decimal, image_url, dietary_tags jsonb array, is_available bool, sort_order). Customer view: horizontal category tabs at the top; below, a responsive card grid showing item photo from Supabase Storage, name, price formatted with locale currency (e.g. $12.00), description, and dietary tag badges (vegan, gluten-free, spicy, nut-free). Cards for unavailable items show a grey overlay with 'Currently Unavailable'. Filter sidebar with multi-select checkboxes for dietary tags — use AND logic so selecting two tags shows only items with BOTH (use Supabase @> operator, not &&). Search bar that filters by name or description in real time. Admin panel at /admin protected by Supabase auth: list all items, edit price, toggle is_available, upload a new photo to Supabase Storage public bucket. QR code page at /qr that renders a qrcode.react QR pointing to the production menu URL stored as an environment variable. Handle empty category state with a 'No items in this section yet' placeholder. Mobile-first layout, minimum 44px tap targets.
```

Limitations:

- Image upload in the Lovable preview may succeed but return 403 in production until the Supabase Storage bucket policy is set to allow anon SELECT
- The dietary filter AI typically generates && (OR) instead of @> (AND) — always verify the generated Supabase query
- The QR code will point to window.location.href in preview, which is the Lovable preview domain — must hardcode the production URL via an environment variable

### V0 — fit 4/10, 5-8 hours

Excellent UI quality for the customer-facing menu — Next.js ISR makes the menu load fast and improves SEO for restaurant discovery. Admin CMS requires extra prompts and manual Vercel env var setup.

1. Prompt V0 with the spec below to generate the customer menu page and admin CRUD routes
2. Add your Supabase environment variables in the V0 Vars panel: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY
3. Run the SQL data model from this page in the Supabase SQL editor
4. In the Next.js API route for admin mutations, use the service role key (server-side only) to bypass RLS for staff edits
5. Configure ISR on the customer menu page with revalidate: 60 so price changes appear within a minute without a full rebuild
6. Deploy to Vercel, set the NEXT_PUBLIC_MENU_URL env var to the production domain for the QR code generator

Starter prompt:

```
Build a digital menu for a restaurant using Next.js App Router, Supabase, and shadcn/ui. Customer page at /menu: fetch menu_categories and menu_items from Supabase with ISR revalidation every 60 seconds. Layout: shadcn/ui Tabs for category navigation; below each tab a CSS Grid of item cards showing Supabase Storage image, name, price (Intl.NumberFormat for locale currency), dietary tag badges. Unavailable items show a greyed-out overlay. Filter sidebar on desktop, bottom sheet on mobile: checkboxes per dietary tag, AND logic using Supabase .contains('dietary_tags', selectedTags). Search input filters visible cards by name client-side. Admin page at /admin/menu (protected by Supabase session check): shadcn/ui DataTable listing all items with inline edit for price and is_available toggle; image upload to Supabase Storage using supabaseClient.storage.from('menu-photos').upload(). QR code component using qrcode.react pointing to process.env.NEXT_PUBLIC_MENU_URL. Handle empty states for no items, no search results, and no filter matches with appropriate placeholder copy and a clear-filters button. Keep localStorage filter state with useEffect guard to prevent SSR hydration errors.
```

Limitations:

- V0 does not auto-provision the Supabase database — you must run the SQL schema manually and configure all environment variables in Vercel Dashboard
- The admin CMS panel requires additional prompts to fully wire image upload and the service role key handling
- ISR revalidation means price changes take up to 60 seconds to appear — this is acceptable for most restaurants but not instant

### Flutterflow — fit 3/10, 1-2 days

Good for a native mobile menu app where swipe gestures, pinch-to-zoom photos, and offline caching matter. Complex JSONB dietary filter queries need custom Dart functions rather than FlutterFlow's visual query builder.

1. Connect FlutterFlow to your Supabase backend in Settings → Supabase, importing the menu_categories and menu_items tables
2. Build the category navigation using Flutter TabBar bound to a Supabase query for active categories ordered by sort_order
3. Create a GridView page bound to menu_items filtered by the selected category_id using a FlutterFlow Supabase query action
4. For the dietary filter, write a custom Dart function that builds the @> containment query dynamically based on selected tags and call it from an Action Flow
5. Add a photo carousel per item using the photo_view Flutter package for pinch-to-zoom on the item detail page
6. Test on a real device via the FlutterFlow mobile app — the web preview will not render Supabase Storage images correctly without the proper bucket policy

Limitations:

- JSONB dietary tag filter with AND logic requires a custom Dart function — FlutterFlow's visual query builder does not support the @> containment operator natively
- The admin CMS for staff edits is better built as a separate web app (Lovable or V0) rather than within the same FlutterFlow project
- Flutter image grids from Supabase Storage require the bucket policy to allow public reads or a custom signed URL action

### Custom — fit 5/10, 1-2 weeks

Full control over POS integration, multi-location menus, role-based pricing, and PDF export — necessary when the digital menu is part of a larger restaurant operating system rather than a standalone display.

1. Next.js App Router with Supabase — ISR for the customer menu, Server Actions for admin mutations
2. Supabase Edge Function as a webhook receiver from POS systems (Square, Toast, Clover) to sync item availability and prices in real time
3. Per-location menu variants using a location_id foreign key on both tables, with Supabase RLS filtering by the staff member's assigned location
4. PDF export of the full menu using a Puppeteer-based Edge Function that renders the /menu page with brand fonts and logo to a downloadable PDF

Limitations:

- POS webhook integration typically adds 3–5 days to the build timeline and requires a paid POS developer account for testing

## Gotchas

- **Images upload in preview but return 403 in production** — Supabase Storage bucket policies default to private. Lovable AI creates the bucket but does not always add a public SELECT policy for the anon role. In the preview, images work because the Lovable preview session is authenticated as the project owner. In production, anonymous customers get a 403 on every image URL. Fix: In Supabase Dashboard, go to Storage, select your menu-photos bucket, open the Policies tab, and add a new policy: 'Allow public read' with SELECT operation for the anon role with no conditions. This single policy change fixes all image 403 errors instantly.
- **Dietary filter returns items that don't match all selected tags** — AI tools almost always generate the Supabase filter using the && (overlap) operator instead of @> (containment). The overlap operator returns items tagged with ANY of the selected tags — so filtering for 'vegan AND gluten-free' shows every vegan item and every gluten-free item, not just the intersection. Fix: In the generated Supabase query, change .overlaps('dietary_tags', tags) or .contains (with wrong semantics) to .contains('dietary_tags', tags) which maps to the @> operator in PostgreSQL. Double-check by running the raw SQL: SELECT * FROM menu_items WHERE dietary_tags @> '["vegan","gluten-free"]'::jsonb in the Supabase SQL editor.
- **Staff menu edits don't appear for customers until page refresh** — AI-generated menu UIs fetch data once on component mount using a standard useEffect. When a staff member marks an item unavailable or changes a price, the change is written to Supabase but customers see the stale data until they manually refresh the page. This creates a confusing gap especially for availability changes during a busy service. Fix: Add a Supabase Realtime channel subscription on the menu_items table. In the customer menu component, set up supabase.channel('menu').on('postgres_changes', { event: '*', schema: 'public', table: 'menu_items' }, handleUpdate).subscribe(). This pushes changes to all connected customers within about one second of a staff edit.
- **QR code in Lovable preview points to the preview iframe URL** — When qrcode.react generates the QR using window.location.href, it captures the Lovable preview domain (e.g., preview.lovable.app) instead of the published menu URL. Anyone who scans the printed QR code ends up at the preview URL, which may be unreachable or session-gated. Fix: Store the production menu URL in Lovable Secrets as VITE_MENU_URL and read it in the QR generator component using import.meta.env.VITE_MENU_URL. Never derive the URL from window.location.href at build time.
- **V0 generates localStorage filter access during SSR, causing hydration mismatch** — V0 often generates filter state persistence using localStorage.getItem inside a component body. Next.js runs components server-side first, where localStorage is undefined. The server renders one state and the client hydrates with a different state, triggering a hydration mismatch warning and sometimes a visible layout flash. Fix: Wrap all localStorage access inside a useEffect(() => { ... }, []) hook so it only runs on the client. For the unit preference or filter state, initialize the React state to a safe default (e.g., empty array for filters) and update it in useEffect after the component mounts.

## Best practices

- Store dietary tags as a normalized JSONB array and always query with @> containment — never build OR filter logic for dietary preferences
- Use sort_order columns on both menu_categories and menu_items so restaurant owners control display order from the admin panel without touching code
- Serve item photos through an image CDN or Supabase Storage transformation URLs — never load full-resolution uploads in the card grid on mobile connections
- Make the 'item unavailable' state visually obvious with a greyed-out overlay and 'Currently Unavailable' text, not just a missing price
- Generate QR codes from a hardcoded environment variable URL, never from window.location.href, so printed table tents survive domain changes
- Implement Supabase Realtime on menu_items so availability changes propagate to all customers within seconds during service
- Keep the admin panel on a separate route with an explicit auth check — never rely on hiding the admin link as a security measure
- Cache Supabase Storage image URLs rather than generating signed URLs on every render — signed URLs are only needed for private buckets

## Frequently asked questions

### Can I update prices without redeploying the app?

Yes — that is the core purpose of the admin CMS. Prices and availability are stored in Supabase rows, not in code. When a staff member edits a price in the admin panel and saves, the change is written to the database and immediately visible to customers on the next page load. If you add Supabase Realtime, the change appears on all open customer screens within about one second.

### How do I show when an item is sold out or temporarily unavailable?

Set is_available = false on the menu_items row. The customer-facing card renders a greyed-out overlay with 'Currently Unavailable' text. The item still appears in the menu so customers know it exists; it just can't be ordered. Staff flip the toggle back to true when the item is back — no redeploy needed. With Supabase Realtime enabled, the overlay appears within seconds of the staff edit.

### What's the best way to handle multiple menu sections (breakfast, lunch, dinner)?

Use the menu_categories table with a sort_order column. Each time period gets its own category row. You can extend the schema with a time_available_from and time_available_to field on menu_categories and conditionally show categories based on the current time in the customer-facing UI. For the simplest build, just use the is_active toggle on categories — staff activate breakfast at opening and deactivate it at 11am.

### Can customers order directly from the digital menu or is it view-only?

The menu builder described here is view-only — it shows what's available and at what price, but does not take orders or process payments. To add ordering, you need a cart state, an orders table in Supabase, and payment gateway integration (Stripe is the standard choice). That is a separate build that typically takes an additional 8–16 hours with Lovable or V0.

### How do I add allergen warnings that comply with UK or EU food labeling laws?

Add an allergens JSONB array column to menu_items alongside dietary_tags. Populate it with the 14 major EU allergens (celery, cereals containing gluten, crustaceans, eggs, fish, lupin, milk, molluscs, mustard, nuts, peanuts, sesame, soya, sulphur dioxide). Display allergen icons prominently on each card and on the item detail page. For full Natasha's Law compliance in the UK, you also need the ingredient list to be complete and accurate — which requires a content management process, not just a schema.

### Can I embed the digital menu on my existing restaurant website?

Yes. The customer-facing menu page is a standard URL that you can iframe into any existing website. The simplest approach is a <iframe src='https://yourmenu.app/menu' style='width:100%;height:800px;border:none'></iframe> tag. If you want deeper integration (shared branding, same domain), deploy the Next.js app on a subdomain and point your existing site's menu link there instead.

### What image size works best for menu item photos?

Upload at 1200x800px and let the CDN serve optimized sizes per device. In the card grid, request a 400x300px thumbnail; on the item detail page, load the full 1200x800px image. Supabase Storage transformation URLs support this with a width= query parameter. Keeping originals at 1200x800px balances quality and upload size — photos above 2MB slow the admin panel upload noticeably.

### How do I handle seasonal items that appear only at certain times?

The simplest approach: use the is_available toggle on individual items to activate seasonal specials manually at the start of the season. For automated scheduling, add available_from and available_until date columns to menu_items and filter them in the customer query. A Supabase Edge Function triggered by pg_cron can auto-toggle is_available based on the date — this is a 30-minute addition once the base menu builder is running.

---

Source: https://www.rapidevelopers.com/app-features/digital-menu-builder
© RapidDev — https://www.rapidevelopers.com/app-features/digital-menu-builder
