---
title: "Location"
description: "Get a coarse location hint from the declared platform's headers for sensible defaults, and geocode addresses through network, never for access decisions."
section: "interfaces"
status: "Building for 0.7 · ships as a beta API"
spec: "geo"
canonical: "https://mainsdk.dev/docs/interfaces/geo"
updated: "2026-10-28"
---

# Location

> Get a coarse location hint from the declared platform's headers for sensible defaults, and geocode addresses through network, never for access decisions.

Status: Building for 0.7 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/geo · Updated: 2026-10-28

`geo` gives you a coarse place from the request, for defaults such as a currency, and turns a typed address into a point. The place is a hint and never decides access.

A place guessed from a network address is wrong for people on a VPN or roaming, so using it to allow or deny someone locks out real people and lets attackers in. MainSDK refuses it in `policy`, `access` and `auth.require`.

## At a glance

- Status: Building for 0.7 · ships as a beta API
- Phase: 2 (Cloud and Teams, 1.0)
- Milestone: 0.7 Teams and assure
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/geo.md
- Category: Platform, OS and network

Calls:

- `geo.of(req)`
- `geo.geocode(address, { country? })`
- `createGeo(settings)`
- `parseGeo(raw)`
- `geoHosts(config)`
- `geoEdge(config)`

In main.yaml:

```yaml
geo:
  driver: platform-headers
  edge: cloudfront
  key: secret:MAPBOX_TOKEN
  cache-ttl: 30d
  timeout: 2s
```

Guarantees, whatever the driver:

- Location is a hint, never used for access
- The hint never holds an IP address and reads only the declared platform's headers
- An address is personal data, sent only to a host that serves the tenant's region and cached per tenant under a digest
- A geocoding provider that is down gives null and the app serves on


## Drivers

Pick one in main.yaml with `geo.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Platform headers | `platform-headers` | yes |  |
| Mapbox | `mapbox` |  |  |
| Google Maps Platform | `google-maps` |  |  |
| Local table in the testing kit | `local` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-GEO-0002` | check | `geo.of` is used in access code: in a file under a folder named `access`, `policy`, `policies` or `auth`, or its call, or a variable it was put in, sits in the arguments of `auth.require`, `access.check`, `access.decide` or `policy.decide` |
| `MAIN-GEO-0005` | check | A `geo` key is wrong (runtime: 400): an unknown `driver` or `edge`, a provider driver with no `key: secret:NAME`, or `geo.of` used with no declared edge |
| `MAIN-GEO-0001` | runtime |  |
| `MAIN-GEO-0003` | runtime |  |
| `MAIN-GEO-0004` | runtime |  |
| `MAIN-GEO-0006` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| geo@1 contract | 2 | 0.7 | `geo#contract` |
| Request location hint from the declared platform | 2 | 0.7 | `geo#hint` |
| Guard that keeps a hint out of policy | 2 | 0.7 | `geo#guard` |
| Geocoding through network | 2 | 0.7 | `geo#geocode` |
| Platform headers | 2 | 0.7 | `geo#drivers` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="main.yaml"
schema: 1
deploy:
  target: vercel
geo:
  driver: platform-headers
```

```ts title="app/api/defaults/route.ts"
import { geo } from "mainsdk/geo";

export async function GET(request: Request) {
  const hint = geo.of(request);   // { kind, source, country?, region?, city?, timezone? }
  return Response.json({ currency: hint.country === "IN" ? "INR" : "USD" });
}
```

`geo.of()` reads the headers of the one platform your app declares, here Vercel. It makes no call, never throws for a bad header, and returns an empty hint when the headers are missing.

## Configuration

```yaml title="main.yaml"
geo:
  driver: platform-headers      # platform-headers | mapbox | google-maps | local
  edge: cloudfront              # vercel | cloudflare | fly | cloudfront; defaults to deploy.target
  key: secret:MAPBOX_TOKEN      # mapbox and google-maps only
  cache-ttl: 30d                # 0 turns the cache off
  timeout: 2s                   # one geocode call; the answer is null after this
