Back to Blog

Build a Currency Converter App (2026)

V
Vlado Grigirov
April 05, 2026
Currency Converter Tutorial JavaScript Web App API Integration

How to Build a Currency Converter App from Scratch

Building a currency converter is one of the best beginner-to-intermediate web development projects. It combines API integration, DOM manipulation, user input handling, and responsive design into a single, useful application. By the end of this guide, you'll have a production-ready converter that fetches live exchange rates and handles edge cases gracefully. If you're new to how exchange rates work, our exchange rates explainer covers the fundamentals.

What You'll Build

The finished app will:

  • Convert between 170+ currencies using live exchange rates
  • Auto-detect the user's locale for smart currency defaults
  • Cache rates locally to minimize API calls
  • Handle offline scenarios and API errors
  • Work on desktop and mobile with a responsive layout

Project Setup

Create three files in a new directory:

currency-converter/
├── index.html
├── style.css
└── app.js

The HTML Structure

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Currency Converter</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <div class="converter">
    <h1>Currency Converter</h1>
    <div class="input-group">
      <input type="number" id="amount" value="1" min="0" step="any">
      <select id="fromCurrency"></select>
    </div>
    <button id="swap" aria-label="Swap currencies">⇅</button>
    <div class="input-group">
      <input type="number" id="result" readonly>
      <select id="toCurrency"></select>
    </div>
    <p id="rateDisplay" class="rate-info"></p>
    <p id="lastUpdated" class="meta-info"></p>
  </div>
  <script src="app.js"></script>
</body>
</html>

The layout is intentionally simple: two input groups (amount + currency selector), a swap button, and informational text showing the current rate and last update time.

Core CSS

* { margin: 0; padding: 0; box-sizing: border-box; }

body {
  font-family: system-ui, -apple-system, sans-serif;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1rem;
}

.converter {
  background: white;
  border-radius: 1rem;
  padding: 2rem;
  width: 100%;
  max-width: 420px;
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
}

h1 {
  text-align: center;
  margin-bottom: 1.5rem;
  color: #1a1a2e;
}

.input-group {
  display: flex;
  gap: 0.5rem;
  margin: 0.75rem 0;
}

.input-group input {
  flex: 1;
  padding: 0.75rem 1rem;
  border: 2px solid #e2e8f0;
  border-radius: 0.5rem;
  font-size: 1.1rem;
}

.input-group select {
  width: 120px;
  padding: 0.75rem;
  border: 2px solid #e2e8f0;
  border-radius: 0.5rem;
  font-size: 0.95rem;
  background: #f8fafc;
}

#swap {
  display: block;
  margin: 0.5rem auto;
  background: none;
  border: 2px solid #e2e8f0;
  border-radius: 50%;
  width: 40px;
  height: 40px;
  font-size: 1.2rem;
  cursor: pointer;
  transition: all 0.2s;
}

#swap:hover {
  background: #667eea;
  color: white;
  border-color: #667eea;
}

