How to Build a Currency Converter App in SwiftUI
A currency converter fetches live exchange rates from a public API and lets users instantly convert amounts between world currencies — ideal for travelers, freelancers billing internationally, or anyone who needs quick FX math on the go.
Prerequisites
- Mac with Xcode 16 or later
- Apple Developer Program ($99/year) — required for TestFlight and App Store distribution
- Basic Swift and SwiftUI knowledge (you should be comfortable with
@StateandList) - A free API key from exchangerate-api.com or open.er-api.com (free tier is sufficient)
- Network entitlement: the app calls an external HTTPS endpoint, so
App Transport Securitymust allow it (all modern HTTPS URLs are fine by default)
Architecture overview
The app has three logical layers. The data layer uses a single SwiftData @Model called CachedRates that stores the last-fetched rate table (a JSON blob keyed by currency code) so the app works offline. An @Observable view model called RatesStore owns the URLSession fetch logic, decodes the response, and writes to SwiftData. The view layer is a single ContentView with two Picker wheels and a TextField — no navigation stack needed for a beginner build. External state (selected currencies, amount) lives as @State inside ContentView; the model container is injected via .modelContainer in the App struct.
CurrencyConverter/ ├── CurrencyConverterApp.swift # @main, .modelContainer injection ├── Models/ │ └── CachedRates.swift # @Model — stores rates dict + timestamp ├── ViewModels/ │ └── RatesStore.swift # @Observable — URLSession fetch + decode ├── Views/ │ ├── ContentView.swift # Main converter UI │ └── CurrencyPickerRow.swift # Reusable picker + flag row ├── Services/ │ └── ExchangeRateService.swift # async fetch, decoding ├── Resources/ │ └── currencies.json # Static list of 170 currency codes + names └── PrivacyInfo.xcprivacy # Required for App Store
Step-by-step
1. Create the Xcode project
Open Xcode 16, choose File → New → Project → iOS App. Set the interface to SwiftUI, storage to SwiftData, and give it the bundle ID you plan to use on App Store Connect (e.g. com.yourname.currencyconverter). Enable SwiftData at creation time so the container plumbing is scaffolded for you.
// CurrencyConverterApp.swift
import SwiftUI
import SwiftData
@main
struct CurrencyConverterApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(for: CachedRates.self)
}
}
2. Define the SwiftData model
Store the last-fetched rate table as a single row in SwiftData. Encoding the rates dictionary as a JSON Data blob is the simplest approach — no need for a separate table per currency at this complexity level. The fetchedAt timestamp lets you show "rates as of…" and decide when to refresh.
// Models/CachedRates.swift
import Foundation
import SwiftData
@Model
final class CachedRates {
var base: String // e.g. "USD"
var ratesJSON: Data // JSON-encoded [String: Double]
var fetchedAt: Date
init(base: String, ratesJSON: Data, fetchedAt: Date = .now) {
self.base = base
self.ratesJSON = ratesJSON
self.fetchedAt = fetchedAt
}
/// Decoded rates dictionary, computed on access.
var rates: [String: Double] {
(try? JSONDecoder().decode([String: Double].self, from: ratesJSON)) ?? [:]
}
}
3. Build the converter UI
The core interface is a Form with two currency pickers, an amount field, and a large result label. Keep the interaction model simple: changing any input immediately updates the converted amount via a computed property — no "Convert" button needed.
// Views/ContentView.swift
import SwiftUI
import SwiftData
struct ContentView: View {
@Environment(\.modelContext) private var modelContext
@Query private var cached: [CachedRates]
@State private var store = RatesStore()
@State private var fromCurrency = "USD"
@State private var toCurrency = "EUR"
@State private var amountText = "1"
private var convertedAmount: Double? {
guard
let amount = Double(amountText),
let rates = cached.first?.rates,
let fromRate = rates[fromCurrency],
let toRate = rates[toCurrency],
fromRate != 0
else { return nil }
return (amount / fromRate) * toRate
}
var body: some View {
NavigationStack {
Form {
Section("Amount") {
HStack {
TextField("0", text: $amountText)
.keyboardType(.decimalPad)
.font(.title2.monospacedDigit())
Picker("From", selection: $fromCurrency) {
ForEach(store.currencyCodes, id: \.self) { code in
Text(code).tag(code)
}
}
.labelsHidden()
.frame(width: 90)
}
}
Section("Converted") {
HStack {
if let result = convertedAmount {
Text(result, format: .number.precision(.fractionLength(2)))
.font(.title2.monospacedDigit().bold())
} else {
Text("—").foregroundStyle(.secondary)
}
Spacer()
Picker("To", selection: $toCurrency) {
ForEach(store.currencyCodes, id: \.self) { code in
Text(code).tag(code)
}
}
.labelsHidden()
.frame(width: 90)
}
}
if let cachedRates = cached.first {
Section {
Text("Rates as of \(cachedRates.fetchedAt.formatted(date: .abbreviated, time: .shortened))")
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.navigationTitle("Currency Converter")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("Refresh", systemImage: "arrow.clockwise") {
Task { await store.fetchRates(modelContext: modelContext) }
}
.disabled(store.isFetching)
}
}
.task {
await store.fetchRatesIfStale(modelContext: modelContext, cached: cached.first)
}
}
}
}
#Preview {
ContentView()
.modelContainer(for: CachedRates.self, inMemory: true)
}
4. Fetch live exchange rates with URLSession
The view model uses Swift concurrency (async/await) to call the exchange rate API, decode the response, and upsert the single CachedRates row in SwiftData. Rates are refreshed automatically on launch if the cache is older than one hour — saving unnecessary network calls for users who open the app repeatedly in a short window.
// ViewModels/RatesStore.swift
import Foundation
import SwiftData
import Observation
// Open Exchange Rates (free tier) response shape
private struct ERResponse: Decodable {
let base: String
let rates: [String: Double]
}
@Observable
final class RatesStore {
var isFetching = false
var errorMessage: String?
// Sorted list of currency codes for Pickers
var currencyCodes: [String] = Locale.commonISOCurrencyCodes.sorted()
// Refresh if cache is missing or older than 1 hour
func fetchRatesIfStale(modelContext: ModelContext, cached: CachedRates?) async {
if let cached, Date.now.timeIntervalSince(cached.fetchedAt) < 3600 { return }
await fetchRates(modelContext: modelContext)
}
func fetchRates(modelContext: ModelContext) async {
isFetching = true
defer { isFetching = false }
// Replace YOUR_KEY with your free API key from open.er-api.com
let urlString = "https://v6.exchangerate-api.com/v6/YOUR_KEY/latest/USD"
guard let url = URL(string: urlString) else { return }
do {
let (data, response) = try await URLSession.shared.data(from: url)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
errorMessage = "Server error"
return
}
let decoded = try JSONDecoder().decode(ERResponse.self, from: data)
let ratesData = try JSONEncoder().encode(decoded.rates)
// Upsert: delete old row, insert fresh one
let descriptor = FetchDescriptor()
let existing = try modelContext.fetch(descriptor)
existing.forEach { modelContext.delete($0) }
let fresh = CachedRates(base: decoded.base, ratesJSON: ratesData)
modelContext.insert(fresh)
try modelContext.save()
} catch {
errorMessage = error.localizedDescription
}
}
}
5. Add a Privacy Manifest
Apple requires a PrivacyInfo.xcprivacy file in every app submitted to the App Store. Your app makes outbound network requests and uses no device identifiers, so the manifest is short — but missing it will block your submission with a hard rejection.
<!-- PrivacyInfo.xcprivacy — add via File → New → File → App Privacy -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>CA92.1</string>
</array>
</dict>
</array>
</dict>
</plist>
Common pitfalls
-
Hardcoding the API key in source. Even on a free key, committing credentials to git is a bad habit. Store the key in
Secrets.xcconfig(excluded from git via.gitignore) and read it as anInfo.plistentry at runtime. Never ship a paid API key in a binary — it will be extracted. -
Floating-point rounding errors in the result label. Always format converted amounts with
.number.precision(.fractionLength(2))(or aNumberFormatter). Displaying1.0000000000000002to users is an instant 1-star review. - App Store rejection for missing rate-source attribution. The App Store review team will flag an app that displays exchange rates without crediting the data provider. Add a small "Powered by ExchangeRate-API" footer or an About screen — check your chosen API's terms of service.
-
Forgetting the Privacy Manifest entirely. As of iOS 17 / 2024+, omitting
PrivacyInfo.xcprivacytriggers an automated rejection email before a human reviewer ever sees your app. It takes minutes to add but days to fix after the fact. - Not testing on a real device with airplane mode. SwiftData caching works in Simulator, but edge cases (missing model container, first-launch with no cache) only surface reliably on hardware. Always toggle airplane mode on a real iPhone to verify the offline fallback.
Adding monetization: Ad-supported
The most common monetization path for a free utility like a currency converter is a banner ad at the bottom of the screen, typically via Google AdMob. Integrate the AdMob SDK via Swift Package Manager, create a GADBannerView wrapped in a UIViewRepresentable, and place it inside a VStack pinned to the bottom of your root view. Register your app and ad unit IDs in App Store Connect's "Advertising" section and declare ad-delivery domains in your Privacy Manifest under NSPrivacyTrackingDomains — Apple's App Tracking Transparency framework requires a permission prompt before showing personalized ads to users on iOS 14+. A non-personalized ad fallback is a good default until the user grants permission. Alternatively, integrate Apple Search Ads attribution and offer a one-time $0.99 "Remove Ads" in-app purchase via StoreKit 2's Product.purchase() API for users who prefer an ad-free experience.
Shipping this faster with Soarias
Soarias automates the parts of this build that have nothing to do with writing code. After you describe your app concept, it scaffolds the Xcode project with the correct SwiftData container, generates the PrivacyInfo.xcprivacy pre-filled for network-access reasons, configures fastlane with your provisioning profile and App Store Connect API key, and queues the first TestFlight upload — all without you touching App Store Connect's web UI. For a currency converter it also pre-wires a URLSession service stub with the exchangerate-api.com response shape already decoded, so you skip straight to customizing the UI.
At beginner complexity a manual setup (project creation, Privacy Manifest research, fastlane init, ASC metadata entry) typically burns 3–5 hours before a single line of app logic is written. Soarias compresses that to roughly 20 minutes of answering prompts, leaving a full weekend free for the actual converter logic and UI polish. For a $79 one-time purchase, that's a strong return on time even on the very first app you ship.
Related guides
FAQ
Does this work on iOS 16?
The code as written targets iOS 17+ because it uses @Observable (new in iOS 17) and SwiftData (also iOS 17+). To support iOS 16 you would need to replace @Observable with ObservableObject / @Published and swap SwiftData for Core Data or a plain JSON file in Application Support. Given that iOS 17 and 18 adoption is now above 95%, targeting iOS 17+ is the pragmatic choice for a new app in 2026.
Do I need a paid Apple Developer account to test this?
No — you can run the app on a personal device via Xcode for free using a free Apple ID. Free provisioning lets you install on up to three devices and the profile expires after seven days. However, you do need the $99/year Apple Developer Program membership to upload to TestFlight or submit to the App Store. Network calls work fine under free provisioning, so you can develop and test the full URLSession integration without spending anything until you're ready to ship.
How do I submit this to the App Store?
Archive the app in Xcode (Product → Archive), then use the Organizer window to validate and distribute to the App Store. You'll need a completed App Store Connect listing (app name, description, screenshots for iPhone 6.9", privacy nutrition labels, and pricing). Apple's review typically takes 24–48 hours for a first submission. Using fastlane's deliver action or Soarias automates the metadata and screenshot upload steps considerably.
What's the best free exchange rate API for a beginner project?
open.er-api.com offers 1,500 free requests/month with no credit card required — more than enough for a personal app. exchangerate-api.com provides 1,500 free requests/month with a slightly richer response (includes currency names). Both return a simple JSON object with a rates dictionary keyed by ISO 4217 currency code. Avoid building around an API with no free tier until you know your app will gain traction — free tiers are perfect for getting to App Store submission.
Last reviewed: 2026-05-11 by the Soarias team.