<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Miyuru Lakshitha]]></title><description><![CDATA[Miyuru Lakshitha]]></description><link>https://miyuru.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6ad761cf3291fa9f400fab15/a0cc3b09-f9a2-4c75-8673-e8c8ca7ef9e5.png</url><title>Miyuru Lakshitha</title><link>https://miyuru.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Thu, 22 Oct 2026 10:49:51 GMT</lastBuildDate><atom:link href="https://miyuru.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[How I built a 7,800-page nutrition site with Astro and Cloudflare Pages]]></title><description><![CDATA[A few weeks ago I wanted a simple answer: how much protein is in 100 g of cooked lentils? The top results were slow, covered in ads, or hid the numbers…]]></description><link>https://miyuru.hashnode.dev/how-i-built-a-7-800-page-nutrition-site-with-astro-and-cloudflare-pages</link><guid isPermaLink="true">https://miyuru.hashnode.dev/how-i-built-a-7-800-page-nutrition-site-with-astro-and-cloudflare-pages</guid><category><![CDATA[Astro]]></category><category><![CDATA[webdev]]></category><category><![CDATA[cloudflare]]></category><category><![CDATA[SEO]]></category><dc:creator><![CDATA[Miyuru Lakshitha]]></dc:creator><pubDate>Wed, 21 Oct 2026 03:37:10 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad761cf3291fa9f400fab15/916a22b0-3f64-4e9a-84ea-e266eabc3274.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A few weeks ago I wanted a simple answer: how much protein is in 100 g of cooked lentils? The top results were slow, covered in ads, or hid the numbers behind an app download. The data itself is public. The USDA publishes lab-measured nutrition values for thousands of foods for free.</p>
<p>So I built <a href="https://nutrinfo.net">Nutrinfo</a>: one fast page per food, generated straight from USDA data, plus a few calculators and an AI meal analyzer. This post covers how it is put together, with the real code.</p>
<p><em>Disclosure: this article was written with AI, based on the project's actual code and data. It was first published on <a href="https://dev.to/miyuru/how-i-built-a-7800-page-nutrition-site-with-astro-and-cloudflare-pages-338m">DEV</a>.</em></p>
<h2>The stack</h2>
<ul>
<li><strong>Astro</strong> for static generation, with <code>output: 'static'</code></li>
<li><strong>Cloudflare Pages</strong> for hosting, with one <strong>Pages Function</strong> for the AI endpoint</li>
<li><strong>GitHub Actions</strong> to build and deploy on every push to <code>main</code></li>
<li><strong>USDA FoodData Central, SR Legacy</strong> as the data source (7,793 foods, up to 150 nutrients each)</li>
</ul>
<p>No database and no server. Every food page is plain HTML on Cloudflare's edge.</p>
<h2>Step 1: turning a 200 MB JSON file into site data</h2>
<p>USDA ships SR Legacy as one large JSON file. A Node script reads it once and writes two compact files:</p>
<ol>
<li><code>src/data/foods.json</code> with the nutrients Astro needs at build time</li>
<li><code>public/search-index.json</code>, a tiny array-of-arrays index for client-side search</li>
</ol>
<p>The interesting parts are the slugs and the serving sizes:</p>
<pre><code class="language-js">const slugify = (s) =&gt;
  s.toLowerCase()
    .normalize('NFKD').replace(/[\u0300-\u036f]/g, '')
    .replace(/%/g, ' percent ')
    .replace(/&amp;/g, ' and ')
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-+|-+$/g, '')
    .slice(0, 90)
    .replace(/-+$/, '');

// Duplicate names exist in USDA data, so fall back to the FDC ID.
let slug = slugify(name);
if (seen.has(slug)) slug = `${slug}-${f.fdcId}`;
</code></pre>
<p>USDA also publishes household measures ("1 cup, sliced", "1 medium") with gram weights, so each page gets a serving picker for free.</p>
<p>The search index stores each food as <code>[slug, name, kcal, protein, carbs, fat, categoryIndex]</code>. For 7,793 foods that is about 1 MB, small enough to fetch once and search in the browser with no backend.</p>
<h2>Step 2: one Astro page per food</h2>
<p>Astro's <code>getStaticPaths</code> does the heavy lifting:</p>
<pre><code class="language-astro">---
import { foods } from '../../lib/data.mjs';

