Skip to content
|By Muhammad Wilayat Shah|27 views • 5 unique IPs

How to Build a Home Loan EMI Calculator in JavaScript

Build a home loan EMI calculator in JavaScript and TypeScript: the EMI formula, an amortization schedule that ends at zero, rounding fixes, React and Laravel code.

ShareLinkedInX
How to Build a Home Loan EMI Calculator in JavaScript

Almost every property portal and real estate CRM needs a home loan EMI calculator, and almost every first version gets a few numbers wrong. The monthly installment is a few rupees off the bank's quote, the amortization schedule doesn't end at exactly zero, or the page shows NaN when someone enters a 0% rate. This guide walks through the EMI formula, the common mistakes behind those bugs, and a tested JavaScript and TypeScript implementation you can drop into a React or Next.js page or a Laravel backend.

Problem: Why a Home Loan EMI Calculator Gives Wrong Numbers

An EMI (equated monthly installment) is a fixed monthly payment that covers both interest and principal, so the loan reaches zero after the last installment. The math is simple, but small implementation choices change the result: how you convert the annual rate, when you round, how you handle the final payment, and what you do when the rate changes partway through the loan.

For a buyer comparing financing options, a calculator that is off by a little looks untrustworthy, and one that is off by a lot leads to bad decisions. If you are also helping users decide whether to finance a purchase at all, pair it with a rent vs buy framework.

Symptoms

  • The EMI is slightly different from the bank's quote or from a spreadsheet's PMT result.
  • The last row of the amortization schedule shows a small negative balance, or a few rupees left over.
  • Total interest changes when you switch between showing rounded and unrounded values.
  • The calculator returns NaN or Infinity for a 0% rate or an empty field.
  • Entering the rate as 12 instead of 0.12 produces an absurd installment.
  • Numbers are wrong after a rate reset on a floating-rate loan because the calculator assumes the original rate for the whole tenure.

Root Cause: EMI Formula and Rounding Mistakes

The EMI formula

With principal P, monthly rate r (annual percentage divided by 12, then by 100) and number of monthly payments n:

Code
EMI = P × r × (1 + r)^n / ((1 + r)^n − 1)

If r = 0:  EMI = P / n

This is the same calculation a spreadsheet's PMT(rate, nper, pv) function performs, which makes it a handy reference when you test your code.

Common causes of wrong results

  • Wrong rate conversion: using the annual rate directly, or dividing by 100 twice.
  • Rounding too early: rounding the monthly rate or each intermediate value, which compounds small errors over 240 or 300 months.
  • No final-payment adjustment: once the EMI is rounded to paisa, the last installment must absorb the leftover difference.
  • Division by zero: at 0% the formula becomes 0 / 0.
  • Different bank conventions: some lenders calculate interest on a daily balance, charge fees or insurance inside the installment, or use Islamic financing structures with their own schedules. A generic calculator gives an estimate, not the bank's official schedule.
home loan emi calculator amortization schedule showing the interest part of each EMI falling and the principal part rising over 20 years
Example inputs: 10,000,000 principal, 12% annual rate, 20 years. The installment stays the same while the interest share shrinks.

Step-by-Step: Build the EMI Calculator in JavaScript

Step 1: Validate and normalize the inputs

Accept the annual rate as a percentage, the way users and banks quote it, and the tenure in years or months. Reject negative or missing values before doing any math.

Code
export type LoanInput = {
  principal: number;      // e.g. 10_000_000
  annualRatePct: number;  // e.g. 12 for 12%
  months: number;         // e.g. 240 for 20 years
};

export function validate({ principal, annualRatePct, months }: LoanInput): string | null {
  if (!Number.isFinite(principal) || principal <= 0) return "Enter a loan amount above 0";
  if (!Number.isFinite(annualRatePct) || annualRatePct < 0) return "Enter a valid interest rate";
  if (!Number.isInteger(months) || months <= 0) return "Enter a tenure of at least 1 month";
  return null;
}

Step 2: Calculate the EMI without early rounding

Code
export function calculateEmi({ principal, annualRatePct, months }: LoanInput): number {
  const r = annualRatePct / 12 / 100;
  if (r === 0) return principal / months;
  const f = (1 + r) ** months;
  return (principal * r * f) / (f - 1);
}

Step 3: Round once, to the currency's smallest unit

