Inspiration
When I started taking fitness seriously, a lot of the food I liked had to go. Cheesecake was the big one. I basically stopped eating it.
So I went looking for "low-calorie, high-protein" versions of my favorite dishes online. Most of them disappointed me. Either the calories were way higher than the recipe claimed, or there was barely any protein in it. Eventually I figured out that I could remake a lot of my favorite dishes myself so they fit my macros and still tasted good. So I thought to myself, why not turn this into an app?
That's how RecipeUpgrader started. You type in a dish, pick a goal (cutting, maintaining or bulking), and get a rewritten version of that dish next to the standard recipe, with real macros so you can compare them directly. If you enter a ZIP code, it also shows nearby grocery stores and which high-protein substitutes they actually sell. Guest mode is the default, and signing in is only needed if you want to save recipes.
How I built it
The frontend is React and TypeScript on Cloudflare Pages. It talks to a Node and Fastify API on Render, and all API keys stay on the server. Supabase handles the optional login and saved recipes, using Postgres with row-level security.
Model routing with Nemotron on Nebius Token Factory
I didn't want every request going to the biggest model, so I split the work in two:
| Step | Model | Why |
|---|---|---|
| Generate a standard recipe for the dish, with ingredients, quantities and steps | Nemotron Nano/Super | This is structured output, so it should be fast and cheap |
| Rewrite that recipe for the chosen goal, push protein up, keep the taste, and prefer ingredients sold nearby | Nemotron Ultra | Juggling all of those constraints at once takes real reasoning |
Token Factory uses an OpenAI-compatible API, so switching models meant changing one environment variable. That made it easy to try different Nemotron tiers for each step.
The LLM never does the math
This came straight out of my original frustration. Online recipes got their own numbers wrong, and LLMs do the same thing. So Nemotron decides what to change, and my code works out what the result adds up to using USDA FoodData Central data:
$$ \text{kcal} = 4P + 4C + 9F $$
where P, C and F are grams of protein, carbs and fat.
Location, Overpass and Tavily
OpenStreetMap's Overpass API finds grocery stores near the user. Tavily then checks whether items from a short list of common high-protein substitutes (nonfat Greek yogurt, reduced-fat cream cheese, egg whites, protein powder and so on) are sold at the closest store. The app shows real store names and addresses with Google Maps and Apple Maps links. If the location lookup fails, the request still goes through and you get a normal rewrite.
Login and saved recipes
Supabase Auth checks the JWT against Supabase's public signing keys instead of a shared secret. Every saved-recipe query runs through a Postgres client scoped to that user's own access token, so row-level security in the database is what keeps users out of each other's recipes. To test it, I made two real accounts on the live database and confirmed the second one couldn't see any of the first one's recipes.
Validation, security and tests
Every request, third-party response and model output is checked against shared Zod schemas. If Nemotron returns malformed JSON, the API retries once and then returns a clean error. Dependabot and CodeQL run on every push, and the API and website each have their own Content-Security-Policy. There are about 77 automated tests covering the nutrition math, schemas, security-sensitive code and API routes, plus a Playwright end-to-end test of the guest flow.
Challenges I ran into
AI makes up nutrition numbers
My first version asked the model for macros directly. The numbers looked believable and were wrong, which was exactly the problem I had with recipes online. Moving all the math into code with USDA data fixed it.
Keeping the taste
Early rewrites hit the protein target by more or less turning cheesecake into a protein shake. I kept adjusting the prompt until the dish stayed recognizable and still met the numbers.
Volume isn't weight
A cup of flour and a cup of water weigh very different amounts, but my first unit-conversion code treated them the same. I fixed it by only allowing grams, kilograms, milliliters and liters, and rejecting any other unit at the schema level.
Prompt injection through web search
Tavily pulls text from the web. A page could include something like "ignore previous instructions," and without protection that text would end up in my prompt. Here's how I handled it:
- Instructions stay in the system message. Untrusted content goes in labeled tags and is treated as data.
- Untrusted text gets sanitized: HTML is stripped, length is capped and fake tags are removed.
- Raw web text never reaches the reasoning model. Tavily results are first reduced to simple facts like
{ingredient, store, available, price}. - Model output is only shown as plain text. It's never executed or rendered as HTML.
- A test sends a fake injection attempt through the real availability-checking code and confirms it never shows up in the output.
Bugs that only showed up in production
Two problems never appeared locally. One was a Node.js version mismatch that crashed recipe saving, because Supabase's client needed a newer Node feature than the server had. The other was a security header that quietly blocked login because I hadn't updated it for a new third-party connection. I only caught both by testing the deployed site myself.
Time
I built this in about two and a half days, so some things didn't make it. The Kroger integration got cut, and the cache is still in memory instead of a persistent shared one.
What I learned
- It's worth routing models by task. Using a lighter Nemotron model for generation and saving Ultra for the rewrite kept the app fast and made my credits last longer.
- Let the LLM handle judgment calls and let code handle the arithmetic. Splitting the work that way made the results more trustworthy.
- Any AI app that reads from the web has an attack surface. Thinking about prompt injection early changed how I designed the whole pipeline.
- Passing tests locally doesn't mean the app works in production. Some of the hardest bugs only showed up with the real Node version, CSP and database.
- I learned how to build on Nebius Token Factory and pick between Nemotron model tiers.
What's next
- Let people set exact calorie and protein targets instead of picking a preset goal
- Build weekly meal plans from saved recipes
- Handle dietary restrictions and allergies when choosing substitutes
- Add Kroger integration for real-time prices and stock
- Replace the in-memory cache with a persistent shared one for location and availability lookups
Built With
- cloudflare
- codeql
- dependabot
- fastify
- github-actions
- nebius
- node.js
- nvidia-nemotron
- openai-api
- openstreetmap
- overpass-api
- playwright
- pnpm
- postgresql
- react
- render
- supabase
- tailwindcss
- tanstack-query
- tavily
- typescript
- usda-fooddata-central
- vite
- zod
Log in or sign up for Devpost to join the conversation.