Command Palette

Search for a command to run...

Set Up Country Detection

In this guide, you'll learn how the storefront picks a sales region for a first-time visitor, how to enable an IP lookup through ip-api, and how to add a detection provider of your own.

A visitor who has never opened the store has no region yet. Rather than falling back to whichever region the backend happens to return first, the storefront resolves their country in the middleware and stores it in the cookie, which every page and the cart then read. Detection itself is a pluggable provider, the same way address autocomplete is.

How Provider Selection Works

reads the active provider from an environment variable and switches on it:

src/lib/geolocation/detect.ts
1export const resolveGeolocationProvider = (): GeolocationProvider => {
2 switch (true) {
3 case isIpApi(geolocationProvider):
4 return ipApiProvider
5 default:
6 return platformProvider
7 }
8}

reads and checks it for the value, both in the same file. Any other value, including an unset variable, falls through to , which reads the geo headers your hosting platform already sets.

The middleware touches the feature in one place. returns a function that writes the cookie onto whichever response the request ends up returning, so cookie precedence, the fallback chain, and the debug headers all stay inside :

src/middleware.ts
const applyCountry = await resolveCountry(request, regionMap)

The order it resolves in is the same for every provider: an existing cookie wins, then the provider's answer, then , then the first region the backend returned. The cookie comes first because the region switcher in the header writes to it, and detection must never override a choice the visitor made themselves.

Warning: 

ships , and is not a country code, so no seeded region matches it. That leaves the last link in the chain, the first region the backend returned, which is an arbitrary country. Set a real lowercase ISO 3166-1 alpha-2 code, for example or , so a failed lookup lands somewhere you chose.

Use the Hosting Platform's Geo Headers

The default provider needs no configuration and makes no network call. It reads the country your platform already resolved, in this order:

SourcePlatform
Cloudflare Workers
Vercel
Cloudflare
Google App Engine
Fastly
, nginx GeoIP or your own proxy

Values that mean "country unknown", , and , are discarded rather than treated as a country, because a region lookup for fails and drops the visitor into the fallback chain.

On Vercel and Cloudflare this is all you need. The header is free, instant, and more accurate than any third-party lookup, so leave unset there.

Enable the ip-api Provider

Deployments without geo headers, and local development, need a real lookup. Set the provider in :

apps/storefront/.env.local
1GEOLOCATION_PROVIDER=ip-api
2GEOLOCATION_PROVIDER_API_KEY=

Neither variable is prefixed with , because detection runs only on the server and the key must stay out of the browser bundle.

The provider checks the platform headers first and calls ip-api only when none of them carried a country, so enabling it on Vercel costs nothing. A successful lookup for a visitor's IP is cached for an hour, and concurrent requests for the same IP are collapsed into a single call. The provider also honours ip-api's rate limit budget: it reads the and response headers and stops calling for seconds once the budget is spent or a comes back, which is what keeps a busy store from being banned for an hour.

Warning: 

ip-api works without a key, but its free endpoint is HTTP only, allows 45 requests per minute, and its terms forbid commercial use, naming "local currency or closest store for visitors of an online shop" as an example of what is not allowed. That is exactly what a store does with the result, so a live store needs a pro key. With a key set, the provider queries over HTTPS instead, and nothing else has to change.

How the Visitor's IP Decides the Lookup

reads first, then , , , and , and normalizes what it finds: it strips a port, strips IPv6 brackets, and unwraps an IPv4-mapped IPv6 address, so is recognized as the loopback address . What it ends up with picks one of three behaviors:

Client addressBehavior
A public addressLooked up as the visitor's own IP, cached for an hour, 1.5 second timeout
Loopback or a private rangeThe visitor shares the server's network, so ip-api is called with no IP and geolocates the server's own egress address. Never cached, 5 second timeout
No address in any headerNo country is resolved, and the fallback chain takes over

The middle row is what makes and a LAN address resolve to the country the machine actually browses from, in and in a and run alike. Because nothing is cached there, switching a VPN and clearing changes the region on the next request. It costs one lookup per cookieless page load, which is why it is limited to visitors on the server's own network.

The last row is deliberately not a guess. Geolocating the server's egress address for a visitor whose IP is unknown would pin every such visitor to the country of the datacentre. If a deployment resolves no country at all, the reverse proxy in front of it is not forwarding .

Check How a Region Was Resolved

Set and the middleware annotates every response with what it decided. The flag works in any mode, a production build included:

apps/storefront/.env.local
GEOLOCATION_DEBUG=true
HeaderValue
, , or one of the reasons
What the provider returned, if it resolved nothing
, , or when the cookie answered
The visitor's address, , or
The country that was applied

Open the network panel, reload, and read the headers on the document request. means the cookie was never cleared, means never reached the server, and a source names which link in the chain answered.

A country the middleware could not resolve is stored for five minutes instead of a year, so one failed lookup can't pin a visitor to the fallback region for good. A country the visitor picked in the region switcher, and one that was really detected, are both kept for a year.

Add a Provider of Your Own

Every provider implements the same two-field contract:

src/lib/geolocation/types.ts
1export type GeolocationContext = {
2 headers: Headers
3 cf?: { country?: string | null }
4}
5
6export type GeolocationProvider = {
7 name: string
8 lookup: (context: GeolocationContext) => Promise<string | null>
9}

returns a lowercase ISO 3166-1 alpha-2 code, or when it resolved nothing. is not decoration: it goes into the cache key, so two providers never read each other's results.

To add a provider for another service, or a local database such as MaxMind GeoLite2:

  1. Create a file under that exports a . Call from first if you want the free header to keep winning, and wrap any network call in from to get the hour-long cache and the concurrent-request collapsing.
  2. Add a for it in the switch shown above, alongside a small helper next to .
  3. Point at the id your and helper check for.

is the shorter example to model on, at 50 lines with no network call at all. shows what a real lookup adds: a timeout, a rate-limit budget, and the three-way branch on the client address.

References

Edited Aug 26, 2026·Edit this page