Technology·13 min read

Expo DOM Components: Building Universal Apps Beyond Webviews

Expo DOM components let you render web content natively inside React Native without traditional webview hacks. Here is how to use them to build truly universal apps that share UI logic across every platform.

Nate Laquis

Nate Laquis

Founder & CEO

Why Expo DOM Components Change the Universal App Game

For years, "universal app" meant one of two things: either you built a React Native app and bolted on a web export that never quite felt right, or you embedded a WebView inside your native app and accepted the performance tradeoffs. Both approaches worked, but neither was great. Expo DOM components offer a third path that is genuinely different.

Expo DOM components let you use the "use dom" directive to render React DOM elements directly inside a React Native app. Instead of wrapping everything in a traditional WebView with clunky message-passing, you get a lightweight bridge that renders web content as a first-class citizen alongside native components. The DOM component receives props directly from your React Native tree, supports callbacks, and manages its own styling context. Think of it as having a tiny, focused web runtime embedded exactly where you need it, without the overhead of a full browser instance.

This matters because certain UI patterns are simply better expressed with web technologies. Rich text editors, complex data tables, SVG-heavy visualizations, and interactive maps with custom overlays are all areas where the DOM ecosystem has decades of mature, battle-tested libraries. Rather than rebuilding these from scratch in React Native (or settling for mediocre native ports), you can drop in the real web library and have it work.

Multiple mobile devices displaying cross-platform universal app interfaces built with Expo

We have shipped about 40 universal apps in the last two years at Kanopy. The ones that leverage DOM components for the right use cases consistently hit their launch timelines 3 to 4 weeks earlier than projects that try to reimplement everything natively. That is not a small difference when your runway is measured in months.

How DOM Components Actually Work Under the Hood

Understanding the architecture helps you make better decisions about when to use DOM components and when to stick with pure native. Here is what happens when you add "use dom" to a component file.

The Bridge Model

When Expo encounters a file with the "use dom" directive, it compiles that component into a separate web bundle. At runtime on iOS or Android, the component renders inside an optimized native web container (not a full WebView). Props from the React Native parent are serialized and passed across the bridge. Callback functions are proxied so the DOM component can trigger native-side actions like navigation, haptics, or camera access.

On the web platform, the same component renders as standard React DOM. No bridge is needed because you are already in a browser context. This is the key insight: the "use dom" directive is a no-op on web, which means your component code is truly universal. Write it once, and it renders natively where it makes sense and as standard web HTML where that makes sense.

Props and Serialization

DOM components accept serializable props: strings, numbers, booleans, arrays, and plain objects. Functions are proxied through the bridge as async callbacks. This is similar to how React Server Components handle the server/client boundary. If you have worked with RSC patterns (and if you have not, check our RSC guide for startups), the mental model transfers directly.

One limitation: you cannot pass React elements or complex class instances across the boundary. If you need to render native components inside a DOM component, you will need to restructure your component tree so the native and DOM layers are siblings, not nested. In practice, this is rarely a problem. Most DOM component use cases are self-contained: a rich text editor, a chart, a payment form.

Styling and Layout

Inside a DOM component, you use standard CSS. Tailwind, CSS Modules, vanilla CSS, styled-components: they all work. The DOM component gets its own isolated styling context, so there is no leaking between native styles and web styles. The container automatically sizes to fit its content, or you can set explicit dimensions from the native side via style props.

This isolation is actually a feature. Teams often struggle with universal styling because React Native's flexbox implementation differs subtly from the web's. With DOM components, you stop fighting those differences. The native parts of your app use React Native styling. The web parts use real CSS. Everyone is happy.

When to Use DOM Components (and When Not To)

DOM components are a precision tool, not a sledgehammer. Using them for the wrong use case will bloat your app and confuse your team. Here is a practical framework for deciding when they earn their place.

