DEV Community

Akash Singh
Akash Singh

Posted on

One Jinja2 template, 147 calculator pages — here's the architecture

Last month someone on Dev.to asked how many templates I use for my calculator pages. When I said "one" they thought I was joking.

I'm not. One Jinja2 template renders 147 different calculator pages. BMI calculators, unit converters, loan estimators, percentage tools — all the same HTML file. Here's the pattern that makes it work.

The problem with 147 separate files

Imagine this: you want to change the layout of your calculator result section. With 147 separate template files, that's 147 edits. Miss one and now your kilometers-to-miles converter has a different layout than everything else. And good luck catching that in code review.

I learned this the painful way. My first 20 calculators each had their own template. By calculator 21, I was copy-pasting so much that I introduced 3 different bugs from stale HTML. Something had to change.

The architecture (4 layers)

The whole pattern comes down to four layers:

URL request → Route handler → Data config → Universal template → Page
Enter fullscreen mode Exit fullscreen mode

Let me walk through each one.

Layer 1: The route handler

One FastAPI route catches every calculator URL:

@router.get("/{category_slug}/{calc_slug}", response_class=HTMLResponse)
async def calc_tool(request: Request, category_slug: str, calc_slug: str):
    calc = CALCULATORS.get(calc_slug)
    if not calc or calc["category"] != category_slug:
        raise HTTPException(status_code=404, detail="Calculator not found")

    cat = CALC_CATEGORIES.get(category_slug, {})
    related = get_related_calculators(calc_slug)

    return templates.TemplateResponse("calc_tool.html", {
        "request": request,
        "slug": calc_slug,
        "calc": calc,
        "category_slug": category_slug,
        "category": cat,
        "related": related,
    })
Enter fullscreen mode Exit fullscreen mode

That's it. /calc/length/cm-to-inches, /calc/health/bmi-calculator, /calc/finance/percentage-calculator — they all hit this same function. The URL tells it which data to pull, and the template handles the rest.

Layer 2: The data configuration

This is where the magic actually lives. Every calculator is a Python dictionary:

CALCULATORS["bmi-calculator"] = {
    "category": "health",
    "type": "multi_input",
    "title": "BMI Calculator",
    "h1": "BMI Calculator — Body Mass Index",
    "inputs": [
        {"id": "weight", "label": "Weight", "unit": "kg",
         "placeholder": "70", "type": "number"},
        {"id": "height", "label": "Height", "unit": "cm",
         "placeholder": "175", "type": "number"},
    ],
    "formula_js": "weight / Math.pow(height / 100, 2)",
    "result_label": "Your BMI",
    "result_unit": "kg/m²",
    "ranges": [
        {"max": 18.5, "label": "Underweight", "color": "#38bdf8"},
        {"max": 24.9, "label": "Normal weight", "color": "#22c55e"},
    ],
    "faq": [
        {"q": "What is BMI?", "a": "BMI is a measure..."},
    ],
}
Enter fullscreen mode Exit fullscreen mode

Notice the formula_js field — that's a JavaScript expression stored in Python config. The template injects it into a <script> tag on the page. The browser does the actual math. My server never touches user data.

Layer 3: Auto-generating converters

Manually writing 147 of those dictionaries would defeat the purpose. So I wrote helper functions that generate converter pairs automatically:

def _pair(cat, from_name, from_abbr, from_slug,
          to_name, to_abbr, to_slug, factor):
    """Generate BOTH directions of a converter."""
    s1, d1 = _conv(cat, from_name, from_abbr, from_slug,
                   to_name, to_abbr, to_slug, factor)
    s2, d2 = _conv(cat, to_name, to_abbr, to_slug,
                   from_name, from_abbr, from_slug, 1.0 / factor)
    return [(s1, d1), (s2, d2)]
Enter fullscreen mode Exit fullscreen mode

One line of code creates two calculators. "cm to inches" and "inches to cm" from a single _pair() call. 12 pairs give me 24 length converters. Do that across length, weight, temperature, volume, area, speed, data, and time — and you get most of those 147 pages from maybe 80 lines of config.

Layer 4: The universal template

The Jinja2 template branches on calc.type:

{% if calc.type in ['converter', 'temperature'] %}
    <input type="number" id="calc-input" placeholder="Enter value">
    <div id="calc-result-value">—</div>

    {% if calc.quick_table %}
    <table class="calc-ref-table">
        {% for val in calc.quick_table %}
        <tr><td>{{ val }}</td><td>—</td></tr>
        {% endfor %}
    </table>
    {% endif %}

{% elif calc.type == 'multi_input' %}
    {% for inp in calc.inputs %}
    <label>{{ inp.label }} ({{ inp.unit }})</label>
    <input type="number" id="calc-{{ inp.id }}"
           data-var="{{ inp.id }}"
           placeholder="{{ inp.placeholder }}">
    {% endfor %}

{% elif calc.type == 'percentage' %}
    {% for mode in calc.modes %}
    <button class="calc-mode-tab"
            data-formula="{{ mode.formula_js }}">
        {{ mode.label }}
    </button>
    {% endfor %}

{% elif calc.type == 'date' %}
    <input type="date" id="calc-date-input">
{% endif %}
Enter fullscreen mode Exit fullscreen mode

Converters get a single input field with a reference table. Multi-input calculators (BMI, EMI) get dynamic input fields generated from the config. Percentage calculators get tabbed modes. Date tools get date pickers.

One file. Every layout variation handled by {% if calc.type %}.

What's good about this pattern

Adding a calculator takes 30 seconds. I open calculator_data.py, add a dictionary or a _pair() call, and it's live. No new template, no new route, no new JavaScript file.

Consistency is automatic. Change the FAQ layout? One edit, 147 pages update. Change the color scheme? Same. Every calculator looks and works identically because they all render from the same template.

SEO with zero extra work. Each dictionary has meta_desc, h1, and title fields. 147 pages with unique meta descriptions — all generated from the config. Google sees unique content on every page because the FAQs, formulas, and reference tables are all different.

What breaks

I won't pretend this is perfect.

Edge cases get messy. Temperature conversion isn't a simple multiply-by-factor operation (Celsius to Fahrenheit needs (C × 9/5) + 32). I had to add a temperature type separate from converter. Every exception adds another {% elif %} to the template.

The template got long. It started at 80 lines. It's now over 200. Those conditional blocks accumulate. At some point you start wondering if maybe you should split into 3-4 sub-templates.

Debugging sucks sometimes. When a calculator page renders wrong, you have to check: is it the data config? The template branch? The JavaScript formula? The CSS? Four layers means four places bugs can hide.

When should you use this pattern?

If you're building 5 pages that share a layout — probably don't bother. Copy-paste is fine for 5 pages.

If you're building 20+ and they follow predictable variations? Data-driven templating saves you from drowning in maintenance. The rule I follow: when you catch yourself copy-pasting a template for the third time, stop and extract the config.

The calculators hub runs 147 pages on this pattern. Adding number 148 would take me about 30 seconds. That's DRY taken to its logical extreme — and honestly, it's one of the better architectural decisions I've stumbled into.

What's the most DRY thing you've ever built? I'm curious whether anyone else has pushed this kind of template reuse further than they expected.

Top comments (0)