<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Hugo Rus</title>
    <description>The latest articles on DEV Community by Hugo Rus (@hugo_rus_630dd942fcf7cc62).</description>
    <link>https://dev.to/hugo_rus_630dd942fcf7cc62</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4004016%2F5d302419-e375-4c77-b600-eeb065755471.png</url>
      <title>DEV Community: Hugo Rus</title>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hugo_rus_630dd942fcf7cc62"/>
    <language>en</language>
    <item>
      <title>Deep-Linking in React Native Was Miserable. Expo Router Made It Accidental.</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Wed, 15 Jul 2026 13:25:52 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/deep-linking-in-react-native-was-miserable-expo-router-made-it-accidental-1ndc</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/deep-linking-in-react-native-was-miserable-expo-router-made-it-accidental-1ndc</guid>
      <description>&lt;ul&gt;
&lt;li&gt;Pre-Expo Router, deep-linking meant &lt;code&gt;react-native-linking&lt;/code&gt; + URL schemes + Universal Links + a hand-synced &lt;code&gt;linking&lt;/code&gt; config — roughly two weeks of work that broke on every screen rename.&lt;/li&gt;
&lt;li&gt;Three things reliably broke in prod: &lt;strong&gt;renamed screens&lt;/strong&gt; (silent 404s), &lt;strong&gt;missing params&lt;/strong&gt; (crashes), and &lt;strong&gt;state mismatch&lt;/strong&gt; (app resets to home instead of stacking).&lt;/li&gt;
&lt;li&gt;Expo Router fixes this by making the URL structure &lt;em&gt;be&lt;/em&gt; the file structure — there's no separate config to desync.&lt;/li&gt;
&lt;li&gt;One gotcha remains: Universal Links still need an &lt;code&gt;apple-app-site-association&lt;/code&gt; file and Xcode entitlements. That part's on Apple, not the framework.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Deep-linking used to be the feature that dev teams promised at kickoff and quietly cut before launch. In three separate React Native apps I've shipped, the deep-linking ticket got moved to "next quarter" at least twice before someone finally forced it.&lt;/p&gt;

&lt;p&gt;Expo Router changed that — not because it's easier (though it is), but because deep-linking is now the &lt;em&gt;default&lt;/em&gt;, not an add-on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 2022 baseline
&lt;/h2&gt;

&lt;p&gt;Getting a link like &lt;code&gt;myapp://profile/42&lt;/code&gt; to open the profile screen for user 42 used to require:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;react-native-linking&lt;/code&gt; set up with a URL scheme in &lt;code&gt;Info.plist&lt;/code&gt; and &lt;code&gt;AndroidManifest.xml&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Universal Links (iOS) or App Links (Android) if you wanted &lt;code&gt;https://myapp.com/profile/42&lt;/code&gt; to open the app instead of Safari.&lt;/li&gt;
&lt;li&gt;A separate &lt;code&gt;linking&lt;/code&gt; config passed to &lt;code&gt;NavigationContainer&lt;/code&gt; that mapped URL patterns to screen names &lt;em&gt;and&lt;/em&gt; params — kept in sync by hand with the navigator config.&lt;/li&gt;
&lt;li&gt;Deep-link testing across cold-start, warm-start, and background-resume paths, each of which broke differently.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;By the time you had all four working, you'd spent two weeks and had a URL-to-screen mapping that broke every time someone renamed a screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three things that used to break in prod
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Renamed screens.&lt;/strong&gt; Someone renames &lt;code&gt;ProfileScreen&lt;/code&gt; to &lt;code&gt;UserProfileScreen&lt;/code&gt;, forgets to update the linking config, and deep links silently 404 to the home screen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing params.&lt;/strong&gt; &lt;code&gt;myapp://profile&lt;/code&gt; (no id) crashes the screen because someone forgot the id fallback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State mismatch.&lt;/strong&gt; The user is deep in a Stack navigator when the link arrives; the app resets to home instead of stacking correctly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What Expo Router does differently
&lt;/h2&gt;

&lt;p&gt;Because the URL structure &lt;em&gt;is&lt;/em&gt; the route file structure, you can't have a mismatch between what a URL says and what screens exist. &lt;code&gt;myapp://profile/42&lt;/code&gt; maps to &lt;code&gt;app/profile/[id].tsx&lt;/code&gt; because that file exists. Rename the file and the URL structure changes with it. There's no separate config file to keep in sync.&lt;/p&gt;

&lt;p&gt;That single property — one source of truth instead of two — eliminates the first failure mode entirely, and makes the other two far easier to reason about.&lt;/p&gt;

&lt;h2&gt;
  
  
  A support-team flow that just works
&lt;/h2&gt;

&lt;p&gt;My team recently shipped a support-tools feature: our CS team can send customers a link like &lt;code&gt;myapp://support/ticket/T-1234&lt;/code&gt; from a Slack app.&lt;/p&gt;

&lt;p&gt;Before Expo Router, that would have been a three-week ticket — design the URL schema, wire the linking config, test across cold-start states. With Expo Router, it was two files (&lt;code&gt;app/support/ticket/[id].tsx&lt;/code&gt; and a Slack app integration) and a two-day build.&lt;/p&gt;

&lt;p&gt;The link works whether the user has the app closed, in the background, or open on another screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one remaining gotcha
&lt;/h2&gt;

&lt;p&gt;Universal Links — the iOS feature that makes &lt;code&gt;https://myapp.com/profile/42&lt;/code&gt; open your app instead of Safari — still needs an &lt;code&gt;apple-app-site-association&lt;/code&gt; file served from your website, plus the right entitlements in Xcode. Expo Router doesn't automate this; it's an Apple-side concern. Set aside half a day for it when you first turn Universal Links on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Deep-linking used to be a feature you promised in month 1 and delivered in month 4. In an Expo Router app it's a feature you get in month 0 by not opting out.&lt;/p&gt;

&lt;p&gt;If your team is still hand-rolling deep-link config in 2026, you're solving a problem the framework already solved.&lt;/p&gt;

&lt;p&gt;What's the deep-link bug that cost you the most time — a silent 404, a cold-start crash, or Universal Links entitlements? Drop it in the comments.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>mobile</category>
      <category>ux</category>
    </item>
    <item>
      <title>How to Build a Video Calling App with React Native and WebRTC</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Wed, 15 Jul 2026 13:21:07 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/how-to-build-a-video-calling-app-with-react-native-and-webrtc-44jd</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/how-to-build-a-video-calling-app-with-react-native-and-webrtc-44jd</guid>
      <description>&lt;ul&gt;
&lt;li&gt;WebRTC is built into every modern OS, and &lt;code&gt;react-native-webrtc&lt;/code&gt; is production-hardened — you can ship a 1-to-1 video call in a weekend with open-source pieces only.&lt;/li&gt;
&lt;li&gt;Every call has four parts: a &lt;strong&gt;signaling server&lt;/strong&gt; (Socket.io), a &lt;strong&gt;STUN server&lt;/strong&gt; (Google's free one), an optional &lt;strong&gt;TURN server&lt;/strong&gt; (for the ~15–20% of calls that can't go P2P), and the &lt;strong&gt;peer connection&lt;/strong&gt; on each device.&lt;/li&gt;
&lt;li&gt;This guide builds all of it: signaling, media capture, offer/answer exchange, ICE handling, and the standard call controls.&lt;/li&gt;
&lt;li&gt;The hardest part isn't the API — it's the network. Test on real cellular, log ICE state transitions, and don't skip TURN in production.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Ten years ago, adding video calling to a mobile app meant licensing a proprietary SDK, paying per-minute fees, and hoping the vendor didn't disappear. Today, &lt;a href="https://webrtc.org/" rel="noopener noreferrer"&gt;WebRTC&lt;/a&gt; is built into every modern OS, &lt;code&gt;react-native-webrtc&lt;/code&gt; is production-hardened, and you can ship a working 1-to-1 video call in a weekend. The catch? Nobody tells you the whole story. Most tutorials skip signaling, gloss over Expo compatibility, or quietly funnel you into a paid SDK before you write any real WebRTC code.&lt;/p&gt;

