# How to Build a Recipe App with Lovable

- Tool: How to Build with Lovable
- Difficulty: Beginner
- Compatibility: Lovable Free or higher
- Last updated: April 2026

## 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.

## Before you start

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

## Step-by-step guide

### 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.

```
Create a recipe app with Supabase. Set up these tables:

- ingredients: id (uuid pk), name (text unique not null), created_at
- 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
- 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)
- user_favorites: user_id (uuid references auth.users), recipe_id (uuid references recipes), saved_at, PRIMARY KEY (user_id, recipe_id)

RLS:
- recipes: anon/authenticated SELECT where is_published=true, authenticated INSERT/UPDATE/DELETE where created_by=auth.uid() or admin
- recipe_ingredients: anon/authenticated SELECT, authenticated INSERT/UPDATE/DELETE where recipe belongs to auth.uid()
- user_favorites: authenticated users manage their own rows only
- ingredients: anon/authenticated SELECT, authenticated INSERT

Create 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.

Create GIN index on recipes.search_vector.
Create 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.

```
Build a recipe browser page at src/pages/Recipes.tsx.

Layout:
- Page header 'Cookbook' with a recipe count
- Command search component (shadcn/ui):
  - CommandInput with placeholder 'Search by recipe name or ingredient...'
  - On input change (debounced 300ms): query supabase.from('recipes').textSearch('search_vector', searchQuery).eq('is_published', true)
  - CommandEmpty: 'No recipes found. Try a different ingredient or name.'
- Filter row below search:
  - Cuisine filter: a horizontal scrollable list of Badge ToggleGroup buttons — All, Italian, Mexican, Asian, American, etc. (populated from distinct cuisine values)
  - Difficulty filter: ToggleGroup — All, Easy, Medium, Hard
  - Max cook time: a Select with options — Any, Under 30 min, Under 1 hour
- Recipe Cards in a responsive grid (3 columns desktop, 2 tablet, 1 mobile):
  - Recipe photo (16:9 aspect, from image_url, fallback placeholder)
  - Title (h3 link to /recipes/:slug)
  - Cuisine Badge and Difficulty Badge side by side
  - Cook time icon + text, Servings icon + text
  - Heart icon Button: filled red if in user_favorites, outline if not. Click toggles favorite.
  - Brief description (2 lines, truncated)

Favorite toggle logic:
- On click: optimistic UI update immediately
- Check if favorite exists: if yes, delete from user_favorites; if no, insert
- 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.

```
Build a recipe detail page at src/pages/RecipeDetail.tsx. Route: /recipes/:slug.

Fetch: supabase.from('recipes').select('*, recipe_ingredients(quantity, unit, notes, sort_order, ingredients(name))').eq('slug', slug).single()

Layout:
- Large hero image (full width, 400px height, object-cover)
- Recipe title (h1), description paragraph
- Badges row: Cuisine, Difficulty, 'X min prep', 'X min cook', 'X servings'
- Horizontal Separator
- Two-column layout (desktop) / stacked (mobile):
  - Left: Ingredients section
    - Servings adjuster (number Input with +/- Buttons, default=recipe.servings)
    - Ingredient list: each item on its own line: '[quantity] [unit] [ingredient name] — [notes]'
    - Scale quantities proportionally when servings change
  - Right: Instructions section
    - Render instructions markdown: marked.parse() the instructions field
    - Each instruction step (if numbered list in markdown) gets a step number Badge
- Nutrition card below: calories, protein, carbs, fat (if columns exist)
- 'Save Recipe' heart Button (same favorites logic as Cards)
- 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.

```
Build an admin recipe editor at src/pages/admin/RecipeEditor.tsx. Route: /admin/recipes/new and /admin/recipes/:id/edit.

Form sections (react-hook-form + zod):

Basic Info:
- Title (Input, required — auto-fills slug on change)
- Description (Textarea)
- Cuisine (Input with datalist suggestions from existing cuisines)
- Difficulty (Select: Easy, Medium, Hard)
- Prep Time Minutes (Input number), Cook Time Minutes (Input number), Servings (Input number)
- Tags (multiple Input values shown as Badges below the field)
- Is Published (Switch)

Image Upload:
- File input (image/* only)
- Upload to 'recipe-images' bucket at path recipes/{slug}/{filename}
- Show thumbnail preview, replace on new upload

Ingredients Builder:
- 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
- '+' Button to add new ingredient row
- Trash icon to remove a row

Instructions:
- Textarea with monospace font and markdown note

On Save:
- Upsert recipes row
- Delete existing recipe_ingredients for this recipe, re-insert all current rows
- 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 example

File: `src/hooks/useRecipeSearch.ts`

```typescript
import { useState, useCallback, useEffect } from 'react'
import { useDebounce } from '@/hooks/useDebounce'
import { supabase } from '@/integrations/supabase/client'

export interface RecipeFilter {
  query: string
  cuisine: string | null
  difficulty: string | null
  maxCookMinutes: number | null
}

export interface RecipeSummary {
  id: string
  title: string
  slug: string
  image_url: string | null
  cuisine: string
  difficulty: string
  cook_time_minutes: number
  prep_time_minutes: number
  servings: number
  description: string | null
}

export function useRecipeSearch(filters: RecipeFilter) {
  const [recipes, setRecipes] = useState<RecipeSummary[]>([])
  const [loading, setLoading] = useState(false)
  const [count, setCount] = useState(0)

  const debouncedQuery = useDebounce(filters.query, 300)

  const fetchRecipes = useCallback(async () => {
    setLoading(true)
    try {
      let query = supabase
        .from('recipes')
        .select('id, title, slug, image_url, cuisine, difficulty, cook_time_minutes, prep_time_minutes, servings, description', { count: 'exact' })
        .eq('is_published', true)
        .order('created_at', { ascending: false })

      if (debouncedQuery.trim().length > 0) {
        query = query.textSearch('search_vector', debouncedQuery.trim())
      }
      if (filters.cuisine) {
        query = query.eq('cuisine', filters.cuisine)
      }
      if (filters.difficulty) {
        query = query.eq('difficulty', filters.difficulty)
      }
      if (filters.maxCookMinutes) {
        query = query.lte('cook_time_minutes', filters.maxCookMinutes)
      }

      const { data, count: total, error } = await query
      if (error) throw error
      setRecipes(data ?? [])
      setCount(total ?? 0)
    } finally {
      setLoading(false)
    }
  }, [debouncedQuery, filters.cuisine, filters.difficulty, filters.maxCookMinutes])

  useEffect(() => { fetchRecipes() }, [fetchRecipes])

  return { recipes, loading, count, refetch: fetchRecipes }
}
```

## Common mistakes

- **Not updating search_vector when recipe_ingredients change** — If only the recipes table has the search trigger, adding new ingredients to a recipe does not update the search index. Searching for an ingredient added after the recipe was created returns no results. Fix: 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.
- **Storing ingredient quantities as numbers** — Cooking uses mixed fractions like '1/2 cup' or '1½ tsp' that cannot be stored accurately as floats. 0.333... for 1/3 cup is imprecise and displays poorly. Fix: 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.
- **Using a public Storage bucket for recipe images without size limits** — Without file size restrictions, a user can upload a 50MB RAW photo file which inflates your storage quota and makes the recipe page load slowly. Fix: 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.
- **Not compressing uploaded recipe images causing slow page loads** — A high-resolution photo uploaded directly to Supabase Storage can be 3-8MB. Loading a recipe grid with 20 uncompressed images means the browser downloads 60-160MB, causing long load times especially on mobile connections. Fix: 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.

## 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.

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/recipe-app
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/recipe-app
