SvelteKitJavaScriptGeolocationTutorial

IP Geolocation in SvelteKit — Server-Side Country Detection and Currency Display

ApogeoAPI5 min read

SvelteKit geolocation options

SvelteKit runs server-side code in +page.server.ts loaders and hooks.server.ts. These are the ideal places for IP geolocation — the IP address is available from the request object, and the geo data flows to the client as a typed prop.

Option 1: Global hook (detect country on every request)

Create src/hooks.server.ts to detect country once and attach it to event.locals:

// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';

const API_KEY = import.meta.env.APOGEOAPI_KEY ?? '';
const geoCache = new Map<string, { data: App.Geo; ts: number }>();
const TTL = 4 * 60 * 60 * 1000; // 4 hours

function subnet(ip: string): string {
  const parts = ip.split('.');
  return parts.length === 4 ? parts.slice(0, 3).join('.') : ip;
}

async function getGeo(ip: string): Promise<App.Geo> {
  const key = subnet(ip);
  const cached = geoCache.get(key);
  if (cached && Date.now() - cached.ts < TTL) return cached.data;

  try {
    const res = await fetch(`https://api.apogeoapi.com/v1/ip/${ip}`, {
      headers: { 'X-API-Key': API_KEY },
      signal: AbortSignal.timeout(3000),
    });
    const data = (await res.json()) as App.Geo;
    geoCache.set(key, { data, ts: Date.now() });
    return data;
  } catch {
    return { country_code: 'US', country_name: 'United States', currency: 'USD' } as App.Geo;
  }
}

export const handle: Handle = async ({ event, resolve }) => {
  const ip =
    event.request.headers.get('x-forwarded-for')?.split(',')[0].trim() ??
    event.getClientAddress();
  event.locals.geo = await getGeo(ip);
  return resolve(event);
};

Declare the types in src/app.d.ts:

// src/app.d.ts
declare global {
  namespace App {
    interface Geo {
      country_code: string;
      country_name: string;
      city?: string;
      timezone?: string;
      currency?: string;
      isp?: string;
    }
    interface Locals {
      geo: Geo;
    }
  }
}
export {};

Option 2: Page server loader

Pass geo data as a typed prop to your page component via a +page.server.ts load function:

// src/routes/pricing/+page.server.ts
import type { PageServerLoad } from './$types';

export const load: PageServerLoad = async ({ locals, fetch }) => {
  const { geo } = locals;
  const currency = geo.currency ?? 'USD';

  // Fetch exchange rate for the visitor's currency
  let rate = 1.0;
  if (currency !== 'USD') {
    const rateRes = await fetch('https://api.apogeoapi.com/v1/exchange-rates/USD', {
      headers: { 'X-API-Key': import.meta.env.APOGEOAPI_KEY },
    });
    const { rates } = await rateRes.json();
    rate = rates[currency] ?? 1.0;
  }

  return {
    country: geo.country_code,
    currency,
    rate,
    plans: [
      { name: 'Basic',        usdPrice: 19 },
      { name: 'Starter',      usdPrice: 29 },
      { name: 'Professional', usdPrice: 79 },
    ].map(p => ({ ...p, localPrice: Math.round(p.usdPrice * rate * 100) / 100 })),
  };
};

Svelte component: localised pricing table

<!-- src/routes/pricing/+page.svelte -->
<script lang="ts">
  import type { PageData } from './$types';
  export let data: PageData;

  const formatter = (currency: string) =>
    new Intl.NumberFormat('en', { style: 'currency', currency });
</script>

<h1>Pricing for {data.country} visitors</h1>

<div class="plans">
  {#each data.plans as plan}
    <div class="plan">
      <h2>{plan.name}</h2>
      <p class="price">
        {formatter(data.currency).format(plan.localPrice)}/month
      </p>
      <p class="original">
        USD {plan.usdPrice}/month
      </p>
    </div>
  {/each}
</div>

Country-gated content with a Svelte store

// src/lib/stores/geo.ts
import { writable } from 'svelte/store';
import type { App } from '$app/environment';

export const geoStore = writable<App.Geo | null>(null);
<!-- src/routes/+layout.svelte -->
<script lang="ts">
  import { geoStore } from '$lib/stores/geo';
  import type { LayoutData } from './$types';
  export let data: LayoutData;

  geoStore.set(data.geo);
</script>

<slot />
<!-- Any nested component -->
<script lang="ts">
  import { geoStore } from '$lib/stores/geo';
  const EU = new Set(['DE','FR','IT','ES','NL','AT','BE','PT','SE','FI','DK','PL','CZ']);
</script>

{#if $geoStore && EU.has($geoStore.country_code)}
  <p>🇪🇺 VAT included. <a href="/privacy-gdpr">GDPR notice</a></p>
{/if}

Environment variable setup

# .env
APOGEOAPI_KEY=your_api_key_here
# SvelteKit exposes vars without VITE_ prefix only to server-side code
# (hooks.server.ts, +page.server.ts, +layout.server.ts)

Try ApogeoAPI free

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

Get your free API key