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 to adapter-vercel, adapter-cloudflare, or adapter-node if you need geo personalization.
  • Localhost dev. event.getClientAddress() returns 127.0.0.1 which ApogeoAPI rejects. Hardcode a real IP behind dev guard for local testing.
  • Server-only env. $env/static/private only works in server contexts (+page.server.ts, +layout.server.ts, hooks, endpoints). Never reference APOGEOAPI_KEY in +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