Tilbage til blog

How to Build a Currency Converter with Vue.js and a Live Exchange Rate API (2026 Guide)

V
Vlado Grigirov
April 28, 2026
Currency API Vue.js Tutorial Exchange Rates JavaScript Finexly Composition API

If you need a Vue currency converter that pulls real-time exchange rates from a live API, this tutorial walks you through it end to end. You'll build a production-ready Vue 3 component using the Composition API, TypeScript, and a free currency exchange rate API — with proper debouncing, caching, error handling, and SSR-safe patterns for Nuxt.

By the end you'll have a reusable <CurrencyConverter /> component, a useCurrencyRates composable you can drop into any Vue 3 project, and a clear understanding of the trade-offs that matter when shipping currency conversion to real users.

What you'll build

A Vue 3 currency converter with these features:

  • Live exchange rates for 170+ currencies via the Finexly API
  • Reactive conversion that updates as the user types
  • Currency swap button (USD → EUR becomes EUR → USD with one click)
  • Debounced API calls so you don't spam the network
  • In-memory caching to keep latency low and stay within free-tier quotas
  • Error handling, loading states, and proper TypeScript types
  • SSR-friendly so it works in Nuxt 3 without hydration mismatches

The final component is around 80 lines of code. The composable is another 60. That's it.

Prerequisites

You should be comfortable with:

  • Vue 3 syntax (the <script setup> form)
  • Basic TypeScript
  • Calling a REST API with fetch

You'll also need a Finexly API key. Grab one from the dashboard — it takes about 30 seconds and the free plan gives you 1,000 requests per month with no credit card. If you've never used the service, the Finexly API documentation has a 5-minute quickstart.

Step 1: Scaffold a Vue 3 project with Vite

If you're starting fresh:

npm create vite@latest finexly-converter -- --template vue-ts
cd finexly-converter
npm install
npm run dev

Vite gives you Vue 3, TypeScript, and hot module reload out of the box. Open src/App.vue and clear the boilerplate — we'll replace it with the converter shortly.

If you're integrating into an existing Nuxt 3 project, you can skip this step. The composable below works identically in Nuxt because it uses standard ref and computed from vue.

Step 2: Store the API key safely

Never paste your Finexly API key directly into a component. Put it in .env.local:

VITE_FINEXLY_API_KEY=your_key_here

Vite exposes any variable prefixed with VITE_ to the client. For Nuxt, use NUXT_PUBLIC_FINEXLY_API_KEY and read it from useRuntimeConfig().public.finexlyApiKey.

If you're worried about exposing the key in a client-side bundle, proxy the request through a backend route or a serverless function. We'll show that pattern in Step 7.

Step 3: Build the useCurrencyRates composable

The composable is the heart of the converter. It owns the fetch, the cache, and the loading/error state — so the component stays presentational.

Create src/composables/useCurrencyRates.ts:

import { ref, type Ref } from 'vue'

const API_KEY = import.meta.env.VITE_FINEXLY_API_KEY
const BASE_URL = 'https://api.finexly.com/v1'

// Module-level cache: shared across every component that calls this composable.
// Each entry lives for 5 minutes — long enough to feel instant, short enough
// to stay accurate during volatile FX moves.
const cache = new Map<string, { rates: Record<string, number>; expires: number }>()
const TTL_MS = 5 * 60 * 1000

interface RatesResponse {
  base: string
  date: string
  rates: Record<string, number>
}

export function useCurrencyRates() {
  const rates: Ref<Record<string, number>> = ref({})
  const loading = ref(false)
  const error = ref<string | null>(null)

  async function fetchRates(base: string) {
    const cached = cache.get(base)
    if (cached && cached.expires > Date.now()) {
      rates.value = cached.rates
      return
    }

    loading.value = true
    error.value = null
    try {
      const url = `${BASE_URL}/latest?base=${base}&apikey=${API_KEY}`
      const res = await fetch(url)
      if (!res.ok) throw new Error(`Finexly returned ${res.status}`)
      const data: RatesResponse = await res.json()

      rates.value = data.rates
      cache.set(base, { rates: data.rates, expires: Date.now() + TTL_MS })
    } catch (e) {
      error.value = e instanceof Error ? e.message : 'Failed to load rates'
    } finally {
      loading.value = false
    }
  }

  function convert(amount: number, from: string, to: string): number {
    if (from === to) return amount
    const fromRate = rates.value[from]
    const toRate = rates.value[to]
    if (!fromRate || !toRate) return 0
    return (amount / fromRate) * toRate
  }

  return { rates, loading, error, fetchRates, convert }
}