```

| Key | Type | Default | What it does |
| --- | --- | --- | --- |
| `driver` | string | `platform-headers` | The hint always comes from the headers. `mapbox` and `google-maps` add geocoding. `local` is the testing kit's table |
| `edge` | string | `deploy.target` when it is `vercel`, `cloudflare` or `fly` | The platform whose headers `geo.of` reads. Set it when you run on Docker or Node behind CloudFront or another edge. With none declared the hint is empty |
| `key` | secret reference | none | Required for `mapbox` and `google-maps`, as `secret:NAME`. Never put the key itself in `main.yaml` |
| `cache-ttl` | duration | `30d` | How long a found place is cached per tenant. A missing address is cached for one hour at most. `0` turns the cache off, and Mapbox is then asked for a temporary result |
| `timeout` | duration | `2s` | The longest one geocode call may take. After it the answer is `null` |

The headers each platform sets:

| Edge | Country | Region | City | Time zone |
| --- | --- | --- | --- | --- |
| `vercel` | `x-vercel-ip-country` | `x-vercel-ip-country-region` | `x-vercel-ip-city` | `x-vercel-ip-timezone` |
| `cloudflare` | `cf-ipcountry` | `cf-region-code` | `cf-ipcity` | `cf-timezone` |
| `cloudfront` | `cloudfront-viewer-country` | `cloudfront-viewer-country-region` | `cloudfront-viewer-city` | `cloudfront-viewer-time-zone` |
| `fly` | `x-mainsdk-country` | `x-mainsdk-region` | `x-mainsdk-city` | `x-mainsdk-timezone` |

Cloudflare sends region, city and time zone only with its visitor location headers turned on. Fly sends no country header itself, so set `x-mainsdk-country` (and the others) from your own GeoIP step. Headers of every other platform are ignored, so a visitor cannot give your app a place by sending them.

### Building it by hand

The SDK builds `geo` for you from `main.yaml`. Call these only when you wire it by hand, for example in a test or a custom runtime.

| Name | What it is |
| --- | --- |
| `parseGeo(raw)` | Reads the `geo` section (kebab-case keys) into a config. An unknown `driver` or `edge`, or a bad duration, fails with `MAIN-GEO-0005`. |
| `createGeo(settings)` | Builds the interface over a `geo@1` driver. Settings: `config`, `edge`, `driver`, `store` (the cache, tenant-scoped), `tenant` (the id and region of the running tenant) and `log`. Optional: `driverConfig` (the key, already read), `fetch` (the network layer's) and `now`. |
| `geoHosts(config)` | The hosts the chosen driver calls, so the network allow list can include them. |
| `geoEdge(config)` | The platform the config declares, or `undefined`. |

## What you get

A `GeoHint` for defaults, and a `Place` for an address. The hint never holds an IP address, and each field is checked: the country is two letters (not `XX` or `T1`), the region a short code, the city cleaned and cut at 64 characters, the time zone an IANA name. A value that fails is left out.

```ts
type GeoHint = {
  readonly kind: "geo-hint";
  readonly source: "vercel" | "cloudflare" | "fly" | "cloudfront" | "none";
  readonly country?: string;    // ISO 3166-1 alpha-2, upper case
  readonly region?: string;
  readonly city?: string;
  readonly timezone?: string;   // IANA
};
type Place = {
  lat: number; lon: number;
  precision: "address" | "street" | "city" | "region" | "country";
  country?: string; region?: string; city?: string; label?: string;
};
```

An address is personal data. `geo.geocode()` sends it through [`network`](https://mainsdk.dev/docs/interfaces/network) only to a provider host that serves the tenant's region, caches the place per tenant under a digest (never the address), and never writes the address to a log, an audit record, an error or a metric.

## Examples

### Geocode a delivery address

```ts title="app/checkout/actions.ts"
"use server";
import { geo } from "mainsdk/geo";

export async function placeFor(address: string) {
  const place = await geo.geocode(address, { country: "IN" });
  return place ?? { precision: "unknown" };   // null: not found, or the provider is down
}
```

`geo.geocode()` returns `null` when nothing matches and when the provider is down, so checkout keeps working with the typed address. It fails with `MAIN-GEO-0003` for an empty address, one over 512 characters or a driver with no geocoder, and with `MAIN-GEO-0004` when the driver has no host for the tenant's region.

### Suggest a market in a Hono API

```ts title="api/market.ts"
import { Hono } from "hono";
import { geo } from "mainsdk/geo";

const app = new Hono();

app.get("/suggested-market", (c) => {
  const { country } = geo.of(c.req.raw);
  return c.json({ suggested: country ?? null });
});

