Skip to content

Interested in AI, automation, blockchain, web and apps

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

Work/Web Development/KO

Timed Reveal Travel Log Map Promotion Site

Timed Reveal Travel Log Map Promotion Site

A promotion site that flips to full content on schedule

A launch nobody has to press

A promotion site that turns a five-day trip taken by six members of an idol group into a route fans walk on a map.

The launch was split in two. The first launch showed nothing but a schedule table, and once the second launch time passed, the same URL had to become the real thing. The switch had to run off a set time rather than a deploy, and until then, hitting a URL like /trips/1 directly could not return any content.

Content had to be managed directly in an admin: five days of photos, video, audio, and writing, plus a per-member route, all uploaded, reordered, and swapped out without a developer in the loop.

System boundary and external dependencies
System boundary and external dependencies

Three managed apps, one CDN boundary

A Turborepo monorepo with two Next.js apps (web, admin) and one Hono API

web and admin show the same data in different ways. I wanted the logic that hurts when it is wrong, the lock decision above all, to live in exactly one place, so I exported Hono's AppType straight into an hc client and let both frontends share the same types. If the API had lived entirely in Next.js Route Handlers, admin would have ended up calling web's internal functions, or the logic would have been copied. The team was small enough that splitting into more services was not realistic, and the domain itself was six tables.

ECS Fargate + ALB + three CloudFront distributions + Aurora PostgreSQL Serverless v2

The traffic shape was extremely spiky. Everything arrives at the launch moment and it is near zero the rest of the time. Serverless v2 keeps the idle bill low, and ECS lets me raise desired count by hand right before launch. prod web actually deploys with desired count 4, api and admin with 2. With a fixed launch time, a late scale-out is not something you get to undo. There was no room to wait for autoscaling to react, so I chose to size up ahead of time.

The browser goes through a same-origin /api/upstream proxy, server components go straight to the ALB via API_INTERNAL_URL

The moment you attach a custom domain, browser CORS and CloudFront's front-door policy both show up at once. Route every browser call through a Route Handler on the same origin and CORS never comes into existence. Server components, on the other hand, run inside the container and have no reason to take a lap through the CDN, so they hit the ALB DNS directly and pick the target group with an X-Forwarded-Service: api header. Node.js fetch (undici) will not take a relative path, so the server side was forced onto absolute URLs, which is why path resolution ended up forking in two.

Deployment and infrastructure
Deployment and infrastructure

Web starts after migrations

There are three ECS services (api, web, admin) behind an ALB, each with its own CloudFront in front. Media goes out through a separate S3 bucket and a separate CloudFront. The part of deployment I worried about most was ordering. DB migrations run themselves as the API container boots. The trick is that the HTTP server comes up first and migrations run behind it. /health always returns 200 so the ECS health check passes, while /ready only returns 200 once migrations finish. In the meantime every path except /health and /ready is blocked with a 503. The deploy workflow waits for the ECS rollout to reach COMPLETED, then polls /ready, and only after a 200 does it ship web and admin. Skip that order and you get a brief window where a web build expecting the new schema is looking at the old one. This piece goes deep on exactly one thing: map rendering. Splitting CloudFront's RSC cache key, admin auth, and the media upload flow are each worth their own writeup, so I am leaving them out here.

Core data model
Core data model

Dropping the map library and putting a camera on a single SVG

Say "map" and the reflex is Leaflet or Mapbox: bake the hand-drawn illustration into tiles, attach a lat/lng to each spot, drop markers. But this map is a drawing, not a place. There is no lat/lng to begin with, and if you invent one, every revision of the artwork means re-tracing the coordinates. On top of that the pins were not simple markers but card decks, several member photos stacked at an angle. The moment you try to build that on top of a library's marker API, you spend more time fighting the library than drawing.

