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:
isPlatformBrowserguard 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