&lt;p&gt;This guide walks through the entire flow — from a fresh React Native project to two phones streaming video and audio to each other over a real signaling server. You'll build a video calling app using open-source pieces: &lt;code&gt;react-native-webrtc&lt;/code&gt;, Socket.io for signaling, and Google's public STUN server. No SDK lock-in, no per-minute pricing, no black boxes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is WebRTC and why use it in React Native?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WebRTC (Web Real-Time Communication) is an open standard that lets browsers and native apps exchange audio, video, and arbitrary data directly between peers with sub-second latency.&lt;/strong&gt; In React Native, the &lt;a href="https://github.com/react-native-webrtc/react-native-webrtc" rel="noopener noreferrer"&gt;&lt;code&gt;react-native-webrtc&lt;/code&gt;&lt;/a&gt; library exposes the same JavaScript APIs you'd use in a browser — &lt;code&gt;RTCPeerConnection&lt;/code&gt;, &lt;code&gt;MediaStream&lt;/code&gt;, &lt;code&gt;getUserMedia&lt;/code&gt; — but backed by native iOS and Android WebRTC bindings that hit the hardware encoder.&lt;/p&gt;

&lt;p&gt;That matters for three reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Latency.&lt;/strong&gt; WebRTC targets sub-500ms glass-to-glass. HTTP streaming protocols like HLS sit at 6–30 seconds. For conversation, WebRTC is the only game in town.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Peer-to-peer by default.&lt;/strong&gt; For 1-to-1 calls, media flows directly between devices. You don't pay for bandwidth on your server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No vendor lock-in.&lt;/strong&gt; The protocol is a W3C standard. You can switch signaling servers, TURN providers, or client libraries without rewriting your app.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tradeoff: WebRTC hands you plumbing, not a product. You supply the signaling channel, the connection lifecycle, and the UI. That's exactly what we'll build.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four pieces of every WebRTC call
&lt;/h2&gt;

&lt;p&gt;Before the code, the mental model. Every video calling app has four moving parts, and every bug you'll ever hit lives in one of them.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;What we'll use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Signaling server&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Exchanges connection metadata (SDP offers/answers and ICE candidates) between peers before the call starts. Not part of WebRTC.&lt;/td&gt;
&lt;td&gt;Node.js + Socket.io&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;STUN server&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Helps a peer discover its public IP so peers behind NAT can find each other.&lt;/td&gt;
&lt;td&gt;Google's free &lt;code&gt;stun:stun.l.google.com:19302&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TURN server&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Relays media when direct P2P fails (~10–20% of calls in the wild).&lt;/td&gt;
&lt;td&gt;Skipped for the tutorial; production notes below.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Peer connection&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;RTCPeerConnection&lt;/code&gt; object on each device that negotiates and streams media.&lt;/td&gt;
&lt;td&gt;&lt;code&gt;react-native-webrtc&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The dance goes like this: Device A creates an "offer" (an SDP blob describing its media capabilities), sends it through the signaling server to Device B. Device B answers. Both sides then trade ICE candidates — possible network paths — until they find one that works. Media starts flowing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Node.js 20+&lt;/strong&gt; and either &lt;strong&gt;npm&lt;/strong&gt; or &lt;strong&gt;yarn&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;React Native 0.74+&lt;/strong&gt; or &lt;strong&gt;Expo SDK 51+&lt;/strong&gt; (with Expo Dev Client — Expo Go does not support native WebRTC modules)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Xcode 15+&lt;/strong&gt; for iOS builds, &lt;strong&gt;Android Studio&lt;/strong&gt; with API 30+ for Android&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two physical devices&lt;/strong&gt; for testing. The iOS Simulator has no camera, and the Android emulator's virtual camera is unreliable for WebRTC.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're on Expo, make sure you're on the &lt;a href="https://docs.expo.dev/develop/development-builds/introduction/" rel="noopener noreferrer"&gt;Expo Dev Client workflow&lt;/a&gt;, not Expo Go. &lt;code&gt;react-native-webrtc&lt;/code&gt; ships native code that Expo Go can't load.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Install and configure react-native-webrtc
&lt;/h2&gt;

&lt;p&gt;Create your project and install the library:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx create-expo-app@latest video-call-tutorial
&lt;span class="nb"&gt;cd &lt;/span&gt;video-call-tutorial
npx expo &lt;span class="nb"&gt;install &lt;/span&gt;react-native-webrtc @config-plugins/react-native-webrtc
npm &lt;span class="nb"&gt;install &lt;/span&gt;socket.io-client
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;@config-plugins/react-native-webrtc&lt;/code&gt; package handles platform permissions for you. Add it to your &lt;code&gt;app.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"plugins"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"@config-plugins/react-native-webrtc"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"cameraPermission"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow $(PRODUCT_NAME) to access your camera for video calls."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"microphonePermission"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow $(PRODUCT_NAME) to access your microphone for video calls."&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On bare React Native, add these permissions manually:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;iOS&lt;/strong&gt; (&lt;code&gt;ios/YourApp/Info.plist&lt;/code&gt;): &lt;code&gt;NSCameraUsageDescription&lt;/code&gt; and &lt;code&gt;NSMicrophoneUsageDescription&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Android&lt;/strong&gt; (&lt;code&gt;android/app/src/main/AndroidManifest.xml&lt;/code&gt;): &lt;code&gt;CAMERA&lt;/code&gt;, &lt;code&gt;RECORD_AUDIO&lt;/code&gt;, &lt;code&gt;MODIFY_AUDIO_SETTINGS&lt;/code&gt;, &lt;code&gt;BLUETOOTH_CONNECT&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now rebuild the native app — you cannot use hot reload for this step:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx expo prebuild
npx expo run:ios     &lt;span class="c"&gt;# or run:android&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: Build a minimal signaling server
&lt;/h2&gt;

&lt;p&gt;WebRTC gives you the media pipe. It doesn't tell peers how to find each other. You need a signaling server whose only job is to relay three types of messages: offers, answers, and ICE candidates.&lt;/p&gt;

&lt;p&gt;Create a &lt;code&gt;server/&lt;/code&gt; folder alongside your app with this &lt;code&gt;index.js&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Server&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;socket.io&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;io&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Server&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3001&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;cors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;origin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rooms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;connection&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;join&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;peers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rooms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
    &lt;span class="nx"&gt;peers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;rooms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;peers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// Tell this socket about existing peers so it knows to send the offer&lt;/span&gt;
    &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;peers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;peers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;offer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sdp&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;offer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sdp&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;answer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sdp&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;answer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sdp&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;io&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;disconnect&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;peers&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;rooms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;filtered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;peers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;filtered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;rooms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;filtered&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;rooms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;roomId&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Signaling server running on :3001&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install and run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd &lt;/span&gt;server
npm init &lt;span class="nt"&gt;-y&lt;/span&gt;
npm &lt;span class="nb"&gt;install &lt;/span&gt;socket.io
node index.js
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire signaling server. It's 30 lines because it should be. Anything more complex belongs in your app layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Capture local media
&lt;/h2&gt;

&lt;p&gt;Back in your app, request camera and microphone access. This uses &lt;code&gt;mediaDevices.getUserMedia&lt;/code&gt;, which returns a &lt;code&gt;MediaStream&lt;/code&gt; you can preview and later attach to the peer connection.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;mediaDevices&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MediaStream&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native-webrtc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getLocalStream&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;MediaStream&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;mediaDevices&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUserMedia&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;audio&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;video&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;facingMode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ideal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1280&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ideal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;720&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;frameRate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ideal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Render it with &lt;code&gt;RTCView&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;RTCView&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native-webrtc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;RTCView&lt;/span&gt;
  &lt;span class="na"&gt;streamURL&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;localStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toURL&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;objectFit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"cover"&lt;/span&gt;
  &lt;span class="na"&gt;mirror&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;mirror&lt;/code&gt; flips the preview horizontally, which is what users expect for the front camera. On the remote video, leave it off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Create the peer connection
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;RTCPeerConnection&lt;/code&gt; object is the heart of the call. Give it your ICE server config, add your local tracks, and wire up the event handlers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;RTCPeerConnection&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;RTCSessionDescription&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;RTCIceCandidate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native-webrtc&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RTCPeerConnection&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;iceServers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stun:stun.l.google.com:19302&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="c1"&gt;// In production, add a TURN server here (see below)&lt;/span&gt;
  &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;localStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTracks&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addTrack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;track&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;localStream&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;track&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;setRemoteStream&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;streams&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;icecandidate&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;remotePeerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;candidate&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;connectionstatechange&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PC state:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;connectionState&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;connectionState&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Reconnect or tear down&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth noting:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;addTrack&lt;/code&gt;, not &lt;code&gt;addStream&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;addStream&lt;/code&gt; is deprecated in the Unified Plan SDP semantic that all modern WebRTC implementations use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trickle ICE.&lt;/strong&gt; The &lt;code&gt;icecandidate&lt;/code&gt; handler fires as candidates are discovered. Send each one immediately rather than waiting for gathering to finish — this shaves seconds off call setup.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 5: Exchange offer and answer