I used no map library at all. I fetch a single SVG exported from the illustration, drop it into the DOM with innerHTML, define the camera as three numbers, {k, cx, cy}, and turn that into a viewBox string written back onto the SVG every frame. Spot positions come from reading the getBBox() center of [data-spot-id] elements inside the SVG, projecting that through the viewBox into container pixels, and absolutely positioning an HTML div pin there. The dotted leader lines and diamond anchors that connect a pin to its actual spot are drawn in screen coordinates on a separate overlay SVG. Wheel zoom runs through dampZoomTarget, which shrinks the step near the limits, and constrainCamera, which blends the center back toward the home position the closer you get to minimum zoom.

It is vector, so it holds up at 4.7x, and the whole tile-baking pipeline disappears. There is exactly one coordinate system, the viewBox the illustrator handed over, so the artwork can change and the code stays put as long as data-spot-id survives. Because pins are ordinary HTML, the photo deck layout, the pressed state, and Next.js routing are all just DOM, and switching days is a display toggle on the #DAY${d} group. The price of skipping the library was writing inertia, pinch, and edge damping myself, but with all of that math in my hands, a requirement like "at minimum zoom it always returns to the home framing" is a one-liner.

Launch is one row in the database, not a deploy

The usual approach is to compare unlocksAt against the current time on the client and paint a locked UI over things.

I pushed the lock down to the server. The launch_phases table holds exactly two rows, phase 1 and 2, and currentPhase is computed by comparing each row's unlocks_at to now. Next.js middleware checks that value on every request: below phase 2, everything except / and /health is rewritten to /404 with Cache-Control: no-store, and /api/* simply returns a 404.

Knowing the URL does not get you in, because the decision lives outside the browser. And launch comes unbundled from deployment.

Walkthrough

What a user can do

Arrive before launch, see only the schedule table, and leave
Arrive before launch, see only the schedule table, and leave

Pull the schedule table back up after launch
Pull the schedule table back up after launch

Flip through the day posters and pick a day that has opened
Flip through the day posters and pick a day that has opened

Zoom and drag the hand-drawn map to take in the day
Zoom and drag the hand-drawn map to take in the day

Pick a member and keep only their route on the map
Pick a member and keep only their route on the map

Tap a pin to open that spot's gallery
Tap a pin to open that spot's gallery

Tap a thumbnail to see photos and video full size
Tap a thumbnail to see photos and video full size

Turn the BGM off and on, and let it step aside for video
Turn the BGM off and on, and let it step aside for video

An operator signs in to admin
An operator signs in to admin

Set the first and second launch times in KST and save
Set the first and second launch times in KST and save

Upload each day's title and its locked and unlocked thumbnails
Upload each day's title and its locked and unlocked thumbnails

Swap a day's BGM file and listen back to it
Swap a day's BGM file and listen back to it

Create stops and lay out each member's visit order
Create stops and lay out each member's visit order

Upload photos, video, audio, and writing to a stop and tag members
Upload photos, video, audio, and writing to a stop and tag members

Replace static assets like the favicon, OG image, and map SVG
Replace static assets like the favicon, OG image, and map SVG

Add and remove admin accounts
Add and remove admin accounts

1 / 1

No IaC, one 2,300 line map

There is not one line of IaC. ECS task definitions get patched by an inline Python script inside GitHub Actions that strips fields out of a describe-task-definition result and swaps environment variables in. The ALB DNS and the S3 bucket name sit in the workflow file as string literals, and rebuilding the cluster would mean remembering everything that was clicked together in the console. This should have been CDK or Terraform from day one. At the time the launch date came first and "just get it up" won, and I still do not think that call was badly wrong. I just paid the entire bill at once when it came time to stand up the second environment (prod).

The map view sitting there as a single 2,300-line component is debt too. Camera, pin layout, gestures, and routing are tangled inside one component, so almost nothing is testable. The camera math is already cleanly factored into pure functions, so that is where extraction into hooks should have started.

I only covered the map and the launch gate here. The BGM that survives route changes pulls in sessionStorage checkpoints, a dual Web Audio / HTMLAudio path, and automatic ducking during video playback, which is a whole piece on its own. I will write that one separately.

Read next

3D Scroll Interaction Recruiting Landing Site

APM COMPANY — 2025