Skip to content

Build a weekly meal planner with React and the Tiny Plates API

Tick your diets, press one button and get a week of lunches and dinners. Swap anything you don't fancy.

It's Sunday evening. Someone asks what's for dinner this week. Nobody knows. You scroll through recipes for twenty minutes, pick three, forget about lunch, and on Wednesday you're eating toast again.

Let's fix that with a meal planner. You tick any diets you follow, press one button, and get a whole week: a quick lunch every day, an easy dinner on weeknights and something bigger to cook at the weekend. Don't fancy Tuesday's curry? Hit swap and get something else.

It's a Vite and React app, around 300 lines, and it only needs one Tiny Plates endpoint: /search.

Here's the finished thing, running for real. Tick a box and plan a week:

Live, on the real API

What's for dinner this week?

Easy lunches, weeknight dinners that don't fuss, and something to linger over at the weekend.

Anything we should keep in mind?

Your week goes here, Monday to Sunday, lunch and dinner. Don't fancy something? Swap it for another.

Nice, right? Let's build it. First, grab a free key from the sign-up page and try a search with curl:

curl 'https://api.tinyplates.dev/search?query=vegan+easy+curry&without=milk,eggs&fields=dietary,times,title,url&limit=3' \
  -H 'Authorization: Bearer rd_your_api_key'

/search matches on meaning, not just words. Ask for "easy curry" and you'll get a chickpea curry even if the word "easy" is nowhere in it. without leaves out recipes with those allergens, and fields trims each recipe down to what you need. Trimmed, a result looks something like this:

{
  "results": [
    {
      "id": "6a9821c38f8122ec891b6d12",
      "title": "Chickpea and Spinach Curry",
      "url": "https://example.com/chickpea-spinach-curry",
      "dietary": { "dairyFree": true, "glutenFree": true, "vegan": true, "vegetarian": true },
      "times": { "total": { "seconds": 2100 } },
      "score": 0.82
    }
  ]
}

Two things matter for a meal planner. dietary tells us which diets a recipe fits, and times.total tells us how long it takes. That's enough to plan a week.

Set up the app

npm create vite@latest meal-planner -- --template react-ts
cd meal-planner
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'
        }
      }
    }
  }
})

Search with diets

Now src/api.ts. One type for the fields we ask for and one function that searches.

The interesting bit is the diets. We use each one twice. First as a hint: we put "vegan" in the query, so the search leans towards vegan recipes. Then as a rule: a recipe only gets in if its dietary.vegan flag is actually true. A recipe with no flag at all doesn't get in either. We'd rather show fewer recipes than a "vegan" lasagne with cheese on top. And just to be safe, the matching allergens go in without.

export type Diet = 'dairy-free' | 'gluten-free' | 'vegan' | 'vegetarian'

type DietFlag = 'dairyFree' | 'glutenFree' | 'vegan' | 'vegetarian'

export interface Recipe {
  dietary?: Partial<Record<DietFlag, boolean>>
  id: string
  media?: { role?: string; type?: string; url: string }[]
  times?: { total?: { seconds: number } }
  title: string
  url: string
}

// Each diet has a flag that must be true and allergens to leave out.
const rules: Record<Diet, { flag: DietFlag; without: string[] }> = {
  'dairy-free': { flag: 'dairyFree', without: ['milk'] },
  'gluten-free': { flag: 'glutenFree', without: ['gluten-cereals', 'wheat'] },
  vegan: { flag: 'vegan', without: ['crustaceans', 'eggs', 'fish', 'milk', 'molluscs'] },
  vegetarian: { flag: 'vegetarian', without: ['crustaceans', 'fish', 'molluscs'] }
}

export async function searchRecipes(query: string, diets: Diet[]) {
  const without = [...new Set(diets.flatMap((diet) => rules[diet].without))]
  const params = new URLSearchParams({
    fields: 'dietary,media,times,title,url',
    language: 'en',
    limit: '30',
    query: [...diets, query].join(' ')
  })

  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[] }

  return data.results.filter((recipe) =>
    diets.every((diet) => recipe.dietary?.[rules[diet].flag] === true)
  )
}

The API's error messages are written for people, so we pass them straight through. Forget the key and the app says "An API key is required" instead of "Error 401".

Plan the week

This is the fun part, in src/plan.ts. A week has 14 slots, and they're not all the same. A Tuesday lunch should be quick. A Saturday dinner can take all afternoon. So every slot gets a kind: lunch, weeknight or weekend.

For each kind, we have a few ways of asking. If we always searched for "easy weeknight dinner", every week would look the same. So each plan picks a couple of phrases at random, searches them all at once and shuffles what comes back.

Then we sort by time. A lunch fits if it's done in 30 minutes, a weeknight dinner in 50, and a weekend project should take at least an hour. Recipes that fit go first, ones we don't know the time of come next, and ones that don't fit go last. They still count, though. A slow lunch beats an empty slot.

import { type Diet, type Recipe, searchRecipes } from './api'

export const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']

export type Meal = 'dinner' | 'lunch'
export type Slot = 'lunch' | 'weekend' | 'weeknight'

export interface PlannedDay {
  day: string
  dinner?: Recipe
  lunch?: Recipe
}

// A few ways of asking for each slot, so the week isn't seven soups.
const phrases: Record<Slot, string[]> = {
  lunch: ['quick lunch salad', 'easy soup for lunch', 'simple sandwich or wrap', 'quick lunch bowl'],
  weekend: ['slow-cooked weekend dinner', 'Sunday roast dinner', 'homemade dinner project'],
  weeknight: ['easy weeknight dinner', 'quick one-pan dinner', 'simple pasta dinner', 'easy curry']
}

export function slotFor(day: string, meal: Meal): Slot {
  if (meal === 'lunch') {
    return 'lunch'
  }

  return day === 'Saturday' || day === 'Sunday' ? 'weekend' : 'weeknight'
}

function shuffle<T>(items: T[]) {
  return items
    .map((item) => ({ item, key: Math.random() }))
    .sort((first, second) => first.key - second.key)
    .map(({ item }) => item)
}

// 0 fits the slot, 1 we don't know, 2 doesn't fit.
function timeFit(slot: Slot, recipe: Recipe) {
  const seconds = recipe.times?.total?.seconds

  if (seconds === undefined) {
    return 1
  }

  const minutes = seconds / 60
  const fits = slot === 'lunch' ? minutes <= 30 : slot === 'weeknight' ? minutes <= 50 : minutes >= 60

  return fits ? 0 : 2
}

// Searches a few random phrases for one slot and puts the best fits first.
export async function findRecipes(slot: Slot, diets: Diet[], searches: number) {
  const picked = shuffle(phrases[slot]).slice(0, searches)
  const lists = await Promise.all(picked.map((phrase) => searchRecipes(phrase, diets)))

  return shuffle(lists.flat()).sort((first, second) => timeFit(slot, first) - timeFit(slot, second))
}

export function planWeek(pools: Record<Slot, Recipe[]>): PlannedDay[] {
  const used = new Set<string>()

  function take(slot: Slot) {
    const recipe = pools[slot].find((candidate) => !used.has(candidate.id))

    if (recipe) {
      used.add(recipe.id)
    }

    return recipe
  }

  // The weekend picks first. It has the fewest recipes to choose from.
  const saturday = take('weekend')
  const sunday = take('weekend')

  return days.map((day) => ({
    day,
    dinner: day === 'Saturday' ? saturday : day === 'Sunday' ? sunday : take('weeknight'),
    lunch: take('lunch')
  }))
}

planWeek is where it all comes together. The used set makes sure no recipe shows up twice in a week, even if the same pasta came back for both "simple pasta dinner" and "quick lunch bowl". And the weekend goes first on purpose. There aren't many slow-cooked recipes that also fit a strict diet, so we let Saturday and Sunday take theirs before the weeknights can use them up.

Swap a meal

Everyone has a meal they just don't want. So every slot gets a swap button. A swap searches again for that kind of slot and takes the first recipe that isn't already on the week.

There's one catch. If you swap the curry for a stir fry and then swap the stir fry, you don't want the curry back. So we also remember what you've turned down, in skipped, and leave those out too.

There's a second, sneakier one. Plan a vegan week, then untick vegan and press swap. Which diets should the swap use? The week was planned as vegan, so the swap should stay vegan. That's why the app keeps plannedDiets apart from the boxes.

The app

src/App.tsx holds it together:

import { useState } from 'react'

import type { Diet } from './api'
import { MealCard } from './MealCard'
import { findRecipes, type Meal, type PlannedDay, planWeek, slotFor } from './plan'

const dietOptions: Diet[] = ['dairy-free', 'gluten-free', 'vegan', 'vegetarian']

const labels = { lunch: 'Quick lunch', weekend: 'Weekend project', weeknight: 'Weeknight dinner' }

