DEV Community

Cover image for What I Learned Building 50+ Calculators with Next.js, TypeScript, and a Declarative Engine
Dilawar Hussain
Dilawar Hussain

Posted on

What I Learned Building 50+ Calculators with Next.js, TypeScript, and a Declarative Engine

Six months ago, I started building ToolsArena — a platform with 50+ calculators spanning chemistry, finance, health, construction, and more. Not 50+ simple widgets. Each one has unit conversions, validation rules, dynamic forms, interactive graphs, and thousands of words of expert-written educational content.

Architecture flow diagram of a declarative calculator engine with 4 pipeline stages: inputs, unit conversion, formula evaluation, and output formatting

I made mistakes. I rewrote core systems three times. I discovered that pHCalculator auto-converted to p-h-calculator instead of ph-calculator. I learned that currency outputs and scientific notation should never mix.

This is the honest story of what I learned.


1. The Naive Approach That Almost Worked

I started the way most developers would: one React component per calculator. A MolarityCalculator.tsx here, a BMICalculator.tsx there. Each component had its own state management, its own validation logic, its own way of handling units.

For the first three calculators, this was fast. Copy-paste, rename, tweak. By calculator number seven, I was drowning. Every bug fix had to be replicated across files. The unit conversion logic for mass was copied into four places, and each copy had a slightly different bug.

The breaking point came when I needed to add a showWhen feature — show or hide inputs based on other inputs' values. I found myself writing the same conditional rendering logic for the tenth time.

Something had to change.

2. The Declarative Engine

The insight was simple: every calculator is just three things — inputs, a formula, and outputs. The inputs describe what data to collect. The formula computes the result. The outputs describe how to display it.

Instead of writing components, I defined types.

type Calculator = {
  slug: string;
  title: "string;"
  inputs: CalculatorInput[];
  formula: string;       // JavaScript code string
  outputs: CalculatorOutput[];
};
Enter fullscreen mode Exit fullscreen mode

Each calculator became a data file exporting this object:

const molarityCalculator: Calculator = {
  slug: 'molarity-calculator',
  title: "'Molarity Calculator',"
  inputs: [
    {
      name: 'mass',
      type: 'number',
      label: 'Mass of Solute',
      unitOptions: [
        { label: 'g', value: 'g', toBase: 1 },
        { label: 'mg', value: 'mg', toBase: 0.001 },
        { label: 'kg', value: 'kg', toBase: 1000 },
      ],
      unitType: 'mass',
    },
    { name: 'molarMass', type: 'number', label: 'Molar Mass (g/mol)' },
    { name: 'volume', type: 'number', label: 'Volume of Solution',
      unitOptions: [
        { label: 'L', value: 'L', toBase: 1 },
        { label: 'mL', value: 'mL', toBase: 0.001 },
      ],
      unitType: 'volume',
    },
  ],
  formula: `
    const moles = mass / molarMass;
    const molarity = moles / volume;
    return { moles, molarity, mass, volume };
  `,
  outputs: [
    { key: 'molarity', label: 'Molarity (M)',
      format: { type: 'number', decimals: 4, suffix: ' M' } },
    { key: 'moles', label: 'Moles of Solute',
      format: { type: 'number', decimals: 4, suffix: ' mol' } },
  ],
};
Enter fullscreen mode Exit fullscreen mode

The engine reads the formula string and evaluates it in a sandboxed scope where input values are available as variables. The unitOptions tell the engine to multiply user input by toBase before passing it to the formula — so all formulas work in base units (grams, liters) regardless of what the user selected.

This was the breakthrough. Adding a new calculator went from writing a React component to editing a data file.

3. Unit Conversions Are Harder Than They Look

Unit conversions seem simple. User enters 500, picks mL, engine converts to 0.5 L. Done.

The trick is the reverse direction. The formula returns results in base units (say, 1.5 L). But the user selected mL on the input. The output should display in mL too.

