Build a "what's for dinner?" button with a random recipe API
One button, one random dinner, with filters for vegetarian and quick meals.
Every evening someone asks "what's for dinner?" and nobody has an answer. So let's build a button for it. One button, one random dinner. It's a nice first API project, because it's small, it's useful, and you'll actually press it.
Let's be upfront about one thing first. Tiny Plates doesn't have a random recipe endpoint. If you want one that does, there are good options. Spoonacular has /recipes/random, which can filter by tags like "vegetarian". TheMealDB has random.php, and its test key works without signing up. Check their terms though, because TheMealDB asks you to become a supporter before you release an app publicly. We compare a few more in free food APIs.
But you don't really need a special endpoint for this. Randomness is one line of JavaScript. What you need is a good pile of recipes to pick from. Here's the plan:
- Fetch a page of 100 recipes from
/popular. That's one request. - Keep only the ones that match your filters, like vegetarian or under 30 minutes.
- Pick one with
Math.random().
Each click after the first one is free, because we pick from the page we already have. That matters, since the free plan gives you 50 requests a day. Popular recipes are also a nice pool to pick from. They're the well-loved ones, so you won't land on something weird.
Keep the key off the page
Grab a free key from the sign-up page. Then don't put it in your HTML. Anything in browser code is public. Anyone can open dev tools and copy it.
So we add a tiny server in between. The browser asks it for /api/popular, and it forwards the request to Tiny Plates with the key attached. Here's server.mjs. It needs Node 18 or newer and no packages:
import { readFile } from 'node:fs/promises'
import { createServer } from 'node:http'
const key = process.env.TINYPLATES_API_KEY
createServer(async (request, response) => {
const url = new URL(request.url, 'http://localhost')
if (url.pathname === '/api/popular') {
const upstream = await fetch(`https://api.tinyplates.dev/popular${url.search}`, {
headers: { Authorization: `Bearer ${key}` }
})
response.writeHead(upstream.status, { 'Content-Type': 'application/json' })
response.end(await upstream.text())
return
}
response.writeHead(200, { 'Content-Type': 'text/html' })
response.end(await readFile('index.html'))
}).listen(3000)
It only forwards /popular, so nobody can use your key for anything else through it. Start it with TINYPLATES_API_KEY=rd_your_api_key node server.mjs and open http://localhost:3000.
Now index.html. Two checkboxes, a button and an empty card:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>What's for dinner?</title>
</head>
<body>
<h1>What's for dinner?</h1>
<label><input type="checkbox" id="veggie" /> Vegetarian</label>
<label><input type="checkbox" id="quick" /> Under 30 minutes</label>
<button id="pick">Pick dinner</button>
<div id="card"></div>
<script type="module">
// The script below goes here.
</script>
</body>
</html>
Our little server only serves index.html, so the script lives right inside it. Here it is:
const card = document.querySelector('#card')
let recipes = null
async function loadPage(offset) {
const response = await fetch(`/api/popular?limit=100&offset=${offset}`)
const body = await response.json().catch(() => null)
if (!response.ok) {
throw new Error(body?.error?.message ?? `Tiny Plates answered ${response.status}.`)
}
return body.recipes
}
async function getRecipes() {
if (!recipes) {
// A random page from the top 1,000, so you don't always get the same 100.
recipes = await loadPage(Math.floor(Math.random() * 10) * 100)
if (recipes.length === 0) {
recipes = await loadPage(0)
}
}
return recipes
}
function fits(recipe, { veggie, quick }) {
const seconds = recipe.times.total?.seconds
const isMain = recipe.courses.includes('main-course') || recipe.courses.includes('dinner')
if (!isMain) return false
if (veggie && recipe.dietary?.vegetarian !== true) return false
if (quick && !(seconds && seconds <= 30 * 60)) return false
return true
}
function show(recipe) {
const image = recipe.media.find((item) => item.type === 'image' && item.role === 'hero')
?? recipe.media.find((item) => item.type === 'image')
const minutes = recipe.times.total && Math.round(recipe.times.total.seconds / 60)
const parts = [
image && Object.assign(document.createElement('img'), { src: image.url, alt: '', width: 320 }),
Object.assign(document.createElement('h2'), { textContent: recipe.title }),
minutes && Object.assign(document.createElement('p'), { textContent: `Ready in ${minutes} minutes` }),
Object.assign(document.createElement('a'), { href: recipe.url, textContent: 'Open the recipe' })
]
card.replaceChildren(...parts.filter(Boolean))
}
document.querySelector('#pick').addEventListener('click', async () => {
const filters = {
quick: document.querySelector('#quick').checked,
veggie: document.querySelector('#veggie').checked
}
try {
const options = (await getRecipes()).filter((recipe) => fits(recipe, filters))
if (options.length === 0) {
card.textContent = 'Nothing in this batch fits. Untick a box, or reload for a new batch.'
return
}
show(options[Math.floor(Math.random() * options.length)])
} catch (error) {
card.textContent = error.message
}
})
A few notes. The courses check keeps cakes and cocktails out, since popular recipes include everything. For the diet, we check vegetarian === true. A missing flag means "we don't know", and you don't want to guess on someone's dinner. Times are in seconds and can be missing too, so a recipe without a total time won't pass the quick filter. We build the card with textContent instead of innerHTML, so a recipe title can't inject anything into your page. And the link goes to recipe.url, the page the recipe came from. Please keep that. It's how the cook gets credit.
If the filters leave you with nothing, try a different pool. /search matches on meaning, so /search?query=quick%20vegetarian%20dinner&limit=50 gives you a list that already leans the right way. Keep the fits check anyway, because search finds things that are close, not things that are guaranteed. You'd forward /search in the server the same way.
Now the React version. In a Vite app, the dev proxy from our Fridge Roulette guide does the server's job, so the key stays out of the browser there too. Copy fits from above into src/fits.ts and export it. Then src/App.tsx:
import { useRef, useState } from 'react'
import { fits } from './fits'
export default function App() {
const pool = useRef<any[] | null>(null)
const [filters, setFilters] = useState({ quick: false, veggie: false })
const [pick, setPick] = useState<any>(null)
const [error, setError] = useState<string | null>(null)
async function pickDinner() {
setError(null)
try {
if (!pool.current) {
const offset = Math.floor(Math.random() * 10) * 100
const response = await fetch(`/api/popular?limit=100&offset=${offset}`)
const body = await response.json()
if (!response.ok) throw new Error(body.error?.message)
pool.current = body.recipes
}
const options = pool.current!.filter((recipe) => fits(recipe, filters))
setPick(options[Math.floor(Math.random() * options.length)] ?? null)
if (options.length === 0) setError('Nothing fits. Untick a box.')
} catch (cause) {
setError(cause instanceof Error ? cause.message : 'Something went wrong.')
}
}
return (
<main>
<h1>What's for dinner?</h1>
{(['veggie', 'quick'] as const).map((name) => (
<label key={name}>
<input
type="checkbox"
checked={filters[name]}
onChange={(event) => setFilters({ ...filters, [name]: event.target.checked })}
/>
{name === 'veggie' ? 'Vegetarian' : 'Under 30 minutes'}
</label>
))}
<button onClick={pickDinner}>Pick dinner</button>
{error && <p>{error}</p>}
{pick && (
<article>
<h2>{pick.title}</h2>
<a href={pick.url}>Open the recipe</a>
</article>
)}
</main>
)
}
I've used any to keep it short. The Fridge Roulette guide has proper types for a recipe if you want them. Same deal when you deploy: the Vite proxy only runs in development, so put a small serverless function in front, just like server.mjs.
Want to take it further? A Slack bot is a great next step. A /dinner slash command runs on your server anyway, so the key is safe there. A browser new-tab page is fun too, but an extension ships its code to users, so still go through your own server. You could also store the pool in localStorage for a day, skip what you picked this week, or fetch the winner from /recipes/{id}?servings=6 to cook for a crowd.
And if your real question is "what can I make with what's in the fridge?", that's a different button. We built it:
Fridge Roulette
Live, on the real API. Go on, give it a spin.
Grab a free key and press the button tonight.