&lt;/h2&gt;

&lt;p&gt;The device that initiates the call creates the offer. The receiving device answers. Both sets of SDP are relayed through your signaling server.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Caller:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;offer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createOffer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setLocalDescription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;offer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;offer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;remotePeerId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sdp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;offer&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Callee&lt;/strong&gt; (on receiving the offer):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;offer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sdp&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setRemoteDescription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RTCSessionDescription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sdp&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;answer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createAnswer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setLocalDescription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;answer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;answer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sdp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;answer&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Caller&lt;/strong&gt; (on receiving the answer):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;answer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;sdp&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setRemoteDescription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RTCSessionDescription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sdp&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Both&lt;/strong&gt; sides handle incoming ICE candidates:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ice&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;candidate&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addIceCandidate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;RTCIceCandidate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;candidate&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;addIceCandidate failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At this point, if you run the app on two devices that can reach each other over the network, video and audio start flowing. You've built a working WebRTC call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Add the call controls
&lt;/h2&gt;

&lt;p&gt;A working call isn't a shippable feature. Users expect mute, video toggle, camera flip, and hangup. All of them are one or two lines with the WebRTC API.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mute audio:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;toggleMute&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;localStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getAudioTracks&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;enabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Toggle video:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;toggleVideo&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;localStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getVideoTracks&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;enabled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Switch camera:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;switchCamera&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;localStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getVideoTracks&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_switchCamera&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Hang up:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;hangup&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;localStream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTracks&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;forEach&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="nx"&gt;pc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;leave&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note: setting &lt;code&gt;track.enabled = false&lt;/code&gt; keeps the transport open and sends silence/black frames — much faster to un-mute than fully removing the track.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common pitfalls (and how to avoid them)
&lt;/h2&gt;

&lt;p&gt;After shipping WebRTC in production a few times, these are the traps engineers walk into.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"It works on Wi-Fi but not on cellular."&lt;/strong&gt; You need a TURN server. Symmetric NAT on carrier networks blocks direct P2P for a significant chunk of your users. Skipping TURN in tests is fine; skipping it in production leaves 15–20% of calls broken.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"The remote video is black on iOS."&lt;/strong&gt; Almost always an &lt;code&gt;RTCView&lt;/code&gt; rendering issue — the stream URL updated but the component didn't re-render. Wrap &lt;code&gt;&amp;lt;RTCView&amp;gt;&lt;/code&gt; in a &lt;code&gt;key={remoteStream.toURL()}&lt;/code&gt; to force remount when the stream changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Audio works but video doesn't."&lt;/strong&gt; Check that both peers actually added a video track before &lt;code&gt;createOffer&lt;/code&gt; was called. SDP is negotiated once — if the offer only advertised audio, no video will flow even if you add a video track later. Use &lt;code&gt;pc.addTransceiver&lt;/code&gt; up front if you want to reserve the slot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Metro bundler complains about &lt;code&gt;require.internal&lt;/code&gt; after installing."&lt;/strong&gt; You installed &lt;code&gt;react-native-webrtc&lt;/code&gt; but forgot to rebuild. Native modules can't hot-reload — always &lt;code&gt;npx expo prebuild &amp;amp;&amp;amp; npx expo run:ios&lt;/code&gt; after installing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Calls fail to connect but no errors log."&lt;/strong&gt; Attach a &lt;code&gt;connectionstatechange&lt;/code&gt; handler. WebRTC failures are silent — the connection just doesn't succeed. Also log &lt;code&gt;iceConnectionState&lt;/code&gt; transitions; they're the fastest signal that ICE gathering is going wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scaling beyond 1-to-1
&lt;/h2&gt;

&lt;p&gt;The tutorial above scales fine to two people because media flows peer-to-peer. Add a third person and you have a mesh topology: each participant maintains a peer connection to every other participant. Bandwidth on a mobile device scales O(n) with participants, and CPU scales O(n) with encoding.&lt;/p&gt;

&lt;p&gt;Mesh works up to about 4 participants on modern phones. Beyond that, you need a &lt;strong&gt;Selective Forwarding Unit (SFU)&lt;/strong&gt; — a server that receives one stream from each participant and forwards it to the others. Open-source options include &lt;a href="https://mediasoup.org/" rel="noopener noreferrer"&gt;mediasoup&lt;/a&gt;, &lt;a href="https://janus.conf.meetecho.com/" rel="noopener noreferrer"&gt;Janus&lt;/a&gt;, and &lt;a href="https://livekit.io/" rel="noopener noreferrer"&gt;LiveKit&lt;/a&gt;. All three work with &lt;code&gt;react-native-webrtc&lt;/code&gt; on the client — only the signaling protocol changes.&lt;/p&gt;

&lt;p&gt;For TURN in production, run &lt;a href="https://github.com/coturn/coturn" rel="noopener noreferrer"&gt;coturn&lt;/a&gt; on a cheap VPS or use a managed provider like &lt;a href="https://www.twilio.com/docs/stun-turn" rel="noopener noreferrer"&gt;Twilio's Network Traversal Service&lt;/a&gt; or &lt;a href="https://xirsys.com/" rel="noopener noreferrer"&gt;Xirsys&lt;/a&gt;. Budget roughly $0.40 per GB of relayed traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Does react-native-webrtc work with Expo Go?
&lt;/h3&gt;

&lt;p&gt;No. &lt;code&gt;react-native-webrtc&lt;/code&gt; ships native iOS and Android modules that Expo Go cannot load. Move to Expo Dev Client or the bare workflow and use EAS Build (or a local &lt;code&gt;expo run:ios&lt;/code&gt;/&lt;code&gt;expo run:android&lt;/code&gt;) to include the native code.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much does a WebRTC video calling app cost to run?
&lt;/h3&gt;

&lt;p&gt;For 1-to-1 calls, near zero — media flows peer-to-peer. Your only costs are the signaling server (a $5/month VPS handles thousands of concurrent calls) and TURN relay for the ~15–20% of calls that fall back to relayed mode. Budget $0.40 per GB of TURN traffic.&lt;/p&gt;

&lt;h3&gt;
  
  
  What's the difference between STUN and TURN?
&lt;/h3&gt;

&lt;p&gt;STUN helps a peer discover its own public IP so two peers behind NAT can attempt a direct connection. TURN is a fallback: when direct P2P fails (usually because both peers are behind symmetric NAT), TURN acts as a media relay. STUN is cheap and free servers exist; TURN uses your bandwidth and typically costs money.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is WebRTC secure?
&lt;/h3&gt;

&lt;p&gt;Yes. All WebRTC media is encrypted with DTLS-SRTP by default — it's not optional in the spec. The signaling channel is your responsibility; use WSS (secure WebSockets) or HTTPS for whatever you build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;You now have every piece needed for a production-quality React Native video calling app: a signaling server, a peer connection lifecycle, media capture, offer/answer exchange, ICE handling, and the standard call controls. Add a TURN server, wire the UI to your app's state management, and you're 80% of the way to shipping.&lt;/p&gt;

&lt;p&gt;The hardest part of WebRTC isn't the API — it's the network. Test on real cellular connections, log ICE state transitions, and instrument connection failures early. Everything else is UI polish — and if you'd rather move fast on that layer, &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=webrtc-video-call" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; generates the call-lobby, in-call overlay, and post-call screens so you can spend your time on the ICE candidate that just failed instead.&lt;/p&gt;

&lt;p&gt;What part of WebRTC bit you hardest — signaling, NAT traversal, or the native build? Drop it in the comments.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>webrtc</category>
      <category>expo</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>How to Build a Project Management App Like Trello in 2026</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Mon, 13 Jul 2026 13:40:26 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/how-to-build-a-project-management-app-like-trello-in-2026-3bip</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/how-to-build-a-project-management-app-like-trello-in-2026-3bip</guid>
      <description>&lt;ul&gt;
