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