AngularTypeScriptTutorialGeolocationRxJS

IP Geolocation in Angular: Country Detection Service, HTTP Interceptor, and Pipe

ApogeoAPI6 min read

Angular projects often need country detection for localized pricing, content gating, or currency display. Here's a complete implementation using Angular services, RxJS, and an HTTP interceptor — following Angular 17+ standalone component patterns.

Setup

ng new my-app --standalone  # Angular 17+
# No extra packages needed — uses Angular's HttpClient

Add your key to environment.ts:

// src/environments/environment.ts
export const environment = {
  production: false,
  apogeoApiKey: 'your_api_key_here',
  apogeoApiBase: 'https://api.apogeoapi.com/v1',
};

GeoService

// src/app/services/geo.service.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, of, shareReplay } from 'rxjs';
import { catchError, map, tap } from 'rxjs/operators';
import { environment } from '../../environments/environment';

export interface GeoData {
  countryCode: string;
  countryName: string;
  currencyCode: string;
  city?: string;
  timezone?: string;
}

export interface ExchangeRate {
  usdRate: number;
  lastUpdated: string;
  stale: boolean;
}

const FALLBACK: GeoData = {
  countryCode: 'US',
  countryName: 'United States',
  currencyCode: 'USD',
};

@Injectable({ providedIn: 'root' })
export class GeoService {
  private readonly apiBase = environment.apogeoApiBase;
  private readonly headers = { 'X-API-Key': environment.apogeoApiKey };

  // Memoize: same observable replayed to all subscribers, fetched only once
  private geoCache$: Observable<GeoData> | null = null;
  private fxCache = new Map<string, Observable<number>>();

  constructor(private http: HttpClient) {}

  getVisitorGeo(): Observable<GeoData> {
    if (!this.geoCache$) {
      // Check sessionStorage first
      const stored = sessionStorage.getItem('visitor-geo');
      if (stored) {
        this.geoCache$ = of(JSON.parse(stored) as GeoData);
        return this.geoCache$;
      }

      this.geoCache$ = this.http
        .get<GeoData>(`${this.apiBase}/ip/self`, { headers: this.headers })
        .pipe(
          tap(data => sessionStorage.setItem('visitor-geo', JSON.stringify(data))),
          catchError(() => of(FALLBACK)),
          shareReplay(1),
        );
    }
    return this.geoCache$;
  }

  getExchangeRate(currency: string): Observable<number> {
    if (currency === 'USD') return of(1);

    if (!this.fxCache.has(currency)) {
      const stored = sessionStorage.getItem(`fx:${currency}`);
      if (stored) {
        this.fxCache.set(currency, of(parseFloat(stored)));
      } else {
        this.fxCache.set(
          currency,
          this.http
            .get<ExchangeRate>(`${this.apiBase}/currencies/${currency}/rate`, { headers: this.headers })
            .pipe(
              map(r => r.usdRate),
              tap(rate => sessionStorage.setItem(`fx:${currency}`, String(rate))),
              catchError(() => of(1)),
              shareReplay(1),
            ),
        );
      }
    }
    return this.fxCache.get(currency)!;
  }

  getCountries(): Observable<Array<{ iso2: string; name: string; flag: string }>> {
    const stored = sessionStorage.getItem('apogeo-countries');
    if (stored) return of(JSON.parse(stored));

    return this.http
      .get<{ data: Array<{ iso2: string; name: string; flag: string }> }>(
        `${this.apiBase}/countries?fields=iso2,name,flag`,
        { headers: this.headers },
      )
      .pipe(
        map(r => r.data),
        tap(data => sessionStorage.setItem('apogeo-countries', JSON.stringify(data))),
        catchError(() => of([])),
        shareReplay(1),
      );
  }
}

LocalCurrencyPipe

// src/app/pipes/local-currency.pipe.ts
import { Pipe, PipeTransform, inject } from '@angular/core';
import { Observable, combineLatest, map, of, switchMap } from 'rxjs';
import { AsyncPipe } from '@angular/common';
import { GeoService } from '../services/geo.service';

const SYMBOLS: Record<string, string> = {
  EUR: '€', GBP: '£', BRL: 'R$', ARS: '$', MXN: '$', CLP: '$',
  COP: '$', JPY: '¥', CNY: '¥', INR: '₹',
};

@Pipe({ name: 'localCurrency', standalone: true, pure: false })
export class LocalCurrencyPipe implements PipeTransform {
  private geo = inject(GeoService);

  transform(usdAmount: number): Observable<string> {
    return this.geo.getVisitorGeo().pipe(
      switchMap(geoData =>
        this.geo.getExchangeRate(geoData.currencyCode).pipe(
          map(rate => {
            const local = Math.round(usdAmount * rate);
            const symbol = SYMBOLS[geoData.currencyCode] ?? geoData.currencyCode + ' ';
            return `${symbol}${local.toLocaleString()} ${geoData.currencyCode}`;
          }),
        ),
      ),
    );
  }
}

