Going Universal: From a brownfield React Native and Next.js stack to one Expo app
Users•React Native••9 minutes read
Gunnar Torfi
Guest Author
A small team unified a brownfield React Native & Next.js stack into one universal Expo app. No big rewrite. Just steady steps to a shared UI & shared features.

We’re a small team.
We had an Expo app with 40+ screens and a Next.js web app. And not enough people to keep both in sync (this product is actually maintained by a single developer). So we decided to stop building everything twice. We went universal (gradually!).
This is the story of how we turned Noona into a universal app without a big‑bang rewrite. Just steady steps.
Where we started
Two apps, two sets of components, two ways to do routing, analytics, auth, the whole thing. Every time we fixed a bug in one place, the other one got jealous.
- Mobile: Expo (React Native)
- Web: Next.js
- Reality: features drifted, copy‑paste code, review fatigue
A lot of you are familiar with this pain. To us, it just didn’t make sense to keep doing everything twice. So we started the migration.
The tiny first win: a monorepo and one shared Button
We moved to a Turborepo monorepo with a minimal setup:
apps/web(the existing web app)apps/native(the existing Expo app)packages/ui(shared React Native components)packages/config(TS, ESLint, Prettier)
The only goal to begin with: render one shared Button on web, iOS, and Android. If that round‑trip works, the path is clear. Make sure there aren’t any Typescript errors, that you have a functioning production build on both platforms, and commit clearly for a good checkpoint.
Why this helps:
- You stop thinking “copy code” and start thinking “share code”.
- You get fast feedback on build + tooling assumptions.
Turborepo gives you simple workspaces and caching; start with their “structuring a repository” docs if you’ve never set it up.
Shared UI: React Native → Web
We built packages/ui on top of React Native primitives and used react‑native‑web to adapt them to DOM for web.
Some nice to have components we made from the get-go:
- Nativewind: In order to use Tailwind in the shared layer, we use Nativewind for native. Took some time to get right w.r.t. CSS variables and dark mode, but definitely worth it.
- Image: uses expo-image on native and next/image for web.
- Icons: lucide-react-native for native and lucide-react for web - each icon has two files, e.g. earth.tsx and earth.web.tsx
- Link: uses expo-router’s Link on native and next/link for web
- Various components: we have some platform specific components to leverage the platform in those cases. This enabled us to use things like Radix/base-ui for web and native pickers. This meant we created a separate checkbox, a read more component, switch, date picker, modal root, apple pay button, popover, portal, and radio buttons.
- Various hooks: we’ve also accumulated a bunch of useful hooks over the migration, all of which have different implementations on native and web. Some are no-ops on one platform but call platform APIs on the other (like useSearchParams). We have hooks like useNavigate for navigation (again expo-router on native and next/router on web), useSearchParams, useFocusEffect, use3DSecure, useSessionStorage, useApplePay and useWindowFocus.
If this “RN to DOM” leap is new to you, the react‑native‑web repo is a good starting point.
Features live as packages, not as app files
New features go into packages/ui/features/feature-*. Each feature exports a component that you can render in both apps. You could also structure this in any way you’d like, e.g. each feature could be its own package, all features could be in one package, etc.
- Web: import the feature in a route
- Native: import the feature in a screen
- Platform glue (auth, routing bits, deep links) stays thin
This kept us shipping. Every new feature was written once and ran on both platforms.
The “temporary provider” that made this actually work
We didn’t migrate our whole auth or analytics stack on day one. Instead, we wrapped the shared UI in a temporary provider that accepts things like user, analytics.track, feature flags, and theme. Both apps pass their own implementations down. The shared UI just calls the interfaces.
This is what let us start today, without a months‑long plumbing project.
Routing: we moved the app to Expo Router
Midway, we switched the mobile app to Expo Router. Reason: future web. File‑based routes + deep links give you the same mental model across platforms. When you’re ready, web can live here too.
Expo Router is file‑based routing for native and web, built on top of React Navigation.
The long middle: slow, steady, and boring
From here it was manual work. We didn’t pause product to “do the migration.” We just moved things when we touched them.
- Touch a feature? Port it to
packages/ui/features/feature-*, then ship. - Touch a component? Move it to
packages/ui, then ship. - Holidays and summer weeks = nice time for small refactors.
This had a bonus: web and mobile caught up on feature parity.
Our first shared feature was… checkout 😅
We didn’t start with a tiny settings screen. The first feature we built and shipped in the new world was Reservation Confirmation (checkout)—the most complex screen we have. It collects booking details, handles payment and 3DS flows, and confirms the reservation.
We also built a universal payment interface around Adyen:
- On native, it talks to Adyen’s React Native SDK.
- On web, it talks to Adyen’s web components.
- Handles adding cards, displaying payment selectors, verifying CVV, 3DS flows, etc.
This let us keep one code path for the screen and just import the payment layer which was exported by .native.ts and .web.ts, under the hood.
Cutting over
Once most features lived in shared packages, we could:
- Point web to the shared feature modules and shared UI
- Move web routing (and linking rules) to match Expo Router’s structure
- Delete the old Next.js app when there is nothing left that depends on it
At that point, we had one Expo project that runs everywhere.
If SEO matters, Expo’s static export can prerender pages for web, which is helpful when you fully embrace Expo Router on the web. We’re excited to see SSR support in Expo for those dynamic content pages that need good SEO.
What worked for us
- Start microscopic. One shared Button proves the pipeline.
- Write new features “the new way.” Don’t wait for a grand rewrite.
- Keep a thin provider bridge. It unblocks shared code today.
- Move when you touch. Refactor only in the areas you’re actively shipping.
- Pick one gnarly feature early. It forces your abstractions to be real.
*One thing we’d do differently if we had to do it all again: Track a simple metric (e.g., % of screens using shared UI) to keep morale up.
A quick look ahead: react‑strict‑dom and friends
We’re excited about the react‑strict‑dom work and the push toward standardized Web APIs for native modules. React Native 0.82 also introduced DOM-like nodes via refs. The direction helps universal apps feel less “special,” and more like normal React that happens to run on many platforms. We’re tracking this closely.
Final note
This took months, not weeks. We shipped product the whole time. Holidays and quiet weeks became our "migration sprints." If you're a small team sitting on a brownfield RN + Next.js setup: you can do this without drama. Be patient. Move one piece at a time. It adds up.
It is quite clear that the React team and the ecosystem is putting a lot of effort into enabling Universal React. I believe that the future for React is definitely Universal.
For us, going universal wasn't just about reducing code duplication—it fundamentally changed how we think about building product. We now ship features simultaneously on web and mobile without coordination overhead. Our design system stays consistent. Bug fixes propagate everywhere. And as a small team, that velocity matters.
If you're considering this path, start small. Prove the tooling works with one shared component. Then let each feature you ship pull you forward. The brownfield migration is gradual, but it's real, and it's worth it.


