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
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
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.
1Create a recipe app with Supabase. Set up these tables:23- ingredients: id (uuid pk), name (text unique not null), created_at4- 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_at5- 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)78RLS:9- recipes: anon/authenticated SELECT where is_published=true, authenticated INSERT/UPDATE/DELETE where created_by=auth.uid() or admin10- recipe_ingredients: anon/authenticated SELECT, authenticated INSERT/UPDATE/DELETE where recipe belongs to auth.uid()11- user_favorites: authenticated users manage their own rows only12- ingredients: anon/authenticated SELECT, authenticated INSERT1314Create 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.1516Create 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.
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.
1Build a recipe browser page at src/pages/Recipes.tsx.23Layout:4- Page header 'Cookbook' with a recipe count5- 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, Hard12 - Max cook time: a Select with options — Any, Under 30 min, Under 1 hour13- 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 side17 - Cook time icon + text, Servings icon + text18 - Heart icon Button: filled red if in user_favorites, outline if not. Click toggles favorite.19 - Brief description (2 lines, truncated)2021Favorite toggle logic:22- On click: optimistic UI update immediately23- Check if favorite exists: if yes, delete from user_favorites; if no, insert24- On error: revert optimistic update and show toastPro 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.
Build the recipe detail page
Create the full recipe page with ingredients list, step-by-step instructions rendered from markdown, and nutrition info.
1Build a recipe detail page at src/pages/RecipeDetail.tsx. Route: /recipes/:slug.23Fetch: supabase.from('recipes').select('*, recipe_ingredients(quantity, unit, notes, sort_order, ingredients(name))').eq('slug', slug).single()45Layout:6- Large hero image (full width, 400px height, object-cover)7- Recipe title (h1), description paragraph8- Badges row: Cuisine, Difficulty, 'X min prep', 'X min cook', 'X servings'9- Horizontal Separator10- Two-column layout (desktop) / stacked (mobile):11 - Left: Ingredients section12 - 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 change15 - Right: Instructions section16 - Render instructions markdown: marked.parse() the instructions field17 - Each instruction step (if numbered list in markdown) gets a step number Badge18- 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 supportedExpected 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.
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.
1Build an admin recipe editor at src/pages/admin/RecipeEditor.tsx. Route: /admin/recipes/new and /admin/recipes/:id/edit.23Form sections (react-hook-form + zod):45Basic 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)1314Image Upload:15- File input (image/* only)16- Upload to 'recipe-images' bucket at path recipes/{slug}/{filename}17- Show thumbnail preview, replace on new upload1819Ingredients 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 reordering21- '+' Button to add new ingredient row22- Trash icon to remove a row2324Instructions:25- Textarea with monospace font and markdown note2627On Save:28- Upsert recipes row29- Delete existing recipe_ingredients for this recipe, re-insert all current rows30- Navigate to the recipe detail pageExpected 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
1import { useState, useCallback, useEffect } from 'react'2import { useDebounce } from '@/hooks/useDebounce'3import { supabase } from '@/integrations/supabase/client'45export interface RecipeFilter {6 query: string7 cuisine: string | null8 difficulty: string | null9 maxCookMinutes: number | null10}1112export interface RecipeSummary {13 id: string14 title: string15 slug: string16 image_url: string | null17 cuisine: string18 difficulty: string19 cook_time_minutes: number20 prep_time_minutes: number21 servings: number22 description: string | null23}2425export function useRecipeSearch(filters: RecipeFilter) {26 const [recipes, setRecipes] = useState<RecipeSummary[]>([])27 const [loading, setLoading] = useState(false)28 const [count, setCount] = useState(0)2930 const debouncedQuery = useDebounce(filters.query, 300)3132 const fetchRecipes = useCallback(async () => {33 setLoading(true)34 try {35 let query = supabase36 .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 })4041 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 }5354 const { data, count: total, error } = await query55 if (error) throw error56 setRecipes(data ?? [])57 setCount(total ?? 0)58 } finally {59 setLoading(false)60 }61 }, [debouncedQuery, filters.cuisine, filters.difficulty, filters.maxCookMinutes])6263 useEffect(() => { fetchRecipes() }, [fetchRecipes])6465 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.
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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation