---
title: "Build a weekly meal planner app with React — Tiny Plates"
description: "How to build an automatic meal planner with React and the Tiny Plates API: plan a week of lunches and dinners, filter by vegan or gluten-free, swap any meal."
url: "https://www.tinyplates.dev/blog/build-a-weekly-meal-planner-with-react"
---

# 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.

15 October 2026

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.

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](https://www.tinyplates.dev/sign-up) and try a search with curl:

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

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

```bash
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](https://www.tinyplates.dev/blog/build-a-fridge-roulette-app-with-react), so have a look there if you want the reasons. 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'
        }
      }
    }
  }
})

```

## 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`.

```ts
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.

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

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

```tsx
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](https://www.tinyplates.dev/blog/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](https://www.tinyplates.dev/docs/mcp) to Claude and ask it to plan a vegetarian week for you.

The full list of endpoints is in the [API docs](https://www.tinyplates.dev/docs). Now go plan something. Wednesday doesn't have to be toast.
