Build a Professional Currency Converter 💱
Currency conversion is one of the most practical tools you can build. Whether you're creating an e-commerce platform, a travel app, or a financial dashboard, understanding how to handle real-time exchange rates is essential. In this comprehensive guide, we'll build a production-ready currency converter from scratch.
Unlike simple calculators, a currency converter requires external data. Exchange rates fluctuate constantly based on global markets, so we need to integrate with reliable APIs and handle the complexities that come with real-time financial data.
Understanding Exchange Rates 📊
Before diving into code, let's understand the fundamentals of currency exchange.
What Are Exchange Rates?
An exchange rate represents how much one currency is worth in terms of another. For example, if the EUR/USD rate is 1.10, it means 1 Euro equals 1.10 US Dollars.
Key Terminology:
- Base Currency: The currency you're converting FROM
- Quote Currency: The currency you're converting TO
- Bid Price: The rate at which dealers buy the base currency
- Ask Price: The rate at which dealers sell the base currency
- Mid-Market Rate: The average between bid and ask (what consumers typically see)
Types of Exchange Rate Systems
- Fixed (Pegged) Rates: Government maintains a constant rate against another currency
- Floating Rates: Market forces determine the exchange rate
- Managed Float: Government occasionally intervenes to stabilize the rate
Understanding these concepts helps you explain to users why rates change and why different sources may show slightly different values.
Choosing an Exchange Rate API 🔌
Several APIs provide exchange rate data. Here's a comparison of popular options:
| Provider | Free Tier | Update Frequency | Currencies |
|---|---|---|---|
| ExchangeRate-API | 1,500 req/mo | Daily | 160+ |
| Open Exchange Rates | 1,000 req/mo | Hourly | 170+ |
| Fixer.io | 100 req/mo | Hourly | 170+ |
| CurrencyAPI | 300 req/mo | Daily | 150+ |
| FreeCurrencyAPI | 5,000 req/mo | Daily | 150+ |
For this guide, we'll use a generic approach that works with any JSON-based API.
Step 1: The Core Conversion Logic 🧮
The fundamental conversion formula is simple:
Result = Amount × Exchange Rate
However, the implementation requires careful handling of edge cases:
interface ConversionResult {
amount: number;
from: string;
to: string;
rate: number;
result: number;
timestamp: Date;
}
function convertCurrency(
amount: number,
fromCurrency: string,
toCurrency: string,
rates: Record<string, number>
): ConversionResult | null {
// Validate inputs
if (amount <= 0 || isNaN(amount)) {
console.error("Invalid amount provided");
return null;
}
// Handle same currency conversion
if (fromCurrency === toCurrency) {
return {
amount,
from: fromCurrency,
to: toCurrency,
rate: 1,
result: amount,
timestamp: new Date()
};
}
// Get rates (assuming USD as base)
const fromRate = rates[fromCurrency];
const toRate = rates[toCurrency];
if (!fromRate || !toRate) {
console.error("Currency not found in rates");
return null;
}
// Calculate cross rate: first convert to USD, then to target
const rate = toRate / fromRate;
const result = amount * rate;
return {
amount,
from: fromCurrency,
to: toCurrency,
rate,
result: Math.round(result * 100) / 100, // Round to 2 decimal places
timestamp: new Date()
};
}
Why Cross Rates?
Most APIs provide rates relative to a base currency (usually USD). To convert between two non-USD currencies, you need to calculate the cross rate:
EUR to GBP = (GBP/USD) / (EUR/USD)
This is why we divide toRate by fromRate in the code above.
Step 2: Building the React Component 💻
Here's a complete, production-ready currency converter component:
"use client"
import { useState, useEffect, useCallback } from "react"
import { ArrowRightLeft, RefreshCw, TrendingUp, AlertCircle } from "lucide-react"
interface ExchangeRates {
base: string;
rates: Record<string, number>;
timestamp: number;
}
const POPULAR_CURRENCIES = [
{ code: "USD", name: "US Dollar", symbol: "$" },
{ code: "EUR", name: "Euro", symbol: "€" },
{ code: "GBP", name: "British Pound", symbol: "£" },
{ code: "JPY", name: "Japanese Yen", symbol: "¥" },
{ code: "CAD", name: "Canadian Dollar", symbol: "$" },
{ code: "AUD", name: "Australian Dollar", symbol: "$" },
{ code: "CHF", name: "Swiss Franc", symbol: "Fr" },
{ code: "CNY", name: "Chinese Yuan", symbol: "¥" },
{ code: "INR", name: "Indian Rupee", symbol: "₹" },
{ code: "MXN", name: "Mexican Peso", symbol: "$" },
{ code: "SGD", name: "Singapore Dollar", symbol: "$" },
{ code: "NZD", name: "New Zealand Dollar", symbol: "$" },
]
export default function CurrencyConverter() {
const [amount, setAmount] = useState<string>("100")
const [fromCurrency, setFromCurrency] = useState("USD")
const [toCurrency, setToCurrency] = useState("EUR")
const [rates, setRates] = useState<Record<string, number>>({})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
// Fetch exchange rates
const fetchRates = useCallback(async () => {
setLoading(true)
setError(null)
try {
// Replace with your preferred API endpoint
const response = await fetch(
`https://api.exchangerate-api.com/v4/latest/USD`
)
if (!response.ok) {
throw new Error("Failed to fetch exchange rates")
}
const data: ExchangeRates = await response.json()
setRates(data.rates)
setLastUpdated(new Date())
// Cache in localStorage for offline use
localStorage.setItem("exchangeRates", JSON.stringify({
rates: data.rates,
timestamp: Date.now()
}))
} catch (err) {
// Try to load cached rates
const cached = localStorage.getItem("exchangeRates")
if (cached) {
const { rates: cachedRates, timestamp } = JSON.parse(cached)
setRates(cachedRates)
setLastUpdated(new Date(timestamp))
setError("Using cached rates (offline mode)")
} else {
setError("Unable to load exchange rates")
}
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchRates()
}, [fetchRates])
// Calculate conversion
const calculate = (): string => {
const numAmount = parseFloat(amount)
if (isNaN(numAmount) || numAmount <= 0) return "0.00"
if (fromCurrency === toCurrency) return numAmount.toFixed(2)
const fromRate = rates[fromCurrency] || 1
const toRate = rates[toCurrency] || 1
const result = (numAmount / fromRate) * toRate
return result.toFixed(2)
}
// Swap currencies
const handleSwap = () => {
setFromCurrency(toCurrency)
setToCurrency(fromCurrency)
}
// Get exchange rate for display
const getExchangeRate = (): string => {
if (!rates[fromCurrency] || !rates[toCurrency]) return "—"
const rate = rates[toCurrency] / rates[fromCurrency]
return rate.toFixed(4)
}
const result = calculate()
return (
<div className="max-w-2xl mx-auto p-6 bg-white border rounded-xl shadow-lg">
{/* Header */}
<div className="flex items-center justify-between mb-6 pb-4 border-b">
<div className="flex items-center gap-3">
<div className="bg-green-100 p-2 rounded-lg text-green-700">
<TrendingUp size={24} />
</div>
<div>
<h2 className="text-xl font-bold text-slate-800">
Currency Converter
</h2>
{lastUpdated && (
<p className="text-xs text-slate-500">
Updated: {lastUpdated.toLocaleTimeString()}
</p>
)}
</div>
</div>
<button
onClick={fetchRates}
disabled={loading}
className="p-2 hover:bg-slate-100 rounded-lg transition"
>
<RefreshCw
size={20}
className={`text-slate-500 ${loading ? 'animate-spin' : ''}`}
/>
</button>
</div>
{/* Error Message */}
{error && (
<div className="flex items-center gap-2 p-3 mb-4 bg-amber-50 border border-amber-200 rounded-lg text-amber-700 text-sm">
<AlertCircle size={16} />
{error}
</div>
)}
{/* Converter Form */}
<div className="space-y-4">
{/* Amount Input */}
<div>
<label className="text-xs font-bold uppercase text-slate-500 mb-1 block">
Amount
</label>
<input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
className="w-full p-4 border-2 rounded-xl font-mono text-2xl focus:border-green-500 focus:outline-none"
placeholder="Enter amount"
min="0"
step="0.01"
/>
</div>
{/* Currency Selectors */}
<div className="flex items-center gap-4">
<div className="flex-1">
<label className="text-xs font-bold uppercase text-slate-500 mb-1 block">
From
</label>
<select
value={fromCurrency}
onChange={(e) => setFromCurrency(e.target.value)}
className="w-full p-3 border rounded-lg bg-slate-50 font-medium"
>
{POPULAR_CURRENCIES.map((currency) => (
<option key={currency.code} value={currency.code}>
{currency.code} - {currency.name}
</option>
))}
</select>
</div>
<button
onClick={handleSwap}
className="mt-6 p-3 bg-slate-100 hover:bg-slate-200 rounded-full transition"
>
<ArrowRightLeft size={20} className="text-slate-600" />
</button>
<div className="flex-1">
<label className="text-xs font-bold uppercase text-slate-500 mb-1 block">
To
</label>
<select
value={toCurrency}
onChange={(e) => setToCurrency(e.target.value)}
className="w-full p-3 border rounded-lg bg-slate-50 font-medium"
>
{POPULAR_CURRENCIES.map((currency) => (
<option key={currency.code} value={currency.code}>
{currency.code} - {currency.name}
</option>
))}
</select>
</div>
</div>
{/* Result Display */}
<div className="bg-gradient-to-r from-green-600 to-emerald-600 text-white p-6 rounded-xl text-center mt-6">
<div className="text-green-200 text-sm uppercase mb-2">
Converted Amount
</div>
<div className="text-4xl font-bold font-mono">
{loading ? "..." : result} {toCurrency}
</div>
<div className="text-green-200 text-sm mt-2">
1 {fromCurrency} = {getExchangeRate()} {toCurrency}
</div>
</div>
</div>
</div>
)
}
Step 3: Implementing Offline Support 📴
For a professional tool, offline support is crucial. Users may need conversions when traveling without internet access.
Caching Strategy
const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours
function getCachedRates(): Record<string, number> | null {
try {
const cached = localStorage.getItem("exchangeRates");
if (!cached) return null;
const { rates, timestamp } = JSON.parse(cached);
const age = Date.now() - timestamp;
// Return cached data if less than 24 hours old
if (age < CACHE_DURATION) {
return rates;
}
return null; // Cache expired
} catch {
return null;
}
}
function setCachedRates(rates: Record<string, number>): void {
localStorage.setItem("exchangeRates", JSON.stringify({
rates,
timestamp: Date.now()
}));
}
Service Worker Integration
For complete offline support, consider adding a service worker that caches API responses:
// In your service worker
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('exchangerate-api.com')) {
event.respondWith(
caches.match(event.request).then((cached) => {
const fetched = fetch(event.request).then((response) => {
const clone = response.clone();
caches.open('exchange-rates').then((cache) => {
cache.put(event.request, clone);
});
return response;
});
return cached || fetched;
})
);
}
});
Step 4: Error Handling Best Practices ⚠️
Currency converters deal with real money, so robust error handling is essential:
Common Error Scenarios
- Network Failures: API is unreachable
- Rate Limits: Exceeded API quota
- Invalid Currencies: User enters unsupported currency code
- Stale Data: Cached rates are too old
- Precision Errors: JavaScript floating-point issues
Handling Precision
Financial calculations require careful handling of decimal precision:
function preciseMultiply(a: number, b: number): number {
// Convert to integers, multiply, then convert back
const precision = 10000; // 4 decimal places
return Math.round(a * precision) * Math.round(b * precision) / (precision * precision);
}
// Or use a library like decimal.js for critical applications
import Decimal from 'decimal.js';
function convertWithPrecision(amount: number, rate: number): string {
return new Decimal(amount).times(rate).toFixed(2);
}
Step 5: Advanced Features 🚀
Historical Rate Comparison
Show users how the rate has changed:
function RateChange({ current, previous }: { current: number; previous: number }) {
const change = ((current - previous) / previous) * 100;
const isPositive = change > 0;
return (
<span className={isPositive ? "text-green-600" : "text-red-600"}>
{isPositive ? "▲" : "▼"} {Math.abs(change).toFixed(2)}%
</span>
);
}
Favorite Currency Pairs
Let users save frequently used conversions:
function useFavoritePairs() {
const [favorites, setFavorites] = useState<string[]>(() => {
const saved = localStorage.getItem("favoritePairs");
return saved ? JSON.parse(saved) : ["USD-EUR", "EUR-GBP"];
});
const addFavorite = (from: string, to: string) => {
const pair = `${from}-${to}`;
if (!favorites.includes(pair)) {
const updated = [...favorites, pair];
setFavorites(updated);
localStorage.setItem("favoritePairs", JSON.stringify(updated));
}
};
return { favorites, addFavorite };
}
Real-World Use Cases 🌍
E-Commerce Price Display
Show prices in the customer's local currency:
function ProductPrice({ priceUSD }: { priceUSD: number }) {
const { userCurrency, rates } = useCurrency();
const localPrice = convertCurrency(priceUSD, "USD", userCurrency, rates);
return (
<div>
<span className="text-2xl font-bold">{localPrice}</span>
<span className="text-sm text-gray-500 ml-2">
({priceUSD.toFixed(2)} USD)
</span>
</div>
);
}
Travel Budget Calculator
Help travelers plan expenses:
function TravelBudget({ dailyBudget, days, homeCurrency, destCurrency }) {
const total = dailyBudget * days;
const converted = convertCurrency(total, homeCurrency, destCurrency, rates);
return (
<div className="p-4 bg-blue-50 rounded-lg">
<h3>Your {days}-Day Budget</h3>
<p className="text-3xl font-bold">{converted} {destCurrency}</p>
<p className="text-sm">({total} {homeCurrency} at home)</p>
</div>
);
}
SEO and Accessibility Considerations 🎯
Semantic HTML
Use proper form labels and ARIA attributes:
<label htmlFor="amount-input" className="sr-only">
Enter amount to convert
</label>
<input
id="amount-input"
type="number"
aria-describedby="amount-help"
aria-label="Amount in source currency"
/>
<span id="amount-help" className="sr-only">
Enter the amount you want to convert
</span>
Keyboard Navigation
Ensure all controls are accessible via keyboard:
<button
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
handleSwap();
}
}}
tabIndex={0}
role="button"
aria-label="Swap currencies"
>
<ArrowRightLeft />
</button>
Performance Optimization 📈
Debouncing Input
Avoid excessive calculations while typing:
import { useDebouncedValue } from '@/hooks/useDebounce';
function CurrencyConverter() {
const [amount, setAmount] = useState("100");
const debouncedAmount = useDebouncedValue(amount, 300);
// Only recalculate when debounced value changes
const result = useMemo(() => {
return calculate(parseFloat(debouncedAmount));
}, [debouncedAmount, rates]);
}
Memoization
Prevent unnecessary re-renders:
const currencyOptions = useMemo(() =>
POPULAR_CURRENCIES.map(c => ({
value: c.code,
label: `${c.code} - ${c.name}`
})),
[]
);
Conclusion 🎉
Building a currency converter teaches you essential skills that apply to many real-world applications:
- API Integration: Fetching and handling external data
- State Management: Managing complex, interdependent state
- Error Handling: Gracefully handling failures
- Caching: Implementing offline-first strategies
- Accessibility: Building inclusive user interfaces
Ready to try it yourself? Head over to our Currency Converter Tool to see these concepts in action, or start building your own version using the code examples above.
Frequently Asked Questions
Q: Why do exchange rates differ between sources? A: Different sources may update at different times, use different rate types (bid/ask/mid-market), or add their own margins for profit.
Q: How often should I refresh exchange rates? A: For most applications, hourly updates are sufficient. Real-time rates are only necessary for trading applications.
Q: Can I use this for actual financial transactions? A: This tool provides indicative rates for reference. For actual transactions, always use the rates provided by your financial institution.
Q: How do I handle cryptocurrencies? A: Cryptocurrencies require different APIs (like CoinGecko or CoinMarketCap) and more frequent updates due to higher volatility.