Skip to content

Generate a meal plan and grocery list from an API

Turn diets, allergens and a time budget into a week of dinners and one shopping list, in four requests.

A meal plan sounds like one feature. It's really three small problems in a row. First you find recipes that fit the people eating them. Then you pick seven that make a good week. Then you turn those seven into one shopping list.

Plenty of apps need this. Meal kits, grocery apps, fitness apps, the family app that just wants to answer "what's for dinner". This post is the backend side: a TypeScript module you can run on your server that takes some constraints and gives back a week of dinners plus a grocery list. It uses two Tiny Plates endpoints, /search and /shopping-list, and four requests per plan.

If you want the front end, we already built one. Build a weekly meal planner with React has the UI, with diet boxes and a swap button on every meal. Here's the live version:

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.

This post goes the other way. Less clicking, more logic. Let's start with what the caller asks for:

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

export interface Constraints {
  diets: Diet[]
  people: number
  weeknightMinutes: number
  without: string[]
}

diets are hard rules. without is a list of allergens to leave out, like peanuts or sesame. people is how many you're cooking for. weeknightMinutes is the time budget: Monday to Friday dinners must be done in that long, while the weekend can take as long as it likes. There's no money budget, because the API doesn't know prices. If you need that, it's a job for your grocery partner's data.

Step one: a pool of recipes that fit

We need candidates to pick from. /search returns at most 50 recipes per request, so we run three searches with different phrases and merge them. That gives us up to 150 recipes to work with, often a bit fewer after duplicates.

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

interface Ingredient {
  ingredientId?: string
  name: string
  optional: boolean
}

export interface Recipe {
  categories?: string[]
  courses?: string[]
  cuisines?: string[]
  dietary?: Partial<Record<DietFlag, boolean>>
  id: string
  ingredients?: Ingredient[]
  servings?: { quantity?: number }
  times?: { total?: { seconds: number } }
  title: string
  url: string
}

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

const phrases = ['easy weeknight dinner', 'simple family dinner', 'slow-cooked weekend dinner']

async function get<T>(path: string, params: Record<string, string>) {
  const response = await fetch(`https://api.tinyplates.dev${path}?${new URLSearchParams(params)}`, {
    headers: { Authorization: `Bearer ${process.env.TINYPLATES_API_KEY}` }
  })

  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
}

function suits(recipe: Recipe, diets: Diet[]) {
  const courses = recipe.courses ?? []
  const isDinner = courses.length === 0 || courses.includes('dinner') || courses.includes('main-course')

  return isDinner && diets.every((diet) => recipe.dietary?.[rules[diet].flag] === true)
}

export async function gather(constraints: Constraints) {
  const without = [
    ...new Set([...constraints.without, ...constraints.diets.flatMap((diet) => rules[diet].without)])
  ]

  const lists = await Promise.all(
    phrases.map((phrase) =>
      get<{ results: Recipe[] }>('/search', {
        fields: 'categories,courses,cuisines,dietary,ingredients,servings,times,title,url',
        language: 'en',
        limit: '50',
        query: [...constraints.diets, phrase].join(' '),
        ...(without.length > 0 ? { without: without.join(',') } : {})
      })
    )
  )

  const byId = new Map(lists.flatMap((list) => list.results).map((recipe) => [recipe.id, recipe]))

  return [...byId.values()].filter((recipe) => suits(recipe, constraints.diets))
}

The diets get used three ways, the same as in the React guide. They go in the query as a hint, so the search leans towards vegan recipes when you ask for vegan. Their allergens go in without, so the API filters those out for us. And then each recipe's dietary flag must actually be true. A missing flag means we don't know, and "we don't know" isn't good enough for someone who doesn't eat meat. We also drop anything with a course that isn't dinner or a main course, like a dessert, but keep recipes with no course at all, since lots of home recipes aren't tagged.

/search has no diet or time parameters, which is why those checks live in our code. It does have fields, and we use it. We ask for exactly what the planner needs, including ingredients and servings, which we'll need in a minute.

Step two: pick a week

Now the fun part. We have a pool. We want seven dinners that are:

  1. Doable. Weeknight dinners fit the time budget.
  2. Varied. Not five Italian dinners. Not three soups.
  3. Thrifty. Recipes that share ingredients, so half a bag of spinach from Monday gets used on Thursday instead of going in the bin.

Those pull against each other a bit. The most thrifty week is the same dish seven times. So we go day by day and score every candidate. Shared ingredients add points, up to a cap of three, and a random number between 0 and 1 breaks ties. The cap is what keeps it honest. Sharing helps a recipe win, but it can't win on sharing alone, and every plan comes out a little different.

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

// Everyone has these, so sharing them doesn't save anything.
const staples = new Set(['black-pepper', 'flour', 'kosher-salt', 'olive-oil', 'salt', 'sugar', 'vegetable-oil', 'water'])

export interface PlannedDay {
  day: string
  recipe?: Recipe
}

function ingredientKeys(recipe: Recipe) {
  return (recipe.ingredients ?? [])
    .filter((ingredient) => !ingredient.optional)
    .map((ingredient) => ingredient.ingredientId ?? ingredient.name.toLowerCase())
    .filter((key) => !staples.has(key))
}

function labels(recipe: Recipe) {
  return [
    ...(recipe.cuisines ?? []).map((cuisine) => `cuisine:${cuisine}`),
    ...(recipe.categories ?? []).map((category) => `category:${category}`)
  ]
}

function fitsTime(recipe: Recipe, weekend: boolean, weeknightMinutes: number) {
  const seconds = recipe.times?.total?.seconds

  return weekend || (seconds !== undefined && seconds <= weeknightMinutes * 60)
}

