<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: DC10101</title>
    <description>The latest articles on DEV Community by DC10101 (@dc10101).</description>
    <link>https://dev.to/dc10101</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3927121%2Fb4298246-e104-45d5-a185-14b0d06b0b51.png</url>
      <title>DEV Community: DC10101</title>
      <link>https://dev.to/dc10101</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dc10101"/>
    <language>en</language>
    <item>
      <title>Lessons from Building 370 Static Calculator Pages with Astro and Vanilla JS</title>
      <dc:creator>DC10101</dc:creator>
      <pubDate>Tue, 12 May 2026 13:11:55 +0000</pubDate>
      <link>https://dev.to/dc10101/lessons-from-building-370-static-calculator-pages-with-astro-and-vanilla-js-46j</link>
      <guid>https://dev.to/dc10101/lessons-from-building-370-static-calculator-pages-with-astro-and-vanilla-js-46j</guid>
      <description>&lt;p&gt;I wanted to see how far I could push Astro without a backend or any UI framework. Over the last few months, I built a multilingual calculator site — 48 financial and utility calculators across 5 languages (English, German, French, Spanish, Polish), which Astro generates into roughly 370 static pages.&lt;/p&gt;

&lt;p&gt;The interesting part wasn't the math itself, but keeping the whole thing maintainable as it scaled. This post covers the architecture, the i18n approach, how I separated calculator logic from UI, and things I'd do differently.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Astro&lt;/strong&gt; — static site generation, zero client JS by default&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vanilla JavaScript&lt;/strong&gt; — no React, no Vue, just plain JS for calculator logic&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chart.js&lt;/strong&gt; — interactive charts (lazy-loaded)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Netlify&lt;/strong&gt; — hosting with automatic deploys&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CSS custom properties&lt;/strong&gt; — theming and responsive design&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No backend. No database. No auth. Just HTML, JS, and math.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Astro Worked Well
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Static-first&lt;/strong&gt; — calculators are self-contained pages, no server needed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Component islands&lt;/strong&gt; — &lt;code&gt;.astro&lt;/code&gt; components for layout, JS only where needed&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;i18n routing&lt;/strong&gt; — language prefixes (&lt;code&gt;/en/&lt;/code&gt;, &lt;code&gt;/de/&lt;/code&gt;, &lt;code&gt;/fr/&lt;/code&gt;, &lt;code&gt;/es/&lt;/code&gt;, &lt;code&gt;/pl/&lt;/code&gt;) handled by Astro, though I still had to manage translated slugs, hreflang, and per-language metadata myself&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fast builds&lt;/strong&gt; — ~370 pages build in under 11 seconds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero JS by default&lt;/strong&gt; — pages load instantly, calculator scripts load only on their pages&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Separating Calculator Logic from UI
&lt;/h2&gt;

&lt;p&gt;Every calculator follows this pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/calculators/loan.js           → Pure math (no DOM)
src/pages/en/loan-calculator.astro → Page template + DOM wiring
src/i18n/translations.js          → UI strings
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The calculator modules export pure functions with zero DOM dependencies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;calculateMonthlyPayment&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;principal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;annualRate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;years&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;monthlyRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;annualRate&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;years&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;monthlyRate&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;principal&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;principal&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;monthlyRate&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;monthlyRate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;payments&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then in the Astro page, the &lt;code&gt;&amp;lt;script&amp;gt;&lt;/code&gt; block imports and wires it to the DOM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;&amp;lt;script&amp;gt;
  import { calculateMonthlyPayment } from '../../calculators/loan.js';

  const form = document.getElementById('calc-form');
  const resultEl = document.getElementById('result');

  form.addEventListener('input', () =&amp;gt; {
    const principal = parseFloat(document.getElementById('amount').value) || 0;
    const rate = parseFloat(document.getElementById('rate').value) || 0;
    const years = parseFloat(document.getElementById('years').value) || 1;

    const payment = calculateMonthlyPayment({ principal, annualRate: rate, years });
    resultEl.textContent = payment.toLocaleString('en-US', {
      style: 'currency', currency: 'USD'
    });
  });
&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This separation made it easy to test calculations independently and reuse the same math across all 5 language versions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The i18n Challenge
&lt;/h2&gt;

