← Writing
Sept 2026 · 9 min

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.

Leave a comment

Optional, never published.

Comments are reviewed before they appear.

Questions, or a project?

Either is welcome — I read every message.

Get in touch