.rate-info { text-align: center; color: #4a5568; margin-top: 1rem; font-size: 0.9rem; }
.meta-info { text-align: center; color: #a0aec0; font-size: 0.75rem; margin-top: 0.25rem; }

@media (max-width: 480px) {
  .converter { padding: 1.25rem; }
  .input-group { flex-direction: column; }
  .input-group select { width: 100%; }
}

Connecting to a Live Exchange Rate API

The converter needs real-time rates. We'll use Finexly's currency exchange rate API because it offers 1,000 free requests per month, supports 170+ currencies, and returns JSON with sub-50ms response times.

Getting Your API Key

  1. Sign up at finexly.com (no credit card required)
  2. Copy your API key from the dashboard
  3. Store it as a constant in your app (for production, use environment variables)

The JavaScript Engine

const API_KEY = 'YOUR_API_KEY';
const API_BASE = 'https://api.finexly.com/v1';
const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes

let ratesCache = { data: null, timestamp: 0 };

// Popular currencies first for better UX
const POPULAR = ['USD', 'EUR', 'GBP', 'JPY', 'CAD', 'AUD', 'CHF', 'CNY'];

async function fetchRates(base = 'USD') {
  const now = Date.now();

  // Return cached data if fresh
  if (ratesCache.data && ratesCache.data.base === base
      && (now - ratesCache.timestamp) < CACHE_DURATION) {
    return ratesCache.data;
  }

  try {
    const response = await fetch(
      `${API_BASE}/latest?api_key=${API_KEY}&base=${base}`
    );

    if (!response.ok) {
      throw new Error(`API returned ${response.status}`);
    }

    const data = await response.json();
    ratesCache = { data, timestamp: now };
    return data;

  } catch (error) {
    console.error('Failed to fetch rates:', error);

    // Fall back to stale cache if available
    if (ratesCache.data) {
      console.warn('Using stale cached rates');
      return ratesCache.data;
    }

    throw error;
  }
}

This implements a stale-while-revalidate pattern: if the API is down, the app still works with the last known rates rather than showing an error. For more on production caching strategies, see our caching and error handling best practices guide.

Populating Currency Dropdowns

function populateCurrencySelects(currencies) {
  const fromSelect = document.getElementById('fromCurrency');
  const toSelect = document.getElementById('toCurrency');

  // Sort: popular currencies first, then alphabetical
  const sorted = Object.keys(currencies).sort((a, b) => {
    const aPopular = POPULAR.indexOf(a);
    const bPopular = POPULAR.indexOf(b);
    if (aPopular !== -1 && bPopular !== -1) return aPopular - bPopular;
    if (aPopular !== -1) return -1;
    if (bPopular !== -1) return 1;
    return a.localeCompare(b);
  });

  sorted.forEach(code => {
    fromSelect.add(new Option(code, code));
    toSelect.add(new Option(code, code));
  });

  // Smart defaults based on user locale
  fromSelect.value = 'USD';
  toSelect.value = guessUserCurrency();
}

function guessUserCurrency() {
  const locale = navigator.language || 'en-US';
  const regionMap = {
    'en-GB': 'GBP', 'en-AU': 'AUD', 'en-CA': 'CAD',
    'de': 'EUR', 'fr': 'EUR', 'ja': 'JPY', 'zh': 'CNY'
  };

  for (const [prefix, currency] of Object.entries(regionMap)) {
    if (locale.startsWith(prefix)) return currency;
  }
  return 'EUR';
}

A small UX detail that makes a big difference: detecting the user's browser locale to pre-select their likely home currency. Nobody wants to scroll through 170 currencies to find EUR when they're in Germany.

The Conversion Logic

async function convert() {
  const amount = parseFloat(document.getElementById('amount').value);
  const from = document.getElementById('fromCurrency').value;
  const to = document.getElementById('toCurrency').value;
  const resultInput = document.getElementById('result');
  const rateDisplay = document.getElementById('rateDisplay');
  const lastUpdated = document.getElementById('lastUpdated');

  if (isNaN(amount) || amount < 0) {
    resultInput.value = '';
    rateDisplay.textContent = 'Enter a valid amount';
    return;
  }

  if (from === to) {
    resultInput.value = amount.toFixed(2);
    rateDisplay.textContent = `1 ${from} = 1 ${to}`;
    return;
  }

  try {
    const data = await fetchRates(from);
    const rate = data.rates[to];

    if (!rate) {
      rateDisplay.textContent = `Rate unavailable for ${to}`;
      return;
    }

    const converted = amount * rate;
    resultInput.value = converted.toFixed(
      converted < 1 ? 6 : converted < 100 ? 4 : 2
    );

    rateDisplay.textContent = `1 ${from} = ${rate.toFixed(6)} ${to}`;
    lastUpdated.textContent = `Updated: ${new Date().toLocaleTimeString()}`;

  } catch (error) {
    rateDisplay.textContent = 'Unable to fetch rates. Please try again.';
  }
}

Notice the dynamic decimal precision: small values like BTC rates show 6 decimals, medium values show 4, and large values show 2. This avoids both "0.00" for tiny rates and "12345.678901" for large ones. For a deeper understanding of how these rates are determined, see our ISO 4217 currency codes guide.

Wiring Up Event Listeners

document.getElementById('amount').addEventListener('input', convert);
document.getElementById('fromCurrency').addEventListener('change', () => {
  ratesCache = { data: null, timestamp: 0 }; // Invalidate cache on base change
  convert();
});
document.getElementById('toCurrency').addEventListener('change', convert);
document.getElementById('swap').addEventListener('click', () => {
  const from = document.getElementById('fromCurrency');
  const to = document.getElementById('toCurrency');
  [from.value, to.value] = [to.value, from.value];
  ratesCache = { data: null, timestamp: 0 };
  convert();
});

// Initialize
(async () => {
  try {
    const data = await fetchRates('USD');
    populateCurrencySelects(data.rates);
    convert();
  } catch (e) {
    document.getElementById('rateDisplay').textContent =
      'Failed to load rates. Check your API key.';
  }
})();

The swap button lets users quickly reverse the conversion direction, which is by far the most common action after the initial conversion.

Adding Offline Support

For a more robust app, add a service worker that caches the last known rates:

// In app.js - save rates to localStorage as backup
function persistRates(data) {
  try {
    localStorage.setItem('cachedRates', JSON.stringify({
      data,
      timestamp: Date.now()
    }));
  } catch (e) {
    // Storage full or unavailable - fail silently
  }
}

function loadPersistedRates() {
  try {
    const stored = localStorage.getItem('cachedRates');
    if (stored) {
      const parsed = JSON.parse(stored);
      // Accept rates up to 24 hours old
      if (Date.now() - parsed.timestamp < 24 * 60 * 60 * 1000) {
        return parsed.data;
      }
    }
  } catch (e) {
    // Corrupted data - fail silently
  }
  return null;
}

Call persistRates(data) after each successful API fetch, and use loadPersistedRates() as a fallback when the network is unavailable.

Performance Optimization

Three things matter most for a converter app:

1. Debounce the input handler. Without debouncing, typing "1500" fires four API lookups. A simple 300ms debounce fixes this:

function debounce(fn, delay) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
  };
}