A few details worth flagging:

  • The cache lives at the module level on purpose. Two components calling the composable share the same cache, which means switching between routes won't refetch the same base currency.
  • rates is typed as Record<string, number> so you can feed it directly into a <select> later.
  • The convert function does cross-rate math, so you don't have to refetch every time the "from" currency changes. More on that in Step 5.

Step 4: The converter component

Create src/components/CurrencyConverter.vue:

<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useCurrencyRates } from '@/composables/useCurrencyRates'

const { rates, loading, error, fetchRates, convert } = useCurrencyRates()

const amount = ref(100)
const from = ref('USD')
const to = ref('EUR')

const converted = computed(() =>
  convert(amount.value, from.value, to.value).toFixed(2)
)

const currencies = computed(() => Object.keys(rates.value).sort())

function swap() {
  ;[from.value, to.value] = [to.value, from.value]
}

// Debounce: refetch base rates only after the user has stopped switching
// "from" for 300ms. Amount changes never refetch.
let timeout: ReturnType<typeof setTimeout> | null = null
watch(from, (newBase) => {
  if (timeout) clearTimeout(timeout)
  timeout = setTimeout(() => fetchRates(newBase), 300)
})

onMounted(() => fetchRates(from.value))
</script>

<template>
  <div class="converter">
    <h2>Currency Converter</h2>

    <div class="row">
      <input v-model.number="amount" type="number" min="0" />
      <select v-model="from">
        <option v-for="c in currencies" :key="c" :value="c">{{ c }}</option>
      </select>
    </div>

    <button @click="swap" aria-label="Swap currencies">⇅</button>

    <div class="row">
      <output>{{ loading ? '...' : converted }}</output>
      <select v-model="to">
        <option v-for="c in currencies" :key="c" :value="c">{{ c }}</option>
      </select>
    </div>

    <p v-if="error" class="error">{{ error }}</p>
  </div>
</template>

Drop <CurrencyConverter /> into App.vue and you have a working converter. The list of currencies populates as soon as the first fetch lands. Typing into the amount field updates the result reactively without hitting the network. Swapping currencies reorders the dropdowns. Changing the from currency triggers a debounced refetch.