Strong Use Cases

  • Rich text editing: Libraries like TipTap, ProseMirror, and Lexical are years ahead of any React Native equivalent. Wrapping them in a DOM component gives you a production-grade editor with formatting toolbars, collaborative editing, and markdown support in days rather than months.
  • Complex data visualization: D3.js, Recharts, and Highcharts produce beautiful, interactive charts. The React Native charting ecosystem is improving but still lacks feature parity. For dashboards with 10+ chart types, DOM components save significant development time.
  • Payment forms: Stripe Elements and other PCI-compliant payment form SDKs are web-first. Using them inside a DOM component means you get the same security guarantees and pre-built UI that powers millions of web checkouts.
  • Embeddable content: iframes for YouTube videos, social media embeds, third-party widgets. These require a DOM context by definition.
  • Legacy web code migration: If you are converting a web app to a universal app, DOM components let you migrate screen by screen rather than rewriting everything at once. Ship the native shell first, embed existing web screens as DOM components, then gradually replace them with native versions based on usage data.

Avoid DOM Components For

  • Core navigation and layout: Your tab bar, stack navigator, and primary layout should be native. DOM components inside navigation shells create jarring transitions and accessibility issues.
  • High-frequency interaction: Gesture-heavy interfaces, drag-and-drop builders, and real-time drawing canvases need native touch handling. The bridge adds just enough latency (2 to 5ms per round-trip) to make 60fps gesture tracking inconsistent.
  • Simple forms: A login screen or profile editor does not need DOM. React Native's TextInput, Switch, and Picker components handle these perfectly. Adding a DOM component for a simple form adds bundle size for zero benefit.
  • Lists and feeds: FlatList and FlashList in React Native are heavily optimized for virtualization and recycling. A DOM-based scrolling list will never match their memory efficiency on a phone with 4GB of RAM.

The rule of thumb: if a mature web library would save you more than a week of development time compared to the best native alternative, use a DOM component. Otherwise, stay native.

Project Architecture for Universal Apps with DOM Components

Getting the architecture right upfront prevents painful refactors later. Here is the project structure we use at Kanopy for universal Expo apps that mix native and DOM components.

Directory Structure

We organize files by feature, with a clear separation between native components, DOM components, and shared logic:

  • app/ contains your Expo Router file-based routes. These are always native-first.
  • components/native/ holds React Native components that render with native primitives (View, Text, Pressable).
  • components/dom/ holds "use dom" components. Each file in this directory starts with the directive and can import web-only libraries.
  • components/shared/ contains platform-agnostic logic: hooks, utilities, constants, validation schemas, and TypeScript types.
  • lib/ stores API clients, state management, and other infrastructure code that works on both platforms.

Routing with Expo Router

Your route definitions live in the app/ directory and are always native. A typical screen component imports from both native and DOM directories:

The native parts of the screen (header, navigation controls, action buttons) render instantly with native performance. The DOM component (in this example, a rich text editor) loads in the embedded web context and integrates seamlessly through prop passing.

State Management Across the Bridge

This is where teams get tripped up. Because DOM components run in a separate JavaScript context on native platforms, you cannot share a Zustand store or React context directly between native and DOM layers. Instead, pass state down as props and changes up as callbacks. For complex scenarios, use a message-based approach:

  • Simple state: Pass as props. The DOM component receives the current value and calls an onChange callback. Works for 80% of cases.
  • Shared async state: Use React Query (TanStack Query) independently in both contexts, pointing at the same API endpoints. Cache invalidation keeps them in sync naturally.
  • Real-time sync: For collaborative features, use a WebSocket connection inside the DOM component and a separate one in the native context, both connected to the same backend channel. Libraries like Ably or Supabase Realtime handle this well.
Developer working on universal app architecture with code editor and component diagrams

The golden rule: treat the native/DOM boundary like a network boundary. Keep it thin, pass only what you need, and design for eventual consistency rather than shared mutable state.

Performance Optimization and Bundle Size Management

DOM components add weight to your app bundle because they include a web runtime. On a typical project, the base overhead is about 800KB to 1.2MB for the first DOM component. Each additional DOM component adds incremental size based on its web dependencies. Here is how to keep things fast and lean.

Lazy Loading DOM Components

Never import DOM components at the top level of your screen files. Use React.lazy() combined with Suspense to load them only when the user navigates to a screen that needs them. On a dashboard app we built last quarter, lazy loading DOM chart components reduced the initial app startup time from 2.8 seconds to 1.6 seconds on a mid-range Android device (Samsung Galaxy A54).