document.getElementById('amount')
  .addEventListener('input', debounce(convert, 300));

2. Preload popular pairs. If your users mostly convert USD to EUR, prefetch that pair on page load so the first conversion feels instant.

3. Use the batch endpoint. If you need multiple currency pairs simultaneously (like a dashboard showing 10 rates), use Finexly's batch endpoint instead of making 10 separate requests. See the API documentation for details.

Deploying Your Converter

The simplest deployment options for a static app:

Netlify / Vercel: Drag and drop the folder, get a free HTTPS URL in seconds. Both offer generous free tiers.

GitHub Pages: Push to a repo, enable Pages in settings. Free hosting tied to your GitHub account.

Your own server: Drop the files into any web server's public directory. No build step needed.

For production use, move the API key to a backend proxy to avoid exposing it in client-side JavaScript. A minimal Node.js proxy takes about 15 lines of code — our Node.js integration guide covers this pattern.

Extending the App

Once the basic converter works, consider adding:

  • Historical rate charts: Fetch past rates from Finexly's historical API and plot them with Chart.js
  • Multiple target currencies: Show one source amount converted to 5-10 currencies simultaneously
  • Favorite pairs: Let users pin their most-used currency pairs
  • Embeddable widget: Package the converter as an iframe or web component that other sites can embed (great for backlinks)

You can also try our live currency converter to see a production implementation of these patterns.

Common Pitfalls

Floating-point arithmetic: JavaScript's 0.1 + 0.2 !== 0.3 problem applies to currency math. For display purposes, toFixed() is fine. For financial calculations, use integer arithmetic (store cents, not dollars) or a library like decimal.js.

Rate staleness: Rates change constantly. Always show users when rates were last updated. For real-time requirements beyond what REST provides, see our REST vs WebSocket comparison.

API key exposure: Client-side API keys are visible in browser DevTools. For a personal project this is fine (Finexly's free tier is rate-limited). For production, proxy requests through your backend.

Wrapping Up

You now have a fully functional currency converter that handles live rates, caching, offline fallback, and responsive layout. The complete project is under 200 lines of code across three files. From here, you can expand it into a full financial dashboard, embed it as a widget, or use it as a portfolio piece.

Ready to build? Get your free API key and start converting in under 5 minutes. Check our pricing plans if you need higher rate limits for production use.

Vlado Grigirov

Senior Currency Markets Analyst & Financial Strategist

Vlado Grigirov is a senior currency markets analyst and financial strategist with over 14 years of experience in foreign exchange markets, cross-border finance, and currency risk management. He has wo...

View full profile →