Step 5: Cross-rate conversion (so you don't burn requests)

If you only ever want to convert from one base, you can skip this section. But most converters let users pick both sides — and a naïve implementation refetches on every "from" change.

Cross-rate math fixes that. If you have USD as the base and rates for EUR and JPY in terms of USD, the EUR → JPY rate is just (1 / EUR_rate) * JPY_rate. That's exactly what the convert function in Step 3 does:

return (amount / fromRate) * toRate

This means you only need to fetch once per session. Big win for free-tier quotas. The watcher on from becomes a defensive measure — if a user picks an exotic currency that isn't in the cached rate table, refetching with that base guarantees the conversion still works.

Step 6: Loading skeletons and error states

Spinners are fine, but a layout shift while rates load is jarring. Render placeholder dropdown options on the first fetch:

<select v-model="from" :disabled="loading && currencies.length === 0">
  <option v-if="currencies.length === 0">Loading...</option>
  <option v-for="c in currencies" :key="c" :value="c">{{ c }}</option>
</select>

For the error path, show a retry button instead of a dead-end message:

<div v-if="error" class="error-box">
  <p>{{ error }}</p>
  <button @click="fetchRates(from)">Retry</button>
</div>

Treat 429 (rate limited) and 5xx separately if you want to be polite. With Finexly's 1,000 free monthly requests and 5-minute caching, you'd need a real spike to hit the limit — but a clean retry path makes the UI feel solid.

Step 7: Hide your API key with a server proxy

Anything in import.meta.env.VITE_* ends up in the client bundle. For most read-only currency widgets that's acceptable — the worst case is someone scraping rates with your key. If you want defense in depth, proxy the request server-side.

With a Nuxt 3 server route:

// server/api/rates.get.ts
export default defineEventHandler(async (event) => {
  const { base } = getQuery(event)
  const config = useRuntimeConfig()
  return await $fetch(
    `https://api.finexly.com/v1/latest?base=${base}&apikey=${config.finexlyApiKey}`
  )
})

Then point the composable at /api/rates?base=${base} instead of the Finexly origin. The key never leaves the server. Same pattern works in any Vue meta-framework — Nuxt, Quasar, or a plain Express backend.

Step 8: Production checklist

Before you ship:

  • Cache aggressively. FX rates don't change every second. A 5-minute TTL on the client and a 1-minute TTL on the server is more than enough for almost every display use case. See our caching and error handling guide for production-tested patterns.
  • Round correctly. Currency display rounds to the currency's minor unit (2 decimals for USD, 0 for JPY, 3 for KWD). Use Intl.NumberFormat(locale, { style: 'currency', currency: to.value }) instead of .toFixed(2).
  • Format with Intl. new Intl.NumberFormat('en-US', { style: 'currency', currency: 'EUR' }).format(123.45) gives you "€123.45" and respects the user's locale.
  • SSR-safe fetches. In Nuxt, prefer useFetch over a raw fetch inside onMounted so the rates are available during server render and don't trigger a hydration mismatch.
  • Monitor your quota. Add a small logger that warns when you've burned through 80% of your monthly request budget. For higher volume, the pricing plans start where the free tier ends.

Vue 2 (Options API) variation

If you're on Vue 2, the same logic translates almost line-for-line:

export default {
  data() {
    return { rates: {}, loading: false, error: null, amount: 100, from: 'USD', to: 'EUR' }
  },
  computed: {
    converted() {
      const fr = this.rates[this.from]
      const tr = this.rates[this.to]
      return fr && tr ? ((this.amount / fr) * tr).toFixed(2) : '0.00'
    },
    currencies() {
      return Object.keys(this.rates).sort()
    },
  },
  mounted() { this.fetchRates(this.from) },
  watch: {
    from(newBase) { this.fetchRates(newBase) },
  },
  methods: {
    async fetchRates(base) {
      this.loading = true
      try {
        const res = await fetch(
          `https://api.finexly.com/v1/latest?base=${base}&apikey=${process.env.VUE_APP_FINEXLY_KEY}`
        )
        const data = await res.json()
        this.rates = data.rates
      } catch (e) {
        this.error = e.message
      } finally {
        this.loading = false
      }
    },
  },
}

The Composition API is cleaner for sharing logic across components, but Options API works fine for a single converter widget. If your team prefers a more functional approach, the JavaScript integration guide covers framework-agnostic patterns.

Common pitfalls (and how to avoid them)

Hydration mismatch in Nuxt. Calling fetch in onMounted works on the client but breaks SSR consistency. Use useFetch or useAsyncData instead.

Stale rates after a long session. The 5-minute TTL above means a tab left open all day shows last hour's rates. Refresh on visibilitychange:

document.addEventListener('visibilitychange', () => {
  if (!document.hidden) fetchRates(from.value)
})

Floating-point drift. JavaScript numbers are doubles. 0.1 + 0.2 !== 0.3. For monetary amounts, multiply to integer cents (amount * 100), do the math, then divide. Or use a library like dinero.js for anything checkout-related.

CORS errors in development. Some currency APIs disallow direct browser calls. Finexly allows browser origins for client-side use; the proxy in Step 7 fixes the others.

Why Finexly for Vue projects

A few things matter when picking a currency exchange rate API for a frontend app: response time, accuracy, free-tier generosity, and clean JSON. Finexly aims for all four — sub-50ms p95 latency, mid-market rates updated every 60 seconds, 170+ currencies, and a JSON shape that drops straight into a Vue ref with no massaging.

If you want to see how it stacks up against alternatives, our currency API comparison goes through the trade-offs in detail. Or use the side-by-side comparison page.

Frequently Asked Questions

Can I use this tutorial with Vue 2?

Yes. The composable pattern is Vue 3 only, but the underlying logic — fetch rates, store them in data, compute the conversion — works identically in the Options API. The Vue 2 example above is a drop-in replacement.

Is the Finexly API free for Vue projects?

The free plan gives you 1,000 requests per month, which is more than enough for a side project, a portfolio piece, or a small SaaS. With 5-minute caching, that supports roughly 200 daily active users. See pricing plans for higher volumes.

How do I avoid exposing my API key in the client bundle?

Proxy the request through a server route as shown in Step 7. The VITE_* and NUXT_PUBLIC_* prefixes both make variables client-visible. Anything sensitive should live behind a server function.

How accurate are the rates?

Finexly aggregates mid-market rates from multiple Tier-1 liquidity providers and refreshes every 60 seconds. That's accurate enough for display and most pricing applications. For trade execution, you'd want a streaming feed instead — see our REST vs WebSocket guide.

Can I convert historical amounts?

Yes — Finexly's /historical endpoint accepts a date parameter and returns rates for any business day. The pattern is identical to the /latest endpoint above; just swap the URL. The historical exchange rates API guide covers it in detail.

Wrap up

You now have a Vue 3 currency converter that handles real-world concerns — caching, debouncing, error states, SSR, and API key safety. The composable pattern means you can drop the same useCurrencyRates into a navbar widget, a checkout page, or a dashboard chart without rewriting the fetch logic.

Ready to try it on your own project? Get your free Finexly API key — no credit card required. Start with 1,000 free requests per month and upgrade only when your traffic outgrows the free tier.

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 →