Skip to content

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.

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.

Separate things with commas, up to 20.

  • eggs
  • spinach
  • feta
  • rice

Fun? Ok, let's build it. First, grab a free key from the sign-up page and try the pantry endpoint with curl:

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:

{
  "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

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

Put your key in .env.local:

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:

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:

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:

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:

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.

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.
  • Respect allergies. Add without=peanuts to the pantry call and those recipes never come up.
  • Tell your agent. Connect the Tiny Plates MCP server to Claude and just ask "what can I make with eggs and spinach?"

The full list of endpoints is in the API docs. Now go spin something. Your fridge is waiting.