← Academy

How to Build a Calorie Tracker App

Real daily targets with BMR/TDEE, accurate portions, and AI food logging , the honest version.

Want to build a food tracker like MyFitnessPal or Cal AI? You will , screen by screen: a profile that sets your calorie target, a daily dashboard with a calorie ring, and a food log you fill by searching or by describing or photographing a meal with AI.

The API gives you a 500k-food database, barcode lookup, and AI logging. What it cannot give you is the calorie target , that comes from the user , and you will also handle the part nobody documents: what to do when the AI is confidently wrong.

To keep the focus on the app, we persist a single dummy user to the browser local storage. In a real app you would swap that for a database and auth (Supabase, Firebase, or your own backend) , the screens and the math do not change.

3 chapters · ~75 min · Next.js · TypeScript · Tailwind · Claude

Chapter 1 · Project: Nutrition Tracker

The profile & your calorie target

Build the profile screen, compute the target with Mifflin-St Jeor BMR/TDEE, and drive the ring.

~25 min · IntermediateView on GitHub →

What you are building

Live demoBMR → TDEE → target

1359

kcal left

1400 / 2759

BMR

1780

resting

TDEE

2759

maintenance

Target

2759

maintain

Protein81/160g
Fat39/77g
Carbs181/357g
Sex
Activity
Goal

Drag anything , the target is pure client-side math (Mifflin-St Jeor × activity + goal). The API never sees it.

This is the dashboard you are building. Drag the profile and watch the target move: BMR is your resting burn, TDEE multiplies it by an activity factor for maintenance, a goal delta sets the final calorie target, and the macro split follows. The ring fills as you log food against it.

Build the profile screen

The first screen is a profile: sex, age, height, weight, activity, and goal. Those six inputs are everything the target needs , wire each to state and the dashboard above re-targets instantly.

Persist it so it survives a refresh. For the tutorial we save one dummy user to the browser local storage; in a real app you would store it on the user account in a database (Supabase, Firebase, or your own backend). The screen and the math are identical either way.

pages/index.tsx
const DEFAULT_PROFILE: Profile = {
  sex: 'male', age: 30, heightCm: 180, weightKg: 80, activity: 'moderate', goal: 'maintain',
};

// Tutorial persistence: one dummy user in local storage.
// In production, save this to the user's row in your database instead.
const KEY = 'ymove-demo-profile';

const [profile, setProfile] = useState<Profile>(() => {
  if (typeof window === 'undefined') return DEFAULT_PROFILE;
  try { return JSON.parse(window.localStorage.getItem(KEY)!) as Profile; } catch { return DEFAULT_PROFILE; }
});

useEffect(() => {
  window.localStorage.setItem(KEY, JSON.stringify(profile));
}, [profile]);

The math

One pure function turns the profile into the full target. BMR is Mifflin-St Jeor (the modern standard); multiplying by an activity factor , 1.2 sedentary to 1.9 athlete , gives TDEE, your maintenance calories, and a goal delta (cut -500 / maintain 0 / bulk +300) sets the final target.

Macro targets follow from the calories: protein at 2 g/kg bodyweight (a strength-friendly default), fat at 25% of calories, carbs filling the remainder.

The 4 / 4 / 9 in the macro math are the Atwater factors: protein and carbs are 4 kcal per gram, fat is 9, so grams convert straight to calories (that is the "4-4-9 rule"). The Math.max(1000, …) is a safety floor , never target an extreme deficit, and as a rule do not set a goal below your BMR.

npm install ymove-exercise-api
lib/targets.ts
export interface Profile {
  sex: 'male' | 'female'; age: number; heightCm: number; weightKg: number;
  activity: 'sedentary' | 'light' | 'moderate' | 'active' | 'athlete';
  goal: 'cut' | 'maintain' | 'bulk';
}

const ACTIVITY = { sedentary: 1.2, light: 1.375, moderate: 1.55, active: 1.725, athlete: 1.9 };
const GOAL = { cut: -500, maintain: 0, bulk: 300 };

function mifflinBMR(p: Profile) {
  const base = 10 * p.weightKg + 6.25 * p.heightCm - 5 * p.age;
  return p.sex === 'male' ? base + 5 : base - 161;
}

export function computeTargets(p: Profile) {
  const bmr = Math.round(mifflinBMR(p));
  const tdee = Math.round(bmr * ACTIVITY[p.activity]);
  const calories = Math.max(1000, tdee + GOAL[p.goal]);
  const protein = Math.round(2 * p.weightKg);            // 2 g/kg
  const fat = Math.round((calories * 0.25) / 9);         // 25% of kcal
  const carbs = Math.max(0, Math.round((calories - protein * 4 - fat * 9) / 4));
  return { bmr, tdee, calories, protein, fat, carbs };
}

Drive the ring

The calorie ring is an SVG circle whose strokeDashoffset is driven by intake / target , the target you just computed. Recompute targets with useMemo so editing the profile re-targets the whole dashboard live.

pages/index.tsx
const target = useMemo(() => computeTargets(profile), [profile]);
const pct = Math.min(100, Math.round((totals.kcal / target.calories) * 100));
const c = 2 * Math.PI * 80;

<circle r={80} cx={100} cy={100} fill="none" stroke="#10b981" strokeWidth={10}
  strokeDasharray={c} strokeDashoffset={c - (pct / 100) * c} strokeLinecap="round" />

Chapter 2 · Project: Nutrition Tracker

The food log: search & portions

Build the food-log screen: search 500k foods and scale any portion correctly (macros are per serving, not per 100g).

~20 min · IntermediateView on GitHub →

The Food model (read this carefully)

