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:

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

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:

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:

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 →

Related Articles