Skip to main content
RapidDev - Software Development Agency

How to Build a Recipe App with Lovable

Build a searchable recipe cookbook in Lovable with an ingredients-based search, recipes and ingredients tables, a favorites system, and a Command search component. Badge components display cuisine type and difficulty. The full-text search uses Supabase tsvector so searching 'chicken garlic' instantly finds every matching recipe — build it in about an hour.

What you'll build

  • recipes and ingredients tables with a junction for ingredient lists and quantity tracking
  • tsvector search_vector column combining recipe name, ingredients, and tags with a GIN index
  • Command search component that finds recipes by name or ingredient in real time
  • Recipe Cards with cuisine Badge, difficulty Badge, cook time, and a heart favorites button
  • Favorites system using a user_favorites junction table with a Saved Recipes page
  • Recipe detail page with full ingredients list, step-by-step instructions, and nutrition info
  • Admin recipe editor with image upload to Supabase Storage
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner13 min read1–1.5 hoursLovable Free or higherApril 2026RapidDev Engineering Team
TL;DR

Build a searchable recipe cookbook in Lovable with an ingredients-based search, recipes and ingredients tables, a favorites system, and a Command search component. Badge components display cuisine type and difficulty. The full-text search uses Supabase tsvector so searching 'chicken garlic' instantly finds every matching recipe — build it in about an hour.

What you're building

A recipe app's most valuable feature is ingredient-based search — the ability to type 'chicken lemon' and find all recipes that use both. Standard LIKE queries can do this for individual ingredients but are slow and miss stemming variations ('lemons' vs 'lemon'). The tsvector search vector combines the recipe title, all ingredient names, and any tags into a single indexed field that PostgreSQL's full-text engine handles with stemming included.

The schema uses a recipes_ingredients junction table to link recipes to their ingredients with quantity and unit. This normalized structure lets you search ingredients accurately and display the full ingredient list on the recipe detail page. The search_vector trigger concatenates all ingredient names along with the recipe title so the search engine covers both recipe names and individual ingredients in one query.

Favorites use a simple user_favorites table with a (user_id, recipe_id) pair. The heart button on each recipe card toggles a favorite by inserting or deleting the row. RLS ensures users can only see and manage their own favorites.

Final result

A full-featured recipe cookbook with ingredient search, favorites, beautiful cards, and an admin editor — all in under 2 hours.

Tech stack

LovableFrontend recipe browser
SupabaseDatabase, Auth, Storage
Supabase StorageRecipe photo uploads
shadcn/uiCommand, Badge, Card, Avatar, ToggleGroup components

Prerequisites

  • Lovable Free account or higher
  • Supabase project with URL and anon key saved to Cloud tab → Secrets
  • A Supabase Storage bucket named 'recipe-images' set to public
  • Supabase Auth for favorites and admin features (email/password works fine)

Build steps

1

Create the recipe schema with ingredient search

The key schema design decision is the search_vector trigger. It must include ingredient names from the junction table, not just the recipe title. This requires a custom trigger function that joins across tables.

prompt.txt
1Create a recipe app with Supabase. Set up these tables:
2
3- ingredients: id (uuid pk), name (text unique not null), created_at
4- recipes: id (uuid pk), title (text not null), slug (text unique), description (text), cuisine (text, e.g. 'Italian', 'Mexican', 'Thai'), difficulty (text check in ('easy','medium','hard')), prep_time_minutes (int), cook_time_minutes (int), servings (int), calories_per_serving (int), image_url (text), instructions (text, markdown), tags (text array), is_published (bool default true), created_by (uuid references auth.users), search_vector (tsvector), created_at, updated_at
5- recipe_ingredients: id (uuid pk), recipe_id (uuid references recipes on delete cascade), ingredient_id (uuid references ingredients), quantity (text, e.g. '2'), unit (text, e.g. 'cups'), notes (text, e.g. 'finely chopped'), sort_order (int)
6- user_favorites: user_id (uuid references auth.users), recipe_id (uuid references recipes), saved_at, PRIMARY KEY (user_id, recipe_id)
7
8RLS:
9- recipes: anon/authenticated SELECT where is_published=true, authenticated INSERT/UPDATE/DELETE where created_by=auth.uid() or admin
10- recipe_ingredients: anon/authenticated SELECT, authenticated INSERT/UPDATE/DELETE where recipe belongs to auth.uid()
11- user_favorites: authenticated users manage their own rows only
12- ingredients: anon/authenticated SELECT, authenticated INSERT
13
14Create a trigger function update_recipe_search_vector() that fires AFTER INSERT/UPDATE on recipes AND after INSERT/DELETE on recipe_ingredients. It should build the vector by concatenating the recipe title, description, tags (array_to_string), and all ingredient names from recipe_ingredients joined with ingredients.
15
16Create GIN index on recipes.search_vector.
17Create index on recipes(cuisine) and recipes(difficulty).