export default function App() {
  const [diets, setDiets] = useState<Diet[]>([])
  const [error, setError] = useState<string | null>(null)
  const [planning, setPlanning] = useState(false)
  const [plannedDiets, setPlannedDiets] = useState<Diet[]>([])
  const [skipped, setSkipped] = useState<string[]>([])
  const [swapping, setSwapping] = useState<string | null>(null)
  const [week, setWeek] = useState<PlannedDay[]>([])

  function toggle(diet: Diet) {
    setDiets((current) =>
      current.includes(diet) ? current.filter((other) => other !== diet) : [...current, diet]
    )
  }

  async function plan() {
    setError(null)
    setPlanning(true)

    try {
      const [lunch, weekend, weeknight] = await Promise.all([
        findRecipes('lunch', diets, 2),
        findRecipes('weekend', diets, 1),
        findRecipes('weeknight', diets, 2)
      ])

      if (lunch.length + weekend.length + weeknight.length === 0) {
        setError("We couldn't find recipes for those diets. Untick one of the boxes and plan again.")

        return
      }

      setPlannedDiets(diets)
      setSkipped([])
      setWeek(planWeek({ lunch, weekend, weeknight }))
    } catch (cause) {
      setError(cause instanceof Error ? cause.message : 'Something went wrong. Try again.')
    } finally {
      setPlanning(false)
    }
  }

  async function swap(day: string, meal: Meal) {
    const current = week.find((entry) => entry.day === day)?.[meal]
    const onTheWeek = week.flatMap((entry) => [entry.lunch?.id, entry.dinner?.id])
    const exclude = new Set([...onTheWeek, ...skipped])
    const slot = slotFor(day, meal)

    setError(null)
    setSwapping(`${day}-${meal}`)

    try {
      const recipes = await findRecipes(slot, plannedDiets, 3)
      const replacement = recipes.find((recipe) => !exclude.has(recipe.id))

      if (!replacement) {
        setError(`We've run out of other ${labels[slot].toLowerCase()} ideas. Keep this one, or plan a new week.`)

        return
      }

      setWeek((days) =>
        days.map((entry) => (entry.day === day ? { ...entry, [meal]: replacement } : entry))
      )

      if (current) {
        setSkipped((ids) => [...ids, current.id])
      }
    } catch (cause) {
      setError(cause instanceof Error ? cause.message : 'Something went wrong. Try again.')
    } finally {
      setSwapping(null)
    }
  }

  return (
    <main>
      <h1>Meal Planner</h1>

      <fieldset>
        <legend>Any diets?</legend>
        {dietOptions.map((diet) => (
          <label key={diet}>
            <input type="checkbox" checked={diets.includes(diet)} onChange={() => toggle(diet)} />
            {diet}
          </label>
        ))}
      </fieldset>

      <button disabled={planning} onClick={plan}>
        {planning ? 'Planning…' : 'Plan my week'}
      </button>

      {error && <p className="error">{error}</p>}

      <ol className="week">
        {week.map((entry) => (
          <li key={entry.day}>
            <h2>{entry.day}</h2>
            {(['lunch', 'dinner'] as const).map((meal) => (
              <MealCard
                key={meal}
                label={labels[slotFor(entry.day, meal)]}
                onSwap={() => swap(entry.day, meal)}
                recipe={entry[meal]}
                swapping={swapping === `${entry.day}-${meal}`}
              />
            ))}
          </li>
        ))}
      </ol>
    </main>
  )
}

Look at the empty case in plan. Tick all four diets and you might get nothing back at all. A blank week looks broken, so we say what happened and what to do about it. When only some slots are empty, the card says so and the swap button is right there.

The meal card

Each slot is a small card with a photo, the title, how long it takes and the swap button:

import type { Recipe } from './api'

interface Props {
  label: string
  onSwap: () => void
  recipe?: Recipe
  swapping: boolean
}

export function MealCard({ label, onSwap, recipe, swapping }: Props) {
  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

  return (
    <article className="meal">
      <p className="label">{label}</p>

      {recipe ? (
        <>
          {image && <img src={image.url} alt="" />}
          <a href={recipe.url} target="_blank" rel="noreferrer">
            {recipe.title}
          </a>
          {seconds !== undefined && <p className="muted">{Math.round(seconds / 60)} min</p>}
        </>
      ) : (
        <p className="muted">Nothing for this slot yet. Try a swap.</p>
      )}

      <button disabled={swapping} onClick={onSwap}>
        {swapping ? 'Swapping…' : 'Swap'}
      </button>
    </article>
  )
}

Add some CSS (a grid of seven days works well on a laptop, one column on a phone), run npm run dev and plan your week.

A plan uses five requests: two lunch searches, two weeknight searches and one for the weekend. A swap uses three. The free plan gives you 50 a day, which is plenty of weeks.

Before you ship it, remember the Vite proxy only runs in development. When you deploy, you need a small function on the server that adds the key and forwards /api/* to Tiny Plates. The React code stays the same.

Where to take it next

A few ideas if you want to keep going:

  • Make a shopping list. Send the week's recipe ids to /shopping-list and you get one list for all of it, added up and sorted by aisle. We wrote about that in One shopping list for a week of recipes.
  • Cook for the family. Fetch a recipe with /recipes/{id}?servings=4 and every amount is scaled for you.
  • Remember the week. Save week and skipped to localStorage, so the plan is still there on Wednesday.
  • Ask your agent. Connect the Tiny Plates MCP server to Claude and ask it to plan a vegetarian week for you.

The full list of endpoints is in the API docs. Now go plan something. Wednesday doesn't have to be toast.