Skip to content

Interested in AI, automation, blockchain, web and apps

Seoul, KR--:-- GMT
Let’s Talk

Work/App Development/KO

Travel App That Turns Links into Map Pins

Travel App That Turns Links into Map Pins

Turning chat room links into pins on a map

When deciding where to go on a trip, what actually happens is not search, it is collecting links. Blog posts, Instagram posts, YouTube vlogs, a Naver Cafe review someone dropped in a group chat, they pile up across browser tabs and chat rooms. But once you land, none of those links leave anything on a map. What is left is manual work: open each one, read the shop name, retype it into a map app, add it to a saved list.

The requirement fit in one sentence. A user pastes one link, the places mentioned in that post get pulled out and land as pins on their map, and the whole map can be handed to someone else to subscribe to. There were two constraints. First, the sources to collect from include Naver blog and Naver Cafe posts, and some cafe posts are members-only and unreadable without logging in. Second, lists already saved in Google Maps had to be importable as they are.

This post covers only two axes: collecting content from behind a login wall, and the place extraction pipeline. Queue-based post-processing, the Expo dev client and OTA release strategy, and the Google Maps migration and search screen are each covered in separate posts.

System boundary and external dependencies
System boundary and external dependencies

One Lambda, three queues, no review

I put one tRPC router on a single AWS Lambda and stood a CloudFront Router (sst.aws.Router) in front of it

The app, the admin console and the web client all had to share the same types as they are. There is no separate schema to keep in sync: import the AppRouter type and the contract has nowhere to drift. Link processing traffic is spiky too, so per-invocation billing fit better than an always-on instance The Lambda timeout is 60 seconds, so one link has to finish inside that. I deploy it with memory: 2048 MB and architecture: arm64, and only in production does it sit in private VPC subnets to reach the database

Postgres lives on Supabase, but I never touch its REST layer, only PostGIS and Drizzle

The query the map screen really asks is "the places, in the maps I have turned on, that fall inside the viewport I am looking at right now". Store place.location as geometry(point, 4326), put a GIST index on it, and one ST_DWithin finishes it. From Supabase I took only Storage and Auth Since I decided against RLS, every permission check moves inside the tRPC procedures. Public or not, owner or not, subscriber or not: each procedure has to verify it itself

I use gpt-4o-mini for place extraction, but I never ask the model for coordinates

The model only judges which place names this post is about. The actual coordinates and place ids are settled by Google Places and Naver map search. The longer version is in the highlight below The schema has to be forced with structured output (zodResponseFormat) for the post-processing to stay simple

Deployment and infrastructure
Deployment and infrastructure

Server extracts, device logs in

place and map_to_items sit at the center of the data model. place is globally shared, with google_place_id and naver_place_id as unique keys, so the same place is the same row no matter who saved it. What differs per user gathers in map_to_items (which map holds it, through which link) and user_map_enable (is this map turned on right now). The markers the map screen draws are, in the end, the result of joining the map_to_items of the enabled maps to place and then cutting by radius.

Core data model
Core data model

Posts behind a login wall are read by the user's device, not by the server

Since private Naver Cafe posts have to be read somehow, the first idea that comes to mind is a service Naver account on the server, its cookies stored and reused for scraping. A step further and you put a headless browser on Lambda to keep the login session alive. But that is one account performing every user's request, so posts in cafes that account never joined stay unreadable, and the day it gets blocked every user is blocked at once. Above all, the server is impersonating someone else's cafe membership, which is not clean.

The server tries once anonymously, and if it gets bounced to a login page or the cafe API answers 401, it stops right there. It then returns a dedicated result to the app: naver-cafe-on-device-crawl-needed. On that signal the app mounts a WebView kept off screen. With sharedCookiesEnabled and useSharedProcessPool turned on, that WebView carries the user's own Naver session as is. Only when there is no session does it surface the Naver login form in a modal, and an injected script strips the header, the footer and the app login buttons so only the identity check area is left. Once the login is done it navigates back to the original post, polls until post_title or ArticleTitle shows up, hands the whole outerHTML back to the app, and the app calls createLink again with it in the html parameter. The server side extractor branches on preCrawledContent: when it is there it skips the network and parses div.se-main-container straight out of that HTML, so both paths merge into the same pipeline.

No credentials ever cross over to the server. The user's cookies stay inside the WebView on the user's device, and what the server receives is one blob of already rendered HTML. If the user is a member of that cafe it reads, and if not it does not, so the permission boundary stays exactly the one the original service draws. Not putting a headless browser on Lambda also kept the deploy size and the cold start where I wanted them. Most of all, failures are not silent. The signal the server throws is spelled out as one explicit error string, so the app can tell "this post needs a login" apart from "this post just failed" and show a different screen for each.

I asked the LLM for search terms, not coordinates

The obvious approach is to ask for the name, the address and the latitude and longitude in one JSON. The model will invent coordinate shaped numbers for coordinates it does not know.

From the model I take only { name, region, isKorea, isChina }, and the coordinates, addresses and place ids are settled by Naver map search inside Korea and by Google Places everywhere else. maps.app.goo.gl short URLs embedded in the body skip the LLM entirely: a regex expands them and they join the candidate list. At the storage step the place id is the unique key and rows go in with onConflictDoNothing.

The one actor that could invent coordinates disappears from the pipeline, and a candidate whose search returns zero results simply drops out. Deduplication is not something done afterwards: the schema keeps duplicates from being made in the first place. The prompt and the post-processing details of this extraction pipeline are covered separately.

What a user can do

Open the app for the first time and start with a social account
Open the app for the first time and start with a social account

Paste a link and put its spots on the map
Paste a link and put its spots on the map

Bring in spots from a cafe post that only shows after logging in
Bring in spots from a cafe post that only shows after logging in

Search for a place the AI missed and add it yourself
Search for a place the AI missed and add it yourself

Tap a pin to see that place's links and visit record
Tap a pin to see that place's links and visit record

Filter by category and find spots by name or memo
Filter by category and find spots by name or memo

Turn maps on and off in the map list, and make new ones
Turn maps on and off in the map list, and make new ones

Change a map's name and address, and decide if it is public
Change a map's name and address, and decide if it is public

Share my map, and the person who gets it subscribes
Share my map, and the person who gets it subscribes

Look inside a subscribed map, then drop the subscription
Look inside a subscribed map, then drop the subscription

Move a whole saved list over from Google Maps
Move a whole saved list over from Google Maps

Read the policies, then log out or delete the account
Read the policies, then log out or delete the account

Open a guidebook and page through its contents and map
Open a guidebook and page through its contents and map

1 / 1

A map capped at 50 pins

I did not get everything cleanly finished. The one that nags most is the markers. The map today folds identical coordinates with uniqBy and then draws only the first 50. A TODO reading 추후 마커 합치기로 변경 (switch to marker clustering later) is still sitting in the code. For a user who saved a few hundred spots, that means the map shows something that is not true.

The riskiest dependency is Naver map search. It is not a public API: I pull window.__RQ_STREAMING_STATE__ out of the response HTML of m.map.naver.com with a regex and JSON.parse it. One markup change and domestic place extraction stops entirely. I picked it knowing that, because I could not give up the accuracy, but doing it again I would hang a synthetic monitor on this exact spot. On the day the response shape changes, I want to know before the users do.

There are effectively no tests. A jest-expo config and one component test are all of it, and even somewhere like the link processing pipeline, with many branches and many external dependencies, has no regression test.

Read next

Vocabulary Flashcard App Built from Your Journal

Seiai — 2025