Pro tip: The trigger that updates search_vector needs to fire on recipe_ingredients changes too, not just on recipes updates. Ask Lovable explicitly: 'The update_recipe_search_vector trigger should fire on BOTH the recipes table and the recipe_ingredients table so ingredient changes update the search index.'

Expected result: All tables are created. The search_vector trigger fires on recipe and ingredient changes. GIN index is active. TypeScript types are generated.

2

Build the recipe browser with Command search

Create the main recipe browsing page. The Command component handles keyboard-driven search. Filters for cuisine and difficulty use Badge ToggleGroups.

prompt.txt
1Build a recipe browser page at src/pages/Recipes.tsx.
2
3Layout:
4- Page header 'Cookbook' with a recipe count
5- Command search component (shadcn/ui):
6 - CommandInput with placeholder 'Search by recipe name or ingredient...'
7 - On input change (debounced 300ms): query supabase.from('recipes').textSearch('search_vector', searchQuery).eq('is_published', true)
8 - CommandEmpty: 'No recipes found. Try a different ingredient or name.'
9- Filter row below search:
10 - Cuisine filter: a horizontal scrollable list of Badge ToggleGroup buttons All, Italian, Mexican, Asian, American, etc. (populated from distinct cuisine values)
11 - Difficulty filter: ToggleGroup All, Easy, Medium, Hard
12 - Max cook time: a Select with options Any, Under 30 min, Under 1 hour
13- Recipe Cards in a responsive grid (3 columns desktop, 2 tablet, 1 mobile):
14 - Recipe photo (16:9 aspect, from image_url, fallback placeholder)
15 - Title (h3 link to /recipes/:slug)
16 - Cuisine Badge and Difficulty Badge side by side
17 - Cook time icon + text, Servings icon + text
18 - Heart icon Button: filled red if in user_favorites, outline if not. Click toggles favorite.
19 - Brief description (2 lines, truncated)
20
21Favorite toggle logic:
22- On click: optimistic UI update immediately
23- Check if favorite exists: if yes, delete from user_favorites; if no, insert
24- On error: revert optimistic update and show toast

Pro tip: For the cuisine filter, fetch distinct cuisines dynamically: supabase.from('recipes').select('cuisine').eq('is_published', true). Map to unique values. This way the filter buttons stay current as new recipes with new cuisines are added without code changes.

Expected result: The recipe grid renders. The Command search finds recipes by ingredient name. Clicking cuisine Badges filters the grid. The heart button toggles favorites with optimistic UI.

3

Build the recipe detail page

Create the full recipe page with ingredients list, step-by-step instructions rendered from markdown, and nutrition info.

