RemixIP GeolocationCountry DetectionTutorial
IP Geolocation in Remix: Country Detection and Localized Pricing
ApogeoAPI6 min read
Getting the IP in a Remix loader
In Remix, the request object is a standard Web API Request. The caller's IP comes from the X-Forwarded-For header (set by the proxy) or from the platform context:
// app/utils/geo.server.ts
export function getIP(request: Request): string {
// Cloudflare Pages / Workers inject cf-connecting-ip
const cfIP = request.headers.get('cf-connecting-ip');
if (cfIP) return cfIP;
// Standard proxy header (Vercel, Fly.io, Railway, etc.)
const forwarded = request.headers.get('x-forwarded-for');
if (forwarded) return forwarded.split(',')[0].trim();
// Fallback
return '127.0.0.1';
}
Geolocate in a loader
// app/utils/geo.server.ts
const geoCache = new Map<string, { data: any; expires: number }>();
export async function geolocate(ip: string) {
const subnet = ip.split('.').slice(0, 3).join('.') + '.0';
const cached = geoCache.get(subnet);
if (cached && cached.expires > Date.now()) return cached.data;
try {
const res = await fetch(
`https://api.apogeoapi.com/v1/geo/${ip}?apikey=${process.env.APOGEO_KEY}`,
{ signal: AbortSignal.timeout(2000) }
);
if (!res.ok) throw new Error();
const data = await res.json();
geoCache.set(subnet, { data, expires: Date.now() + 3_600_000 });
return data;
} catch {
return { countryCode: 'US', countryName: 'United States',
currencyCode: 'USD', currencySymbol: '$', currencyRate: 1 };
}
}
// app/routes/pricing.tsx
import type { LoaderFunctionArgs, MetaFunction } from '@remix-run/node';
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import { getIP, geolocate } from '~/utils/geo.server';
export const meta: MetaFunction = () => [{ title: 'Pricing' }];
const PLANS = {
basic: { usd: 19, name: 'Basic' },
starter: { usd: 29, name: 'Starter' },
pro: { usd: 79, name: 'Pro' },
};
export async function loader({ request }: LoaderFunctionArgs) {
const ip = getIP(request);
const geo = await geolocate(ip === '127.0.0.1' ? '8.8.8.8' : ip);
const prices = Object.fromEntries(
Object.entries(PLANS).map(([key, plan]) => [
key,
{
...plan,
local: Math.round(plan.usd * (geo.currencyRate ?? 1)),
currency: geo.currencyCode,
symbol: geo.currencySymbol,
},
])
);
return json({ country: geo.countryCode, prices });
}
export default function PricingPage() {
const { country, prices } = useLoaderData<typeof loader>();
return (
<main>
<h1>Pricing</h1>
<p className="text-sm text-gray-500">Prices for {country}</p>
<div className="grid grid-cols-3 gap-6 mt-8">
{Object.entries(prices).map(([key, plan]) => (
<div key={key} className="border rounded-xl p-6">
<h2 className="font-bold text-lg">{plan.name}</h2>
<div className="text-3xl font-bold my-3">
{plan.symbol}{plan.local}
<span className="text-base font-normal text-gray-500">/mo</span>
</div>
<p className="text-sm text-gray-400">USD {plan.usd}</p>
</div>
))}
</div>
</main>
);
}
Set a country cookie for client-side access
Store the country in a cookie so your client-side components can read it without an extra fetch:
// app/utils/country-cookie.server.ts
import { createCookie } from '@remix-run/node';
export const countryCookie = createCookie('country', {
maxAge: 3600, // 1 hour
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
});
// In root loader — set cookie on every request
export async function loader({ request }: LoaderFunctionArgs) {
const cookieHeader = request.headers.get('Cookie');
const savedCountry = await countryCookie.parse(cookieHeader);
if (savedCountry) {
return json({ country: savedCountry });
}
const ip = getIP(request);
const geo = await geolocate(ip === '127.0.0.1' ? '8.8.8.8' : ip);
return json(
{ country: geo.countryCode },
{
headers: {
'Set-Cookie': await countryCookie.serialize(geo.countryCode),
},
}
);
}
Cloudflare Pages deployment
On Cloudflare Pages with Remix, each request runs as a Cloudflare Worker and has access to request.cf.country directly — no API call needed for the country code:
// app/entry.server.tsx (Cloudflare Pages adapter)
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
// Cloudflare injects country — no API call for country code
const cf = (request as any).cf;
const country = cf?.country ?? 'US';
// Still need ApogeoAPI for currency data
// Cache in KV for near-zero latency
let currency = await env.GEO_CACHE.get(`currency:${country}`);
if (!currency) {
const res = await fetch(
`https://api.apogeoapi.com/v1/countries/${country}?apikey=${env.APOGEO_KEY}`
);
const data = await res.json();
currency = data.currencyCode;
// Cache in KV with 24h TTL — country/currency mapping changes rarely
await env.GEO_CACHE.put(`currency:${country}`, currency, { expirationTtl: 86400 });
}
// Inject into request headers for loaders to read
const enrichedRequest = new Request(request, {
headers: { ...Object.fromEntries(request.headers), 'x-country': country, 'x-currency': currency },
});
return handleRequest(enrichedRequest, /* ... */);
},
};
Resource route for client-side geo
Expose a JSON endpoint for React components that need geo data:
// app/routes/api.geo.ts
import type { LoaderFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';
import { getIP, geolocate } from '~/utils/geo.server';
export async function loader({ request }: LoaderFunctionArgs) {
const ip = getIP(request);
const geo = await geolocate(ip === '127.0.0.1' ? '8.8.8.8' : ip);
return json(geo, {
headers: {
'Cache-Control': 'private, max-age=3600',
},
});
}
// Client-side hook
// import { useEffect, useState } from 'react';
// function useCountry() {
// const [country, setCountry] = useState(null);
// useEffect(() => {
// fetch('/api/geo').then(r => r.json()).then(g => setCountry(g.countryCode));
// }, []);
// return country;
// }
Full API docs: 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