If you've traveled to Korea, you've hit this wall: Google Maps can't give you walking or transit directions here.
Map-data export is restricted, so locals use Naver Map or KakaoMap instead. The usual workaround for visitors is
painful β copy a place's Korean name from Google, paste it into Naver, repeat for every stop.
So I built K-Map Router: paste a Google Maps link β it opens that place (and the route) in
This post isn't a "look how hard I worked" story β honestly, I shipped it fast with heavy AI pair-programming (Claude Code).
It's a field guide to the stuff that's genuinely hard to find documented: how Korean map deep links and coordinates
actually work. If you ever build something in this space, I hope this saves you a few days.
A single Cloudflare Worker serves both the React SPA (static assets) and the POST /api/resolve
endpoint β same origin,
free tier, zero runtime dependencies. Coordinate resolution is server-side (the browser β Google is blocked by CORS) and
is nothing but fetch
- regex + a little decoding. No DB, stateless.
You can't just regex one pattern. A Google Maps URL (after following redirects) hides the coordinates in one of several
shapes, and you have to try them in priority order:
// 1) place pin β most authoritative
// ...!3d{lat}!4d{lng}
// 2) directions waypoint β β οΈ REVERSED: !1d{lng}!2d{lat}
// multiple pairs => last = destination, first = origin
// 3) viewport center β /@{lat},{lng},17z
// 4) ?query= / &destination= / &daddr=
// 5) ?ll= / &sll=
The one that bit me hardest: ** /dir/ directions URLs store !1d{longitude}!2d{latitude} β longitude first.** Read it as
(lat, lng)
and you'll happily return a point that's in the ocean. And when there are multiple pairs, the Links shared from the Google Maps mobile app (the ones with ?g_st=...
) are special. They resolve to something like:
.../maps?saddr=Seoul+Station&daddr=Gyeongbokgung&geocode=FWoPPQId...;FWFrPQIdEYSRBy...
There are no plaintext coordinates anywhere β they're base64url-encoded in the geocode=
param, as a tiny protobuf. Each
;
-separated entry encodes one endpoint:
// 0x15 = field 2, fixed32 (little-endian) = lat * 1e6
// 0x1D = field 3, fixed32 (little-endian) = lng * 1e6
function decodeGeocodeEntry(entry) {
const bin = atob(entry.replace(/-/g, "+").replace(/_/g, "/"));
let lat = null, lng = null;
for (let i = 0; i + 4 < bin.length && (lat === null || lng === null); i++) {
const tag = bin.charCodeAt(i);
if (tag !== 0x15 && tag !== 0x1d) continue;
let v = 0;
for (let j = 3; j >= 0; j--) v = v * 256 + bin.charCodeAt(i + 1 + j); // LE
if (v > 0x7fffffff) v -= 0x100000000;
if (tag === 0x15) lat = v / 1e6; else lng = v / 1e6;
i += 4;
}
return lat !== null && lng !== null ? { lat, lng } : null;
}
Verified against κ²½λ³΅κΆ (Gyeongbokgung): FWFrPQIdEYSRBy...
β 37.579617, 126.977041
. β
Naver (primary β best transit + English):
nmap://route/public?dlat={lat}&dlng={lng}&dname={enc}&appname={APPNAME}
appname
is dname
is optional β omit it and Naver shows the real address. Don't send a literal Destination
placeholder.route/walk
, route/car
, route/public
.Kakao (secondary):
kakaomap://route?ep={lat},{lng}&by=publictransit
by=foot | car | publictransit
.Android: custom schemes are flaky from Chrome. Use an intent://
URL with a built-in store fallback:
intent://route/public?...#Intent;scheme=nmap;package=com.nhn.android.nmap;S.browser_fallback_url=...;end
For desktop fallback, Kakao's legacy link/to
API can't take a start point. But its redirect target can β if you feed it
Kakao's internal WCongnamul coordinates:
https://map.kakao.com/?map_type=TYPE_MAP&target=traffic&rt={sx},{sy},{ex},{ey}&rt1={from}&rt2={to}
WCongnamul turned out to be EPSG:5181 (a GRS80 Transverse Mercator) scaled Γ2.5. I implemented the projection by hand and
it matched Kakao's own conversion to the integer for every test point. (Also: never put a comma in the rt1/rt2
label β it
breaks the parser and silently drops the destination.)
The "Paste from clipboard" button worked everywhere except iOS. Two reasons, both subtle:
text/uri-list
onlytext/plain
. So
navigator.clipboard.readText()
returns an empty string.await
. So a readText()
β fall back to read()
chain NotAllowedError
.The fix is to make exactly one clipboard call inside the gesture, then read the type off the already-resolved
ClipboardItem
(those getType
calls reuse the granted permission):
const items = await navigator.clipboard.read(); // one call, in the gesture
for (const item of items) {
for (const type of ["text/uri-list", "text/plain", "text/html"]) {
if (!item.types.includes(type)) continue;
const text = (await (await item.getType(type)).text()).trim();
// uri-list: first non-comment line is the URL
if (text) return text;
}
}
The iOS "Paste" permission bubble itself is unavoidable β it's OS-enforced for any programmatic clipboard read.
Google encodes the travel mode in the link (travelmode=driving
, dirflg=d
, or !3e0
). Reading it means a shared driving
route opens directly in driving directions in Naver/Kakao β "plan in Google Maps, navigate in Korea," unchanged.
// Detect dark theme var iframe = document.getElementById('tweet-2067124302403772609-944'); if (document.body.className.includes('dark-theme')) { iframe.src = "https://platform.twitter.com/embed/Tweet.html?id=2067124302403772609&theme=dark" }
If you're building anything that bridges Google Maps and Korean map apps, steal the deep-link and coordinate logic β that's
exactly why it's public. Questions welcome. π£