SaaSPricingCurrencyLocalizationTutorial

How to Localize SaaS Pricing by Country: Implementation Guide

ApogeoAPI8 min read

Why localized pricing increases revenue

Most SaaS products set a single USD price and lose customers in markets where $29/month is too expensive. Studies consistently show that localized pricing — showing prices in local currency at adjusted rates — increases conversion in price-sensitive markets by 15–40%.

There are two approaches:

  • Currency conversion: convert $29 USD to €27, £23, etc. at current rates. Same real price, familiar currency symbol.
  • Purchasing power parity (PPP): adjust the price itself based on local ability to pay. $29 becomes R$79 (Brazil) instead of the full converted ~R$149.

This guide implements both. Start with currency conversion (easier), add PPP discounts for specific markets if your funnel data supports it.

Architecture overview

Request → IP → Country → Currency code → Exchange rate → Localized price
           ↑         ↑              ↑
      geo API    country meta    FX API
      (single call covers all three with ApogeoAPI)

Step 1: Get country and currency from IP

ApogeoAPI returns the visitor's country, currency code, and live exchange rate in a single call:

curl "https://api.apogeoapi.com/v1/geo/181.30.52.1?apikey=YOUR_KEY"
# Returns:
# { "countryCode": "AR", "countryName": "Argentina",
#   "currencyCode": "ARS", "currencySymbol": "$", "currencyRate": 892.5 }

Step 2: Build the pricing engine

// lib/pricing.ts
const APOGEO_KEY = process.env.APOGEO_KEY!;

interface GeoData {
  countryCode: string;
  currencyCode: string;
  currencySymbol: string;
  currencyRate: number; // USD = 1.0 baseline
}

// PPP adjustments: countries where we discount from full USD equivalent
// Only add these after A/B testing confirms revenue increase
const PPP_DISCOUNTS: Record<string, number> = {
  BR: 0.55,  // Brazil — 45% discount from USD equivalent
  MX: 0.60,  // Mexico
  AR: 0.40,  // Argentina
  IN: 0.35,  // India
  PL: 0.65,  // Poland
  TR: 0.45,  // Turkey
  ZA: 0.55,  // South Africa
};

// Cache geo lookups at /24 subnet level
const geoCache = new Map<string, { data: GeoData; expires: number }>();

export async function getGeo(ip: string): Promise<GeoData> {
  const subnet = ip.split('.').slice(0, 3).join('.') + '.0';
  const cached = geoCache.get(subnet);
  if (cached && cached.expires > Date.now()) return cached.data;

  const res = await fetch(
    `https://api.apogeoapi.com/v1/geo/${ip}?apikey=${APOGEO_KEY}`,
    { signal: AbortSignal.timeout(2000) }
  );

  if (!res.ok) {
    return { countryCode: 'US', currencyCode: 'USD', currencySymbol: '$', currencyRate: 1 };
  }

  const data: GeoData = await res.json();
  geoCache.set(subnet, { data, expires: Date.now() + 3_600_000 }); // 1h TTL
  return data;
}

export interface LocalizedPrice {
  usdAmount: number;
  localAmount: number;
  localFormatted: string;
  currencyCode: string;
  hasPPP: boolean;
  countryCode: string;
}

export function calculateLocalPrice(
  usdAmount: number,
  geo: GeoData
): LocalizedPrice {
  const pppFactor = PPP_DISCOUNTS[geo.countryCode];

  // With PPP: apply discount to USD amount first, then convert
  // Without PPP: convert at full rate
  const effectiveUSD = pppFactor ? usdAmount * pppFactor : usdAmount;
  const localAmount = Math.round(effectiveUSD * geo.currencyRate);

  // Format with currency symbol
  const formatted = `${geo.currencySymbol}${localAmount.toLocaleString()}`;

  return {
    usdAmount,
    localAmount,
    localFormatted: formatted,
    currencyCode: geo.currencyCode,
    hasPPP: !!pppFactor,
    countryCode: geo.countryCode,
  };
}

Step 3: Next.js API route for the frontend

// app/api/pricing/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { getGeo, calculateLocalPrice } from '@/lib/pricing';

const PLANS = {
  basic:    { usd: 19, name: 'Basic' },
  starter:  { usd: 29, name: 'Starter' },
  pro:      { usd: 79, name: 'Professional' },
};

export async function GET(req: NextRequest) {
  const ip = req.headers.get('x-forwarded-for')?.split(',')[0]
    ?? req.headers.get('x-real-ip')
    ?? '127.0.0.1';

  const geo = await getGeo(ip === '127.0.0.1' ? '8.8.8.8' : ip);

  const prices = Object.fromEntries(
    Object.entries(PLANS).map(([key, plan]) => [
      key,
      calculateLocalPrice(plan.usd, geo),
    ])
  );

  return NextResponse.json({
    country: geo.countryCode,
    currency: geo.currencyCode,
    prices,
  });
}

Step 4: React pricing component

// components/PricingTable.tsx
'use client';
import { useEffect, useState } from 'react';

interface PricingData {
  country: string;
  currency: string;
  prices: {
    [plan: string]: {
      usdAmount: number;
      localFormatted: string;
      hasPPP: boolean;
    };
  };
}

export function PricingTable() {
  const [pricing, setPricing] = useState<PricingData | null>(null);

  useEffect(() => {
    fetch('/api/pricing').then(r => r.json()).then(setPricing);
  }, []);

  if (!pricing) {
    // Skeleton loader while geo resolves
    return <div className="animate-pulse">Loading pricing...</div>;
  }

  return (
    <div>
      {pricing.prices.starter.hasPPP && (
        <div className="bg-green-50 border border-green-200 rounded p-3 mb-4 text-sm">
          🎉 Special pricing available for {pricing.country}
        </div>
      )}

      <div className="grid grid-cols-3 gap-4">
        {Object.entries(pricing.prices).map(([plan, p]) => (
          <div key={plan} className="border rounded-xl p-6">
            <h3 className="font-bold capitalize">{plan}</h3>
            <div className="text-3xl font-bold my-2">
              {p.localFormatted}
            </div>
            <div className="text-sm text-gray-500">
              {p.hasPPP
                ? `Adjusted for ${pricing.country} (USD ${p.usdAmount})`
                : `per month in ${pricing.currency}`}
            </div>
          </div>
        ))}
      </div>
    </div>
  );
}

Step 5: Exchange rate freshness

ApogeoAPI refreshes exchange rates every 4 hours. For the most accurate conversion (important for high-value plans), fetch fresh rates server-side on each pricing page load rather than caching at the CDN.

For edge functions, the { next: { revalidate: 3600 } } option on the fetch call caches the rate at the edge for 1 hour — a good balance between accuracy and latency.

Handling checkout

When the user clicks "Subscribe," send both the currency and local amount to the backend. The backend should re-verify the price server-side (don't trust client-submitted prices) before creating the checkout session:

// app/api/checkout/route.ts
export async function POST(req: NextRequest) {
  const { plan, country } = await req.json();

  // Re-calculate price server-side using the user's IP
  const ip = req.headers.get('x-forwarded-for')?.split(',')[0] ?? '127.0.0.1';
  const geo = await getGeo(ip);

  // Verify country matches (anti-fraud: reject if claimed country != geo country)
  if (geo.countryCode !== country) {
    return NextResponse.json({ error: 'Country mismatch' }, { status: 400 });
  }

  const price = calculateLocalPrice(PLANS[plan].usd, geo);

  // Create checkout with the server-calculated local price
  // (your payment provider code here)
  const session = await createCheckoutSession({
    amount: price.localAmount,
    currency: price.currencyCode.toLowerCase(),
    metadata: { plan, country: geo.countryCode },
  });

  return NextResponse.json({ url: session.url });
}

Testing your pricing

# Test with different country IPs
# Brazil
curl "http://localhost:3000/api/pricing" -H "X-Forwarded-For: 177.71.0.1"

# Germany
curl "http://localhost:3000/api/pricing" -H "X-Forwarded-For: 78.46.0.1"

# India
curl "http://localhost:3000/api/pricing" -H "X-Forwarded-For: 103.91.0.1"

Common pitfalls

  • Don't cache the full page with localized prices at the CDN — if Cloudflare caches a page with Brazilian prices and serves it to a US visitor, you have a problem. Either: (a) serve prices via client-side fetch, (b) use CDN vary on a geo-cookie, or (c) add Vary: X-Forwarded-For (not recommended for cache efficiency).
  • Always re-verify price on the server at checkout — never trust client-submitted prices. A user could manipulate the local price in the browser.
  • Start without PPP — just do currency conversion first. PPP discounts reduce revenue from customers who would have paid full price. Only add PPP for specific markets with clear conversion data.
  • Show "USD X" as anchor — for markets where local pricing might look suspicious, showing "USD 29 / equivalent to EUR 27" builds trust.

Full API reference: api.apogeoapi.com/api/docs. Free tier at app.apogeoapi.com/register.

Try ApogeoAPI free

1,000 requests/month forever. 14-day full-access trial. No credit card.

Get your free API key