Building Cross-Platform Apps with Turborepo and React Strict DOM: A Unified Approach to Next.js and Expo Development
React's promises of "learn once, write anywhere" have never felt more achievable than they do today. After years of building separate codebases for web and mobile applications, I recently took the plunge into creating a unified development environment where my Next.js and Expo applications could share code in meaningful ways.
In this post, I'll share my journey of setting up a monorepo using Turborepo to manage both Next.js and Expo projects with a shared component library. I'll explain why I chose react-strict-dom over react-native-web, the benefits and challenges of this approach, and what I've learned along the way.
Github - WIP (opens in a new tab)
The Monorepo Dream
The idea of having a single repository that houses multiple related projects is nothing new. Companies like Google and Facebook have been using monorepos for years. But for individual developers and smaller teams, setting up and maintaining a monorepo has traditionally been challenging.
That's where Turborepo comes in. It's a build system specifically designed for JavaScript monorepos that makes managing multiple projects significantly easier.
My specific goals for this monorepo were:
- Maintain a Next.js app for web users
- Build an Expo app for mobile users
- Share UI components, business logic, and even entire pages where possible
- Minimize duplication and maintenance overhead
Setting Up the Monorepo Structure
Here's how I structured my monorepo:
rsd-next-expo/
├── apps/
│ ├── next/ # Next.js web application
│ └── expo/ # Expo mobile application
├── packages/
│ └── app/ # Shared component library and logic
├── package.json # Root package.json
└── turbo.json # Turborepo configuration
This organization allowed me to keep platform-specific code in the respective apps
directory while sharing common code through the packages/app
directory.
A critical part of making this work was properly configuring the dependencies. For example, in my Next.js app's package.json:
{
"dependencies": {
"@rsd-next-expo/app": "workspace:*",
"next": "15.2.4",
"react-strict-dom": "^0.0.4"
}
}
And similarly in my Expo app:
{
"dependencies": {
"@rsd-next-expo/app": "workspace:*",
"expo": "~52.0.38",
"react-native": "0.76.7",
"react-strict-dom": "^0.0.4"
}
}
The workspace:*
syntax tells the package manager (I used pnpm) that these dependencies are within the same repository and should be linked rather than installed from an external registry.
Why react-strict-dom Instead of react-native-web?
This is where things get interesting. For years, react-native-web has been the go-to solution for sharing code between React Native and web. Created by Nicolas Gallagher, it reimplements React Native components using web technologies, allowing you to use React Native components in a web context.
However, react-native-web has seen decreasing levels of maintenance lately. Why? Because Gallagher has moved to Meta and is now working on the successor: react-strict-dom. This new library takes the lessons learned from react-native-web and improves upon them with a more principled approach.
react-strict-dom provides a unified interface for React DOM and React Native, allowing components to work seamlessly across platforms. Instead of reimplementing React Native for the web (as react-native-web did), it offers a higher-level abstraction that compiles down to the appropriate platform-specific code.
Here's a simple example of using react-strict-dom in my shared component library:
// packages/app/components/Button.tsx
"use client"; // Needed for Next.js
import React from "react";
import { html, StylecssSheet } from "react-strict-dom";
export function Button({ onPress, title, variant = "primary" }) {
return (
<html.button onClick={onPress} style={[styles.button, styles[variant]]}>
<html.span style={styles.text}>{title}</html.span>
</html.button>
);
}
const styles = css.create({
button: {
borderRadius: 8,
paddingHorizontal: 16,
height: 40,
},
primary: {
backgroundColor: "#3B82F6",
},
text: {
color: "white",
fontWeight: "500",
},
});
This component uses the html
namespace from react-strict-dom, which adapts to the appropriate elements on each platform. On the web, this renders to actual HTML elements, while on React Native, it translates to the corresponding native components.
The Pros and Cons of This Approach
Pros
1. Single Source of Truth
Having UI components defined in one place means they stay consistent across platforms. When I update a button's styling or behavior, it updates everywhere.
2. Faster Development
I spend less time switching contexts between web and mobile development. The mental model stays consistent, and I can focus on building features rather than reimplementing them for each platform.
3. Better Testing
With shared components, you can write tests once and know they apply across platforms. This could significantly improve the reliability of cross-platform features.
4. Future-Proofing
By adopting react-strict-dom, I'm investing in what appears to be the future direction of cross-platform React development backed by Meta. The fact that the creator of react-native-web is now working on this project gives me confidence in its longevity.
5. Collaboration Efficiency
When working with other developers, having a unified codebase means easier collaboration. Nobody has to be an expert in both web and mobile; knowledge transfers more naturally across the team.
Cons
1. Learning Curve
Learning to think in a truly cross-platform way takes time. You need to understand the limitations and capabilities of both platforms and design your shared components accordingly.
2. Dependency Management Challenges
Managing dependencies in a monorepo is complex. I ran into several issues with packages not being resolved correctly, especially when dealing with peer dependencies.
3. TypeScript Configuration Complexity
Getting TypeScript to properly recognize imports across the monorepo required careful configuration of path mappings and project references.
4. Build Process Overhead
The build process is inevitably more complex than a single-platform application. Turbo helps a lot, but there's still overhead in setting up and maintaining the build configuration.
5. Still Early for react-strict-dom
While react-strict-dom is promising, it's still relatively new and evolving. The documentation isn't as comprehensive as more established libraries, and you might encounter edge cases that require workarounds.
Navigating Common Challenges
Platform-Specific Behavior
1. Platform detection: TBD
Platform-Specific Implementations: For more complex cases, I create platform-specific implementations of interfaces defined in the shared package.
Server Components vs. Client Components
Working with Next.js App Router means dealing with the distinction between server and client components. Since React Native components must be client components, I needed to carefully mark all shared components with the "use client" directive:
"use client";
import React from "react";
import { html } from "react-strict-dom";
export function Card({ children }) {
// Component implementation
}
This ensures the component works properly in both environments.
Styling Strategy
I found css.create from react-strict-dom to be the most effective way to handle styling across platforms. It provides a familiar API similar to React Native's StyleSheet but works on both platforms.
For more complex styling needs, especially on the web, I sometimes use platform-specific style enhancements, carefully isolated to avoid breaking cross-platform compatibility.
What I Would Have Done Differently
If I were starting over, there are a few things I would change:
1. Start with a clearer dependency strategy: I initially underestimated the complexity of managing dependencies in a monorepo. I would have spent more time upfront planning how dependencies would be structured.
2. More careful TypeScript planning: peScript configuration across a monorepo deserves more attention than I initially gave it. I would have set up a more comprehensive approach to type sharing from the beginning.
Looking Forward
The future of this approach looks promising. As react-strict-dom matures, I expect it to become the standard way of building cross-platform React applications. The backing from Meta suggests it will continue to evolve and improve.
For my own projects, I'm planning to expand the shared functionality beyond just UI components to include more business logic, data fetching, and state management. The more I can share between platforms, the more value I get from this monorepo approach.