How to Localize SaaS Pricing by Country: Implementation Guide
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