A crypto trading front end usually starts life pointed at a single API host. It works on your machine, it works in the demo, and then someone in another jurisdiction loads it and half the symbols are missing, the order book never fills, or the websocket refuses to upgrade. The uncomfortable reality is that "the exchange" is rarely one endpoint. Large venues run legally distinct entities per region, each with its own hostnames, its own listed assets, and its own rules about who is allowed to connect.
Binance is the canonical example, and it is what the Hedge UI demo connects to. Binance global (binance.com) and Binance.US (binance.us) are separate companies with separate order books and overlapping-but-different listings. A US-based user pointed at the global host will get data they cannot legally trade, and possibly no data at all. So before the first stream opens, the app has to answer a deceptively simple question: where is this person, and which venue should they talk to? This post walks through how the demo answers that in the browser, why the answer cascades through every request that follows, and why client-side detection is a routing optimisation rather than a compliance control.
Why one global endpoint is not enough
If every exchange exposed one host serving every asset to every user, none of this would matter. They do not, for several independent reasons that all happen to push in the same direction.
| Reason | What it means for routing |
|---|---|
| Separate legal entities | Binance.US and Binance global are distinct venues with distinct hostnames and credentials. |
| Different listed assets | A symbol that trades on the global book may not exist on the US book, and vice versa. |
| Data residency | Some jurisdictions expect market and account data to be served from in-region infrastructure. |
| Latency | A user in Virginia talking to a Tokyo edge pays round-trip time they do not need to. |
| Regulatory geo-blocking | Venues actively refuse or restrict connections from regions they are not licensed in. |
The asset-listing difference is the one that bites earliest in development, because it is silent. You do not get an error; you get a shorter symbol list, or a request for SOLUSDT that resolves on one venue and 400s on the other. That single fact - the catalogue of valid symbols depends on the venue, which depends on the region - is why region detection cannot be an afterthought bolted on near the network layer. It has to resolve before anything else reads market data.
Detecting the region in the browser
The demo detects region with a single IP lookup. The provider lives at src/client-location/client-location-provider.tsx, and the whole thing is about thirty lines. It calls a third-party geolocation endpoint, reads the country code off the response, and stores it.
// src/client-location/client-location-provider.tsx export const ClientLocationProvider = ({ children, }: ClientLocationProviderProps) => { const [location, setLocation] = useState<string | null>(null); const [isLoading, setIsLoading] = useState(true); useEffect(() => { (async () => { try { const data = await requestLocation(); setLocation(data.country_code); setIsLoading(false); } catch (error) { console.error("Error fetching location:", error); setLocation("EU"); setIsLoading(false); } })(); }, []); return ( <ClientLocationContext.Provider value={{ location, isLoading }}> {isLoading ? null : children} </ClientLocationContext.Provider> ); }; const requestLocation = async () => { const response = await fetch("https://ipapi.co/json"); const data = await response.json(); return data; };
There are three deliberate decisions packed into that small block.
The IP lookup
ipapi.co/json returns a JSON document with a country_code field (plus city, timezone, currency, and more that the demo ignores). It is a zero-config way to turn the caller's IP into an ISO country code without running any backend of your own. For a starter kit that is exactly the right altitude: it demonstrates the pattern without forcing you to stand up a geolocation service on day one. The flip side is that you have outsourced a request on your critical path to a third party, which matters for the latency and failure sections below.
The failure-safe default
If the fetch throws for any reason - network error, rate limit, the user's ad blocker eating the request, the service being down - the catch block sets the location to "EU" and carries on. There is no retry, no error screen, no blank state. The app degrades to a sensible default region and keeps rendering.
Defaulting to "EU" rather than "US" is intentional. The US venue is the more restricted one, with a narrower asset list and stricter access rules. Falling back to the global host when in doubt gives the broadest, most likely-correct experience for an unknown user, and it means a failed lookup never accidentally routes someone into a venue they should not be on. The default is permissive about data, not about jurisdiction.
One country code, one decision
Note what the provider does not do: it does not map countries to regions, parse a list of blocked territories, or branch on anything beyond what downstream code needs. It stores the raw country_code and lets the consumers decide what to do with it. That keeps detection and policy in separate layers, which is what you want when the policy inevitably gets more complicated later.
One source of truth: a Context provider
Region is read in a lot of places - the REST client, the websocket client, and potentially any UI that wants to show which venue you are on. If each of those did its own lookup you would fire several geolocation requests and risk them disagreeing. So detection is a React Context, and everything downstream reads from it.
// src/client-location/client-location-context.ts export const useClientLocation = () => { const context = useContext(ClientLocationContext); if (context === undefined) { throw new Error( "useClientLocation must be used within a ClientLocationProvider", ); } if (context.isLoading || context.location === null) { throw new Error( "Client location is still loading - provider should not render children until ready", ); } return context.location; };
The hook is strict by design. It throws if it is used outside the provider, and it throws if the location has not resolved yet. That second guard pairs with the {isLoading ? null : children} line in the provider: the subtree simply does not mount until a region exists. By the time any component calls useClientLocation(), the answer is guaranteed to be a non-null string. No consumer has to handle a "loading" or "unknown" region, because that state can never reach them.
This is a small but powerful invariant. It means useBaseUrl and every query and stream below it can treat region as a plain synchronous value rather than an async, nullable one. Centralising network-shaping state like this in Context, while leaving the actual server data to a query cache, keeps two concerns that change at different rates from tangling together: the region is decided once at start-up, the market data churns many times a second.
The trade-off is a hard gate on first paint. Nothing renders until the IP lookup resolves or fails, so the geolocation request sits squarely on your time-to-interactive. The failure-safe default keeps that bounded - a dead lookup still resolves quickly into "EU" - but if you wanted to soften it you would render a skeleton during isLoading instead of null, accepting that some children might briefly assume the default region.
Selecting the host, and why the choice cascades
With a guaranteed region in hand, host selection is trivial. Both the websocket and REST clients have a tiny useBaseUrl hook that branches on the single string. Here is the websocket one, from src/binance/binance-stream.ts:
// src/binance/binance-stream.ts const useBaseUrl = () => { const location = useClientLocation(); if (location === "US") { return "wss://stream.binance.us:9443"; } return "wss://stream.binance.com"; };
The REST client in src/binance/binance-rest.ts mirrors it exactly, swapping https://api.binance.us for https://api.binance.com. Real-time data flows over react-use-websocket (version 4.13.0), and the REST calls go through TanStack React Query 5.90.12. Both libraries see only the resolved base URL; neither knows or cares how the region was determined.
What looks like a one-line if is actually the root of a dependency tree. The host determines which exchange entity you are talking to, which determines the catalogue of tradable symbols, which determines which stream names are even valid to subscribe to. Ask wss://stream.binance.com for a symbol that only lists on the US book and the subscription silently does nothing. The region choice does not just pick a server; it picks an entire universe of valid requests.
The exchange-info call makes the cascade concrete. The two venues expose subtly different APIs, so the demo shapes the request based on region:
// src/binance/binance-rest.ts export const useBinanceExchangeInfo = () => { const location = useClientLocation(); const baseUrl = useBaseUrl(); let url = `${baseUrl}/api/v3/exchangeInfo`; // The global API supports filtering params that the US API does not. if (location !== "US") { url += "?showPermissionSets=false&symbolStatus=TRADING"; } return useQuery({ queryKey: [url], queryFn: () => get<ExchangeInfo>(url), }); };
The global venue understands showPermissionSets and symbolStatus query parameters; the US one does not, so they are omitted there. The same logical endpoint - "tell me the symbols you list" - is phrased differently per region. That is the cascade in miniature, and it is why region has to be a first-class, tree-wide value rather than a constant tucked into a config file.
Because queryKey includes the fully built URL, React Query caches per venue automatically. A user who somehow switched regions would get a cleanly separated cache rather than a global host's symbols bleeding into a US session.
Latency: routing to the nearest region
Routing to the right legal entity often routes you to the nearer one too, which is a free latency win. A user in the US talking to binance.us is generally hitting closer infrastructure than they would reaching across to a global edge. For a trading UI, where the gap between a price update arriving and a human seeing it is the whole product, shaving a round trip off every message matters.
It is worth being precise about what regional routing does and does not buy you here. Picking the nearby host reduces the propagation delay on the persistent websocket connection, so every tick arrives sooner. It does nothing for the cost of the geolocation lookup itself, which is a one-time hit at startup, nor for client-side render cost once data arrives. The win compounds, though: a streaming book sends thousands of messages over a session, and a connection seated tens of milliseconds closer pays that dividend on every one of them. Regional routing is one line item in a larger budget; the full breakdown of where milliseconds go in a streaming UI is covered in the piece on latency budgets for trading interfaces.
Graceful failure
Geolocation is a request to a third party on your start-up path, which means it is a thing that will eventually fail. The demo's posture is to never let that failure become the user's problem.
- Default region, always. The
catchresolves to"EU"so the app is never stuck in an undetermined state. A failed lookup is indistinguishable, from the rendering layer's point of view, from a successful one. - Bounded start-up. Because the failure path resolves immediately rather than retrying in a loop, a dead geolocation service delays first paint by one timed-out request, not indefinitely.
- A visible region indicator. Surface the resolved region somewhere in the UI. If detection guesses wrong, an EU label shown to a US user is the cue that something is off, and it turns an invisible mis-route into something a person can notice and report.
- A user override. Let people change the region manually. Since everything reads from one Context value, an override is just a setter on that provider; the new value propagates to every host, query, and stream on the next render with no other wiring. This is also your escape hatch for travellers and VPN users whose IP does not reflect where they actually are.
This is the same philosophy applied to networks rather than rendering: assume the dependency can vanish and decide in advance what the UI does when it does. The broader version of that argument, applied to component trees and render-time errors, is in the write-up on error boundaries and graceful degradation.
The hard truth: client-side geo is not a compliance boundary
Everything above is a UX and routing optimisation. It is emphatically not a way to enforce who is allowed to trade what, and it is important to be honest about that, because it is an easy thing to quietly assume you have solved.
IP geolocation is a heuristic. It is wrong at the edges - mobile carriers, corporate VPNs, satellite links, datacentre IPs - and it is trivially defeated on purpose. Anyone running a commercial VPN can present whatever country they like, and ipapi.co will faithfully report the exit node. The demo's lookup will happily route a VPN user to whichever venue their exit node implies. If your business rules depend on keeping certain users off certain markets, a client-side if (location === "US") is not a lock. It is a sign on an unlocked door.
Real enforcement lives on the server and in your onboarding. The combination that actually holds up is server-side checks on every privileged request (re-deriving region from the request's own IP and from the authenticated account's verified jurisdiction) plus KYC at signup that establishes who the user legally is and where they reside. The client's job is to route to the most likely-correct venue and make the experience smooth; the server's job is to refuse anything that should be refused, regardless of what the client claims. Treat the browser as a hint provider and never as the authority. The wider threat model for code running in a hostile browser is laid out in the post on security for browser-based trading applications.
Region is not language, and not currency
One last trap worth naming: region, language, and currency are three different axes, and conflating them produces strange results. The location lookup answers "which venue and infrastructure," not "what language does this person read" or "what currency do they think in." A user in Germany routed to the global venue might want a US-dollar-quoted book and an English interface; a user in the US might want Spanish. Driving your <html lang>, number formatting, or copy off the same country_code that picks your API host couples decisions that should move independently.
Keep the geolocation Context scoped to what it is good at - network routing - and let localisation be its own concern with its own source of truth, ideally honouring an explicit user choice over any inferred default. The full treatment of how those axes interact in a trading UI is in the article on internationalisation for crypto exchanges.
Testing region routing without leaving your desk
Because detection is a Context value rather than a hardcoded constant, you can put either venue under test without touching a VPN or spoofing your IP. Wrap the tree in a provider that returns a fixed location, and the entire downstream graph - hosts, queries, streams - reroutes accordingly.
// In a test or Storybook story, force a region by providing the Context directly. const FixedRegion = ({ location, children, }: { location: string; children: ReactNode; }) => ( <ClientLocationContext.Provider value={{ location, isLoading: false }}> {children} </ClientLocationContext.Provider> ); // Exercise the US venue with no IP lookup involved. render( <FixedRegion location="US"> <OrderBook symbol="BTCUSDT" /> </FixedRegion>, );
Setting isLoading: false satisfies the hook's guard, so children mount immediately with no geolocation request in the loop. That is the practical pay-off of keeping detection and policy in separate layers: routing logic can be exercised deterministically, and the tests never depend on a live third-party service or on wherever the CI runner happens to sit.
It is still worth one real-world pass with an actual VPN before shipping, because forcing the Context only proves your branches; it does not prove the venues behave the way you assumed. The first time I pointed a US exit node at the demo, the narrower Binance.US symbol list showed up immediately in the dropdown - a blunt reminder that these are two different products, not two URLs for the same data.
Closing checklist
If you are adding regional routing to a trading front end, this is the shape that has held up in practice:
- Detect region once, early, in one place. A single lookup feeding a Context provider beats scattered per-client detection that can disagree.
- Always resolve to a default. Pick a permissive fallback (the demo uses
"EU") so a failed or blocked lookup never leaves the app in an unknown state. - Gate the tree until region is known, but bound the wait. Guaranteeing a non-null region simplifies every consumer; just make sure the failure path resolves fast so you do not punish first paint.
- Let region shape requests, not just hosts. Remember that the venue determines valid symbols, stream names, and even query parameters. Key your cache by the resolved URL so venues stay isolated.
- Make the region visible and overridable. A label plus a manual switch turns a wrong guess from a silent bug into a recoverable one, and covers VPN and travel cases for free.
- Keep region separate from language and currency. Route on the country code, but let localisation carry its own explicit user preference so the three axes can move independently.
- Never mistake the client for the boundary. Back every rule that matters with server-side checks and KYC. Client-side geo is for routing and ergonomics; it is not a compliance control.
Get those right and regional routing becomes one of the quieter parts of the stack - a thirty-line provider that silently puts every user on the correct venue. For more on how patterns like this let a starter kit absorb genuinely hard requirements without bespoke plumbing, see the notes on reducing time to market for crypto exchanges.