I built a unitType matching system. Inputs declare unitType: 'volume'. Outputs declare the same. When the engine renders the result, it finds the input with matching unitType, reads the user's selected unit, and divides the base value by that unit's toBase.

alternativeUnits: [
  { unit: 'L', toBase: 1, label: 'litre' },
  { unit: 'mL', toBase: 0.001, label: 'millilitre' },
  { unit: 'μL', toBase: 0.000001, label: 'microlitre' },
],
Enter fullscreen mode Exit fullscreen mode

This lets me show "1.500 L" as primary with "Other units: 1500 mL | 1,500,000 μL" below. The user never has to convert mentally.

Lesson learned: Always separate the base computation from the display formatting. Don't let formulas produce formatted strings. Return raw numbers and let the engine handle the rest.

4. Dynamic Forms with CompositeInput

The GPA calculator needs a variable number of courses. The user picks how many courses they're taking, and the form generates that many rows — each with a course name, grade, and credit hours.

This was the hardest input type to design. I called it CompositeInput.

{
  type: 'composite',
  name: 'courses',
  label: 'Course Grades',
  generate: { formulaKey: 'numCourses', prefix: 'course' },
  columns: [
    { slot: 'courseName', label: 'Course Name', width: '2fr' },
    { slot: 'gradeLetter', label: 'Grade', width: '1fr' },
    { slot: 'credits', label: 'Credits', width: '1fr' },
  ],
  fields: [
    { name: 'Name', type: 'text', label: 'Course Name', slot: 'courseName',
      placeholder: 'e.g. Calculus I' },
    { name: 'Grade', type: 'select', label: 'Grade', slot: 'gradeLetter',
      options: [
        { label: 'A', value: '4.0' },
        { label: 'B', value: '3.0' },
        { label: 'C', value: '2.0' },
      ] },
    { name: 'Credits', type: 'number', label: 'Credits', slot: 'credits',
      defaultValue: 3 },
  ],
}
Enter fullscreen mode Exit fullscreen mode

The formulaKey references another input — numCourses — so when the user changes the course count, the form dynamically adds or removes rows. The engine generates field names like course1Name, course1Grade, course2Name, course2Grade and maps them into the formula scope.

This pattern scaled beautifully. The same CompositeInput powers the credit card payoff calculator (multiple cards), the amortization schedule (multiple payments), and the calorie logging form (multiple meals).

One detail that took extra thought was placeholder text. A field with placeholder: 'e.g. Calculus I' looks fine on row 1, but row 4 needs something more contextual. I added a placeholderMode property per field: 'first' shows the placeholder only on row 1, 'all' appends the row number ('e.g. Calculus I 4'). Small UX details like this matter when users are entering 10+ rows of data.

5. Lazy Loading 50+ Calculator Files

With 52 data files, I couldn't bundle them all in the initial JavaScript payload. Each file is only about 5-10 KB, but 52 of them would add unnecessary weight to every page load.

The solution was an auto-generated loader:

// lib/calculator-loader.ts — AUTO-GENERATED
export const calculatorLoaders: Record<string, () => Promise<Calculator>> = {
  'molarity-calculator': () => import('@/data/calculators/chemistry/molarityCalculator'),
  'bmi-calculator': () => import('@/data/calculators/health/bmiCalculator'),
  'loan-emi-calculator': () => import('@/data/calculators/finance/loanEmiCalculator'),
  // ... 49 more
};
Enter fullscreen mode Exit fullscreen mode

The page component dynamically imports only the calculator matching the current slug:

const CalculatorEngine = dynamic(
  () => import('@/components/CalculatorEngine'),
  { loading: () => <Skeleton />, ssr: false }
);
Enter fullscreen mode Exit fullscreen mode

I created a single source of truth — calculator-manifest.ts — that lists every calculator with its slug and file name. A npm run generate script reads the manifest and rewrites the loader files, category indexes, and related-calculator pairs. Adding a new calculator is just: create the data file, add one line to the manifest, run npm run generate.