Pricing Component

// src/app/components/pricing/pricing.component.ts
import { Component, OnInit, inject } from '@angular/core';
import { AsyncPipe, NgFor, NgIf } from '@angular/common';
import { Observable, combineLatest, map } from 'rxjs';
import { GeoService, GeoData } from '../../services/geo.service';
import { LocalCurrencyPipe } from '../../pipes/local-currency.pipe';

interface Plan { name: string; usd: number; features: string[]; }

@Component({
  selector: 'app-pricing',
  standalone: true,
  imports: [AsyncPipe, NgFor, NgIf, LocalCurrencyPipe],
  template: `
    <ng-container *ngIf="geo$ | async as geo">
      <p class="geo-note">Prices for {{ geo.countryName }} in {{ geo.currencyCode }}</p>
    </ng-container>

    <div class="plans-grid">
      <div class="plan-card" *ngFor="let plan of plans">
        <h3>{{ plan.name }}</h3>
        <p class="price">{{ plan.usd | localCurrency | async }}</p>
        <ul>
          <li *ngFor="let f of plan.features">{{ f }}</li>
        </ul>
      </div>
    </div>
  `,
})
export class PricingComponent {
  private geo = inject(GeoService);
  geo$ = this.geo.getVisitorGeo();

  plans: Plan[] = [
    { name: 'Basic',        usd: 19, features: ['15,000 req/month', '2 API keys'] },
    { name: 'Starter',      usd: 29, features: ['100,000 req/month', '3 API keys'] },
    { name: 'Professional', usd: 79, features: ['500,000 req/month', '10 API keys'] },
  ];
}

Country Selector Component

// src/app/components/country-selector/country-selector.component.ts
import { Component, OnInit, inject } from '@angular/core';
import { AsyncPipe, NgFor, NgIf } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { GeoService } from '../../services/geo.service';
import { combineLatest } from 'rxjs';
import { map } from 'rxjs/operators';

@Component({
  selector: 'app-country-selector',
  standalone: true,
  imports: [AsyncPipe, NgFor, NgIf, FormsModule],
  template: `
    <select *ngIf="vm$ | async as vm" [(ngModel)]="selected">
      <option *ngFor="let c of vm.countries" [value]="c.iso2">
        {{ c.flag }} {{ c.name }}
      </option>
    </select>
  `,
})
export class CountrySelectorComponent {
  private geo = inject(GeoService);
  selected = 'US';

  vm$ = combineLatest({
    countries: this.geo.getCountries(),
    geoData: this.geo.getVisitorGeo(),
  }).pipe(
    map(({ countries, geoData }) => {
      this.selected = geoData.countryCode;
      return { countries };
    }),
  );
}

HTTP Interceptor — Automatic API Key Header

If you make multiple ApogeoAPI calls, use an interceptor instead of passing headers everywhere:

// src/app/interceptors/apogeo.interceptor.ts
import { HttpInterceptorFn } from '@angular/common/http';
import { environment } from '../../environments/environment';

export const apogeoInterceptor: HttpInterceptorFn = (req, next) => {
  if (!req.url.includes('apogeoapi.com')) return next(req);

  return next(req.clone({
    setHeaders: { 'X-API-Key': environment.apogeoApiKey },
  }));
};
// src/app/app.config.ts
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { apogeoInterceptor } from './interceptors/apogeo.interceptor';

export const appConfig = {
  providers: [
    provideHttpClient(withInterceptors([apogeoInterceptor])),
  ],
};

With the interceptor in place, you can remove headers: this.headers from the GeoService HTTP calls.

Angular Universal (SSR) Compatibility

On the server side, sessionStorage doesn't exist. Guard all sessionStorage calls:

import { isPlatformBrowser } from '@angular/common';
import { PLATFORM_ID, inject } from '@angular/core';

// Inside service:
private platformId = inject(PLATFORM_ID);
private isBrowser = isPlatformBrowser(this.platformId);

// Then:
if (this.isBrowser) {
  const stored = sessionStorage.getItem('visitor-geo');
  // ...
}

Summary

  • GeoService: RxJS shareReplay(1) ensures one HTTP call per session, shared across all components
  • LocalCurrencyPipe: declarative USD→local conversion in templates
  • CountrySelectorComponent: auto-selects visitor's country on init
  • HTTP Interceptor: cleaner than passing headers in every request
  • SSR-safe: isPlatformBrowser guard for Angular Universal

API docs: api.apogeoapi.com/api/docs — free tier at app.apogeoapi.com/register (no credit card).

Try ApogeoAPI free

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

Get your free API key