prompt.txt
1Build a recipe detail page at src/pages/RecipeDetail.tsx. Route: /recipes/:slug.
2
3Fetch: supabase.from('recipes').select('*, recipe_ingredients(quantity, unit, notes, sort_order, ingredients(name))').eq('slug', slug).single()
4
5Layout:
6- Large hero image (full width, 400px height, object-cover)
7- Recipe title (h1), description paragraph
8- Badges row: Cuisine, Difficulty, 'X min prep', 'X min cook', 'X servings'
9- Horizontal Separator
10- Two-column layout (desktop) / stacked (mobile):
11 - Left: Ingredients section
12 - Servings adjuster (number Input with +/- Buttons, default=recipe.servings)
13 - Ingredient list: each item on its own line: '[quantity] [unit] [ingredient name] — [notes]'
14 - Scale quantities proportionally when servings change
15 - Right: Instructions section
16 - Render instructions markdown: marked.parse() the instructions field
17 - Each instruction step (if numbered list in markdown) gets a step number Badge
18- Nutrition card below: calories, protein, carbs, fat (if columns exist)
19- 'Save Recipe' heart Button (same favorites logic as Cards)
20- Sharing: native share button using navigator.share() if supported

Expected result: The recipe detail page renders with hero image, ingredient list, and markdown instructions. Adjusting servings scales ingredient quantities proportionally. The save button toggles favorites.

4

Build the recipe editor with image upload

Create the admin recipe editor with fields for all recipe properties, an ingredient builder, and image upload to Supabase Storage.