function best(candidates: Recipe[], basket: Set<string>) {
  let top: Recipe | undefined
  let topScore = -1

  for (const recipe of candidates) {
    const shared = ingredientKeys(recipe).filter((key) => basket.has(key)).length
    const score = Math.min(shared, 3) + Math.random()

    if (score > topScore) {
      top = recipe
      topScore = score
    }
  }

  return top
}

export function planWeek(pool: Recipe[], constraints: Constraints): PlannedDay[] {
  const basket = new Set<string>()
  const counts = new Map<string, number>()
  const used = new Set<string>()

  return days.map((day) => {
    const weekend = day === 'Saturday' || day === 'Sunday'
    const open = pool.filter((recipe) => !used.has(recipe.id))
    const fitting = open.filter((recipe) => fitsTime(recipe, weekend, constraints.weeknightMinutes))
    const varied = fitting.filter((recipe) => labels(recipe).every((label) => (counts.get(label) ?? 0) < 2))
    const recipe = best(varied.length > 0 ? varied : fitting, basket)

    if (recipe) {
      used.add(recipe.id)
      ingredientKeys(recipe).forEach((key) => basket.add(key))
      labels(recipe).forEach((label) => counts.set(label, (counts.get(label) ?? 0) + 1))
    }

    return { day, recipe }
  })
}

A few things are worth pointing out.

Ingredients are compared by ingredientId when there is one. That's a canonical id like spring-onion, so a recipe that says "scallions" and one that says "green onions" still count as sharing. Staples don't count. Every recipe uses salt, so sharing salt tells you nothing.

Variety is a simple rule: no more than two dinners from the same cuisine or the same category in one week. If the pool is too thin for that, say a strict vegan, gluten-free week, we let the rule go rather than leave a night empty. The time budget is stricter. A weeknight with no recipe that fits stays empty, because a two-hour stew on a Tuesday isn't a plan anyone will cook. Your app can show "nothing found" and offer to loosen the constraints.

And the basket grows as the week fills in. Monday is pure luck. By Thursday, the planner is actively looking for something that uses up the coriander.

Step three: one grocery list

Now we send the week to /shopping-list. It takes up to 20 recipe ids, so seven dinners fit easily, and you'd still have room for lunches. Add :servings after an id and that recipe gets scaled first.

There's a catch, and it comes from how the endpoint really works. A recipe can only be scaled if it says how many it serves. Ask to scale one that doesn't, and the whole request fails with a message saying so. That's why we asked for servings in step one. Recipes that know their size get scaled, and the rest go in as written.

interface ShoppingItem {
  category: string
  display: string
  name: string
  plusMore?: boolean
  recipeIds: string[]
}

export async function groceryList(week: PlannedDay[], people: number) {
  const recipes = week.flatMap(({ recipe }) => {
    if (!recipe) {
      return []
    }

    return [recipe.servings?.quantity ? `${recipe.id}:${people}` : recipe.id]
  })

  const list = await get<{ items: ShoppingItem[] }>('/shopping-list', { recipes: recipes.join(',') })
  const aisles = new Map<string, ShoppingItem[]>()

  for (const item of list.items) {
    aisles.set(item.category, [...(aisles.get(item.category) ?? []), item])
  }

  return aisles
}

Each item has a display line ready to print, an aisle in category (produce, dairy, meat, pantry, spices and so on), and recipeIds saying which dinners asked for it. Two recipes that both want garlic come back as one line. Here's a real item from a two-recipe list:

{
  "category": "produce",
  "display": "2 cloves garlic",
  "name": "garlic",
  "quantity": { "unit": "clove", "value": 2 },
  "recipeIds": ["69e22168d0295ac8a90118bc", "69e21bd0d0295ac8a9011521"]
}

recipeIds is handy when someone swaps a dinner. You know exactly which lines came from it. plusMore means a recipe also wants some "to taste", so the amount is a minimum. We wrote more about the list in One shopping list for a week of recipes.

Putting it all together:

const constraints: Constraints = {
  diets: ['vegetarian'],
  people: 4,
  weeknightMinutes: 40,
  without: ['peanuts']
}

const week = planWeek(await gather(constraints), constraints)
const aisles = await groceryList(week, constraints.people)

for (const { day, recipe } of week) {
  console.log(`${day}: ${recipe?.title ?? 'Nothing fits. Try a longer time budget.'}`)
}

for (const [aisle, items] of aisles) {
  console.log(`\n${aisle}`)
  items.forEach((item) => console.log(`  ${item.display}`))
}

That's three searches and one shopping list: four requests. The free plan gives you 50 a day and 300 a week, so that's about twelve plans a day while you build. For a real app with lots of users, don't run three searches per person. Most people pick from a handful of diet combinations, so cache the pool per combination for a few hours and every plan after the first costs one request. And if you outgrow the free allowance, get in touch and we'll raise it.

The without filter is based on each recipe's ingredient list. It's informational, not medical advice, so tell your users to check labels too.

A fair word about other options. If you'd rather not own the planning logic at all, Spoonacular has a "generate meal plan" endpoint and stores plans and shopping lists for your users. Edamam has a dedicated Meal Planner API. If you want a plan from one call, those are the better pick. They price things differently, so check their pricing pages before you build on them. We compare them in more detail on our Spoonacular and Edamam pages. Tiny Plates gives you building blocks instead. That's more code, but you decide what "a good week" means, and it's free.

Want to keep going? Add lunches by running the same picker with a lunch pool. Add a swap by calling best again on the open pool. Or hand the whole thing to an agent with the Tiny Plates MCP server and just ask for a vegetarian week.

Grab a free key and plan something.