&lt;li&gt;Trello's whole data model is three nested objects: &lt;strong&gt;boards → lists → cards&lt;/strong&gt;. Everything else decorates that skeleton.&lt;/li&gt;
&lt;li&gt;The 2026 stack: React Native + Expo, &lt;code&gt;gesture-handler&lt;/code&gt; + &lt;code&gt;reanimated&lt;/code&gt; for drag, FlashList for perf, Supabase for realtime, EAS to ship.&lt;/li&gt;
&lt;li&gt;The hard parts nobody warns you about: drag-and-drop is a physics problem, card ordering is a distributed-systems problem, and offline-first is a data-model problem.&lt;/li&gt;
&lt;li&gt;Traditional MVP: 4–8 weeks and $40k+. AI-generated first prototype: under an hour.&lt;/li&gt;
&lt;/ul&gt;




&lt;h1&gt;
  
  
  How to Build a Project Management App Like Trello in 2026
&lt;/h1&gt;

&lt;p&gt;Trello sold to Atlassian for $425M. The idea? Digital sticky notes. The execution? A decade of polish on one very good primitive.&lt;/p&gt;

&lt;p&gt;If you've ever thought about building your own — for a niche Trello ignores, as a portfolio project, or just because you're tired of Power-Ups — this is the modern build guide.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Trello data model (learn this first)
&lt;/h2&gt;

&lt;p&gt;Three nested objects. That's it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Boards&lt;/strong&gt; — one per project&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lists&lt;/strong&gt; — columns inside a board&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cards&lt;/strong&gt; — the actual work items&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything else — labels, checklists, automation, integrations — decorates that skeleton.&lt;/p&gt;

&lt;h2&gt;
  
  
  Must-have MVP features
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Board list + create board&lt;/li&gt;
&lt;li&gt;Draggable lists per board&lt;/li&gt;
&lt;li&gt;Draggable cards between lists&lt;/li&gt;
&lt;li&gt;Card detail (title, description, due date)&lt;/li&gt;
&lt;li&gt;Assignees + team invites&lt;/li&gt;
&lt;li&gt;Real-time sync&lt;/li&gt;
&lt;li&gt;Offline read, queued writes&lt;/li&gt;
&lt;li&gt;Push notifications on mentions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Deliberately skip for v1: Gantt, automation, custom fields, integrations. Trello itself took years.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 2026 stack
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Frontend&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;React Native + Expo (managed workflow)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;react-native-gesture-handler&lt;/code&gt; — drag primitives&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;react-native-reanimated&lt;/code&gt; — 60fps card animations&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@shopify/flash-list&lt;/code&gt; — hundreds of cards without frame drops&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;react-native-mmkv&lt;/code&gt; — local persistence (faster than AsyncStorage)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;State&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Redux Toolkit or Zustand&lt;/li&gt;
&lt;li&gt;Optimistic updates are non-negotiable — the drop must feel instant&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Backend&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Supabase (Postgres + Realtime + Auth) or Firebase&lt;/li&gt;
&lt;li&gt;Three tables: &lt;code&gt;boards&lt;/code&gt;, &lt;code&gt;lists&lt;/code&gt;, &lt;code&gt;cards&lt;/code&gt; with a &lt;code&gt;position&lt;/code&gt; float per card&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Real-time&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Supabase Realtime or Firestore listeners&lt;/li&gt;
&lt;li&gt;CRDTs (Yjs, Liveblocks) if you need deep collaborative editing later — overkill for v1&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Push&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Expo Notifications → APNS/FCM&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Publish&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;EAS Build + EAS Submit → App Store &amp;amp; Play Store&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The hard parts nobody warns you about
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Drag-and-drop is a physics problem.&lt;/strong&gt; Card position, list scroll, screen-edge auto-scroll, haptics, cancel gestures. Budget 2–3 weeks of tuning after "it works."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Ordering is distributed systems.&lt;/strong&gt; Two users drop cards at position 3 simultaneously — what happens? Fractional indexing is the usual answer, but it needs re-balancing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Offline-first is a data-model problem.&lt;/strong&gt; Per-mutation IDs, conflict rules, sync UI. A full week of design work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Push notifications require devops.&lt;/strong&gt; APNS certs expire. Silent notifications get throttled. You will hit this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. App Store review will reject something.&lt;/strong&gt; Usually your privacy policy. Budget one rejection.&lt;/p&gt;

&lt;h2&gt;
  
  
  The AI shortcut
&lt;/h2&gt;

&lt;p&gt;Since 2023, the "generate a working mobile app from a prompt" tools have gotten genuinely good — not "here's a broken scaffold" good, but "here's a running Expo project on your phone" good.&lt;/p&gt;

&lt;p&gt;I've been using RapidNative for this. It outputs real React Native + Expo code — the same stack you'd write by hand — with a full export button. No proprietary runtime.&lt;/p&gt;

&lt;p&gt;The pitch for a Trello clone specifically: Kanban is a well-known pattern. The AI has seen a thousand board layouts. The generic first version is instant, so you skip straight to what makes &lt;em&gt;your&lt;/em&gt; app different.&lt;/p&gt;

&lt;h2&gt;
  
  
  Concrete workflow
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Step 1 — Generate the shell&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Prompt:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Build a project management app for small teams. Users see a list of boards on the home screen. Each board has three columns — Backlog, In Progress, Done — with cards that can be dragged between columns. Cards have a title, description, due date, and assignee avatar."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You get four screens (board list, Kanban view, card detail, new-card form) in about a minute.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2 — Iterate with point-and-edit&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Click an element in the preview, describe the change:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Column header → &lt;em&gt;"Make this pill-shaped with a subtle color per column"&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Card → &lt;em&gt;"Add a checklist with progress bar"&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;FAB → &lt;em&gt;"Open a bottom sheet instead of a new screen"&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Step 3 — Test on your actual phone&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Scan the QR code with your camera. The app loads on your iPhone/Android in seconds. Drag a card. Feel the momentum. Desktop previews lie.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4 — Layer in the differentiators&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;"Add a weekly review screen showing all cards completed in the last 7 days grouped by assignee"&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"Add a template gallery: Bug Tracker, Content Calendar, Sales Pipeline"&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;"Add haptic feedback on successful card drop"&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Step 5 — Export or publish&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Export the code and take it to a developer for backend integration, or publish directly to the App Store and Google Play. Either way, you own the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Traditional vs. AI-generated
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Traditional team&lt;/th&gt;
&lt;th&gt;AI-generated&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;First prototype&lt;/td&gt;
&lt;td&gt;4–8 weeks&lt;/td&gt;
&lt;td&gt;&amp;lt; 1 hour&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App Store submission&lt;/td&gt;
&lt;td&gt;3–6 months&lt;/td&gt;
&lt;td&gt;1–2 weeks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Team size&lt;/td&gt;
&lt;td&gt;1 designer + 2 devs&lt;/td&gt;
&lt;td&gt;1 person&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Realistic MVP cost&lt;/td&gt;
&lt;td&gt;$40k – $120k&lt;/td&gt;
&lt;td&gt;&amp;lt; $50/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Code ownership&lt;/td&gt;
&lt;td&gt;Yours&lt;/td&gt;
&lt;td&gt;Yours (full export)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The traditional path still wins for deep custom logic (proprietary ERP integrations, bespoke real-time engines). For everything else, AI-first plus human polish for the last 20% is the fastest path to production in 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to build next
&lt;/h2&gt;

&lt;p&gt;Once v1 is out and you have retention data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Calendar view + Gantt for dated cards&lt;/li&gt;
&lt;li&gt;Automation rules&lt;/li&gt;
&lt;li&gt;Templates gallery&lt;/li&gt;
&lt;li&gt;Comments + @mentions + push&lt;/li&gt;
&lt;li&gt;Attachments (inline previews)&lt;/li&gt;
&lt;li&gt;Time tracking per card&lt;/li&gt;
&lt;li&gt;AI triage (auto-labels, priority, weekly summaries)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each is a prompt away with generation, and a sprint away in traditional dev.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;Trello is 15 years of polish on one great idea. You will not out-execute Atlassian at their own game. But you can absolutely serve a niche they ignore — freelance photographers, film crews, D&amp;amp;D dungeon masters — better than they ever will.&lt;/p&gt;

&lt;p&gt;The bottleneck used to be time and money. It isn't anymore. Pick a niche, ship in weeks, iterate on real feedback.&lt;/p&gt;

&lt;p&gt;If you want to try the AI path, &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=trello-clone-2026" rel="noopener noreferrer"&gt;RapidNative is free to start&lt;/a&gt; — you'll have a Kanban board running on your phone before your next coffee.&lt;/p&gt;