prompt.txt
1Build an admin recipe editor at src/pages/admin/RecipeEditor.tsx. Route: /admin/recipes/new and /admin/recipes/:id/edit.
2
3Form sections (react-hook-form + zod):
4
5Basic Info:
6- Title (Input, required auto-fills slug on change)
7- Description (Textarea)
8- Cuisine (Input with datalist suggestions from existing cuisines)
9- Difficulty (Select: Easy, Medium, Hard)
10- Prep Time Minutes (Input number), Cook Time Minutes (Input number), Servings (Input number)
11- Tags (multiple Input values shown as Badges below the field)
12- Is Published (Switch)
13
14Image Upload:
15- File input (image/* only)
16- Upload to 'recipe-images' bucket at path recipes/{slug}/{filename}
17- Show thumbnail preview, replace on new upload
18
19Ingredients Builder:
20- A dynamic list where each row has: Quantity (Input), Unit (Input or Select), Ingredient Name (Combobox that searches existing ingredients or creates new ones on submit), Notes (Input), drag handle for reordering
21- '+' Button to add new ingredient row
22- Trash icon to remove a row
23
24Instructions:
25- Textarea with monospace font and markdown note
26
27On Save:
28- Upsert recipes row
29- Delete existing recipe_ingredients for this recipe, re-insert all current rows
30- Navigate to the recipe detail page

Expected result: The recipe editor saves all fields. Ingredient rows are added and removed dynamically. Image upload stores the file and shows a thumbnail. Saving navigates to the recipe page.

Complete code

src/hooks/useRecipeSearch.ts
1import { useState, useCallback, useEffect } from 'react'
2import { useDebounce } from '@/hooks/useDebounce'
3import { supabase } from '@/integrations/supabase/client'
4
5export interface RecipeFilter {
6 query: string
7 cuisine: string | null
8 difficulty: string | null
9 maxCookMinutes: number | null
10}
11
12export interface RecipeSummary {
13 id: string
14 title: string
15 slug: string
16 image_url: string | null
17 cuisine: string
18 difficulty: string
19 cook_time_minutes: number
20 prep_time_minutes: number
21 servings: number
22 description: string | null
23}
24
25export function useRecipeSearch(filters: RecipeFilter) {
26 const [recipes, setRecipes] = useState<RecipeSummary[]>([])
27 const [loading, setLoading] = useState(false)
28 const [count, setCount] = useState(0)
29
30 const debouncedQuery = useDebounce(filters.query, 300)
31
32 const fetchRecipes = useCallback(async () => {
33 setLoading(true)
34 try {
35 let query = supabase
36 .from('recipes')
37 .select('id, title, slug, image_url, cuisine, difficulty, cook_time_minutes, prep_time_minutes, servings, description', { count: 'exact' })
38 .eq('is_published', true)
39 .order('created_at', { ascending: false })
40
41 if (debouncedQuery.trim().length > 0) {
42 query = query.textSearch('search_vector', debouncedQuery.trim())
43 }
44 if (filters.cuisine) {
45 query = query.eq('cuisine', filters.cuisine)
46 }
47 if (filters.difficulty) {
48 query = query.eq('difficulty', filters.difficulty)
49 }
50 if (filters.maxCookMinutes) {
51 query = query.lte('cook_time_minutes', filters.maxCookMinutes)
52 }
53
54 const { data, count: total, error } = await query
55 if (error) throw error
56 setRecipes(data ?? [])
57 setCount(total ?? 0)
58 } finally {
59 setLoading(false)
60 }
61 }, [debouncedQuery, filters.cuisine, filters.difficulty, filters.maxCookMinutes])
62
63 useEffect(() => { fetchRecipes() }, [fetchRecipes])
64
65 return { recipes, loading, count, refetch: fetchRecipes }
66}

Customization ideas

Meal planner with weekly grid

Add a meal_plans table with (user_id, date, meal_type: breakfast/lunch/dinner, recipe_id). Build a weekly calendar grid where users drag recipe cards onto time slots. A 'Shopping List' button aggregates all ingredients from the week's planned recipes into a checklist.

Recipe rating and reviews

Add a recipe_reviews table with rating (1-5), body text, and cook notes. Show average rating on recipe cards. The recipe detail page shows recent reviews below the instructions. Use a trigger to maintain average_rating on the recipes table, matching the directory-service pattern.

Ingredient substitution suggestions

Add a substitutions table (ingredient_id, substitute_ingredient_id, notes). On the recipe detail page, show a 'Substitutions' tooltip for each ingredient listing alternatives. Populate this with common substitutions (butter → olive oil, buttermilk → milk + vinegar).

Smart pantry mode

Add a user_pantry table (user_id, ingredient_id, quantity, unit). Users mark which ingredients they have at home. A 'What can I make?' mode queries recipes where all required ingredients exist in the user's pantry, or shows a 'Missing 1 ingredient' badge for near-matches.

Printable recipe cards

Add a 'Print' button on the recipe detail page that opens a print-optimized view with larger text, no navigation, and all ingredients and instructions on one page. Use CSS @media print to hide non-essential elements and a @page rule to set paper margins.

Common pitfalls

Pitfall: Not updating search_vector when recipe_ingredients change

How to avoid: Add a trigger on the recipe_ingredients table (AFTER INSERT, UPDATE, DELETE) that calls the same update_recipe_search_vector() function, fetching the recipe_id from NEW or OLD and rebuilding the vector.

Pitfall: Storing ingredient quantities as numbers

How to avoid: Store quantity as text ('1/2', '1 1/2', '2-3'). Scale quantities for the servings adjuster by parsing the fraction in JavaScript — libraries like fraction.js handle mixed number parsing cleanly.

Pitfall: Using a public Storage bucket for recipe images without size limits

How to avoid: Add a client-side size check before uploading: if (file.size > 3 * 1024 * 1024) { showToast('Image must be under 3MB'); return; }. Configure Supabase Storage allowed MIME types to image/jpeg, image/png, image/webp.

Pitfall: Not compressing uploaded recipe images causing slow page loads

How to avoid: Use the browser's Canvas API to resize and compress the image before upload. Draw the file to a canvas at a max width of 1200px, then call canvas.toBlob(callback, 'image/webp', 0.8) to compress to WebP at 80% quality. Upload the compressed blob instead of the original file — typical recipe photos compress to under 200KB.

Best practices

  • Build the search_vector trigger to include ingredient names from the recipe_ingredients junction table — not just the recipe title. This is what makes ingredient-based search actually work.
  • Store the slug as a unique lowercase-hyphenated string generated from the title. Recipe URLs like /recipes/lemon-garlic-chicken are shareable and memorable.
  • Use optimistic UI for the favorites heart button. Toggling favorites is a common gesture — users tap it repeatedly. Waiting for a network round-trip before showing the state change feels sluggish.
  • Normalize ingredients into their own table (the ingredients table) rather than storing them as a text array on recipes. This allows searching by exact ingredient, computing 'what can I make from my pantry', and ingredient substitution features.
  • Add a servings adjuster that scales ingredient quantities proportionally. Multiply all quantities by (newServings / originalServings). Parse fractions with a library or simple regex before scaling.
  • Cache the cuisine and difficulty distinct values in component state rather than fetching them on every render. These change rarely and a fresh fetch on mount is sufficient.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a recipe app with Supabase. The recipes table has a search_vector tsvector column. The recipe_ingredients table has ingredient_id referencing an ingredients table. I want a PostgreSQL trigger function that rebuilds recipes.search_vector whenever either the recipes row changes OR when a recipe_ingredients row for that recipe changes. The vector should combine the recipe title, description, array_to_string(tags), and all ingredient names from the junction table. Write the complete trigger function and the CREATE TRIGGER statements for both tables.

Lovable Prompt

Add a 'Saved Recipes' page at /saved. Fetch all recipes in the current user's user_favorites table ordered by saved_at DESC. Render them the same Card grid as the main recipes page with a heart button that is always filled since these are all saved. Add an empty state: 'No saved recipes yet. Tap the heart on any recipe to save it.' Show the count in the page header.

Build Prompt

In Supabase, create an RLS policy on user_favorites that allows authenticated users to INSERT, DELETE, and SELECT only rows where user_id = auth.uid(). Also add a policy that allows anon users to SELECT a count of favorites per recipe (for showing popularity on recipe cards) without exposing which users saved what. Write both CREATE POLICY SQL statements.

Frequently asked questions

Can visitors search recipes without logging in?

Yes. The RLS SELECT policy on recipes allows anon users to read published recipes. The search_vector textSearch query works without authentication. Only favorites require a logged-in session, since they are stored per user_id. Show a 'Sign in to save recipes' prompt when an unauthenticated user clicks the heart button.

How does ingredient-based search work technically?

The search_vector column is a tsvector built by the trigger function. It includes the recipe title, description, tags, AND all ingredient names from the recipe_ingredients junction table. When you search 'garlic lemon', PostgreSQL's to_tsquery converts it to garlic & lemon and finds all recipes where both words appear anywhere in the vector — which includes ingredient names.

How do I handle recipe servings scaling with fractions?

Store quantities as text strings ('1/2', '1 1/4'). In the servings adjuster, parse the fraction to a decimal, multiply by the scale factor (newServings / originalServings), then format back to a readable fraction. The fraction.js npm package handles this cleanly. Install it via npm install fraction.js and import in your servings scaler component.

Can multiple users add recipes, or is it admin-only?

The schema supports both. Currently, is_published defaults to true for simplicity. For a user-contributed cookbook, change is_published to default false and add a moderation queue (similar to the directory-service pattern). The RLS INSERT policy already allows any authenticated user to add recipes — just tie them to a review workflow.

How do I add nutritional information to recipes?

Add columns to the recipes table: calories_per_serving (int), protein_g (numeric), carbs_g (numeric), fat_g (numeric), fiber_g (numeric). Add these fields to the recipe editor form. Show a Nutrition Card on the recipe detail page. For automatic nutrition calculation, call a nutrition API (like Nutritionix or USDA FoodData Central) in an Edge Function during recipe save, passing the ingredient list.

Can I import recipes from other sites automatically?

Not easily — most recipe sites block scraping. The best approach is a manual import form where users paste a recipe URL, and an Edge Function fetches the page HTML and attempts to parse the recipe schema.org JSON-LD that most recipe sites include. Parse the structured data fields into your schema. This works for roughly 70% of modern recipe sites.

Can RapidDev help me add a meal planner or shopping list feature?

RapidDev builds full recipe and food apps on Lovable including meal planners, automated shopping lists aggregated from meal plans, and nutrition dashboards. Reach out if your cookbook needs to grow beyond search and favorites.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.