Feature spec
IntermediateCategory
vertical-tools
Build with AI
4-8 hours with Lovable or V0
Custom build
1-2 weeks custom dev
Running cost
$0-25/mo up to 1K users
Works on
Everything it takes to ship a Digital Menu Builder — parts, prompts, and real costs.
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.
What users consider table stakes in 2026
- Category tabs or accordion sections to navigate menu sections without scrolling the full list
- Item cards with a photo, name, description, price, and dietary/allergen icons (vegan, gluten-free, spicy) visible at a glance
- Search bar to find items by name or ingredient without tapping through categories
- Filter panel for dietary preferences and allergens that applies AND logic — vegan AND gluten-free returns only items matching both
- Real-time price and availability editing for staff without touching code or redeploying the app
- Mobile-first layout with large tap targets and legible font sizes on a 375px screen
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
UIHorizontal 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.
Note: Accordion sections work better on mobile when there are more than 8 categories — tabs overflow and become unreadable on small screens.
Menu item card grid
UIResponsive 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.
Note: Use sort_order on both tables to give staff control over display order without redeploying.
Admin CMS panel
BackendA 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.
Note: Keep the admin route separate from the customer-facing pages — a single shared layout will complicate RLS policies.
Image upload layer
ServiceSupabase 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.
Note: The bucket must be set to public with an anon SELECT policy — Lovable AI defaults to private, which causes 403 errors in production.
Dietary filter engine
DataA 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.
Note: The most common AI mistake is generating an && (overlap) query which gives OR behavior. Always verify the generated query uses @> not &&.
QR code link generator
Serviceqrcode.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.
Note: Client-side generation means no API calls and no cost. The library runs entirely in the browser.
Search index
BackendSupabase 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.
Note: Add a GIN index on the tsvector column for menus with 500+ items to keep search sub-100ms.
The 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.
1create table public.menu_categories (2 id uuid primary key default gen_random_uuid(),3 name text not null,4 sort_order int not null default 0,5 is_active bool not null default true,6 created_at timestamptz not null default now()7);89create table public.menu_items (10 id uuid primary key default gen_random_uuid(),11 category_id uuid references public.menu_categories(id) on delete cascade not null,12 name text not null,13 description text,14 price decimal(10,2) not null,15 image_url text,16 dietary_tags jsonb not null default '[]',17 is_available bool not null default true,18 sort_order int not null default 0,19 created_at timestamptz not null default now()20);2122-- Enable RLS23alter table public.menu_categories enable row level security;24alter table public.menu_items enable row level security;2526-- Public can read active categories and available items27create policy "Public can view active categories"28 on public.menu_categories for select29 using (is_active = true);3031create policy "Public can view available items"32 on public.menu_items for select33 using (is_available = true);3435-- Staff (authenticated users) can manage all records36create policy "Staff can manage categories"37 on public.menu_categories for all38 using (auth.role() = 'authenticated')39 with check (auth.role() = 'authenticated');4041create policy "Staff can manage items"42 on public.menu_items for all43 using (auth.role() = 'authenticated')44 with check (auth.role() = 'authenticated');4546-- Indexes for performance47create index menu_items_category_idx on public.menu_items (category_id, sort_order);48create index menu_items_dietary_tags_idx on public.menu_items using gin (dietary_tags);49create index menu_categories_sort_idx on public.menu_categories (sort_order);Heads up: 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 it — pick your path
Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.
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.
Step by step
- 1Next.js App Router with Supabase — ISR for the customer menu, Server Actions for admin mutations
- 2Supabase Edge Function as a webhook receiver from POS systems (Square, Toast, Clover) to sync item availability and prices in real time
- 3Per-location menu variants using a location_id foreign key on both tables, with Supabase RLS filtering by the staff member's assigned location
- 4PDF 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
Where this path bites
- POS webhook integration typically adds 3–5 days to the build timeline and requires a paid POS developer account for testing
Third-party services you'll need
Most menu builder costs are Supabase-driven. Image CDN is optional but significantly improves load time on mobile connections.
| Service | What it does | Free tier | Paid from |
|---|---|---|---|
| Supabase | Database (menu_categories, menu_items), auth for staff login, Storage for item photos | 500MB DB, 1GB storage, 2 projects | $25/mo (Pro — 8GB DB, 100GB storage) |
| imgix | On-the-fly image resizing and CDN — serves thumbnails at the correct resolution for card grids instead of full-size uploads | None | From $10/mo (approx) |
| qrcode.react (npm) | Client-side QR code generation for the table tent / printed menu link | Free, no API call needed | Free |
| Cloudflare Images | Alternative image optimization CDN — stores and transforms item photos at the edge | None | $5/mo per 100,000 images (approx) |
Swipe the table sideways to see pricing.
What it costs to run
Drag through the tiers to see how your monthly bill scales with users — no surprises later.
Estimated monthly running cost
Supabase free tier covers storage and DB easily. QR code generation is client-side with no API cost. Image bandwidth at this scale stays within free limits.
Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.
What breaks when AI tools build this
The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.
Images upload in preview but return 403 in production
Symptom: 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
Symptom: 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
Symptom: 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
Symptom: 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
Symptom: 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
When You Need Custom Development
Lovable and V0 handle single-location, view-only digital menus well. These requirements push past what a two-session AI build can reliably deliver:
- Menu must sync in real time with a POS system (Square, Toast, Clover) via their webhooks — price and availability changes in the POS should reflect on the menu within seconds without manual edits
- Multi-location support where each branch has separate pricing, item availability, and portion sizes managed by branch managers with scoped admin access
- Custom PDF export of the full menu with brand fonts, logo, and layout matching printed menus — required by some franchise agreements and marketing teams
- Role-based editing where servers can mark items as temporarily unavailable during service but only managers can change prices or add new items
RapidDev builds these features for production
Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.
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.
Need this feature production-ready?
RapidDev builds a digital menu builder into real apps — auth, database, payments — at $13K–$25K.
Book a free consultation30-min call. No commitment.