&lt;p&gt;What niche would you build a Trello for? Drop a comment.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>mobile</category>
      <category>ai</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>The React Native Accessibility Bugs Every App Ships With (And How to Fix Them)</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Fri, 10 Jul 2026 11:21:34 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/the-react-native-accessibility-bugs-every-app-ships-with-and-how-to-fix-them-3kie</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/the-react-native-accessibility-bugs-every-app-ships-with-and-how-to-fix-them-3kie</guid>
      <description>&lt;h1&gt;
  
  
  The React Native Accessibility Bugs Every App Ships With (And How to Fix Them)
&lt;/h1&gt;

&lt;p&gt;I've reviewed a lot of React Native codebases in the last year. Every single one shipped with the same six accessibility bugs. Here they are, with fixes.&lt;/p&gt;

&lt;p&gt;Quick context: WCAG 2.2 AA is the operative standard for mobile app accessibility in 2026 — the EU's Accessibility Act made it enforceable last year, and ADA lawsuits against mobile apps are climbing. React Native gives you the tools to comply. Most teams just don't use them right.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: Icon-only buttons with no label
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Broken&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt; &lt;span class="na"&gt;onPress&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;addToCart&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Icon&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"plus"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// Fixed&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityRole&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityLabel&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Add to cart"&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityHint&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Adds one to your current cart"&lt;/span&gt;
  &lt;span class="na"&gt;onPress&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;addToCart&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Icon&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"plus"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;VoiceOver reads the first one as "Button." Zero context. Every icon-only tap target needs &lt;code&gt;accessibilityLabel&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: FlatList rows read as a wall of text
&lt;/h2&gt;

