SvelteKitTutorialPricing
SvelteKit Localized Pricing with ApogeoAPI in 30 Lines (Server Hooks)
ApogeoAPI5 min read
SvelteKit's server hooks run on every request before the page is rendered. Combined with ApogeoAPI, you can detect the visitor's country and look up the live exchange rate, then pass everything down to every +page.svelte as typed locals.
The hook
// src/hooks.server.ts
import type { Handle } from '@sveltejs/kit';
import { APOGEOAPI_KEY } from '$env/static/private';
const APOGEO = 'https://api.apogeoapi.com/v1/api/geo';
export const handle: Handle = async ({ event, resolve }) => {
const ip =
event.request.headers.get('x-forwarded-for')?.split(',')[0] ??
event.getClientAddress();
if (ip && APOGEOAPI_KEY) {
try {
const res = await fetch(`${APOGEO}/ip/${ip}`, {
headers: { 'X-API-Key': APOGEOAPI_KEY },
});
if (res.ok) {
const data = await res.json();
event.locals.geo = {
country: data.country?.iso2,
currency: data.country?.currency,
rate: data.country?.currencyRate, // USD → local
};
}
} catch {
// fail-open
}
}
return resolve(event);
};
Type the locals
// src/app.d.ts
declare global {
namespace App {
interface Locals {
geo?: {
country?: string;
currency?: string;
rate?: number;
};
}
}
}
export {};
Use in a +page.server.ts
// src/routes/pricing/+page.server.ts
import type { PageServerLoad } from './$types';
const PLANS = [
{ name: 'Basic', usd: 19 },
{ name: 'Starter', usd: 29 },
{ name: 'Professional', usd: 79 },
];
export const load: PageServerLoad = async ({ locals }) => {
const { currency, rate } = locals.geo ?? {};
const plans = PLANS.map((p) => ({
...p,
localPrice:
currency && rate && currency !== 'USD'
? new Intl.NumberFormat(undefined, {
style: 'currency',
currency,
maximumFractionDigits: 0,
}).format(p.usd * rate)
: null,
}));
return { plans, currency };
};
Render in +page.svelte
<!-- src/routes/pricing/+page.svelte -->
<script lang="ts">
import type { PageData } from './$types';
export let data: PageData;
</script>
<ul>
{#each data.plans as plan}
<li>
<strong>{plan.name}</strong>: ${plan.usd} USD
{#if plan.localPrice}
<span class="muted">≈ {plan.localPrice}</span>
{/if}
</li>
{/each}
</ul>
Caching the FX lookup
The hook above hits ApogeoAPI once per request — great for testing, expensive at scale. Use SvelteKit's fetch caching options:
const res = await event.fetch(`${APOGEO}/ip/${ip}`, {
headers: { 'X-API-Key': APOGEOAPI_KEY },
// SvelteKit-specific: cache by URL for 1h on the server
cache: 'force-cache',
});
For more granular control (e.g. invalidate hourly), wrap the call in a small in-memory Map with TTL. ~15 lines and you're done.
Common pitfalls
- Static adapter. If you're using
@sveltejs/adapter-static, hooks don't run — the site is fully prerendered. Switch toadapter-vercel,adapter-cloudflare, oradapter-nodeif you need geo personalization. - Localhost dev.
event.getClientAddress()returns127.0.0.1which ApogeoAPI rejects. Hardcode a real IP behinddevguard for local testing. - Server-only env.
$env/static/privateonly works in server contexts (+page.server.ts,+layout.server.ts, hooks, endpoints). Never referenceAPOGEOAPI_KEYin+page.svelte— that exposes it to the browser.
Free API key + 1,000 calls/month at apogeoapi.com.
Try ApogeoAPI free
1,000 requests/month forever. 14-day full-access trial. No credit card.
Get your free API key