For screens where the DOM component is below the fold, trigger the import on scroll position rather than on screen mount. This prevents the web runtime from initializing until the user actually needs it.

Shared Web Dependencies

If you have multiple DOM components that use the same web libraries (for example, three chart components all using Recharts), configure your bundler to share those dependencies. Expo's Metro bundler supports this through the unstable_enablePackageExports option. Without shared dependencies, each DOM component bundles its own copy of React DOM, and your app size balloons by 300KB per component.

Measuring Performance

Track these metrics for every DOM component in your app:

  • Bridge initialization time: How long from component mount to first meaningful paint inside the DOM container. Target under 200ms on mid-range devices.
  • Interaction latency: Round-trip time for a callback from DOM to native. Should be under 5ms for simple callbacks. If you are seeing 10ms+, you are serializing too much data.
  • Memory footprint: Each DOM component runs its own JavaScript context. Monitor with Xcode Instruments (iOS) or Android Profiler. A single DOM component should add no more than 15 to 20MB of memory. If you are hitting 40MB+, you likely have a memory leak in your web code.
  • Bundle size impact: Use npx expo-bundle-analyzer to visualize what each DOM component contributes. Set a per-component budget of 500KB (after tree-shaking) and enforce it in CI.

Platform-Specific Optimization

On iOS, the WebKit engine used for DOM components is the same one powering Safari, so you get excellent JavaScript execution speed. On Android, the performance varies by device because the system WebView version differs. Always test on a device running Android 11 or older to catch worst-case scenarios. We keep a Pixel 4a in the office specifically for this purpose.

Real-World Cost and Timeline Breakdown

Let's talk numbers. Here is what it actually costs to build a universal app with Expo DOM components compared to alternative approaches, based on projects we have delivered in the last 18 months.

Approach Comparison for a Medium-Complexity App

Assume a product with 15 screens, user auth, a dashboard with charts, a rich text editor, push notifications, and offline support. Target platforms: iOS, Android, and web.

  • Separate native + web codebases: 16 to 20 weeks. Two teams (or one team working sequentially). Cost: $120K to $180K at agency rates, $200K+ with in-house engineers including benefits.
  • React Native + separate Next.js web app: 12 to 16 weeks. Shared business logic, separate UI layers. Cost: $90K to $140K. You maintain two UI codebases forever.
  • Expo universal app with DOM components: 8 to 12 weeks. Single codebase, single team. Cost: $60K to $100K. The DOM components handle the chart dashboard and rich text editor, while native handles everything else.

The Expo DOM approach is not always the cheapest for simple apps. If your app has no web-heavy UI patterns (no rich text, no complex charts, no payment embeds), a standard Expo universal app without DOM components is simpler and cheaper. DOM components add architectural complexity, so you want to be sure they are earning their keep.

Ongoing Maintenance Costs

Monthly infrastructure costs for a universal Expo app are comparable to any React Native project: $20 to $100/month for EAS Build (Expo Application Services), $0 to $50/month for hosting the web version on Vercel, plus your standard backend costs. DOM components do not add hosting costs, but they do add testing surface area. Budget an extra 10 to 15% for QA time because you need to test the DOM/native boundary on each platform.

Team Composition

The ideal team for a DOM-component-heavy project is 2 to 3 React Native developers who are also comfortable with web technologies. Pure mobile developers will struggle with CSS debugging inside DOM components. Pure web developers will struggle with native build tooling and platform-specific bugs. The sweet spot is developers who have shipped both React web apps and React Native apps. If you are evaluating whether your team can handle this, the Expo SDK migration guide covers the foundational skills needed.

Code on a monitor showing React Native and web integration for cost-effective universal app development

Common Pitfalls and How to Avoid Them

We have made every mistake in the book with DOM components so you do not have to. Here are the patterns that cause the most pain, along with their solutions.

Pitfall 1: Overusing DOM Components