&lt;p&gt;48 calculators times 5 languages = 240 calculator pages, plus category hubs, guide pages, and legal pages brings the total to ~370. The key insight was centralizing all UI text in a config object near the top of each page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LANG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;de&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CFG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;en&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;$&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-US&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;resultLabel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Monthly Payment&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;inputLabels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{...}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;de&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;€&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;de-DE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;resultLabel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Monatliche Rate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;inputLabels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{...}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;fr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;€&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fr-FR&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;resultLabel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Mensualité&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;inputLabels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{...}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;es&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;€&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;es-ES&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;resultLabel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cuota Mensual&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;inputLabels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{...}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;pl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zł&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pl-PL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;resultLabel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Rata miesięczna&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;inputLabels&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{...}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}[&lt;/span&gt;&lt;span class="nx"&gt;LANG&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This way, creating a new language version means copying the page and only touching the ~80-line config block — no hunting through scattered string literals.&lt;/p&gt;

&lt;p&gt;The trickiest part was hreflang. Each language version has its own translated URL slug, and they all need to point to each other:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;---
const slugs = {
  en: 'loan-calculator',
  de: 'kreditrechner',
  fr: 'calculateur-pret',
  es: 'calculadora-prestamo',
  pl: 'kalkulator-kredytowy',
};
---
&amp;lt;head&amp;gt;
  {Object.entries(slugs).map(([lang, slug]) =&amp;gt; (
    &amp;lt;link rel="alternate" hreflang={lang} href={`https://calculy.org/${lang}/${slug}/`} /&amp;gt;
  ))}
&amp;lt;/head&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Getting this wrong means Google treats your translations as duplicates rather than alternatives. I found three hreflang bugs during an audit — all in German pages where the slugs didn't match between the page and the hreflang tags.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lazy-Loading Chart.js
&lt;/h2&gt;

&lt;p&gt;31 of the 48 calculators have advanced versions with Chart.js visualizations. Initially I imported Chart.js at the top of every page — even pages without charts. That added ~207KB to the initial bundle.&lt;/p&gt;

&lt;p&gt;The fix was simple but made a big difference:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;renderChart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canvasId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;chartConfig&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Chart&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chart.js/auto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canvasId&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Chart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;chartConfig&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Only loads Chart.js when user actually needs a chart&lt;/span&gt;
&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getElementById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;show-chart-btn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;click&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;renderChart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;balanceChart&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;line&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;chartData&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;responsive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;maintainAspectRatio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This shaved 207KB off the initial load for pages that have charts behind a toggle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping Large Pages Maintainable
&lt;/h2&gt;

&lt;p&gt;Some advanced calculators grew past 800 lines. At that point, the file becomes painful to work with and impossible to translate efficiently.&lt;/p&gt;

&lt;p&gt;I settled on an extraction pattern. The Budget Calculator went from 1,302 lines (monolithic) to 458 lines per language version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BudgetForm.astro        → Form structure (70 lines)
BudgetResults.astro     → Result cards (100 lines)
BudgetAdvanced.astro    → Charts, tables, export (140 lines)
BudgetSEOContent.astro  → SEO sections (200 lines)
faq-en.ts               → FAQ data array
budget-ui-render.ts     → Chart.js rendering functions
budget-ui-builders.ts   → Config-driven HTML string builders
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The shared code (1,185 lines) is reused across all 5 languages. For 5 language versions, this saved about 4,200 lines total compared to the monolithic approach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Technical SEO for Multilingual Static Pages
&lt;/h2&gt;

&lt;p&gt;Each calculator page needs correct metadata for Google to understand the language relationships:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Self-referencing canonical&lt;/strong&gt; per language version&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;hreflang tags&lt;/strong&gt; pointing to all 5 alternates (the slug bug I mentioned cost me a month of confusion)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JSON-LD structured data&lt;/strong&gt; — FAQPage schema for the FAQ sections, HowTo schema for step-by-step calculations&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Localized meta descriptions&lt;/strong&gt; — not just translated, but adapted to local search intent&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The biggest lesson: a single well-built calculator page with detailed FAQs, worked examples, and proper schema performs better than multiple thin pages targeting variations of the same query.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reusable UI Components
&lt;/h2&gt;

&lt;p&gt;I built a small component library that every calculator shares:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ModeTabs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Pill-style tab switcher between calculator modes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ToggleGroup&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Segmented A/B inline control&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ChartContainer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Standardized Chart.js wrapper with responsive height&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DataTable&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Schedule/amortization table with show-all and CSV download&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ResultCards&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Auto-fit grid of result cards with color modifiers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CalculationHistory&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;localStorage-backed history with built-in i18n&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  By the Numbers
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;48 calculators&lt;/strong&gt; across &lt;strong&gt;5 languages&lt;/strong&gt; = ~370 generated pages&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;31 advanced versions&lt;/strong&gt; with charts, scenarios, and CSV export&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;11-second builds&lt;/strong&gt; on Netlify&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two main dependencies&lt;/strong&gt;: Astro and Chart.js&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mobile-first&lt;/strong&gt; responsive design throughout&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Extract components at 400 lines, not 800&lt;/strong&gt; — refactoring a 1,300-line Astro page while also translating it was miserable&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use a proper i18n library&lt;/strong&gt; instead of hand-rolling config objects — the simplicity was nice at first, but at 48 calculators the duplication adds up&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lazy-load Chart.js from day one&lt;/strong&gt; — I shipped 207KB of unnecessary JS to every page for weeks before fixing this&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test hreflang tags with a crawler early&lt;/strong&gt; — finding slug mismatches manually across 370 pages is not fun&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;p&gt;The finished project is at &lt;a href="https://calculy.org" rel="noopener noreferrer"&gt;calculy.org&lt;/a&gt; if you want to compare the architecture with the end result. While the codebase itself isn't open-source, the patterns described here should transfer to any multilingual Astro project.&lt;/p&gt;

&lt;p&gt;If you've built complex interactive pages in Astro without React or Vue, I'd love to hear how you handled state management and DOM updates — that was probably the roughest edge of going vanilla JS.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Built with Astro, vanilla JS, and a lot of &lt;code&gt;Math.pow()&lt;/code&gt; calls.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>astro</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
