Build a recipe search engine in an afternoon
A search box that understands what you mean, with filters, recipe cards and pages, in one React app.
Most recipe sites have a search box that only does one thing. It looks for the exact words you typed. Search "something warm for a rainy day" and you get nothing, because no recipe has that title.
Let's build a better one. A search box that understands what you mean, a few filters for diet, allergens, cuisine and time, a grid of recipe cards with photos, and pages to click through. It's a Vite and React app, a bit under 300 lines, and it uses one Tiny Plates endpoint: /search.
First, grab a free key from the sign-up page and try a search with curl:
curl 'https://api.tinyplates.dev/search?query=creamy+pasta&language=en&fields=cuisines,dietary,times,title&limit=3' \
-H 'Authorization: Bearer rd_your_api_key'
Trimmed down, you get something like this:
{
"results": [
{
"id": "69e22242d0295ac8a901193a",
"title": "Creamy Shrimp Pasta with Corn and Tomatoes",
"cuisines": ["american"],
"dietary": { "dairyFree": false, "pescatarian": true, "vegan": false },
"times": { "total": { "seconds": 1800 } },
"score": 0.0333
},
{
"id": "69e2274dd0295ac8a9011b11",
"title": "Creamy Vegan Red Pepper Pasta with Blistered Tomatoes",
"cuisines": ["italian"],
"dietary": { "dairyFree": true, "pescatarian": true, "vegan": true },
"times": { "total": { "seconds": 2100 } },
"score": 0.032
}
]
}
Here's what's going on under the hood. Every search runs two ways at once. One looks at the words, like a classic search box. The other looks at the meaning, using embeddings, so "creamy pasta" also finds a carbonara that never uses the word "creamy". Then the two lists get merged on where each one ranked a recipe, and a recipe whose title contains your query gets a little bump. That's the score. It's a rank, not a confidence, so only compare scores inside one response.
/search takes five parameters, and that's all of them:
queryis what to search for. It's required.limitis how many results you want. 10 by default, 50 at most.languagekeeps recipes in one language, likeen,esorsv.withoutleaves out recipes with these allergens, likemilk,peanuts.fieldstrims each recipe down to the fields you name.idandscorealways come back.
Notice what's not there. No offset, and no diet or cuisine filter. Search returns the best matches and stops. That shapes the whole app, so let's talk about it before we write any code.
One request, then everything else is local
The plan is simple. When someone searches, we ask for the top 50 results in one request. The diet, cuisine and time filters then run in the browser on those 50. So does paging. Ticking "vegan" or clicking "next page" costs nothing and feels instant.
Allergens are the exception. They go to the API as without, so a recipe with milk never even arrives. That's the safe way round for allergies. It's also a real filter on the server, so you get up to 50 milk-free results instead of 50 results with the milk ones picked out.
Why not page on the server? Because /search doesn't do it, on purpose. It ranks by relevance and only returns the best matches, and the pagination docs say the same: narrow the query instead. If you really need to walk the whole catalogue, /popular pages with limit and offset. /recipes has proper server-side filters for diet, cuisine and maxTotalMinutes, but it needs a business plan, and those aren't open yet.
There's one more reason to keep requests low. The free plan gives you 50 a day. A search box that fires on every keystroke would use that up before lunch. So we'll debounce the input, wait for at least three letters, and cache what we've already asked.
Set up the app
npm create vite@latest recipe-search -- --template react-ts
cd recipe-search
npm install
Put your key in .env.local, without a VITE_ prefix so it never reaches the browser, and send /api through Vite's dev proxy. It's the same setup as in Build Fridge Roulette, so have a look there if you want the reasons. Here's vite.config.ts:
import react from '@vitejs/plugin-react'
import { defineConfig, loadEnv } from 'vite'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
return {
plugins: [react()],
server: {
proxy: {
'/api': {
changeOrigin: true,
headers: { Authorization: `Bearer ${env.TINYPLATES_API_KEY}` },
rewrite: (path) => path.replace(/^\/api/, ''),
target: 'https://api.tinyplates.dev'
}
}
}
}
})
Next, src/api.ts. A type for the fields we ask for, and one function that searches. The cache is just a Map. Type "pasta", then "pasta bake", then back to "pasta", and the third one is free.
export type Diet = 'dairy-free' | 'gluten-free' | 'vegan' | 'vegetarian'
type DietFlag = 'dairyFree' | 'glutenFree' | 'vegan' | 'vegetarian'
export interface Recipe {
cuisines?: string[]
dietary?: Partial<Record<DietFlag, boolean>>
id: string
media?: { role?: string; type?: string; url: string }[]
score: number
times?: { total?: { seconds: number } }
title: string
url: string
}
export const dietFlags: Record<Diet, DietFlag> = {
'dairy-free': 'dairyFree',
'gluten-free': 'glutenFree',
vegan: 'vegan',
vegetarian: 'vegetarian'
}
const cache = new Map<string, Recipe[]>()
export async function searchRecipes(query: string, without: string[]) {
const key = `${query}|${[...without].sort().join(',')}`
const cached = cache.get(key)
if (cached) {
return cached
}
const params = new URLSearchParams({
fields: 'cuisines,dietary,media,times,title,url',
language: 'en',
limit: '50',
query
})
if (without.length > 0) {
params.set('without', without.join(','))
}
const response = await fetch(`/api/search?${params}`)
if (!response.ok) {
const body = await response.json().catch(() => null)
throw new Error(body?.error?.message ?? `Tiny Plates answered ${response.status}.`)
}
const data = (await response.json()) as { results: Recipe[] }
cache.set(key, data.results)
return data.results
}
As in the other guides, the API's error messages are written for people, so we pass them straight through. Run out of requests for the day and the app says so in plain words.
Debounce the input
Debouncing means "wait until they stop typing". Every keystroke resets a timer. Only when the timer runs out do we search. A small hook in src/useDebounced.ts does it:
import { useEffect, useState } from 'react'
export function useDebounced<T>(value: T, delay: number) {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debounced
}
400 milliseconds feels right. Fast enough that it seems live, slow enough that "chicken curry" is one request, not thirteen.
The filters
The local filters live in src/filters.ts. Diets work like in the meal planner guide: a recipe only passes if its flag is actually true. No flag means we don't know, and we'd rather leave it out than show a "vegan" dish with butter in it. The time filter does the same. If a recipe doesn't say how long it takes, it doesn't pass a "30 minutes or less" filter.
import { type Diet, dietFlags, type Recipe } from './api'
export interface Filters {
cuisine: string
diets: Diet[]
maxMinutes: number | null
}
export function applyFilters(recipes: Recipe[], filters: Filters) {
return recipes.filter((recipe) => {
const seconds = recipe.times?.total?.seconds
if (filters.cuisine && !recipe.cuisines?.includes(filters.cuisine)) {
return false
}
if (filters.maxMinutes !== null && (seconds === undefined || seconds / 60 > filters.maxMinutes)) {
return false
}
return filters.diets.every((diet) => recipe.dietary?.[dietFlags[diet]] === true)
})
}
// Only offer cuisines that are actually in the results, plus the one already picked.
export function cuisineOptions(recipes: Recipe[], picked: string) {
const names = recipes.flatMap((recipe) => recipe.cuisines ?? [])
return [...new Set([...names, picked])].filter(Boolean).sort()
}
Building the cuisine list from the results is a small trick that pays off. You never offer "Ethiopian" for a search that has no Ethiopian recipes, so nobody picks an option that leads to an empty page.
The app
src/App.tsx holds it all together:
import { useEffect, useState } from 'react'
import { type Diet, type Recipe, searchRecipes } from './api'
import { applyFilters, cuisineOptions } from './filters'
import { RecipeCard } from './RecipeCard'
import { useDebounced } from './useDebounced'
const allergens: Record<string, string> = {
eggs: 'Eggs',
'gluten-cereals': 'Gluten',
milk: 'Milk',
peanuts: 'Peanuts',
sesame: 'Sesame',
'tree-nuts-us': 'Tree nuts'
}
const diets: Diet[] = ['dairy-free', 'gluten-free', 'vegan', 'vegetarian']
const pageSize = 12
function toggle<T>(list: T[], item: T) {
return list.includes(item) ? list.filter((other) => other !== item) : [...list, item]
}
export default function App() {
const [cuisine, setCuisine] = useState('')
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [maxMinutes, setMaxMinutes] = useState<number | null>(null)
const [page, setPage] = useState(0)
const [picked, setPicked] = useState<Diet[]>([])
const [query, setQuery] = useState('')
const [results, setResults] = useState<Recipe[]>([])
const [without, setWithout] = useState<string[]>([])
const search = useDebounced(query.trim().toLowerCase(), 400)
useEffect(() => {
if (search.length < 3) {
setResults([])
return
}
let ignore = false
setError(null)
setLoading(true)
searchRecipes(search, without)
.then((recipes) => {
if (!ignore) {
setResults(recipes)
setPage(0)
}
})
.catch((cause) => {
if (!ignore) {
setError(cause instanceof Error ? cause.message : 'Something went wrong. Try again.')
}
})
.finally(() => {
if (!ignore) {
setLoading(false)
}
})
return () => {
ignore = true
}
}, [search, without])
const filtered = applyFilters(results, { cuisine, diets: picked, maxMinutes })
const pages = Math.max(1, Math.ceil(filtered.length / pageSize))
const current = Math.min(page, pages - 1)
const shown = filtered.slice(current * pageSize, (current + 1) * pageSize)
return (
<main>
<h1>Recipe search</h1>
<input
aria-label="Search recipes"
placeholder="Try “creamy pasta” or “something warm for a rainy day”"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<fieldset>
<legend>Diet</legend>
{diets.map((diet) => (
<label key={diet}>
<input
type="checkbox"
checked={picked.includes(diet)}
onChange={() => setPicked(toggle(picked, diet))}
/>
{diet}
</label>
))}
</fieldset>
<fieldset>
<legend>Leave out</legend>
{Object.entries(allergens).map(([id, label]) => (
<label key={id}>
<input
type="checkbox"
checked={without.includes(id)}
onChange={() => setWithout(toggle(without, id))}
/>
{label}
</label>
))}
</fieldset>
<select value={cuisine} onChange={(event) => setCuisine(event.target.value)}>
<option value="">Any cuisine</option>
{cuisineOptions(results, cuisine).map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
<select
value={maxMinutes ?? ''}
onChange={(event) => setMaxMinutes(event.target.value ? Number(event.target.value) : null)}
>
<option value="">Any time</option>
<option value="15">15 minutes or less</option>
<option value="30">30 minutes or less</option>
<option value="60">An hour or less</option>
</select>
{loading && <p className="muted">Searching…</p>}
{error && <p className="error">{error}</p>}
{!loading && !error && search.length >= 3 && filtered.length === 0 && (
<p className="muted">
{results.length > 0
? 'Nothing matches those filters. Untick one and try again.'
: 'No recipes for that. Try describing it a different way.'}
</p>
)}
<ul className="results">
{shown.map((recipe) => (
<li key={recipe.id}>
<RecipeCard recipe={recipe} />
</li>
))}
</ul>
{pages > 1 && (
<nav className="pager">
<button disabled={current === 0} onClick={() => setPage(current - 1)}>
← Previous
</button>
<span>
Page {current + 1} of {pages}
</span>
<button disabled={current === pages - 1} onClick={() => setPage(current + 1)}>
Next →
</button>
</nav>
)}
</main>
)
}
A few details are worth a closer look.
The ignore flag stops a classic bug. Say you type "soup", pause, then type "soup dumplings". Two requests are in flight. If the first one comes back last, it would overwrite the better results. With ignore, an old answer that arrives late is simply dropped.
Paging never needs resetting by hand. current is clamped to the pages that exist, so if you're on page 4 and tick "vegan" down to two pages, you land on page 2. A new search starts back at page 1.
And look at the empty case. "Nothing matches those filters" and "no recipes for that" are different problems with different fixes, so we say which one it is.
The recipe card
Each result is a small card with a photo, the title and a line of details:
import type { Recipe } from './api'
export function RecipeCard({ recipe }: { recipe: Recipe }) {
const images = recipe.media?.filter((media) => media.type === 'image') ?? []
const image = images.find((media) => media.role === 'hero') ?? images[0]
const seconds = recipe.times?.total?.seconds
const details = [
seconds === undefined ? null : `${Math.round(seconds / 60)} min`,
...(recipe.cuisines ?? []),
recipe.dietary?.vegan ? 'vegan' : recipe.dietary?.vegetarian ? 'vegetarian' : null
].filter(Boolean)
return (
<article className="card">
{image && <img src={image.url} alt="" loading="lazy" />}
<a href={recipe.url} target="_blank" rel="noreferrer">
{recipe.title}
</a>
{details.length > 0 && <p className="muted">{details.join(' · ')}</p>}
</article>
)
}
loading="lazy" matters more than it looks. With 12 photos a page, the browser only fetches the ones you scroll to. The link goes to the original recipe, which is also how we ask you to credit the source.
Add some CSS (a grid with repeat(auto-fill, minmax(220px, 1fr)) works well), run npm run dev and search for something vague. "Cozy autumn dinner" is a good one.
One search is one request. Filters and pages are free, and the cache makes repeat searches free too. That's a lot of searching on 50 a day while you build. If you put this in front of real visitors, though, you'll need more than that. Get in touch and we'll raise it. And remember the Vite proxy only runs in development. When you deploy, add a small server function that adds the key and forwards /api/* to Tiny Plates. The React code stays the same.
A fair word before you go. If you already have your own recipes and just want a search box over them, you don't need a recipe API at all. A search engine like Meilisearch or Typesense over your own data is the better pick. Tiny Plates makes sense when you want the recipes as well as the search.
A few ideas if you want to keep going:
- Put the search in the URL. Keep
queryand the filters inURLSearchParams, so people can share a search. - Search by what's in the fridge.
/pantrytakes a list of ingredients instead of words. We built Fridge Roulette on it. - Show the allergens. Add
allergenstofieldsand show what each recipe contains. There's more on that in Allergen data for every recipe. - Search in Swedish or Spanish. Change
languagetosvores.
Every parameter is in the search docs. Grab a free key and give it a go.