export function getStaticPaths() {
  return foods.map((food) =&gt; ({ params: { slug: food.slug }, props: { food } }));
}
const { food } = Astro.props;
---
</code></pre>
<p>Each page has a nutrition label, a calorie breakdown, the full nutrient tables with % Daily Value, a comparison against the category average, and links to similar foods. You can see one here: <a href="https://nutrinfo.net/food/bananas-raw/">bananas, raw</a>.</p>
<p>A full build produces about 7,860 pages and takes around five minutes. That is fine for a site where the data changes rarely.</p>
<h3>Pages that are not thin</h3>
<p>The risk with programmatic pages is that 7,800 near-identical templates get ignored by search engines. Every page computes things that are specific to that food:</p>
<ul>
<li>a short summary sentence ("It is an excellent source of vitamin C (98% DV)...")</li>
<li>how it compares to its category average, in percent</li>
<li>an FAQ block answering calories, protein, carbs and micronutrients in 30 to 50 words, which is the format featured snippets and AI answer engines pick up</li>
<li>"alternatives" from the same category with more protein for similar calories, or fewer calories</li>
</ul>
<p>There are also "foods high in X" ranking pages, like <a href="https://nutrinfo.net/high-in/iron/">foods high in iron</a>, which link down to the food pages. That gives the site a clear hub-and-spoke structure instead of 7,800 orphans.</p>
<h2>Step 3: an AI meal analyzer on a Pages Function</h2>
<p>Static pages are great for single foods, but people eat meals. The <a href="https://nutrinfo.net/ai/">AI meal analyzer</a> takes a text description or a photo and returns an estimate as structured JSON.</p>
<p>It runs as a Cloudflare Pages Function at <code>functions/api/ai.js</code>. Text goes to a model on Groq, photos go to Gemini, and Gemini also acts as the fallback. The system prompt asks for strict JSON matching a schema:</p>
<pre><code class="language-js">const MEAL_SCHEMA = `{
  "items": [{ "name": string, "portion": string, "grams": number, "kcal": number, "protein": number, "carbs": number, "fat": number }],
  "total": { "kcal": number, "protein": number, "carbs": number, "fat": number },
  "score": number,
  "summary": string,
  "tips": [string]
}`;
</code></pre>
<p>The part I am happiest with is rate limiting without any extra service. The Cache API is available in every Pages Function, so it doubles as a best-effort counter:</p>
<pre><code class="language-js">async function rateLimited(ip) {
  const cache = caches.default;
  const now = Date.now();
  const windows = [
    { key: `m/${Math.floor(now / 60_000)}`, max: 8, ttl: 60 },
    { key: `d/${Math.floor(now / 86_400_000)}`, max: 60, ttl: 86_400 },
  ];
  for (const w of windows) {
    const req = new Request(`https://rl.nutrinfo.internal/${encodeURIComponent(ip)}/${w.key}`);
    const hit = await cache.match(req);
    const count = hit ? parseInt(await hit.text(), 10) || 0 : 0;
    if (count &gt;= w.max) return true;
    await cache.put(req, new Response(String(count + 1), { headers: { 'cache-control': `max-age=${w.ttl}` } }));
  }
  return false;
}
</code></pre>
<p>It is per data center, not global, so it is not exact. For keeping a free tool from being drained by one script, it is enough, and it needs no KV, D1 or Durable Object.</p>
<h2>Step 4: deploying 270 MB of HTML</h2>
<p>Uploading the whole <code>dist/</code> folder from a home connection was slow, so the build moved to GitHub Actions:</p>
<pre><code class="language-yaml">- run: npm ci
- run: npm run build
- run: npx wrangler pages deploy dist --project-name nutrinfo --branch main --commit-hash ${{ github.sha }}
  env:
    CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
    CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
</code></pre>
<p>Wrangler only uploads files whose hash changed, so later deploys are much faster than the first one.</p>
<h2>Step 5: the SEO plumbing</h2>
<p>Getting 7,800 URLs discovered matters as much as building them.</p>
<p><strong>Split sitemaps.</strong> <code>@astrojs/sitemap</code> supports chunks, so foods, list pages and everything else get separate sitemaps. Search Console then reports index coverage per section, which tells you quickly if the food pages are being skipped.</p>
<pre><code class="language-js">sitemap({
  chunks: {
    foods: (item) =&gt; (new URL(item.url).pathname.startsWith('/food/') ? item : undefined),
    lists: (item) =&gt; (/^\/(category|high-in)\//.test(new URL(item.url).pathname) ? item : undefined),
  },
}),
</code></pre>
<p><strong>IndexNow.</strong> Bing, Yandex and others accept IndexNow pings. A small script reads the built sitemaps and posts URLs in batches of 10,000, and the deploy workflow has an optional input to run it for given path prefixes.</p>
<p>One gotcha cost me a run: triggering the workflow from Git Bash on Windows with <code>-f indexnow=/methodology/</code> silently turned the input into <code>C:/Program Files/Git/methodology/</code>. The run succeeded and submitted zero URLs. <code>MSYS_NO_PATHCONV=1</code> fixes it.</p>
<p><strong>Structured data.</strong> Each food page has <code>WebPage</code> (with <code>isBasedOn</code> pointing at the USDA dataset), <code>BreadcrumbList</code> and <code>FAQPage</code> JSON-LD.</p>
<h2>Step 6: performance fixes from an audit</h2>
<p>An SEO audit flagged a few things I had missed:</p>
<ul>
<li><strong>Render-blocking fonts.</strong> Google Fonts CSS blocked the first paint. I self-hosted the latin subsets of Inter and Fraunces as variable woff2 files and preloaded them.</li>
<li><strong>Render-blocking CSS.</strong> The whole stylesheet is about 6 KB, so <code>build.inlineStylesheets: 'always'</code> inlines it into every page. No stylesheet request at all.</li>
<li><strong>Missing HSTS and favicon.ico.</strong> Both are a one-line fix in <code>_headers</code> and a generated icon set.</li>
</ul>
<p>On real visits, Cloudflare Web Analytics shows 86% of page loads with a good Largest Contentful Paint.</p>
<h2>Where it is now</h2>
<p>The site is about a week old. Search engines are crawling it, a handful of real people visit each day, and most pages are still waiting to be indexed. I will write a follow-up with real numbers once Search Console has something to say.</p>
<p>What I would do differently: plan the internal linking and the "foods high in" pages from day one instead of adding them after launch. They are the pages people actually link to.</p>
<p>If you want to look around, it is at <a href="https://nutrinfo.net">nutrinfo.net</a>. Feedback on the data pages or the meal analyzer is very welcome, especially if a number looks wrong. Every page links to its USDA source, so it is easy to check.</p>
]]></content:encoded></item></channel></rss>