Nature · Making-of

Seoul Otter Map

I was asked to build a Seoul otter map. More precisely, the map and data had already been made elsewhere, and the request was to actually put it on the internet and make citizen sightings and admin review really work. The job was to turn a local version that ran from a single file into a service where GitHub, Vercel, Supabase and Google sign-in actually mesh together. I went step by step, and the further I got, the more of the work was lining up accounts and settings rather than code.

I thought it was a catch-all. It wasn't.

I'd written the API routes as a single file, api/[...path].js. In Next.js, that's the catch-all pattern that takes every path under /api/. But this project wasn't Next.js; it was plain Vercel serverless functions, and there [...path] isn't a catch-all, it only matches a single segment. As a result, only one-level paths like /api/health worked, and every path two levels or deeper, like /api/observations/public or /api/auth/me, returned 404 at the platform level. That covered most of the API. I fixed it by renaming the file to api/handler.js and adding a separate /api/(.*) → /api/handler rewrite in vercel.json.

I was told I'd exposed a token to the browser

At first the admin features were unlocked with a single ADMIN_TOKEN value. I was told this value was being sent all the way down to the browser, so I switched to signing in with a Google account and having the server check every time whether that email is on an allow list (OIDC). The actual Google OAuth client registration isn't finished yet, though, so as of this writing admin sign-in isn't fully turned on.

The map tile key moved for the same reason

The map's background tiles come from CARTO. At first the API key was put straight into the source code. That got flagged too, and I moved it into a Vercel environment variable. The code keeps only the name carto_api_key, and the value is read from the server settings. But moving the code and actually filling in that environment variable are separate jobs. Right now Vercel production doesn't have the value yet. So when you open the map, instead of tiles, CARTO's "API KEY REQUIRED" watermark covers the whole screen. The map is the entire service, and this part still isn't done.

The server has no Python, so photo processing was rewritten entirely

The code that cleans up submitted photos originally called python3 and Pillow as a subprocess. The serverless runtime doesn't have Python. So I rewrote that part with sharp, a Node.js package. While I was at it, I also found a bug where uploaded photos were never actually recorded in the photo_assets table. With a real PostgreSQL database attached, it would have blown up right away with a foreign key violation. I caught it early, during local testing.

What's not done yet is left as a list, not hidden

Besides Google sign-in and the map tile key, a few more things remain. A shared rate limit for the admin API (Redis-based), a step that scans uploaded photos for malicious files, monitoring that alerts me when something breaks, and a check that clicks through sighting reports, sign-in and review from start to finish in a real browser: these four aren't done yet. Instead of reporting it as finished, I wrote in the handover document exactly "I did up to here, and from here on it's not done." That costs less time later than claiming something half-done is done.

How to use it: a map with four stackable filters

In the left panel you can pick the year (2016, 2017, 2022, 2026), the record type (All, DNA, Camera), sex, and family (F01–F05), and all four can be on at once. The list below shows how many individuals were confirmed at each location, like Seongnaecheon Stream, Hangang Bridge and Jamsil Bridge. Press the "THE OTTER STORY" button on the right for the story year by year, or the "Report an otter" button at the top for the screen where citizens post their own sightings. "Review" is for admins, and right now it isn't behind a sign-in.

Try Seoul Otter Map (in Korean) Go to sudal.ghmun.com →