Keep full precision during the calculation and round only the value you show or post. Working in integer paisa (or cents) for the schedule avoids floating-point drift when you add up hundreds of rows.

Code
const toMinor = (v: number) => Math.round(v * 100);   // rupees to paisa
const toMajor = (v: number) => v / 100;

Step 4: Generate the amortization schedule

Each month, interest is the outstanding balance times the monthly rate, and the rest of the EMI reduces the principal. The final row pays off whatever balance remains, so the schedule always ends at exactly zero.

Code
export type Row = { month: number; emi: number; interest: number; principal: number; balance: number };

export function amortizationSchedule(input: LoanInput): Row[] {
  const r = input.annualRatePct / 12 / 100;
  const emi = toMinor(calculateEmi(input));
  let balance = toMinor(input.principal);
  const rows: Row[] = [];

  for (let month = 1; month <= input.months; month++) {
    const interest = Math.round(balance * r);
    let principalPart = emi - interest;
    let payment = emi;

    if (month === input.months || principalPart > balance) {
      principalPart = balance;            // last payment absorbs the rounding difference
      payment = balance + interest;
    }

    balance -= principalPart;
    rows.push({
      month,
      emi: toMajor(payment),
      interest: toMajor(interest),
      principal: toMajor(principalPart),
      balance: toMajor(balance),
    });
    if (balance === 0) break;
  }
  return rows;
}

Step 5: Show totals users actually care about

Code
export function summary(rows: Row[], principal: number) {
  const totalPaid = rows.reduce((s, r) => s + toMinor(r.emi), 0);
  return {
    totalPaid: toMajor(totalPaid),
    totalInterest: toMajor(totalPaid - toMinor(principal)),
  };
}

export const formatPkr = (v: number) =>
  new Intl.NumberFormat("en-PK", { style: "currency", currency: "PKR", maximumFractionDigits: 0 }).format(v);

Code Examples: React Component and Laravel API

Mortgage calculator in React or Next.js

The calculation is pure, so it runs the same on the server and in the browser. Mark the form as a Client Component because it uses state.

Code
"use client";

import { useMemo, useState } from "react";
import { amortizationSchedule, calculateEmi, formatPkr, summary, validate } from "@/lib/emi";

export default function EmiCalculator() {
  const [principal, setPrincipal] = useState(10_000_000);
  const [rate, setRate] = useState(12);
  const [years, setYears] = useState(20);

  const input = { principal, annualRatePct: rate, months: years * 12 };
  const error = validate(input);

  const result = useMemo(() => {
    if (error) return null;
    const rows = amortizationSchedule(input);
    return { emi: calculateEmi(input), rows, ...summary(rows, principal) };
  }, [principal, rate, years, error]);

  return (
    <form className="emi-calculator" onSubmit={(e) => e.preventDefault()}>
      <label>Loan amount <input type="number" value={principal} onChange={(e) => setPrincipal(+e.target.value)} /></label>
      <label>Annual rate (%) <input type="number" step="0.01" value={rate} onChange={(e) => setRate(+e.target.value)} /></label>
      <label>Tenure (years) <input type="number" value={years} onChange={(e) => setYears(+e.target.value)} /></label>
      {error && <p role="alert">{error}</p>}
      {result && (
        <output>
          <p>Monthly EMI: {formatPkr(result.emi)}</p>
          <p>Total interest: {formatPkr(result.totalInterest)}</p>
          <p>Total paid: {formatPkr(result.totalPaid)}</p>
        </output>
      )}
    </form>
  );
}

For a long schedule, render the rows in a paginated list or let users download a CSV instead of putting 240 rows in the DOM at once.

The same EMI formula in PHP for Laravel

If your listing pages or CRM quotes are generated on the backend, keep one implementation there and return JSON to the frontend so both always agree.

Code
// app/Services/EmiCalculator.php
namespace App\Services;

class EmiCalculator
{
    public function emi(float $principal, float $annualRatePct, int $months): float
    {
        $r = $annualRatePct / 12 / 100;
        if ($r == 0.0) {
            return $principal / $months;
        }
        $f = (1 + $r) ** $months;
        return $principal * $r * $f / ($f - 1);
    }
}

