MainSDK
Search⌘K
Interfaces

Platform, OS and network

Location

geo

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

Building for 0.7ships as a beta APIPhase 2 · Cloud and Teams, 1.0Spec geoFree in your app, Apache-2.0

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
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

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

Calls

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

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

4 drivers. Select one to see its line in main.yaml.

main.yamlPlatform headers (default)
geo:
  driver: platform-headers

Change this one line to swap providers; your calls stay the same.Built-in and local drivers integration

Codes

CodeWhere it appearsMeaning
MAIN-GEO-0002Checkmainsdk check, before deploygeo.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-0005Checkmainsdk check, before deployA 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-0001RuntimeError responses and logsSee the spec
MAIN-GEO-0003RuntimeError responses and logsSee the spec
MAIN-GEO-0004RuntimeError responses and logsSee the spec
MAIN-GEO-0006RuntimeError responses and logsSee the spec

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
geo@1 contractPhase 20.7geo#contract
Request location hint from the declared platformPhase 20.7geo#hint
Guard that keeps a hint out of policyPhase 20.7geo#guard
Geocoding through networkPhase 20.7geo#geocode
Platform headersPhase 20.7geo#drivers

Quick start

main.yaml
schema: 1
deploy:
  target: vercel
geo:
  driver: platform-headers
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

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
KeyTypeDefaultWhat it does
driverstringplatform-headersThe hint always comes from the headers. mapbox and google-maps add geocoding. local is the testing kit's table
edgestringdeploy.target when it is vercel, cloudflare or flyThe 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
keysecret referencenoneRequired for mapbox and google-maps, as secret:NAME. Never put the key itself in main.yaml
cache-ttlduration30dHow 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
timeoutduration2sThe longest one geocode call may take. After it the answer is null

The headers each platform sets:

EdgeCountryRegionCityTime zone
vercelx-vercel-ip-countryx-vercel-ip-country-regionx-vercel-ip-cityx-vercel-ip-timezone
cloudflarecf-ipcountrycf-region-codecf-ipcitycf-timezone
cloudfrontcloudfront-viewer-countrycloudfront-viewer-country-regioncloudfront-viewer-citycloudfront-viewer-time-zone
flyx-mainsdk-countryx-mainsdk-regionx-mainsdk-cityx-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.

NameWhat 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 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

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

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

CodeWhereMeaning and fix
MAIN-GEO-0001runtimeA GeoHint was passed into a policy, access or auth.require context (400). Decide on the person, the device and the resource
MAIN-GEO-0002checkgeo.of is used in access code. Country rules for protected files belong in protect policy
MAIN-GEO-0003runtimeAn empty or oversized address, or a driver with no geocoder (400). Set geo.driver to mapbox or google-maps
MAIN-GEO-0004runtimeThe 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-0005check, runtimeAn 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-0006runtimeA provider could not answer. geo.geocode catches it, logs one line without the address and returns null

See check codes and errors.

Swapping drivers

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 and Google Maps Platform.

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:

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.
  • 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 policy for country rules on protected files, and network and 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.

How do I count where my app is used? With 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

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet