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
- Static-first — calculators are self-contained pages, no server needed
-
Component islands —
.astrocomponents for layout, JS only where needed -
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 - Fast builds — ~370 pages build in under 11 seconds
- 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
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));
}
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>
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: 'zł', locale: 'pl-PL', resultLabel: 'Rata miesięczna', inputLabels: {...} },
}[LANG];
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>
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 }
});
});
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
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
- Extract components at 400 lines, not 800 — refactoring a 1,300-line Astro page while also translating it was miserable
- 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
- Lazy-load Chart.js from day one — I shipped 207KB of unnecessary JS to every page for weeks before fixing this
- 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)