The most common mistake is excitement-driven adoption. A team discovers DOM components, realizes they can use their favorite web libraries, and starts wrapping everything in "use dom." Within a month, the app is 60% web and 40% native, performance is mediocre on both platforms, and the team is debugging bridge serialization issues instead of building features. Limit DOM components to 3 to 5 per app. If you need more, reconsider whether you should be building a progressive web app instead.

Pitfall 2: Passing Large Data Across the Bridge

Serializing a 10,000-row dataset as a prop to a DOM chart component will freeze your app for 200 to 500ms while the data crosses the bridge. Instead, have the DOM component fetch its own data directly from your API. Pass only the query parameters (filters, date range, user ID) as props, and let the DOM component handle the data fetching internally. This also enables the DOM component to show its own loading state, which is better UX.

Pitfall 3: Ignoring Accessibility

DOM components have their own accessibility tree, separate from the native accessibility tree. Screen readers on iOS (VoiceOver) and Android (TalkBack) may not transition smoothly between native and DOM regions. You must manually set accessibilityLabel on the native container and ensure the DOM component's HTML has proper ARIA attributes. Test with actual screen readers on real devices. Simulator-based accessibility testing misses bridge-related issues.

Pitfall 4: Not Handling Offline State

If your DOM component fetches its own data, it needs its own offline handling. The native side of your app might use WatermelonDB or MMKV for offline persistence, but those libraries are not available inside the DOM context. Use IndexedDB or localStorage inside DOM components for offline caching, and sync state with the native side through props/callbacks when connectivity returns.

Pitfall 5: Neglecting Deep Linking

DOM components do not participate in Expo Router's deep linking by default. If a user taps a push notification that should open a specific state inside a DOM component (for example, a specific document in the rich text editor), you need to handle this manually. Pass the deep link parameters as props to the DOM component, and have it navigate to the correct internal state on mount.

Getting Started: Your First DOM Component in 30 Minutes

Enough theory. Here is a practical walkthrough to get your first DOM component running in an existing Expo project.

Prerequisites

You need Expo SDK 52 or later (DOM components were introduced as experimental in SDK 51 and stabilized in SDK 52). If you are on an older SDK, check our migration guides for step-by-step upgrade instructions. You also need to be using Expo Router v3 or later for the file-based routing integration to work correctly.

Step 1: Create Your DOM Component

Create a file in your components/dom/ directory. Add "use dom" as the first line (similar to "use client" in React Server Components). Import any web library you want. For this example, we will use a simple case: rendering a Markdown preview using the react-markdown library.

Step 2: Install Web Dependencies

Run npx expo install react-markdown remark-gfm. These packages will only be bundled into the DOM component's web context, not your native bundle. This is important: adding a web-only dependency does not increase your native app size.

Step 3: Use It in a Screen

In your Expo Router screen file, import the DOM component like any other React component. Pass your markdown string as a prop. The component will render as native web content on iOS/Android and as inline React DOM on the web. No platform-specific code needed.

Step 4: Style and Polish

Inside the DOM component, add a CSS file or use inline styles. On the native side, control the container's dimensions with React Native style props. The DOM component respects width and height from its parent, and you can enable scrolling inside the container by setting the appropriate overflow styles in your CSS.

What to Build Next

Once you have the basic pattern working, try these progressively more complex projects:

  • Week 1: Replace a WebView-based PDF viewer with a DOM component using react-pdf. Compare load times and memory usage.
  • Week 2: Build a rich text editor using TipTap inside a DOM component. Wire up save/load through native-side callbacks.
  • Week 3: Create a dashboard screen with 4 to 5 Recharts visualizations inside a single DOM component. Optimize for lazy loading and measure the performance baseline.

If you are building a universal app and want to avoid the pitfalls we covered above, our team has delivered dozens of production Expo apps with DOM component architectures. Book a free strategy call and we will help you map out the right architecture for your product, identify which screens benefit from DOM components, and scope a realistic timeline and budget.

Need help building this?

Our team has launched 50+ products for startups and ambitious brands. Let's talk about your project.

Expo DOM components universal app developmentReact Native universal appsExpo cross-platform developmentExpo web integration React Nativeuniversal app architecture 2032

Ready to build your product?

Book a free 15-minute strategy call. No pitch, just clarity on your next steps.

Get Started