DEV Community

Cover image for Lessons from Building 370 Static Calculator Pages with Astro and Vanilla JS
DC10101
DC10101

Posted on

Lessons from Building 370 Static Calculator Pages with Astro and Vanilla JS

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.

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.

The Stack

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

No backend. No database. No auth. Just HTML, JS, and math.

Why Astro Worked Well

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

Separating Calculator Logic from UI

Every calculator follows this pattern:

src/calculators/loan.js           → Pure math (no DOM)
src/pages/en/loan-calculator.astro → Page template + DOM wiring
src/i18n/translations.js          → UI strings
Enter fullscreen mode Exit fullscreen mode

The calculator modules export pure functions with zero DOM dependencies:

export function calculateMonthlyPayment({ principal, annualRate, years }) {
  const monthlyRate = annualRate / 100 / 12;
  const payments = years * 12;

  if (monthlyRate === 0) return principal / payments;

  return principal * monthlyRate / (1 - Math.pow(1 + monthlyRate, -payments));
}
Enter fullscreen mode Exit fullscreen mode

Then in the Astro page, the <script> block imports and wires it to the DOM:

<script>
  import { calculateMonthlyPayment } from '../../calculators/loan.js';

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

  form.addEventListener('input', () => {
    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'
    });
  });
</script>
Enter fullscreen mode Exit fullscreen mode

This separation made it easy to test calculations independently and reuse the same math across all 5 language versions.

The i18n Challenge

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:

const LANG = 'de';
const CFG = {
  en: { currency: '$', locale: 'en-US', resultLabel: 'Monthly Payment', inputLabels: {...} },
  de: { currency: '', locale: 'de-DE', resultLabel: 'Monatliche Rate', inputLabels: {...} },
  fr: { currency: '', locale: 'fr-FR', resultLabel: 'Mensualité', inputLabels: {...} },
  es: { currency: '', locale: 'es-ES', resultLabel: 'Cuota Mensual', inputLabels: {...} },
  pl: { currency: '', locale: 'pl-PL', resultLabel: 'Rata miesięczna', inputLabels: {...} },
}[LANG];
Enter fullscreen mode Exit fullscreen mode

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.

The trickiest part was hreflang. Each language version has its own translated URL slug, and they all need to point to each other:

---
const slugs = {
  en: 'loan-calculator',
  de: 'kreditrechner',
  fr: 'calculateur-pret',
  es: 'calculadora-prestamo',
  pl: 'kalkulator-kredytowy',
};
---
<head>
  {Object.entries(slugs).map(([lang, slug]) => (
    <link rel="alternate" hreflang={lang} href={`https://calculy.org/${lang}/${slug}/`} />
  ))}
</head>
Enter fullscreen mode Exit fullscreen mode

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.

Lazy-Loading Chart.js

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.

The fix was simple but made a big difference:

async function renderChart(canvasId, chartConfig) {
  const { Chart } = await import('chart.js/auto');
  const ctx = document.getElementById(canvasId).getContext('2d');
  return new Chart(ctx, chartConfig);
}

// Only loads Chart.js when user actually needs a chart
document.getElementById('show-chart-btn')?.addEventListener('click', () => {
  renderChart('balanceChart', {
    type: 'line',
    data: chartData,
    options: { responsive: true, maintainAspectRatio: false }
  });
});
Enter fullscreen mode Exit fullscreen mode

This shaved 207KB off the initial load for pages that have charts behind a toggle.

Keeping Large Pages Maintainable

Some advanced calculators grew past 800 lines. At that point, the file becomes painful to work with and impossible to translate efficiently.

I settled on an extraction pattern. The Budget Calculator went from 1,302 lines (monolithic) to 458 lines per language version:

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
Enter fullscreen mode Exit fullscreen mode

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.

Technical SEO for Multilingual Static Pages

Each calculator page needs correct metadata for Google to understand the language relationships:

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

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.

Reusable UI Components

I built a small component library that every calculator shares:

Component Purpose
ModeTabs Pill-style tab switcher between calculator modes
ToggleGroup Segmented A/B inline control
ChartContainer Standardized Chart.js wrapper with responsive height
DataTable Schedule/amortization table with show-all and CSV download
ResultCards Auto-fit grid of result cards with color modifiers
CalculationHistory localStorage-backed history with built-in i18n

By the Numbers

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

What I'd Do Differently

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

The Result

The finished project is at calculy.org 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.

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.


Built with Astro, vanilla JS, and a lot of Math.pow() calls.

Top comments (0)