Daeseon Yoo
Back to project
·Tech retro·2 min

Web and app are two surfaces, not one — porting the gym to the Next.js frontend

The sentence gym was built on the Expo mobile app first. Trying to 'see it on web' surfaced a basic truth that's easy to forget in a monorepo: the Next.js frontend and the Expo app are separate surfaces that share only the core package — a feature in one isn't in the other until it's ported.

The confusion

I built the sentence gym on the Expo mobile app. When the ask became "fire up the web and let me see it," I first tried to run the mobile app in a browser via expo start --web — which crash-loops, because the app is native-first (a YouTube player, audio recording, and secure-store all pull native-only modules, and Expo Router won't swap a .web stub for a dynamic [clipId] route). Dead end.

The real web app is a different thing entirely: frontend/ — a Next.js project that deploys to Vercel. It was already shipping with full routes (library, review, practice, compose, …). But the gym wasn't there: I'd built the screen only in mobile/. The shared @shadow-ai/core (the typed API client, the SRS keys, the transform types) was available to both, but the UI lived on one surface.

The port

The frontend consumes core through thin @/lib shims (export * from "@shadow-ai/core/api/..."), so the gym port reused all the logic and only re-implemented the UI in the web's idioms:

Driven live in a headless browser against the running backend: one sentence generated 48 core transforms, the drill and inline check rendered in Korean. next build green.

The takeaway

In a monorepo it's easy to think "the app has feature X" and forget there are two front ends. frontend/ (Next.js, web) and mobile/ (Expo, native) are separate surfaces; they share a core package and nothing else. A feature is only on the surface you built it on. And a native-first app does not "just run on web" — its native modules and dynamic routes break the web bundle; demo it on the simulator, not the browser.

Commit: eb0fa8e.