---
title: "Build a \"What's for Dinner?\" Button: Random Recipe API — Tiny Plates"
description: "Build a what's-for-dinner button with a random recipe API approach: pick from popular recipes, filter vegetarian or quick, in plain HTML and React."
url: "https://www.tinyplates.dev/blog/random-recipe-api"
---

# Build a "what's for dinner?" button with a random recipe API

One button, one random dinner, with filters for vegetarian and quick meals.

24 October 2026

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](https://www.tinyplates.dev/blog/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:

1. Fetch a page of 100 recipes from `/popular`. That's one request.
2. Keep only the ones that match your filters, like vegetarian or under 30 minutes.
3. 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](https://www.tinyplates.dev/sign-up). 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:

```js
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:

```html
<!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:

```js
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](https://www.tinyplates.dev/blog/build-a-fridge-roulette-app-with-react) 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`:

```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](https://www.tinyplates.dev/sign-up) and press the button tonight.
