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.
What you are building
1359
kcal left
1400 / 2759
BMR
1780
resting
TDEE
2759
maintenance
Target
2759
maintain
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.
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.
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.
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).
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}.)
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)));
}Search via a server proxy
This screen is client-rendered, so the browser must never hold the key. A tiny API route calls the SDK and returns JSON. The same pattern wraps logText and logPhoto in the next chapter.
import type { NextApiRequest, NextApiResponse } from 'next';
import { ymove } from '../../lib/ymove';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
const q = typeof req.query.q === 'string' ? req.query.q : '';
if (!q) return res.status(400).json({ error: 'Missing q' });
const result = await ymove.foods.search({ q, pageSize: 10 });
res.status(200).json(result); // { data: Food[], pagination }
}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.
Try it (and watch it fail)
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.
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.
// 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.
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
}
}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.