Hanzo
CommerceRecipes

Multi-Currency

Serve international customers with region-specific currencies and pricing

This recipe covers setting up multi-currency e-commerce with Hanzo Commerce -- configuring regions, creating currency-specific price lists, handling tax-inclusive pricing, and adding a storefront currency selector.

What You Will Build

  • Regions configured with local currencies
  • Price lists with per-region pricing
  • Tax-inclusive pricing for applicable regions
  • Storefront currency selector
  • Automatic currency conversion fallback

Configure Regions

Regions define the geographic areas you serve. Each region has a currency, tax settings, and optional payment providers.

import { Commerce } from '@hanzo/commerce'

const commerce = new Commerce({
  apiKey: process.env.HANZO_API_KEY!,
  environment: 'production',
})

// United States -- prices in USD, tax-exclusive
const us = await commerce.regions.create({
  name: 'United States',
  currency: 'USD',
  taxInclusive: false,
  countries: ['US'],
  taxProvider: 'auto', // automatic tax calculation
})

// European Union -- prices in EUR, tax-inclusive (VAT)
const eu = await commerce.regions.create({
  name: 'European Union',
  currency: 'EUR',
  taxInclusive: true,
  countries: [
    'DE', 'FR', 'IT', 'ES', 'NL', 'BE', 'AT', 'PT',
    'IE', 'FI', 'SE', 'DK', 'PL', 'CZ', 'RO', 'HU',
  ],
  taxRate: 20, // default VAT rate in percentage
})

// United Kingdom -- prices in GBP, tax-inclusive (VAT)
const uk = await commerce.regions.create({
  name: 'United Kingdom',
  currency: 'GBP',
  taxInclusive: true,
  countries: ['GB'],
  taxRate: 20,
})

// Japan -- prices in JPY (no minor units)
const jp = await commerce.regions.create({
  name: 'Japan',
  currency: 'JPY',
  taxInclusive: true,
  countries: ['JP'],
  taxRate: 10,
})

Create Price Lists

Price lists let you set explicit prices per region rather than relying on conversion rates. This gives you full control over local pricing strategy.

const product = await commerce.products.retrieve('prod_widget')

// US pricing (base)
await commerce.priceLists.create({
  name: 'US Pricing',
  regionId: us.id,
  prices: [
    { productId: product.id, amount: 2999 },  // $29.99
  ],
})

// EU pricing (tax-inclusive, rounded to .00)
await commerce.priceLists.create({
  name: 'EU Pricing',
  regionId: eu.id,
  prices: [
    { productId: product.id, amount: 2900 },  // 29.00 EUR (incl. VAT)
  ],
})

// UK pricing
await commerce.priceLists.create({
  name: 'UK Pricing',
  regionId: uk.id,
  prices: [
    { productId: product.id, amount: 2499 },  // 24.99 GBP (incl. VAT)
  ],
})

// Japan pricing (JPY has no minor units, so 3200 = 3200 JPY)
await commerce.priceLists.create({
  name: 'Japan Pricing',
  regionId: jp.id,
  prices: [
    { productId: product.id, amount: 3200 },  // 3,200 JPY (incl. tax)
  ],
})

Automatic Currency Conversion

For regions without explicit price lists, Commerce can fall back to automatic conversion:

await commerce.settings.update({
  currencyConversion: {
    enabled: true,
    baseCurrency: 'USD',
    provider: 'ecb',    // European Central Bank rates
    refreshInterval: '6h',
    roundingMode: 'up',  // round converted prices up to nearest unit
  },
})

Conversion providers:

ProviderSourceUpdate Frequency
ecbEuropean Central BankDaily
openexchangeratesOpen Exchange Rates APIHourly
fixedManual rates you setOn demand

Tax-Inclusive Pricing

In tax-inclusive regions (EU, UK, JP), the price displayed to the customer already includes tax. Commerce handles the math:

Product price (EU):  29.00 EUR (tax-inclusive)
Tax rate:           20% VAT
Net price:          24.17 EUR
Tax amount:          4.83 EUR
Customer pays:      29.00 EUR

Commerce breaks this down automatically on invoices and in the order object:

{
  "subtotal": 2417,
  "taxTotal": 483,
  "total": 2900,
  "taxInclusive": true
}

Storefront Region Detection

Detect the customer's region automatically and allow manual override.

import { commerce } from './commerce'

export async function detectRegion(request: Request): Promise<string> {
  // Try geo-IP from request headers (e.g., Vercel's x-vercel-ip-country)
  const country = request.headers.get('x-vercel-ip-country')

  if (country) {
    const region = await commerce.regions.getByCountry(country)
    if (region) return region.id
  }

  // Fallback to default region
  return process.env.DEFAULT_REGION_ID!
}

Currency Selector Component

Let customers override the auto-detected region:

'use client'

import { useRouter } from 'next/navigation'
import { useState } from 'react'

const regions = [
  { id: 'reg_us', label: 'USD $', flag: 'US' },
  { id: 'reg_eu', label: 'EUR', flag: 'EU' },
  { id: 'reg_uk', label: 'GBP', flag: 'GB' },
  { id: 'reg_jp', label: 'JPY', flag: 'JP' },
]

export function CurrencySelector({ currentRegionId }: { currentRegionId: string }) {
  const router = useRouter()
  const [selected, setSelected] = useState(currentRegionId)

  function handleChange(regionId: string) {
    setSelected(regionId)
    // Store in cookie for server-side access
    document.cookie = `region=${regionId}; path=/; max-age=31536000`
    router.refresh()
  }

  return (
    <select
      value={selected}
      onChange={(e) => handleChange(e.target.value)}
      className="border rounded px-2 py-1 text-sm"
    >
      {regions.map((r) => (
        <option key={r.id} value={r.id}>
          {r.flag} {r.label}
        </option>
      ))}
    </select>
  )
}

Fetching Region-Aware Prices

Pass the region ID when fetching products to get localized prices:

import { commerce } from '@/lib/commerce'
import { cookies } from 'next/headers'

export default async function ProductsPage() {
  const regionId = cookies().get('region')?.value ?? process.env.DEFAULT_REGION_ID!

  const { products } = await commerce.products.list({
    regionId,
    limit: 20,
  })

  // products[].price is now in the region's currency
  // products[].currency reflects the region's currency code
}

Cart and Checkout

Carts are region-scoped. Set the region when creating the cart:

const cart = await commerce.carts.create({
  regionId,
  items: [{ productId: 'prod_widget', quantity: 1 }],
})

// cart.total, cart.taxTotal, cart.currency all reflect the region

Testing

  1. Create at least two regions (USD and EUR)
  2. Create a product with price lists for both regions
  3. Fetch the product with each regionId -- verify prices differ
  4. Create a cart in each region -- verify totals use the correct currency
  5. Complete checkout in each region -- verify payment is charged in the correct currency

Next Steps

  • Review the Region module for advanced region configuration
  • Set up the Tax module for automatic tax calculation
  • Add B2B pricing with customer-group-specific price lists

How is this guide?

Last updated on

On this page