foods.search returns Food objects whose macros are PER SERVING, not per 100g , a classic source of bugs. servingSize is the gram weight of that serving; servingDescription is the human label ("1 cup", "chicken, bone removed").

To support any portion, normalise to per-gram (field ÷ servingSize) and multiply by the grams the user logs. Editing grams then just rescales , no second request. (Alternatively, foods.calculateMeal does this server-side for a list of {foodId, quantityG}.)

pages/index.tsx
import type { Food } from 'ymove-exercise-api';

// Food macros are PER SERVING; normalise to per-gram so any portion scales.
function foodPerG(f: Food) {
  const s = f.servingSize || 1;
  return { calories: f.calories / s, protein: f.protein / s, fat: f.fat / s, carbs: f.carbs / s };
}

// displayed kcal for a logged item = item.grams * item.perG.calories
function setGrams(id: number, grams: number) {
  setMeal((m) => m.map((e) => (e.id === id ? { ...e, grams: Math.max(0, grams) } : e)));
}

Chapter 3 · Project: Nutrition Tracker

AI food logging: text, photo & its limits

Log from a sentence or a photo , and handle where the AI is confidently wrong.

~30 min · IntermediateView on GitHub →

Try it (and watch it fail)

Live demofoods.logText (canned real responses)

Try a phrase:

Run a phrase. Every item comes back "high" confidence , yet rice is matched to rice pudding and eggs to pizza. This is the single most important thing to understand about AI food logging, and the demo lets you feel it before we explain it.

Log a meal from text

POST the text to a route that calls foods.logText. Each item has name, estimatedGrams, a confidence ("high" | "medium" | "low"), the matchedFood it resolved to in the database, and that food's nutrition. totals sums the meal.

npm install ymove-exercise-api
pages/api/log-text.ts
export default async function handler(req, res) {
  if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' });
  const { text } = req.body;
  const result = await ymove.foods.logText({ text });   // { items[], totals }
  res.status(200).json(result);
}

Confidence is not correctness

The nutrition for each item comes from the food the model MATCHED in the database. The portion estimate is usually good; the match often is not , and the confidence score reflects the parse, not the match quality. Logged live: "grilled chicken with rice and broccoli" matched rice to "RICE PUDDING"; "two eggs and toast" matched eggs to a "pan pizza veggie" product (28 g carbs for eggs).

So a wrong match means wrong macros. The fix is product, not model: always surface confidence AND the matched food name, and make the match correctable. Re-matching to a real food reuses the per-gram math from chapter 2 , keep the grams, swap the nutrition.

pages/index.tsx
// Build a per-gram entry from an AI item, keeping confidence + matched name visible.
function entryFromItem(it) {
  const g = it.estimatedGrams || 1;
  const n = it.nutrition ?? { calories: 0, protein: 0, fat: 0, carbs: 0 };
  return {
    id: nextId++, name: it.name, grams: it.estimatedGrams,
    perG: { calories: n.calories / g, protein: n.protein / g, fat: n.fat / g, carbs: n.carbs / g },
    confidence: it.confidence, matchedName: it.matchedFood?.name,
  };
}

// "fix match": replace a bad match with a searched food, keep the grams.
function fixMatch(id, f) {
  setMeal((m) => m.map((e) => e.id === id
    ? { ...e, name: f.displayName, perG: foodPerG(f), confidence: undefined, matchedName: undefined }
    : e));
}

Log from a photo (and its caveats)

foods.logPhoto takes a base64 image and returns the same shape. The model estimates portions from plate and utensil cues, so accuracy depends on lighting, angle and framing , and it is noticeably slower than text.

Gating to budget for: photo (and text) logging requires a Pro/Scale/Enterprise key. Surface the 402/403 (plan) and 429 (rate) responses from your proxy as friendly messages, not crashes. Body limit is 20 MB.

pages/api/log-photo.ts
export const config = { api: { bodyParser: { sizeLimit: '20mb' } } };

export default async function handler(req, res) {
  if (req.method !== 'POST') return res.status(405).json({ error: 'Method not allowed' });
  const { image, media_type } = req.body;            // image = base64 (no data: prefix)
  try {
    const result = await ymove.foods.logPhoto({ image, media_type });
    res.status(200).json(result);
  } catch (e) {
    res.status(e?.status ?? 500).json({ error: e?.message });  // 402/403 gate, 429 cap
  }
}
client: file → base64 → POST
const reader = new FileReader();
reader.onload = () => {
  const base64 = String(reader.result).split(',')[1];   // strip the data: prefix
  post('/api/log-photo', { image: base64, media_type: file.type || 'image/jpeg' });
};
reader.readAsDataURL(file);

Common questions

What is the 4-4-9 rule for calories?
The Atwater factors: protein and carbohydrate provide 4 kcal per gram and fat provides 9. That is how the macro targets convert to calories (protein g × 4 + carbs g × 4 + fat g × 9), and how you split a calorie target back into grams.
How do you calculate TDEE from BMR?
BMR is your resting burn (Mifflin-St Jeor). Multiply it by an activity factor , 1.2 sedentary, 1.375 light, 1.55 moderate, 1.725 active, 1.9 athlete , to get TDEE, your maintenance calories. Then add a goal delta (cut/maintain/bulk).
Is the BMR/TDEE formula accurate?
It is an estimate, usually within ~10%. It ignores body composition, so treat the number as a starting point and adjust it from a 2-3 week weight trend rather than trusting it to the calorie.
Should I ever set a target below my BMR?
As a rule, no , that is why the example clamps to a sensible floor. Eating under your resting needs for long is hard to sustain; a moderate deficit (~500 kcal) below TDEE is the usual cut.