Back to Blog

Next.js, Node and React Native: Building One Product for Web and Mobile

Next.js, Node and React Native: Building One Product for Web and Mobile cover image

The hardest part of shipping a product on web and mobile at the same time is not the code. It is that every decision now has two answers, and the temptation is always to find the clever solution that gives you one.

I have led a few of these builds — a Next.js web app and a React Native mobile app sharing a Node backend — and the projects that went smoothly were the ones where we were disciplined about what we shared and honest about what we did not. The projects that hurt were the ones where someone tried to share the user interface.

Here is the split that has worked for me, and the mistakes that made me arrive at it.

Share the Contract, Not the Components

The dream of writing a component once and rendering it on web and mobile is seductive and, in my experience, a trap. Web and native have different navigation models, different gesture expectations, different accessibility APIs and completely different design conventions. Every abstraction that hides those differences eventually leaks, and you spend your time fighting the abstraction rather than building the feature.

What is genuinely worth sharing sits below the UI:

  • Types. One package with the shape of every API request and response, imported by all three codebases. This alone catches an enormous class of bug at compile time rather than in QA.

  • Validation schemas. Zod schemas defined once and used by the backend to validate input and by both clients to validate forms. When a rule changes, it changes in one place.

  • Business rules that are not UI. Price calculation, permission checks, date and currency formatting, state machines. Pure functions, easy to test, identical everywhere.

  • The API client. A thin typed wrapper around fetch with auth handling and error normalisation.

// packages/shared/src/booking.ts — used by API, web and mobile
export const BookingSchema = z.object({
  serviceId: z.string().uuid(),
  startsAt:  z.string().datetime(),
  guests:    z.number().int().min(1).max(12),
  notes:     z.string().max(500).optional(),
});

export type Booking = z.infer<typeof BookingSchema>;

export function isCancellable(b: Booking, now = new Date()): boolean {
  const hours = (new Date(b.startsAt).getTime() - now.getTime()) / 3_600_000;
  return hours >= 24;
}

That isCancellable function is the kind of thing that, left unshared, ends up implemented three times with three different edge-case behaviours — and the mobile app tells the user they can cancel while the server refuses. I have debugged that exact bug. It is not fun to explain.

Design the API for the Client That Has the Worst Network

Web developers test on fast connections. Mobile users are on a train. If you design your API for the browser and then let the mobile app make the same calls, you get a screen that needs six round trips and takes nine seconds on a weak connection.

Practical things that have made the biggest difference on my projects:

Build endpoints around screens, not around tables. A REST purist gives you /users/:id, /users/:id/bookings and /services, and the client stitches them together. A pragmatist gives the mobile home screen one endpoint that returns exactly what the home screen renders. You can keep the granular endpoints for other uses; you do not have to make every consumer pay for their purity.

Paginate everything from day one. The list that returns 40 items in development returns 4,000 for your best customer. Adding pagination later means changing every client, and mobile clients update on the app store's schedule, not yours.

Version from the first release. This is the thing web-only developers underestimate most. You cannot force a mobile user to update. A version of your app from eight months ago is still out there hitting your API, so a breaking change is not a deploy — it is a compatibility problem you will live with for a year. Add fields, never rename them. Deprecate slowly.

Assume the request will be sent twice. Flaky mobile networks mean retries, and retries mean duplicate bookings and double charges unless writes carry an idempotency key.

Next.js: Pick a Rendering Strategy Deliberately

Next.js gives you several rendering modes, and the failure mode I see most is a team using all of them by accident. Every page ends up client-rendered because someone added "use client" at the top of a layout six months ago and nobody noticed the whole tree went with it.

The rule I hold teams to is simple: server by default, client only where there is interactivity. Push the "use client" boundary as far down the tree as it will go — a page can be a server component that renders one small interactive island rather than the other way around. That decision governs your bundle size more than any optimisation you will do later.

For content that changes occasionally — marketing pages, blog posts, catalogue pages — static generation with revalidation gives you a CDN-fast page that still updates itself. For anything personalised, render on the server per request. And be honest about when you actually need a single-page application feel; a lot of apps ship a heavy client bundle to recreate navigation the browser already does well.

React Native: Where the Time Actually Goes

React Native gets you a genuinely large amount of shared logic between iOS and Android, and the framework itself is rarely the problem. The time goes somewhere else:

The native edges. Push notifications, deep links, camera and photo permissions, background tasks, in-app purchases, biometrics. Each is a day or three of platform-specific work, and none of it appears in the estimate that a web developer produces for the same feature.

Release management. App store review, staged rollouts, supporting old versions, and a mechanism to tell users a critical update exists. Web deploys in minutes; mobile deploys in days. Plan the release calendar accordingly.

Offline and flaky states. A browser tab that loses connection shows an error and the user refreshes. A mobile app that loses connection mid-flow needs to hold the user's input, retry sensibly and not lose their work. This is real engineering effort that almost always gets discovered late.

On the Flutter question, since I get asked: both are good, and the deciding factor is usually your team. If you already have React developers, React Native lets them contribute on day one and share code with the web app. Flutter gives excellent, consistent rendering and a strong widget system, but it is a separate language and a separate talent pool. I would not switch a productive React team to Dart for a marginal performance difference most users will not perceive.

Microservices: Probably Not Yet

The instinct to split a new product into services is strong, and it is usually wrong. A modular monolith — one deployable, clear internal module boundaries, one database with well-separated schemas — will take a small team a very long way. Deploying is one step. Debugging follows a single stack trace. A refactor across boundaries is a rename, not a coordinated release across three repositories.

Split a service out when there is a concrete reason: a piece with a wildly different scaling profile, a genuinely independent team, or a workload that needs different infrastructure entirely. Splitting because the architecture diagram looks more professional costs you months and gives you distributed transactions you did not want.

The Setup I Would Choose Today

A monorepo with three applications and a shared package. Next.js for web, React Native for mobile, Node with NestJS for the API, a shared package holding types, Zod schemas and pure business logic. One CI pipeline that type-checks the whole workspace, so a change to a shared schema fails the build in the app that has not been updated.

That last part is the real payoff. Not that you wrote less code, but that when someone changes the shape of a booking, the compiler tells them about the mobile screen they forgot. Coordination between platforms is the expensive part of building for web and mobile at once — and a shared type is the cheapest coordination mechanism there is.

Related Posts