&lt;p&gt;Each &lt;code&gt;Text&lt;/code&gt; child gets read as a separate element. Users swipe 6 times to hear one product tile.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityLabel&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rating&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; stars`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;View&lt;/span&gt; &lt;span class="na"&gt;accessibilityElementsHidden&lt;/span&gt; &lt;span class="na"&gt;importantForAccessibility&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"no-hide-descendants"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rating&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;View&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Bug 3: Modal backdrops that don't hide from VoiceOver
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;pointerEvents="none"&lt;/code&gt; stops touch. It does &lt;em&gt;not&lt;/em&gt; stop VoiceOver from reading the background content. You need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Modal&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;View&lt;/span&gt; &lt;span class="na"&gt;accessibilityViewIsModal&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* modal content */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;View&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Modal&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plus &lt;code&gt;accessibilityElementsHidden={true}&lt;/code&gt; on the underlying screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 4: Loading spinners that announce silence
&lt;/h2&gt;

&lt;p&gt;Sighted users see the spinner. VoiceOver users hear nothing and assume the app froze.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isLoading&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;AccessibilityInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;announceForAccessibility&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Loading orders...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;isLoading&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Bug 5: Custom toggle switches missing &lt;code&gt;accessibilityRole="switch"&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The built-in &lt;code&gt;&amp;lt;Switch&amp;gt;&lt;/code&gt; handles this. Custom animated switches almost never do:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityRole&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"switch"&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityState&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;checked&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;isOn&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;onPress&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setIsOn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isOn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;AnimatedThumb&lt;/span&gt; &lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isOn&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Bug 6: Dark mode contrast failures
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;#888888&lt;/code&gt; on &lt;code&gt;#121212&lt;/code&gt; = 3.5:1. That's under WCAG AA for body text. Dark themes don't get a pass — run every color combination through a contrast checker. Enforce it in your design tokens, not per-component.&lt;/p&gt;

&lt;h2&gt;
  
  
  Touch targets: WCAG 2.2 SC 2.5.8
&lt;/h2&gt;

&lt;p&gt;Minimum 24×24 CSS pixels. Aim for 44×44 to match iOS and Material guidelines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt;
  &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;minWidth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;44&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;minHeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;44&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;hitSlop&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;hitSlop&lt;/code&gt; extends the tap area without changing layout — huge for tight icon rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  The testing stack that actually catches this
&lt;/h2&gt;

&lt;p&gt;Four layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@react-native-ama/core&lt;/code&gt;&lt;/strong&gt; — runtime overlay in dev builds that flags missing labels, contrast failures, and undersized targets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@testing-library/react-native&lt;/code&gt;&lt;/strong&gt; — &lt;code&gt;getByRole('button', { name: 'Add to cart' })&lt;/code&gt;. If the query fails, your a11y is broken.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;axe DevTools Mobile&lt;/strong&gt; — automated WCAG audit in CI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manual VoiceOver + TalkBack&lt;/strong&gt; — five minutes per release. No tool replaces this.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The angle nobody talks about
&lt;/h2&gt;

&lt;p&gt;Retrofitting accessibility is expensive. Starting with WCAG-aligned defaults is nearly free. That's part of why we built &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=react-native-wcag-compliance-guide" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; — the AI-generated code ships with &lt;code&gt;accessibilityLabel&lt;/code&gt;, &lt;code&gt;accessibilityRole&lt;/code&gt;, and AA-compliant color tokens by default. You still test with VoiceOver, but you're fixing 5 bugs per screen instead of 50.&lt;/p&gt;

&lt;p&gt;WCAG 3.0's editor's draft (Jan 2026) moves to outcome-based scoring, but 2.2 AA is what regulators will hold you to for years. Build the habits now.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>a11y</category>
      <category>mobile</category>
      <category>webdev</category>
    </item>
    <item>
      <title>What Indie React Native Devs Say About EAS 90 Days In</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Wed, 08 Jul 2026 14:17:11 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/what-indie-react-native-devs-say-about-eas-90-days-in-k8h</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/what-indie-react-native-devs-say-about-eas-90-days-in-k8h</guid>
      <description>&lt;ul&gt;
&lt;li&gt;I surveyed 30 indie React Native devs about their EAS Build experience 90 days after migrating from other CI.&lt;/li&gt;
&lt;li&gt;The surprise: the top reported gain wasn't "faster builds" — 19 of 30 said some version of &lt;strong&gt;"I stopped fearing the build."&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;The first-30-days friction clustered around credentials, native module compatibility, and env var propagation.&lt;/li&gt;
&lt;li&gt;Devs bought Priority tier &lt;strong&gt;reactively&lt;/strong&gt;, after one stalled build cost them a business moment.&lt;/li&gt;
&lt;li&gt;The real frame: build velocity is a downstream metric for how much you're willing to iterate.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Over the last month I asked 30 indie React Native devs a short set of questions about their EAS Build experience 90 days after they migrated from whatever their previous CI was (Bitrise, Codemagic, App Center, GitHub Actions, home-rolled). The sample is small and biased — mostly people in the Expo Discord and RN subreddits — but the patterns were tight enough to be worth writing up.&lt;/p&gt;

&lt;p&gt;Here's what came out of it, and why one finding surprised me.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we ran the survey
&lt;/h2&gt;

&lt;p&gt;Five open-ended questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What did you migrate from, and why?&lt;/li&gt;
&lt;li&gt;What did you expect to gain? What did you actually gain?&lt;/li&gt;
&lt;li&gt;What broke or frustrated you in the first 30 days?&lt;/li&gt;
&lt;li&gt;Did you upgrade to Priority tier? If so, when and why?&lt;/li&gt;
&lt;li&gt;If you were advising a friend about to migrate, what would you tell them?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Responses via DM, ~10–15 minutes each. 30 devs, spread across roughly a dozen countries.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trust-in-the-build finding that surprised us
&lt;/h2&gt;

&lt;p&gt;Question 2's "what did you actually gain" was where the interesting pattern emerged. I expected answers like "faster builds" or "less YAML" — and got some of those. But the phrase that came up in 19 of 30 responses, unprompted:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I stopped fearing the build.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Or close variants. "I stopped avoiding native module changes." "I stopped batching commits." "I started shipping small changes daily instead of holding them until a batch was safe."&lt;/p&gt;

&lt;p&gt;The pattern underneath: when your build pipeline is flaky, you develop compensatory behaviors. You batch changes to reduce the number of builds. You avoid touching native modules because a failed native build costs a day. You wait until Friday to try the risky refactor, because if it breaks you can lose a whole afternoon debugging CI instead of code.&lt;/p&gt;

&lt;p&gt;A reliable build removes those compensatory behaviors. What devs called out as the gain wasn't the build itself — it was the freedom to iterate the way they'd want to iterate if CI weren't a threat.&lt;/p&gt;

&lt;p&gt;That's a hard thing to sell on a marketing page. But it's the actual product.&lt;/p&gt;

&lt;h2&gt;
  
  
  What breaks the trust — and how devs recover
&lt;/h2&gt;

&lt;p&gt;The first-30-days frustrations clustered into three:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Credential setup.&lt;/strong&gt; iOS certificate + provisioning profile onboarding was the #1 pain point. Devs who let EAS manage credentials had a smoother start than those who imported their existing P12s. Advice from experienced devs: let EAS manage, then export if you need to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native module compatibility.&lt;/strong&gt; When a native module didn't play well with EAS's build environment, the error messages were opaque. Recovery pattern: &lt;code&gt;eas build --local&lt;/code&gt; to reproduce the failure on your own machine, then debug from there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment variable propagation.&lt;/strong&gt; &lt;code&gt;EXPO_PUBLIC_*&lt;/code&gt; vs. runtime env vs. &lt;code&gt;app.config.js&lt;/code&gt; extras — devs got confused about which one to use where. The consensus fix was to standardize on &lt;code&gt;EXPO_PUBLIC_*&lt;/code&gt; for anything client-side and never look back.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Devs who hit these in the first two weeks and stuck it out ended up as advocates by day 90. Devs who hit these and bounced back to their old CI usually did so before day 14.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Priority-tier decision point most indies hit
&lt;/h2&gt;

&lt;p&gt;Question 4 showed a consistent inflection: solo devs stayed on free/Production tiers, but devs who moved to Priority did so because of a specific incident — a launch day where queue time cost them a demo, or a design review that stalled because a build was in queue.&lt;/p&gt;

&lt;p&gt;Most didn't buy Priority preemptively. They bought it reactively, after one bad wall-clock moment. Once they had it, none of the 8 respondents on Priority downgraded.&lt;/p&gt;

&lt;p&gt;The rough decision rule that emerged: if a stalled build has ever cost you a business moment (a meeting, a demo, a customer), Priority pays for itself the next month.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion: build velocity is a downstream metric
&lt;/h2&gt;

&lt;p&gt;The most useful frame from these 30 conversations: build velocity is a downstream metric. What you actually care about is how much you're willing to iterate. Build velocity feeds into willingness to iterate; willingness to iterate feeds into product quality.&lt;/p&gt;

&lt;p&gt;If you're thinking about migrating to EAS, or from EAS, or between EAS tiers, the question worth asking your team isn't "what's our build time?" — it's "when did we last hold a change because we didn't trust CI would let us ship it small?"&lt;/p&gt;

&lt;p&gt;Answer that honestly and you know what to do next.&lt;/p&gt;




&lt;p&gt;If you're building React Native apps and want to skip straight to a project that's already wired for this kind of fast iteration, &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=indie-eas-90-days" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; generates React Native + Expo apps you can preview and export.&lt;/p&gt;

&lt;p&gt;How about you — when did you last hold a change because you didn't trust CI? Drop a comment.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>indiedev</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Checkout UX in React Native: What 12 User Tests Taught Us About Payment Flow Design</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Wed, 01 Jul 2026 11:55:06 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/checkout-ux-in-react-native-what-12-user-tests-taught-us-about-payment-flow-design-2705</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/checkout-ux-in-react-native-what-12-user-tests-taught-us-about-payment-flow-design-2705</guid>
      <description>&lt;ul&gt;
&lt;li&gt;We ran 12 moderated user tests on a React Native + Stripe checkout flow. Six users completed on the first try; six hit visible friction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Double-taps&lt;/strong&gt; happened because the payment sheet took 300–500ms to init on tap. Fix: init on the previous screen's mount.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Total mismatches&lt;/strong&gt; (app showed $42, sheet showed $39) made users back out. Match totals to the cent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Button copy&lt;/strong&gt; matters: "Pay $42" beat "Buy Now" beat "Continue to Payment."&lt;/li&gt;
&lt;li&gt;Users look &lt;strong&gt;right above the button&lt;/strong&gt; in the last second before tapping — put price, item, and trust anchors there.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;We ran 12 moderated user tests on a checkout flow in a React Native app last month. Same app, same product, same price. The task was simple: buy the thing.&lt;/p&gt;

&lt;p&gt;Six of the twelve completed on the first try. Six had visible friction. This post is what we found and what we changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;p&gt;React Native + Expo + Stripe PaymentSheet. A physical iPhone 13 and Pixel 6a, five sessions per device plus two on an older Android. Screen recording and voice-over throughout. The button that opened the payment sheet was labeled "Continue to Payment" at the start of the study.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 1 — the double-tap
&lt;/h2&gt;

&lt;p&gt;Five of the twelve users tapped the payment button twice. Not deliberately — the first tap didn't visibly do anything for 300–500ms while the sheet initialized, so they tapped again thinking they'd missed. On slower devices, the second tap opened a second Stripe sheet on top of the first.&lt;/p&gt;

&lt;p&gt;The fix is straightforward and undocumented: initialize the sheet on the previous screen's mount, not on the button press. When the user reaches the checkout screen, the sheet is already ready. The tap opens it instantly — no spinner, no double-tap.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;initPaymentSheet&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;paymentIntentClientSecret&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Finding 2 — the total mismatch
&lt;/h2&gt;

&lt;p&gt;Three of the twelve backed out at the payment sheet. The reason: our app showed $42, the Stripe sheet showed $39. The app was rendering with-tax; the sheet was showing pre-tax because we hadn't passed the tax breakdown. One user said out loud: "wait, is this the wrong item?"&lt;/p&gt;

&lt;p&gt;The lesson: whatever total your app shows must match the sheet's total to the cent. Either pass the tax in the PaymentIntent, or show pre-tax on the checkout screen and let the sheet add tax. Never let the two disagree.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 3 — button copy
&lt;/h2&gt;

&lt;p&gt;We ran a mini A/B on the button that opens the sheet: "Continue to Payment" vs. "Buy Now" vs. "Pay $42." Six users saw each variant across the sessions.&lt;/p&gt;

&lt;p&gt;Rough result: "Pay $42" converted best — specific, action-oriented, no ambiguity. "Buy Now" was next. "Continue to Payment" was the worst: the word "continue" made users expect another step, so they hesitated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 4 — where users look right before tapping
&lt;/h2&gt;

&lt;p&gt;Eye-tracking would've been nice; we used the think-aloud protocol instead. Consistently, users glanced at the area immediately above the button in the last second before tapping. That's where they wanted the price, the item name, and any trust anchors (card logos, "Secured by Stripe").&lt;/p&gt;

&lt;p&gt;Not at the top of the screen. Not in the header. Right above the button. Design that area like it's the last decision surface — because it is.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 5 — the receipt matters
&lt;/h2&gt;

&lt;p&gt;Half the users, once payment succeeded, immediately swiped to close the app and check their email for a receipt. If the receipt didn't arrive in the first 5 seconds, they came back to the app anxious.&lt;/p&gt;

&lt;p&gt;We reduced this by showing a "Receipt sent to X" toast on the success screen, with the email address visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist you can steal
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Init PaymentSheet on mount, not on tap.&lt;/li&gt;
&lt;li&gt;Match totals exactly between the app and the sheet.&lt;/li&gt;
&lt;li&gt;Use action-specific button copy — "Pay $42" beats "Continue to Payment."&lt;/li&gt;
&lt;li&gt;Put price, item, and trust anchors immediately above the payment button.&lt;/li&gt;
&lt;li&gt;Show a "receipt sent to X" confirmation before the user swipes away.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are surprising in retrospect. All of them showed up in user testing before we noticed them in code — which is the whole argument for testing checkout with real people before you ship it.&lt;/p&gt;

&lt;p&gt;If you'd rather start from a checkout that already bakes these patterns in, &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=checkout-ux-react-native" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; generates React Native + Stripe flows with the payment sheet, total handling, and success states wired up.&lt;/p&gt;

&lt;p&gt;What's the worst checkout friction you've hit as a user? Drop a comment.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>ux</category>
      <category>payments</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How We Got an LLM to Reliably Generate Theme-Aware React Native Apps</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Tue, 30 Jun 2026 07:11:44 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/how-we-got-an-llm-to-reliably-generate-theme-aware-react-native-apps-15n1</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/how-we-got-an-llm-to-reliably-generate-theme-aware-react-native-apps-15n1</guid>
      <description>&lt;ul&gt;
&lt;li&gt;Asking an LLM to "generate a React Native screen with dark mode" gives you inconsistent results — &lt;code&gt;useColorScheme&lt;/code&gt; on one screen, a &lt;code&gt;dark:&lt;/code&gt; prefix on another, a hardcoded &lt;code&gt;bg-white&lt;/code&gt; on a third.&lt;/li&gt;
&lt;li&gt;The fix isn't a better prompt. It's moving theme switching out of the model's job and into the project scaffold.&lt;/li&gt;
&lt;li&gt;Every generated app starts from a &lt;code&gt;theme.ts&lt;/code&gt; (light + dark via NativeWind v4 &lt;code&gt;vars()&lt;/code&gt;), a root &lt;code&gt;ThemeProvider&lt;/code&gt;, and a &lt;code&gt;tailwind.config.js&lt;/code&gt; binding semantic class names to CSS variables.&lt;/li&gt;
&lt;li&gt;Five system-prompt rules keep the model writing &lt;code&gt;className="bg-background"&lt;/code&gt; and nothing else.&lt;/li&gt;
&lt;li&gt;You export a clean Expo project with no runtime dependency on us.&lt;/li&gt;
&lt;/ul&gt;




&lt;h1&gt;
  
  
  How We Got an LLM to Reliably Generate Theme-Aware React Native Apps
&lt;/h1&gt;

&lt;p&gt;If you've ever asked an LLM to "generate a React Native screen with dark mode support," you know the result. Sometimes you get &lt;code&gt;useColorScheme&lt;/code&gt;. Sometimes you get a &lt;code&gt;dark:&lt;/code&gt; prefix. Sometimes you get a hardcoded &lt;code&gt;bg-white&lt;/code&gt;. Sometimes you get all three in the same file.&lt;/p&gt;

&lt;p&gt;This post is about how we built theme-aware UI as a property of every generated app in &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=theme-aware-react-native-uis" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; — an AI mobile app builder — instead of leaving it as a per-prompt gamble. We'll cover the prompt rules, the scaffold the model writes into, and the architectural choices that made dark mode boring and predictable. (Which, in this context, is high praise.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest version of the problem
&lt;/h2&gt;

&lt;p&gt;For human developers, the standard dark mode recipe in React Native is well documented: call &lt;a href="https://reactnative.dev/docs/usecolorscheme" rel="noopener noreferrer"&gt;useColorScheme&lt;/a&gt;, branch on the result, persist the user's preference, tint the status bar with &lt;code&gt;expo-status-bar&lt;/code&gt;. Books have been written.&lt;/p&gt;

&lt;p&gt;For LLMs generating code, that recipe is a minefield:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The model has to remember the pattern on every generated screen.&lt;/li&gt;
&lt;li&gt;It has to apply it consistently to components it's never seen together.&lt;/li&gt;
&lt;li&gt;It has to handle native primitives (&lt;code&gt;ActivityIndicator&lt;/code&gt;, &lt;code&gt;RefreshControl&lt;/code&gt;, &lt;code&gt;Switch&lt;/code&gt;) that take hex colors, not class names.&lt;/li&gt;
&lt;li&gt;It has to not invent new variations of the pattern (which it loves to do).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In our early experiments, the failure mode was always the same: one screen looked perfect in dark mode, the next one looked unreadable, and the third one had &lt;code&gt;bg-white&lt;/code&gt; hardcoded because the model decided that screen "needed to feel clean."&lt;/p&gt;

&lt;h2&gt;
  
  
  The structural fix: theme switching as a foundation, not a per-component decision
&lt;/h2&gt;

&lt;p&gt;We stopped trying to make the model handle color scheme detection. Instead, we made every generated project start from a scaffold that includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;theme.ts&lt;/code&gt; file with two named exports — &lt;code&gt;lightTheme&lt;/code&gt; and &lt;code&gt;darkTheme&lt;/code&gt; — each declared via NativeWind v4's &lt;code&gt;vars()&lt;/code&gt; helper, mapping semantic CSS variable names to RGB triplets.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;ThemeProvider.tsx&lt;/code&gt; that picks the right &lt;code&gt;vars&lt;/code&gt; object and applies it to the root view.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;tailwind.config.js&lt;/code&gt; set up so class names like &lt;code&gt;bg-background&lt;/code&gt; and &lt;code&gt;text-foreground&lt;/code&gt; resolve to those CSS variables at runtime.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now the model never writes color scheme logic. It writes &lt;code&gt;&amp;lt;View className="bg-background"&amp;gt;&lt;/code&gt; and trusts the foundation to handle the switch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The system prompt rules that make it stick
&lt;/h2&gt;

&lt;p&gt;The hard part isn't designing the scaffold. It's getting the LLM to use it correctly every time. Our system prompt encodes five rules that, taken together, get us a near-100% hit rate on theme correctness across generated screens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 1: semantic classes only.&lt;/strong&gt; No &lt;code&gt;dark:&lt;/code&gt; prefix. No bracket values like &lt;code&gt;bg-[#ffffff]&lt;/code&gt;. No hardcoded hex in &lt;code&gt;className&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 2: theme.ts is fill-in-the-blanks.&lt;/strong&gt; The model can change RGB values; it cannot restructure the file, rename exports, or change variable keys. This invariant is what lets our editor parse it and our theme editor UI work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 3: ThemeProvider is off-limits.&lt;/strong&gt; Specifically: never pass &lt;code&gt;value={...}&lt;/code&gt; to it. LLMs love passing reasonable-looking props. This one would silently break theme switching, so we forbid it by name with the consequence spelled out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 4: inline hex only from theme.ts.&lt;/strong&gt; When a native primitive needs a color prop, the model derives the hex from &lt;code&gt;theme.ts&lt;/code&gt;'s RGB values using an &lt;code&gt;isDark&lt;/code&gt; ternary — never a literal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 5: don't add unrequested variables.&lt;/strong&gt; Including &lt;code&gt;useColorScheme&lt;/code&gt;. The model is told explicitly not to inject color scheme detection that the user didn't ask for, because the system handles it.&lt;/p&gt;

&lt;p&gt;Each rule on its own is small. Together they collapse a huge surface area of failure modes into "the model writes semantic class names, full stop."&lt;/p&gt;

&lt;h2&gt;
  
  
  Deterministic brand palettes
&lt;/h2&gt;

&lt;p&gt;Every project gets a unique palette derived from a hash of its project ID. We pick a brand hue and accent hue, then generate matched light and dark palettes via HSL-to-RGB conversion. The model receives a pre-composed &lt;code&gt;theme.ts&lt;/code&gt; it's told to emit verbatim on the first generation, so the brand identity stays consistent as the project grows.&lt;/p&gt;

&lt;p&gt;The two-mode palette is generated together, from the same hue, in the same color space. That's the trick to making dark mode feel like the same product as light mode instead of an afterthought.&lt;/p&gt;

&lt;h2&gt;
  
  
  The export
&lt;/h2&gt;

&lt;p&gt;When you download a project, you get a clean Expo workspace with &lt;code&gt;theme.ts&lt;/code&gt;, the provider, NativeWind configured, and every screen using semantic classes. No runtime dependency on us. It's a standard React Native + Expo project that happens to have well-organized theming because the AI was given strict rules about how to build it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take from this for any AI-codegen project
&lt;/h2&gt;

&lt;p&gt;If you're shipping an LLM-powered code generator, three things from this approach generalize:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Move correctness into the scaffold, not the prompt.&lt;/strong&gt; Anything the foundation can enforce structurally is one less thing the model can mess up per call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forbid patterns by name with consequences.&lt;/strong&gt; Don't say "use semantic classes." Say "no &lt;code&gt;dark:&lt;/code&gt; prefix, no hex in &lt;code&gt;className&lt;/code&gt;." The model responds to specific bans much better than positive guidelines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make file shape an invariant.&lt;/strong&gt; If your post-processing tools (parsers, editors, watchers) depend on file structure, lock that structure in the prompt. The model will respect it if you're explicit.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Full deep-dive on the architecture (three theming layers, the brand-palette directive, the in-editor theme parser, the live preview, and what shows up in the export ZIP) is on our blog: &lt;a href="https://www.rapidnative.com/blogs/how-rapidnative-generates-theme-aware-react-native-uis?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=theme-aware-react-native-uis" rel="noopener noreferrer"&gt;How RapidNative Generates Theme-Aware React Native UIs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What's the worst LLM-generated theming mess you've had to clean up? Drop a comment with what you're building.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>ai</category>
      <category>mobile</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A Freelance React Native Workflow That Cuts MVP Delivery From 4 Weeks to 1</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Mon, 29 Jun 2026 13:22:57 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/a-freelance-react-native-workflow-that-cuts-mvp-delivery-from-4-weeks-to-1-5cin</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/a-freelance-react-native-workflow-that-cuts-mvp-delivery-from-4-weeks-to-1-5cin</guid>
      <description>&lt;h1&gt;
  
  
  A Freelance React Native Workflow That Cuts MVP Delivery From 4 Weeks to 1
&lt;/h1&gt;

&lt;p&gt;If you freelance on React Native, your build time isn't where you think it is. It's not in component code — it's in the workflow loop around it: pre-kickoff scaffolding, Figma-to-real-app translation, scope-change re-quotes. This post walks through a freelance delivery workflow I've been running with an AI app builder (RapidNative) and the actual tradeoffs I've hit shipping client projects with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The starting state: where freelance React Native time actually goes
&lt;/h2&gt;

&lt;p&gt;Real numbers from my last 12 client projects (solo, $80/h blended, 4-week MVPs):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Phase&lt;/th&gt;
&lt;th&gt;Avg hours&lt;/th&gt;
&lt;th&gt;What I was actually doing&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pre-kickoff scaffolding&lt;/td&gt;
&lt;td&gt;18h&lt;/td&gt;
&lt;td&gt;Expo init, navigation, theme, auth screens, basic state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discovery + design alignment&lt;/td&gt;
&lt;td&gt;14h&lt;/td&gt;
&lt;td&gt;Figma walkthroughs, "what does this look like on a phone" debates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Initial screen build&lt;/td&gt;
&lt;td&gt;35h&lt;/td&gt;
&lt;td&gt;6–8 screens, basic CRUD, mock data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revisions round 1&lt;/td&gt;
&lt;td&gt;10h&lt;/td&gt;
&lt;td&gt;Translating Figma feedback into code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mid-project scope change&lt;/td&gt;
&lt;td&gt;14h&lt;/td&gt;
&lt;td&gt;Re-quoted, sometimes absorbed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API wiring + polish&lt;/td&gt;
&lt;td&gt;18h&lt;/td&gt;
&lt;td&gt;The actually interesting work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~109h&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The boring 70% is the first three rows. AI is genuinely good at those.&lt;/p&gt;

&lt;h2&gt;
  
  
  The workflow I run now
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Pre-kickoff: paste the brief, get a working app
&lt;/h3&gt;

&lt;p&gt;Before the kickoff call, I paste the client's brief into RapidNative. ~10 minutes later there's a working Expo project on a QR code. No npx create-expo-app, no theme tokens, no navigation wiring. The generated project includes Expo Router navigation, a basic design system, screens implied by the brief, and realistic mock data.&lt;/p&gt;

&lt;p&gt;This is the step that costs me 18 hours on a traditional project. It now costs me 10 minutes plus a coffee.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Kickoff call: demo on the client's phone, iterate live
&lt;/h3&gt;

&lt;p&gt;The QR code matters more than the build itself. The client scans, the app opens on their phone, they tap through their own app during the call. When they want a change — "this button should be green," "remove this onboarding screen" — I use the point-and-edit mode. Click the element, type the change, watch it update on their phone in real time.&lt;/p&gt;

&lt;p&gt;This compresses what used to be a week of feedback loops into one call. The technical reason: feedback against a real interactive app is concrete. Feedback against Figma is theoretical.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Async iteration via team collaboration
&lt;/h3&gt;

&lt;p&gt;Between calls, I invite the client into the project as a collaborator. They can comment on specific screens or drop their own prompts for small changes. I wake up to a punch list with screenshots attached to the specific screen.&lt;/p&gt;

&lt;p&gt;If they're non-technical, point-and-edit is dangerous to give them write access on — but a comment-only role is fine.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Mid-project scope change: absorbed in an afternoon
&lt;/h3&gt;

&lt;p&gt;The week-3 "can we add a referral flow?" used to cost me 14 hours of build + 2 hours of awkward emails. Now it's a prompt — "add a referral flow with a share button on the profile screen, a stats screen, and a redemption screen" — and an afternoon of polish. I usually absorb it. The math works because the cost is now hours, not days.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Export → VS Code → ship
&lt;/h3&gt;

&lt;p&gt;When the prototype is signed off, I export to a real React Native + Expo repo and continue in VS Code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Replace mock data with real API calls&lt;/li&gt;
&lt;li&gt;Wire auth properly (the AI scaffolds the screens but I always rewrite the actual flow)&lt;/li&gt;
&lt;li&gt;Add the custom business logic the brief mentioned but the AI guessed at&lt;/li&gt;
&lt;li&gt;Run on a device, fix the inevitable native quirks&lt;/li&gt;
&lt;li&gt;Submit to App Store / Google Play&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The export is a normal Expo project. npx expo start runs it locally. No vendor lock-in.&lt;/p&gt;

&lt;h2&gt;
  
  
  What AI does well, what it doesn't
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Well:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Scaffolding: navigation, theme, layouts, screens&lt;/li&gt;
&lt;li&gt;Standard UI patterns: list/detail, forms, settings, onboarding&lt;/li&gt;
&lt;li&gt;Stylistic iteration: "make this look like X"&lt;/li&gt;
&lt;li&gt;Mock data and basic state management&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Not well:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Payments (Stripe / RevenueCat / IAP) — always rewrite by hand&lt;/li&gt;
&lt;li&gt;Custom auth flows with edge cases — scaffolds the screens, you write the actual flow&lt;/li&gt;
&lt;li&gt;Real-time / WebSocket-heavy features&lt;/li&gt;
&lt;li&gt;Anything with non-obvious business logic — the AI guesses, and the guess is usually wrong in subtle ways&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The split is convenient: the "well" column is the part of freelance projects that's hardest to bill for. The "not well" column is the part you should be billing premium rates on anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical tips after ~10 projects on this workflow
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Export early.&lt;/strong&gt; First day of the project, export the code and run it locally. Catches any environment issues before the demo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't generate, then edit, then regenerate.&lt;/strong&gt; Once you've started hand-editing in VS Code, treat the AI builder as a prototype tool, not a continued source of truth. Mixing both creates merge pain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use point-and-edit for visual changes, prompts for structural changes.&lt;/strong&gt; Trying to do structural changes through point-and-edit produces inconsistent results.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For complex flows, scaffold them as separate small prompts, not one big prompt.&lt;/strong&gt; Asking the AI to "build a complete e-commerce checkout with payment, shipping, tax, and confirmation" produces worse output than asking for each screen individually.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep a client-notes.md in the project root.&lt;/strong&gt; AI builders don't preserve the "why" behind decisions; you do.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The economics question every freelancer asks
&lt;/h2&gt;

&lt;p&gt;If a 4-week MVP becomes a 1-week sprint, do you cut your price 75%? No. You either:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Hold the price and shorten the timeline (clients love this)&lt;/li&gt;
&lt;li&gt;Hold the price and run 3–4× more projects per year&lt;/li&gt;
&lt;li&gt;Productize: "Mobile MVP in 7 days for $X" as a fixed offer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The speed itself is the value. Most clients pay for outcome, not hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it on your next brief
&lt;/h2&gt;

&lt;p&gt;The fastest test: take your next client brief, paste it into &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=ai-app-builder-for-freelancers-deliver-mobile-apps-10x-faster" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt;, and see what comes back in 10 minutes. If it's useful, you saved 2 days of scaffolding. If it's not, you spent 10 minutes.&lt;/p&gt;

&lt;p&gt;I've found it's reliably useful for the 70% of freelance work that's the same on every project. Your mileage with the other 30% — the custom business logic that's why the client hired you — is up to you.&lt;/p&gt;

&lt;p&gt;If you've already explored this space, the &lt;a href="https://www.rapidnative.com/blogs/inside-the-export-pipeline-from-ai-generated-react-native-code-to-the-app-store?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=ai-app-builder-for-freelancers-deliver-mobile-apps-10x-faster" rel="noopener noreferrer"&gt;export pipeline post&lt;/a&gt; goes deeper on what the output actually looks like.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>ai</category>
      <category>freelance</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