// routes/api.php
Route::get('/emi', function (Illuminate\Http\Request $request, App\Services\EmiCalculator $calc) {
    $data = $request->validate([
        'principal' => 'required|numeric|gt:0',
        'rate'      => 'required|numeric|gte:0',
        'months'    => 'required|integer|gt:0',
    ]);
    return ['emi' => round($calc->emi($data['principal'], $data['rate'], $data['months']), 2)];
});

Handling a rate reset on a floating-rate loan

Many home financing products in Pakistan are priced as a benchmark rate such as KIBOR plus a spread and are repriced periodically. When the rate changes, recalculate the EMI from the outstanding balance and the remaining months instead of the original loan amount:

Code
const rows = amortizationSchedule({ principal: 10_000_000, annualRatePct: 12, months: 240 });
const afterYear1 = rows[11].balance;                 // balance after 12 payments
const newEmi = calculateEmi({ principal: afterYear1, annualRatePct: 14, months: 228 });

Show users clearly that the result is an estimate. If you are building listing tools around this, the same pattern of pure, testable functions works well for other property math, like the numbers in analyzing a rental property before you buy.

Verification

Check the code against an independent calculation before you ship it. With example inputs of 10,000,000 principal, 12% annual rate and 240 months:

Code
// Spreadsheet (Excel, Google Sheets, LibreOffice)
=PMT(12%/12, 240, -10000000)        // 110,108.61

// Unit tests (Vitest or Jest)
import { expect, test } from "vitest";
import { amortizationSchedule, calculateEmi } from "./emi";

test("matches PMT", () => {
  expect(calculateEmi({ principal: 10_000_000, annualRatePct: 12, months: 240 })).toBeCloseTo(110108.61, 2);
});

test("first month split", () => {
  const [first] = amortizationSchedule({ principal: 10_000_000, annualRatePct: 12, months: 240 });
  expect(first.interest).toBe(100000);
  expect(first.principal).toBe(10108.61);
});

test("schedule ends at zero", () => {
  const rows = amortizationSchedule({ principal: 10_000_000, annualRatePct: 12, months: 240 });
  expect(rows.at(-1)!.balance).toBe(0);
  expect(rows).toHaveLength(240);
});

test("zero rate", () => {
  expect(calculateEmi({ principal: 1_200_000, annualRatePct: 0, months: 12 })).toBe(100000);
});

Run npx vitest run. The first month's interest of 100,000 is simply 1% of the 10,000,000 balance, which is an easy sanity check to do by hand. Also test the edge cases: a one-month loan, a very small principal, and a high rate.

Prevention

  • Keep the calculation in one pure, unit-tested module and reuse it in the frontend, backend and PDF quotes.
  • Use integer minor units (paisa) for schedules and totals, and round only for display or posting.
  • Always let the final payment absorb the rounding difference.
  • Label inputs clearly, for example "Annual rate (%)", and validate ranges on both client and server.
  • Show a note that bank fees, insurance, daily-balance interest and Islamic financing structures can change the official schedule.
  • Recalculate from the outstanding balance whenever a floating rate resets.

FAQs

What is the EMI formula for a home loan?

EMI = P × r × (1 + r)^n / ((1 + r)^n − 1), where P is the principal, r is the monthly rate (annual percentage ÷ 12 ÷ 100) and n is the number of monthly payments. At 0% interest, EMI = P / n.

Why is my EMI calculator a few rupees different from the bank?

Usually rounding, daily-balance interest, or fees and insurance included in the bank's installment. Compare against the bank's own schedule for the exact figure.

How do I build an amortization schedule in JavaScript?

Each month, multiply the outstanding balance by the monthly rate to get the interest, subtract it from the EMI to get the principal part, and reduce the balance. Work in integer paisa and let the last payment clear the remaining balance.

Should I use floating-point numbers for money?

Floats are fine for the formula itself, but store and add up schedule values as integers in the smallest currency unit, or use a decimal library, to avoid drift.

Does a mortgage calculator in JavaScript work in Next.js server components?

The calculation functions are pure, so they run on the server or the client. Only the interactive form needs "use client".

How do I calculate EMI after a rate change?

Use the outstanding balance at the reset date as the new principal, the new rate, and the remaining number of months.

ShareLinkedInX
Back to blog

Keep reading

Newsletter

Occasional notes on Laravel, React, and shipping production systems — no spam.

Comments

Join the discussion. Replies load on demand, up to 5 levels.

0 threads

Loading editor…
Reviewed before publishing

No comments yet

Be the first to start the conversation.