---
title: "Build Fridge Roulette: a what-can-I-cook app with React — Tiny Plates"
description: "How to build a what-can-I-cook app with Vite, React and the Tiny Plates API. Match recipes to your fridge with /pantry and suggest swaps with /substitutions."
url: "https://www.tinyplates.dev/blog/build-a-fridge-roulette-app-with-react"
---

# Build Fridge Roulette: a what-can-I-cook app with React

Type in what's in your fridge, hit Spin, and get a recipe you can cook tonight.

14 October 2026

It's 7pm. You open the fridge. There are some eggs, half a bag of spinach, a block of feta and a tub of leftover rice. You stare at it for a while. Then you close the door and order a pizza.

Let's fix that with a tiny app. You type in what you've got, hit a big orange Spin button, and a reel of recipes flicks past until it lands on one you can actually cook. If you're missing something, it tells you what to use instead.

We'll call it Fridge Roulette. It's a Vite and React app, about 150 lines, and it uses two Tiny Plates endpoints: `/pantry` and `/substitutions`.

Here's the finished thing, running for real. Type in what's in your fridge and give it a spin:

## Fridge Roulette

Live, on the real API. Go on, give it a spin.

Fun? Ok, let's build it. First, grab a free key from the [sign-up page](https://www.tinyplates.dev/sign-up) and try the pantry endpoint with curl:

```bash
curl 'https://api.tinyplates.dev/pantry?ingredients=eggs,spinach,feta,rice&maxMissing=1' \
  -H 'Authorization: Bearer rd_your_api_key'

```

You get recipes back, best match first. Each one is a normal recipe object with four extra fields. Trimmed down, a result looks something like this:

```json
{
  "title": "Spinach and Feta Frittata",
  "coverage": 0.86,
  "matchedIngredients": ["eggs", "spinach", "feta"],
  "missingIngredients": ["butter"],
  "assumedIngredients": ["olive oil", "salt"]
}

```

`coverage` is how much of the recipe your fridge covers, from 0 to 1\. `missingIngredients` is your shopping list. `maxMissing` sets how many missing things you'll put up with. 0 means "I'm not leaving the house". We also assume you have the basics, like salt, oil and flour. They show up in `assumedIngredients`. Send `staples=false` if your cupboard really is bare.

That's the whole data side. Now let's make it fun.

## Set up the app

```bash
npm create vite@latest fridge-roulette -- --template react-ts
cd fridge-roulette
npm install

```

Put your key in `.env.local`:

```bash
TINYPLATES_API_KEY=rd_your_api_key

```

Notice there's no `VITE_` prefix. That's on purpose. Vite only sends variables starting with `VITE_` to the browser, and an API key should never end up there. Anyone could open dev tools and take it.

So how does the browser call the API? Through Vite's dev proxy. The browser asks `/api/pantry`, and Vite forwards it to Tiny Plates with the key attached. Here's `vite.config.ts`:

```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'
        }
      }
    }
  }
})

```

## Talk to the API

Next, a small `src/api.ts` with a type for the bits we use and two functions:

```ts
export interface PantryRecipe {
  coverage: number
  id: string
  matchedIngredients: string[]
  media: { role?: string; url: string }[]
  missingIngredients: string[]
  times: { total?: { seconds: number } }
  title: string
  url: string
}

export interface Substitution {
  ingredient: string
  options: {
    caution?: string
    ingredient: string
    note?: string
    ratio: number
  }[]
}

async function get<T>(path: string, params: Record<string, string>) {
  const response = await fetch(`/api${path}?${new URLSearchParams(params)}`)

  if (!response.ok) {
    const body = await response.json().catch(() => null)

    throw new Error(body?.error?.message ?? `Tiny Plates answered ${response.status}.`)
  }

  return (await response.json()) as T
}

export async function findRecipes(ingredients: string[], maxMissing: number) {
  const data = await get<{ recipes: PantryRecipe[] }>('/pantry', {
    ingredients: ingredients.join(','),
    limit: '12',
    maxMissing: String(maxMissing)
  })

  return data.recipes
}

export async function findSwaps(ingredients: string[]) {
  if (ingredients.length === 0) {
    return []
  }

  const data = await get<{ substitutions: Substitution[] }>('/substitutions', {
    ingredients: ingredients.join(',')
  })

  return data.substitutions
}

```

One nice thing: when something goes wrong, the API's error messages are written for humans. Forget the key and you'll see "An API key is required. Pass it as an Authorization: Bearer header." Send too many ingredients and it tells you the limit. So we just show `error.message` to the user and don't write our own.

## Spin the wheel

This is the silly part, and the best part. We take the 12 recipes, flick through them on screen, slow down a little each tick, and stop on a random one. No animation library. Just `setTimeout` in `src/spin.ts`:

```ts
import type { PantryRecipe } from './api'

export function spin(recipes: PantryRecipe[], onTick: (recipe: PantryRecipe) => void) {
  const winner = Math.floor(Math.random() * recipes.length)
  const lastTick = recipes.length * 2 + winner

  return new Promise<PantryRecipe>((resolve) => {
    let tick = 0

    function step() {
      onTick(recipes[tick % recipes.length])

      if (tick === lastTick) {
        resolve(recipes[winner])

        return
      }

      tick++
      setTimeout(step, 40 + tick * 4)
    }

    step()
  })
}

```

It goes round the list twice, then stops on the winner. Because it returns a promise, the app can just `await` the spin.

## The app

`src/App.tsx` holds it together. Read the input, ask for recipes, spin, then look up swaps for whatever the winner is missing:

```tsx
import { type FormEvent, useState } from 'react'

import { findRecipes, findSwaps, type PantryRecipe, type Substitution } from './api'
import { RecipeCard } from './RecipeCard'
import { spin } from './spin'

export default function App() {
  const [error, setError] = useState<string | null>(null)
  const [fridge, setFridge] = useState('eggs, spinach, feta, rice')
  const [maxMissing, setMaxMissing] = useState(0)
  const [pick, setPick] = useState<PantryRecipe | null>(null)
  const [showing, setShowing] = useState<PantryRecipe | null>(null)
  const [spinning, setSpinning] = useState(false)
  const [swaps, setSwaps] = useState<Substitution[]>([])

  async function handleSpin(event: FormEvent) {
    event.preventDefault()

    const ingredients = fridge
      .split(',')
      .map((ingredient) => ingredient.trim())
      .filter(Boolean)

    setError(null)
    setPick(null)
    setSwaps([])
    setSpinning(true)

    try {
      const recipes = await findRecipes(ingredients, maxMissing)

      if (recipes.length === 0) {
        setError(
          maxMissing === 0
            ? 'Nothing you can make with exactly that. Tick the box to allow one missing ingredient.'
            : 'No luck this time. Add a few more things from your cupboard.'
        )

        return
      }

      const winner = await spin(recipes, setShowing)

      setPick(winner)
      setSwaps(await findSwaps(winner.missingIngredients))
    } catch (cause) {
      setError(cause instanceof Error ? cause.message : 'Something went wrong. Try again.')
    } finally {
      setSpinning(false)
    }
  }

  return (
    <main>
      <h1>Fridge Roulette</h1>

      <form onSubmit={handleSpin}>
        <label htmlFor="fridge">What's in your fridge?</label>
        <input id="fridge" value={fridge} onChange={(event) => setFridge(event.target.value)} />

        <label className="check">
          <input
            type="checkbox"
            checked={maxMissing === 1}
            onChange={(event) => setMaxMissing(event.target.checked ? 1 : 0)}
          />
          I'm happy to pop to the shop for one thing
        </label>

        <button disabled={spinning}>{spinning ? 'Spinning…' : 'Spin'}</button>
      </form>

      {spinning && showing && <p className="reel">{showing.title}</p>}
      {error && <p className="error">{error}</p>}
      {pick && <RecipeCard recipe={pick} swaps={swaps} />}
    </main>
  )
}

```

Watch the empty case. With `maxMissing=0` and a weird fridge, you can get zero recipes. Instead of a blank screen, we tell people to tick the box. A small thing, but it's the difference between "this is broken" and "oh, ok".

## The recipe card

The winner gets a card with a photo, the cooking time, a little bar showing how much of it you already have, and the swaps.

```tsx
import type { PantryRecipe, Substitution } from './api'

interface Props {
  recipe: PantryRecipe
  swaps: Substitution[]
}

export function RecipeCard({ recipe, swaps }: Props) {
  const image = recipe.media.find((media) => media.role === 'hero') ?? recipe.media[0]
  const seconds = recipe.times.total?.seconds

  return (
    <article className="card">
      {image && <img src={image.url} alt="" />}

      <h2>{recipe.title}</h2>

      {seconds && <p className="muted">Ready in {Math.round(seconds / 60)} minutes</p>}

      <div className="coverage">
        <div style={{ width: `${recipe.coverage * 100}%` }} />
      </div>
      <p className="muted">Your fridge covers {Math.round(recipe.coverage * 100)}% of this.</p>

      {recipe.missingIngredients.length > 0 && (
        <p>
          <strong>You still need:</strong> {recipe.missingIngredients.join(', ')}
        </p>
      )}

      {swaps.map((swap) => {
        const [best] = swap.options

        return (
          <p className="swap" key={swap.ingredient}>
            No {swap.ingredient}? Use {best.ingredient}. {best.note}
            {best.caution && <em> {best.caution}</em>}
          </p>
        )
      })}

      <a href={recipe.url} target="_blank" rel="noreferrer">
        Open the recipe →
      </a>
    </article>
  )
}

```

The swaps are my favourite part. Ask `/substitutions` about butter and you get olive oil, with a `ratio` of 0.75 and a note saying to use three quarters as much. You also get a `caution`: don't do this for pastry or for creaming into a batter, because the solid fat is what gives those their texture. Showing that line is what makes the tip trustworthy. And if we don't know a good swap for something, it goes in `unknown` and we say nothing. No made-up advice.

Add some CSS (a cream background, a chunky orange button, a dark reel with yellow text worked for us), run `npm run dev`, and spin.

Each spin uses two requests, one for the pantry and one for swaps. The free plan gives you 50 a day, which is plenty of dinners.

One last thing before you ship it. The Vite proxy only runs in development. When you deploy, you need something on the server to add the key. A tiny serverless function that forwards `/api/*` to Tiny Plates does the job, and the React code doesn't change at all.

## Where to take it next

A few ideas if you want to keep going:

* **Cook for a crowd.** Fetch the winner with `/recipes/{id}?servings=6` and every amount gets scaled for you.
* **Make a shopping list.** Send the winner to `/shopping-list` and you get its ingredients sorted by aisle. We wrote about that in [One shopping list for a week of recipes](https://www.tinyplates.dev/blog/one-shopping-list-for-a-week-of-recipes).
* **Respect allergies.** Add `without=peanuts` to the pantry call and those recipes never come up.
* **Tell your agent.** Connect the [Tiny Plates MCP server](https://www.tinyplates.dev/docs/mcp) to Claude and just ask "what can I make with eggs and spinach?"

The full list of endpoints is in the [API docs](https://www.tinyplates.dev/docs). Now go spin something. Your fridge is waiting.
