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