No manual import management. No stale loader files. The getCalculator() function is a simple async lookup:

export async function getCalculator(slug: string): Promise<Calculator | null> {
  const loader = calculatorLoaders[slug];
  if (!loader) return null;
  const module = await loader();
  return module.default;
}
Enter fullscreen mode Exit fullscreen mode

Dynamic import + cache. No switch statements, no registry arrays to keep in sync.

6. Content, Authors, and EEAT

A calculator with raw numbers is useful. A calculator with 4,000 words of expert-written educational content is valuable.

Every calculator on the platform has:

  • Expert authors — an MS Chemistry graduate writes the chemistry calculators, an MBA writes the finance ones, a Pharm.D writes the health ones
  • Peer reviewers — a PhD researcher reviews every chemistry formula; a medical doctor reviews health content
  • Worked examples — 4–6 worked problems per calculator using verified numbers from authoritative sources
  • FAQs — 10–15 domain-specific questions with detailed answers
  • Glossary — 8–12 terms with accessible definitions
  • Citations — numbered references to IUPAC, NIST, CDC, WHO, and peer-reviewed textbooks

Here's a real example from the molarity calculator — the ExampleBlock component renders worked problems with KaTeX math:

{
  type: 'example',
  slot: 'description',
  order: 8,
  problem: [
    'A student dissolves <strong>5.85 g</strong> of NaCl (molar mass 58.44 g/mol) in enough water to make <strong>500 mL</strong> of solution. Calculate the molarity of the solution.',
  ],
  solution: [
    'First, convert mass to moles: $$5.85 \\text{ g NaCl} \\div 58.44 \\text{ g/mol} = 0.100 \\text{ mol NaCl}$$',
    'Convert volume to litres: $$500 \\text{ mL} \\div 1000 = 0.500 \\text{ L}$$',
    'Calculate molarity: $$M = 0.100 \\text{ mol} \\div 0.500 \\text{ L} = 0.200 \\text{ M}$$',
  ],
  answer: '<strong>0.200 M NaCl</strong><br>Weigh 5.85 g of NaCl, dissolve in less than 500 mL of water, then top up to exactly 500 mL.',
  source: { name: 'Vogel\'s Textbook of Quantitative Chemical Analysis', url: 'https://www.pearson.com/en-us/subject-catalog/p/vogels-textbook-of-quantitative-chemical-analysis' },
}
Enter fullscreen mode Exit fullscreen mode

Why this matters for SEO and trust: Google's EEAT guidelines reward pages that demonstrate real expertise. A formula alone doesn't cut it. Showing an author with credentials, a reviewer who verified the work, and worked examples with cited sources builds the authority signals that search engines (and users) look for.

You can see the full implementation on the molarity calculator page — the worked examples section, the formula with KaTeX, and the author byline are all rendered from this declarative data structure.

The content itself follows strict quality benchmarks. Each calculator has a minimum of 4,000 unique words across 16+ content blocks. FAQ sections contain 10–15 questions. Worked examples use verified numerical values from cited sources like IUPAC, NIST, and peer-reviewed textbooks. The glossary defines 8–12 domain-specific terms. Every heading is unique on the page — no duplicate <h2> text anywhere.

This is overkill for a simple calculator. But search engines and users reward depth. A page that genuinely teaches you something performs better than one that just computes a number.

