One Product Brain, Two Clients: Next.js and React Native
How much you can genuinely share between web and mobile, and where forcing it starts to cost more than it saves.

There's a tempting idea when you're building both a web and mobile app: write something once, reuse it everywhere, and call it a day.
Sometimes that works.
Sometimes it creates a shared codebase that nobody wants to touch.
Working on both Next.js and React Native has made me realize that the real goal isn't to make web and mobile look the same internally. It's to make them understand the same product.
The product should have one brain.
The clients don't need to share the same body.
Share the Rules, Not Everything
The easiest thing to share is business logic.
If a product has concepts like authentication, user permissions, vehicle information, pricing, warranty status, or order states, those concepts shouldn't behave differently just because the user opened a different client.
That logic belongs closer to the product itself.
For example, something like:
const canClaimWarranty = (warranty: Warranty) => {
return warranty.status === "active" && !warranty.hasClaim;
};
isn't really a web problem or a mobile problem.
It's a product rule.
Those are the pieces worth sharing.
The same applies to API contracts, TypeScript types, validation schemas, constants, and sometimes utility functions. If the logic describes what the product means, sharing it makes both clients more consistent.
A quick test I use: would a backend engineer understand this code without knowing React?
If yes, it's brain. Share it.
If it imports from react-native or next/*, it's body. Think twice.
Web and Mobile Are Different Clients for a Reason
A Next.js application and a React Native application might consume the same API, but they don't have the same job.
On the web, I care about:
SEO
server rendering
URL structure
browser navigation
responsive layouts
large-screen interactions
On mobile, the priorities change:
native navigation
gestures
permissions
push notifications
device capabilities
offline or poor-network scenarios
smaller screens
Trying to make both clients use exactly the same UI abstraction can hide these differences instead of solving them.
A Button doesn't need to be the same implementation on both platforms.
What matters is that both buttons represent the same action.
Where Sharing Actually Helps
There are a few areas where sharing provides a lot of value without creating unnecessary coupling.
API Types
If the backend returns a Vehicle, both clients should agree on what a Vehicle looks like.
type Vehicle = {
id: string;
brand: string;
model: string;
year: number;
price: number;
};
Ideally these come from one place: generated from OpenAPI, tRPC, or a shared Zod schema. The web and the app should disagree about the shape of a Vehicle exactly zero times.
Validation
If an email address, phone number, or form field follows the same rules everywhere, there's little reason to implement those rules twice.
The validation is shareable. The form behavior isn't. On web, Enter submits and Tab moves focus. On mobile, the keyboard type changes per field and the layout has to dodge the on-screen keyboard.
Share the schema. Let each client own the form.
Business Logic
Pricing calculations, permissions, status transitions, filtering rules, and other product-level behavior are usually good candidates for sharing.
The one discipline: keep these functions free of window, localStorage, AsyncStorage, or anything that only exists on one side. If a rule needs to persist something, it takes a storage interface as an argument instead of reaching for one.
State That Models the Product, Not the Screen
Cart contents, current user, feature flags, a draft warranty claim. State that describes what the user has, not what the user is looking at.
The tell that you've drifted: if the shared store has a field like isModalOpen or activeTab, it's a body part wearing a brain costume.
API Client
The way a client talks to the backend can also be standardized.
Both applications can share conventions around authentication headers, retry policy, error normalization, and query definitions, even if the token storage and networking details differ.
This gives you consistency without forcing identical implementations.
The Small, Boring Stuff
Date and number formatting. Translation dictionaries. The catalog of analytics events.
Analytics especially. A single track("warranty_claim_started", {...}) signature means your dashboards don't quietly split into "web claims" and "mobile claims" because two people named the event differently.
Where Sharing Starts to Hurt
The problem usually starts when "reuse" becomes a goal by itself.
I've seen the temptation to create a giant shared component library:
<UniversalCard />
<UniversalModal />
<UniversalButton />
<UniversalForm />
and then make those components understand every platform.
At first, it feels productive.
Then requirements arrive.
The web version needs hover states.
Mobile needs press feedback.
The web modal behaves one way.
Mobile needs a bottom sheet.
The web form follows browser conventions.
Mobile needs keyboard-aware behavior.
Soon, the shared component isn't actually simple anymore. It's a collection of conditions.
if (Platform.OS === "ios") {
// ...
}
if (isMobile) {
// ...
}
if (isWeb) {
// ...
}
One Platform.OS check is a smell.
Three is a confession.
The abstraction is technically reusable, but the cost of understanding it becomes higher than simply having two small implementations.
That's usually a sign that the abstraction is happening at the wrong level.
The Costs That Don't Show Up in the Demo
The demo of a shared codebase always looks good.
The costs arrive later, and they arrive in tooling.
Two bundlers. Next.js runs Turbopack or webpack. React Native runs Metro. They resolve modules differently and choke on different things. A shared package that pulls in a Node-only dependency breaks Metro. A package that assumes Metro's .native.tsx resolution confuses Next. Someone on the team becomes the full-time bundler whisperer.
One React version. Shared code that depends on React means both clients must agree on a React version. React Native usually lags. So Next.js waits too.
Server Components. Next.js increasingly wants components to run on the server. React Native can't run them there. Every shared UI component has to be a client component. The more UI you share, the less of Next.js you can actually use.
Two release cycles. Web ships continuously. Mobile goes through app store review, and users update whenever they feel like it. A change to a shared package can be live on web today and on mobile in two weeks. If that package assumes both sides are on the same version, say a serialized state format, you've built a distributed-systems problem into your frontend.
None of these are reasons to stop sharing.
They're reasons to be deliberate about what you share.
A Better Boundary
I like thinking about the architecture in three layers.
Product layer
What does the product mean?
entities
business rules
validation
permissions
API contracts
Client layer
How does each platform interact with those rules?
web
mobile
UI layer
How should the experience feel on that platform?
Next.js components
React Native components
The first layer has the most opportunity for sharing.
The closer you get to the UI, the more platform-specific the implementation should become.
In a monorepo, that usually looks something like this:
apps/
web/ # Next.js
mobile/ # Expo / React Native
packages/
api/ # types, client, query definitions
domain/ # business rules, validation
state/ # product-level stores
i18n/
analytics/
tokens/ # colors, spacing, typography
Notice what's missing: no packages/screens, no packages/components full of feature UI, no shared navigation.
This doesn't mean there should be zero UI sharing.
Design tokens and dumb layout primitives are usually fine. If React Native Web or Tamagui genuinely makes sense for the product, use it.
The important part is that the abstraction should exist because the platforms actually have something in common, not because we want the codebase to have fewer files.
And enforce it mechanically. A lint rule that forbids react-native imports in packages/domain will save you from a hundred well-meaning shortcuts.
One Product, Different Experiences
Imagine a used-car marketplace.
On the web, a vehicle listing might be optimized for search engines and linked through a URL:
/mobil-bekas/honda/brio
On mobile, the same action might happen through native navigation:
VehicleList → VehicleDetail
The underlying concept is identical:
User wants to browse Honda Brio listings.
But the implementation doesn't need to be.
Web routing is URL-first: the address bar is the source of truth and the browser owns history. Mobile navigation is stack-first: screens push and pop, and the back button has to mean something. These aren't two implementations of the same idea. They're different ideas.
Share the route names and parameters, something like vehicleRoute(id) that both sides consume.
Don't share the navigation.
The product has one mental model.
The clients translate that model into the interaction patterns their platform is good at.
That's a much healthier form of consistency.
The API Becomes Even More Important
When you have multiple clients, the backend becomes a contract between them.
That changes how I think about API design.
An endpoint shouldn't be designed only because it's convenient for one frontend.
It needs to make sense for the product.
GET /vehicles
GET /vehicles/:id
POST /warranties/:id/claims
Both Next.js and React Native consume the same concepts.
The web uses them to render an SEO-friendly marketplace.
The mobile app uses them to build a native browsing and warranty-claim experience.
The clients remain independent while the product remains consistent.
That's the part worth protecting.
Don't Optimize for Lines of Code
One of the easiest traps in shared development is measuring success by how much code you managed to reuse.
But fewer lines don't automatically mean a better architecture.
Sometimes this:
shared/
components/
UniversalButton.tsx
is better.
Sometimes this:
web/
components/
Button.tsx
mobile/
components/
Button.tsx
is better.
If two implementations are genuinely different, duplicating a small amount of UI code is cheaper than maintaining an abstraction that constantly needs exceptions.
A few questions I ask when I'm not sure:
When this breaks, does it break on one platform only? Then it was never really shared. It was two implementations in one file.
Did we build an abstraction to make sharing possible, and does the abstraction have more code than the two things it replaced? Then the abstraction is the product now, and nobody signed up to maintain it.
Would deleting every screen make this code meaningless? If not, it's brain. If yes, it's body.
Duplication isn't always a problem.
Accidental coupling is.
The Real Goal
After working across Next.js and React Native, I don't think the goal should be:
"How much code can we share?"
A better question is:
"What should behave the same, and what should feel different?"
Business rules should behave the same.
The experience doesn't need to.
That's what makes a multi-client product interesting. You're not building the same application twice. You're building two different interfaces around the same product idea.
One product brain.
Two clients.
And just enough shared code to keep them from disagreeing about what the product actually is.
Comments
Nothing here yet. Be the first.