Barcode Lookup: Integrating Open Food Facts

Food Tracker (React + Express + Prisma)

Chapter 3 · Barcode Lookup: Integrating Open Food Facts

The same integration as the sibling course, for the same reasons — a server-side proxy to Open Food Facts, not a direct call from React. What changes here is the cache itself: a Prisma model instead of a raw SQL table, and a genuinely safer write pattern than the sibling's own check-then-insert.

Why Proxy Through the Server At All

Open Food Facts needs no API key, so this isn't about hiding a secret. The real reasons are unchanged from the sibling course:

  • Caching. The same barcode gets scanned repeatedly over time — caching the result server-side avoids re-querying Open Food Facts for a product this app already knows about.
  • Consistency. Every client gets identical lookup behavior, defined in one place.
  • Future-proofing. If Open Food Facts' own API shape changes, or a second data source gets added later, only the server needs to change.

Extending the Schema: A Barcode Cache

A second model in schema.prisma, reusing Chapter 2's own field-naming convention:

// prisma/schema.prisma (appended) model BarcodeCache { barcode String @id name String? category String? cachedAt DateTime @default(now()) }
npx prisma migrate dev --name add_barcode_cache

barcode is the primary key here — deliberately different from the Item model, where the same barcode can legitimately appear on many separate rows (many separate purchases of the same product over time). One barcode maps to exactly one cached product lookup, but potentially many pantry items.

The Lookup Route

// routes/lookup.js import { Router } from "express"; import prisma from "../db.js"; const router = Router(); router.get("/:barcode", async (req, res) => { const { barcode } = req.params; const cached = await prisma.barcodeCache.findUnique({ where: { barcode } }); if (cached) return res.json(cached); const response = await fetch( `https://world.openfoodfacts.org/api/v2/product/${barcode}.json` ); const data = await response.json(); if (data.status !== 1) { return res.status(404).json({ error: "Product not found" }); } const name = data.product.product_name || null; const category = data.product.categories_tags?.[0] || null; const saved = await prisma.barcodeCache.upsert({ where: { barcode }, update: { name, category }, create: { barcode, name, category } }); res.json(saved); }); export default router; // server/index.js import lookupRouter from "./routes/lookup.js"; app.use("/api/lookup", lookupRouter);

The check-first-then-fetch shape is identical to the sibling course's own route. The write at the end is where this course diverges: upsert() instead of a plain insert.

Sibling courseThis course
Cache checkdb.prepare(...).get(barcode)prisma.barcodeCache.findUnique(...)
Cache writeA plain INSERT, run only after the check above found nothingupsert() — insert if the barcode doesn't exist, update it if it does
Two requests for the same brand-new barcode, near-simultaneouslyBoth pass the initial check (neither sees a cache hit yet). The first INSERT succeeds; the second violates the barcode primary key and fails.Both pass the initial check the same way. The first upsert() creates the row; the second upsert() for the same barcode simply updates it instead of failing.
A place Prisma's own abstraction genuinely buys something
This is a real edge case in the sibling course's own design, not a hypothetical one: its check-then-insert pattern has an honest gap the moment two requests for a never-before-cached barcode land close enough together, since barcode is declared PRIMARY KEY there too. Prisma's upsert() is built specifically for exactly this "write it if it's missing, otherwise that's fine" situation — a case where reaching for the ORM's own generated method is a genuine improvement over the raw SQL equivalent, not just a different way of writing the same thing.
No extra package needed for the HTTP call — unchanged from the sibling
fetch is a genuine Node.js global as of Node 18, working identically here whether or not Prisma is involved. This has nothing to do with the database layer at all — it's the same fact, reused.
Open Food Facts' data is genuinely inconsistent — unchanged from the sibling
Because Open Food Facts is crowdsourced, a valid barcode can still return a product with a missing name, no category, or sparse data generally — data.status === 1 only means "a product exists for this barcode," not "this product has complete data." The route above already returns null for missing fields (Prisma's String? fields accept that directly) rather than throwing — Chapter 5's own add-item form has to be built expecting that, exactly as in the sibling course.

Where This Course Is Headed

The camera-scanning React component next — reused essentially unchanged from the sibling course, since decoding a barcode client-side has nothing to do with which backend, or which database layer, receives it afterward.

Hands-On Exercises

Exercise 1

Explain the three reasons this chapter gives for proxying the Open Food Facts lookup through Express rather than calling it directly from React, given that no API key is involved.

📄 View solution
Exercise 2

Using this chapter's own compare-table, explain the exact race condition the sibling course's check-then-insert pattern can hit, and why upsert() doesn't have the same problem.

📄 View solution
Exercise 3

Explain why barcode is the @id (primary key) on BarcodeCache but not on Item, even though both models have a barcode field.

📄 View solution

Chapter 3 Quick Reference

  • Why proxy: caching, consistency across clients, future-proofing — not secrecy (no API key needed)
  • New model: BarcodeCache, keyed by barcode — one lookup per barcode, unlike Item
  • Route: GET /api/lookup/:barcode — checks the cache first via findUnique(), else calls Open Food Facts and saves the result
  • Real Prisma win: upsert() closes a genuine race condition the sibling's own check-then-insert pattern has for a brand-new barcode requested twice at once
  • Node's built-in fetch: no axios/node-fetch dependency needed since Node 18 — unrelated to the database layer
  • Real gotcha, unchanged: Open Food Facts data is crowdsourced and often incomplete — null fields are expected, not an error
  • Next chapter: Camera-Based Barcode Scanning in React