7. Mistakes I Made (So You Don't Have To)

The pH Slug Bug

Calculator slugs are URL paths. I initially derived them from variable names using a camelCase to kebab-case function. That worked until pHCalculator became p-h-calculator instead of ph-calculator. The fix: define slugs explicitly in the manifest rather than deriving them.

Currency + Scientific Notation

Values below 0.0001 or above 10 million trigger scientific notation by default. Great for chemistry. Terrible for money. A loan EMI of $1,234,567.89 shouldn't display as $1.23e6. I had to add an exclusion: type: 'currency' outputs never use scientific notation.

Validation in the Wrong Place

I initially added input validation logic inside the calculator engine component. Every new validation rule meant editing the component. I moved validation into the data files as declarative rules:

validation: {
  dependsOn: {
    field: 'volume',
    rule: 'neq',
    value: '0',
  },
}
Enter fullscreen mode Exit fullscreen mode

The engine processes these automatically. Adding a new validation rule is now a data change, not a code change.

SVG Gauge Coordinates

The rangeBar() helper computes an SVG fill path for a horizontal gauge meter. I had to coordinate the viewBox, the bar rectangle, and the axis labels across 6 calculators that use it. One wrong coordinate and the bar overflows the viewBox. The solution was a fixed coordinate convention documented alongside the helper function so every calculator uses the exact same SVG template.

8. Key Architectural Decisions That Paid Off

Looking back, these decisions saved the most time:

Manifest as the single source of truth. Every import, every route, every related-calculator pairing is derived from one file. Add a calculator in one place, run one command, everything updates.

Slot-based content placement. Content blocks declare a slot (like 'description', 'howto', 'input:mass') instead of being hardcoded in a template. This let me rearrange pages, add contextual help near specific inputs, and place graphs next to specific outputs without touching page templates.

Declarative showWhen conditions. Instead of if (x > 5) { renderY() } scattered across components, each input declares when it should be visible:

showWhen: { field: 'solveFor', operator: 'neq', value: 'mass' }
Enter fullscreen mode Exit fullscreen mode

The engine handles the visibility. I never have to think about conditional rendering.

A unified content block system. Every piece of content on a calculator page — text, formulas, tables, graphs, images, SVGs, 3D scenes, worked examples — is a typed content block with a slot and order. The ContentRenderer component filters blocks by slot, sorts by order, and renders each one through its dedicated component. Need to add a reference table near the molar mass input field? Create a table block with slot: 'input:molarMass' and order: 10. The system places it there automatically. Need a graph after the summary? A graph block with slot: 'summary' and order: 20. This slot system let me build complex, rich pages without touching page templates for individual calculators.

Mode selectors that survive resets. A calculator like the molarity calculator has a "solve for" dropdown — the user picks whether they want to find molarity, moles, or mass. Switching this used to reset all inputs, which was frustrating. I added isModeSelector: true to inputs that define the calculator's mode. When the mode changes, only inputs that depend on that mode reset. Shared inputs like molar mass keep their values. Users no longer have to re-enter everything when switching between solve modes.

Engine-level table formatting. Early calculators had formulas returning formatted strings like "$377.42". This broke sorting, broke alternative unit conversion, and made the table component impossible to reuse. Now formulas return raw numbers, and the engine formats them based on column configs:

columns: [
  { width: '2fr', format: { type: 'currency', decimals: 2,
    currencyCodeFromInput: 'currency' } },
],
Enter fullscreen mode Exit fullscreen mode

The engine strips the currency code from cells and adds it to the column header automatically. Cells stay sortable and reusable.

Dynamic graph generators. Several calculators have interactive charts that regenerate when inputs change — like the compound interest graph showing growth over time at different rates. Each graph has a generator function in a per-calculator file that receives the current input values and returns Recharts-compatible datasets. The engine caches these in memory so switching between calculators doesn't re-fetch data. Adding a new graph is just writing a function and registering its ID in the calculator's content block.

What's Next

The declarative engine now powers 52 calculators across 8 categories. Adding a new calculator takes about a day — create the data file with all educational content, add one line to the manifest, run the generator. The engine handles the rest.

The biggest lesson from this project is simple: declarative data beats imperative code every time when you're managing 50+ variants of the same pattern. The initial cost of building the abstraction is real. But it pays for itself by calculator number five.

You can explore all 50+ calculators at toolsarena.net. If you're building a similar system or have questions about the architecture, I'd love to hear about your approach.


Building ToolsArena — a free platform with 50+ calculators across chemistry, finance, health, and more. I write about TypeScript, Next.js, and engineering practical tools at scale.

Top comments (0)