export default app;
```

The person still chooses. Market overlays resolve from the user's and organisation's context, not from the hint.

## Errors and checks

| Code | Where | Meaning and fix |
| --- | --- | --- |
| `MAIN-GEO-0001` | runtime | A `GeoHint` was passed into a `policy`, `access` or `auth.require` context (400). Decide on the person, the device and the resource |
| `MAIN-GEO-0002` | check | `geo.of` is used in access code. Country rules for protected files belong in `protect` policy |
| `MAIN-GEO-0003` | runtime | An empty or oversized address, or a driver with no geocoder (400). Set `geo.driver` to `mapbox` or `google-maps` |
| `MAIN-GEO-0004` | runtime | The driver has no host that serves the tenant's region, so the address is not sent (400). Use a driver that serves the region |
| `MAIN-GEO-0005` | check, runtime | An unknown `driver` or `edge`, a provider driver with no `key: secret:NAME`, `geo.of` with no declared platform, or `geo.geocode` with no geocoder |
| `MAIN-GEO-0006` | runtime | A provider could not answer. `geo.geocode` catches it, logs one line without the address and returns `null` |

See [check codes](https://mainsdk.dev/docs/reference/check-codes) and [errors](https://mainsdk.dev/docs/reference/errors).

## Swapping drivers

```yaml title="main.yaml"
geo:
  driver: mapbox   # was platform-headers
  key: secret:MAPBOX_TOKEN
```

Your `geo.of()` and `geo.geocode()` calls stay the same. Each driver serves the regions it declares: Mapbox serves `us`, Google Maps Platform `us` and `eu`. For a tenant in another region the call fails with `MAIN-GEO-0004` instead of sending the address. See [Mapbox](https://mainsdk.dev/docs/integrations/mapbox) and [Google Maps Platform](https://mainsdk.dev/docs/integrations/google-maps).

## Testing

`createTestApp` installs the local driver, which geocodes from a table with no network. Set `geo.driver: local` in your test settings, and give a test its own table:

```ts title="app/checkout/geo.test.ts"
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { createTestApp, localGeoDriver } from "@mainsdk/testing";
import { geo } from "mainsdk/geo";

describe("geo", () => {
  it("geo AC4 geocodes from the table", async () => {
    const app = createTestApp({ yaml: "schema: 1\ngeo:\n  driver: local\n" });
    app.socialPorts.geo = localGeoDriver({
      "1 main st, springfield": { lat: 39.78, lon: -89.65, precision: "address", country: "US" },
    });
    assert.equal((await geo.geocode("1 Main St, Springfield"))?.country, "US");
    assert.equal(await geo.geocode("Nowhere Lane"), null);
  });
});
```

`fakeGeoProvider("mapbox")` and `fakeGeoProvider("google-maps")` are fake provider servers for the real drivers: they answer in each provider's response shape, record every request and can be set to `down`, `refused`, `garbage` or `hang` to test the `null` path. Every driver, yours included, runs the `geo@1` conformance suite with `runDriverConformance(driver)`.

## Security and privacy

- Location is a hint, never used for access. Passing a `GeoHint` into a policy, `access` or `auth.require` fails with `MAIN-GEO-0001`, and `mainsdk check` flags `geo.of` in access code. The guard cannot follow a country you copy into a plain string, so do not.
- The hint never holds an IP address, and `geo.of()` reads only the declared platform's headers.
- Addresses you geocode are personal data: sent only to a host that serves the tenant's region, cached under a digest, never logged. Label the fields that store them in [`db`](https://mainsdk.dev/docs/interfaces/db).
- Precise location needs consent. `geo` handles a coarse place and typed addresses, not a device's coordinates.

## FAQ

**Can I block a country with `geo`?** No. Use [`protect`](https://mainsdk.dev/docs/interfaces/protect) policy for country rules on protected files, and [`network`](https://mainsdk.dev/docs/interfaces/network) and [`policy`](https://mainsdk.dev/docs/interfaces/policy) for rules you enforce.

**Does `geo` choose the user's language?** No. The locale comes from the user's choice first; see [Languages and formats](https://mainsdk.dev/docs/interfaces/i18n).

**How do I count where my app is used?** With [`analytics`](https://mainsdk.dev/docs/interfaces/analytics): `analytics.query({ by: ["country"] })`.

**Why is my hint empty?** No platform is declared (`deploy.target` or `geo.edge`), or the platform did not send the header. `mainsdk check` reports the first.

## Next

- [Use location in your app](https://mainsdk.dev/docs/guides/use-geo)
- [Languages and formats](https://mainsdk.dev/docs/interfaces/i18n)
- [Where the app is used](https://mainsdk.dev/docs/interfaces/analytics)
- [Policy decisions](https://mainsdk.dev/docs/interfaces/policy)
