Node.js is one of the most popular runtimes for building APIs, and if your application deals with international payments, pricing, or financial data, you will need exchange rates. This tutorial walks you through building a complete currency conversion service using an exchange rate API in Node.js with Express. We will cover fetching rates, caching, input validation, error handling, and structuring the project for production use.
By the end, you will have a working Express server with a /convert endpoint, a /rates endpoint, and middleware that keeps everything reliable and fast.
What We Are Building
A small Express API with these endpoints:
GET /rates?base=USD— returns latest exchange ratesGET /convert?from=USD&to=EUR&amount=100— converts a currency amountGET /rates/historical?date=2026-01-15&base=USD— returns historical rates
The service will use the Exchange Rate API as its data source, cache responses to minimize API calls, and include proper error handling throughout.
Prerequisites
- Node.js 18+ installed
- An API key from exchange-rateapi.com (free tier, 1,500 requests/month)
- Basic knowledge of Express
Step 1: Project Setup
Initialize the project and install dependencies:
mkdir currency-service && cd currency-service
npm init -y
npm install express node-cache dotenv
Create a .env file for your API key:
EXCHANGE_RATE_API_KEY=your_api_key_here
PORT=3000
Your project structure will look like this:
currency-service/
.env
package.json
src/
index.js
exchangeRateClient.js
routes/
rates.js
convert.js
middleware/
errorHandler.js
validateQuery.js
Step 2: Build the Exchange Rate Client
Create src/exchangeRateClient.js. This module centralizes all communication with the exchange rate API Node.js applications need:
import NodeCache from "node-cache";
const cache = new NodeCache({ stdTTL: 1800 }); // 30-minute default TTL
class ExchangeRateClient {
constructor(apiKey) {
this.apiKey = apiKey;
this.baseUrl = "https://api.allratestoday.com/v1";
}
async fetchLatest(base = "USD") {
const cacheKey = `latest:${base}`;
const cached = cache.get(cacheKey);
if (cached) return cached;
const url = `${this.baseUrl}/latest?base=${encodeURIComponent(base)}`;
const response = await fetch(url, {
headers: { Authorization: `Bearer ${this.apiKey}` },
});
if (!response.ok) {
const body = await response.text();
throw new ApiError(
`Exchange Rate API returned ${response.status}: ${body}`,
response.status
);
}
const data = await response.json();
cache.set(cacheKey, data);
return data;
}
async fetchHistorical(date, base = "USD") {
const cacheKey = `historical:${base}:${date}`;
const cached = cache.get(cacheKey);
if (cached) return cached;
const url = `${this.baseUrl}/historical?date=${encodeURIComponent(date)}&base=${encodeURIComponent(base)}`;
const response = await fetch(url, {
headers: { Authorization: `Bearer ${this.apiKey}` },
});
if (!response.ok) {
const body = await response.text();
throw new ApiError(
`Exchange Rate API returned ${response.status}: ${body}`,
response.status
);
}
const data = await response.json();
cache.set(cacheKey, data, 86400); // Cache historical data for 24 hours
return data;
}
async convert(from, to, amount) {
const rates = await this.fetchLatest(from);
const rate = rates.rates[to];
if (!rate) {
throw new ApiError(`Unsupported currency: ${to}`, 400);
}
return {
from,
to,
amount,
result: parseFloat((amount * rate).toFixed(4)),
rate,
date: rates.date,
};
}
}
class ApiError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
this.name = "ApiError";
}
}
export { ExchangeRateClient, ApiError };
Key design decisions:
- Caching with
node-cache: ThefetchLatestmethod caches results for 30 minutes. This means your service makes at most 2 API calls per hour per base currency, well within the free tier. - Cross-rate conversion: The
convertmethod fetches all rates for thefromcurrency and multiplies locally. One API call handles any conversion pair. - Custom error class:
ApiErrorcarries an HTTP status code so the error handler can return the right response.
Step 3: Create the Routes
Rates Route
Create src/routes/rates.js:
import { Router } from "express";
export function createRatesRouter(client) {
const router = Router();
// GET /rates?base=USD
router.get("/", async (req, res, next) => {
try {
const base = req.query.base || "USD";
const data = await client.fetchLatest(base);
res.json(data);
} catch (err) {
next(err);
}
});
// GET /rates/historical?date=2026-01-15&base=USD
router.get("/historical", async (req, res, next) => {
try {
const { date, base = "USD" } = req.query;
if (!date || !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
return res.status(400).json({
error: "Missing or invalid 'date' parameter. Use YYYY-MM-DD format.",
});
}
const data = await client.fetchHistorical(date, base);
res.json(data);
} catch (err) {
next(err);
}
});
return router;
}
Convert Route
Create src/routes/convert.js:
import { Router } from "express";
export function createConvertRouter(client) {
const router = Router();
// GET /convert?from=USD&to=EUR&amount=100
router.get("/", async (req, res, next) => {
try {
const { from, to, amount } = req.query;
if (!from || !to || !amount) {
return res.status(400).json({
error: "Missing required parameters: from, to, amount",
});
}
const parsedAmount = parseFloat(amount);
if (isNaN(parsedAmount) || parsedAmount <= 0) {
return res.status(400).json({
error: "'amount' must be a positive number",
});
}
const result = await client.convert(
from.toUpperCase(),
to.toUpperCase(),
parsedAmount
);
res.json(result);
} catch (err) {
next(err);
}
});
return router;
}
Step 4: Error Handling Middleware
Create src/middleware/errorHandler.js:
import { ApiError } from "../exchangeRateClient.js";
export function errorHandler(err, req, res, next) {
console.error(`[${new Date().toISOString()}] ${err.message}`);
if (err instanceof ApiError) {
return res.status(err.statusCode || 502).json({
error: err.message,
});
}
res.status(500).json({
error: "Internal server error",
});
}
This middleware catches errors from route handlers (passed via next(err)) and returns structured JSON error responses. The ApiError class lets you propagate the upstream status code (e.g., 429 for rate limiting) to the caller.
Step 5: Wire Everything Together
Create src/index.js:
import "dotenv/config";
import express from "express";
import { ExchangeRateClient } from "./exchangeRateClient.js";
import { createRatesRouter } from "./routes/rates.js";
import { createConvertRouter } from "./routes/convert.js";
import { errorHandler } from "./middleware/errorHandler.js";
const app = express();
const port = process.env.PORT || 3000;
const apiKey = process.env.EXCHANGE_RATE_API_KEY;
if (!apiKey) {
console.error("EXCHANGE_RATE_API_KEY is not set in environment variables.");
process.exit(1);
}
const client = new ExchangeRateClient(apiKey);
// Request logging
app.use((req, res, next) => {
console.log(`${req.method} ${req.url}`);
next();
});
// Routes
app.use("/rates", createRatesRouter(client));
app.use("/convert", createConvertRouter(client));
// Health check
app.get("/health", (req, res) => {
res.json({ status: "ok" });
});
// Error handler (must be last)
app.use(errorHandler);
app.listen(port, () => {
console.log(`Currency service running on port ${port}`);
});
Update package.json to use ES modules and add a start script:
{
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js"
}
}
Step 6: Test It
Start the server:
npm run dev
Test the endpoints with cURL:
# Fetch latest rates
curl http://localhost:3000/rates?base=USD
# Convert 250 USD to EUR
curl "http://localhost:3000/convert?from=USD&to=EUR&amount=250"
# Fetch historical rates
curl "http://localhost:3000/rates/historical?date=2026-01-15&base=USD"
Expected response from /convert:
{
"from": "USD",
"to": "EUR",
"amount": 250,
"result": 230.35,
"rate": 0.9214,
"date": "2026-05-21"
}
Adding Rate Limiting to Your Own API
If you expose this service to the public, add rate limiting to protect both your server and your upstream API quota:
npm install express-rate-limit
import rateLimit from "express-rate-limit";
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window per IP
standardHeaders: true,
legacyHeaders: false,
message: { error: "Too many requests. Try again later." },
});
app.use(limiter);
This prevents any single client from exhausting your API quota. Combine this with the 30-minute cache in the ExchangeRateClient and your free tier will handle significant traffic.
Writing Tests
Use Node's built-in test runner (Node 18+) to test the conversion logic without making real API calls:
// test/convert.test.js
import { describe, it, mock } from "node:test";
import assert from "node:assert";
import { ExchangeRateClient } from "../src/exchangeRateClient.js";
describe("ExchangeRateClient.convert", () => {
it("converts USD to EUR correctly", async () => {
const client = new ExchangeRateClient("test-key");
// Mock the fetchLatest method
client.fetchLatest = async () => ({
base: "USD",
date: "2026-05-21",
rates: { EUR: 0.9214, GBP: 0.7891 },
});
const result = await client.convert("USD", "EUR", 100);
assert.strictEqual(result.result, 92.14);
assert.strictEqual(result.rate, 0.9214);
});
it("throws for unsupported currency", async () => {
const client = new ExchangeRateClient("test-key");
client.fetchLatest = async () => ({
base: "USD",
date: "2026-05-21",
rates: { EUR: 0.9214 },
});
await assert.rejects(
() => client.convert("USD", "XYZ", 100),
{ message: "Unsupported currency: XYZ" }
);
});
});
Run with:
node --test test/convert.test.js
Production Considerations
Environment Variables
Never hardcode API keys. Use dotenv for local development and your hosting platform's secrets manager (Vercel environment variables, AWS Secrets Manager, etc.) for production.
Graceful Degradation
If the Exchange Rate API is temporarily unreachable and the cache is cold, your service should return a 503 with a clear message rather than hanging or crashing:
async fetchLatest(base = "USD") {
const cacheKey = `latest:${base}`;
const cached = cache.get(cacheKey);
if (cached) return cached;
try {
// ... fetch from API ...
} catch (err) {
// Return stale data if available
const stale = cache.get(cacheKey);
if (stale) {
console.warn("Serving stale cached data due to API error");
return stale;
}
throw err;
}
}
Logging and Monitoring
In production, replace console.log with a structured logging library like pino. Track metrics like cache hit rate, API response times, and error rates. If your cache hit rate drops below 90%, your TTL may be too short.
Deployment
This service is stateless (the cache is in-memory and can be cold-started), so it deploys easily to any platform:
- Docker: Use a Node.js Alpine image, copy your code, and run
node src/index.js - Vercel/Netlify: Convert routes to serverless functions
- Railway/Render: Push to Git and configure the start command
- AWS Lambda: Wrap the Express app with
@vendia/serverless-express
Conclusion
Building a currency conversion service with an exchange rate API in Node.js is straightforward when you follow a clean architecture: a centralized client that handles caching and error propagation, Express routes that validate input and delegate to the client, and middleware that catches errors and returns consistent responses.
The Exchange Rate API provides the data layer: 160+ currencies, latest and historical rates, and a free tier that supports real applications. The caching strategy in this tutorial means your Node.js service stays fast and your API quota stays safe even under load.
Ready to build your own currency service? Sign up for a free API key at exchange-rateapi.com and follow this tutorial to have a working /convert endpoint in under 30 minutes. Check the API documentation for the full endpoint reference.
Start Using the Exchange Rate API Today
Free tier with 1,500 requests/month. 160+ currencies updated every 60 seconds. No credit card required.
Get Your Free API Key →