<?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>React Native i18n in 2026: A Practical Expo Router Playbook</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Mon, 03 Aug 2026 07:22:27 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/react-native-i18n-in-2026-a-practical-expo-router-playbook-1eha</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/react-native-i18n-in-2026-a-practical-expo-router-playbook-1eha</guid>
      <description>&lt;p&gt;If your React Native app only speaks English, you're leaving most of the world's smartphone users on the table. Here's the modern stack for shipping a multilingual Expo app without three days of glue code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;react-i18next&lt;/strong&gt; for translation management (TypeScript-first, the default choice in the RN ecosystem)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;expo-localization&lt;/strong&gt; for device locale detection (or &lt;code&gt;react-native-localize&lt;/code&gt; for bare RN)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Intl&lt;/strong&gt; for dates, numbers, currencies (built into Hermes)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I18nManager&lt;/strong&gt; for RTL layout flipping&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Setup in 4 Steps
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Install
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx expo &lt;span class="nb"&gt;install &lt;/span&gt;expo-localization
npm i i18next react-i18next
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Translation files
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;locales/
  en.json
  es.json
  ar.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;"welcome"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Welcome"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cart"&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;"itemCount_one"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{{count}} item"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"itemCount_other"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{{count}} items"&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;Use the ICU plural suffixes (&lt;code&gt;_one&lt;/code&gt;, &lt;code&gt;_other&lt;/code&gt;, &lt;code&gt;_few&lt;/code&gt;, &lt;code&gt;_many&lt;/code&gt;, &lt;code&gt;_zero&lt;/code&gt;). Arabic has six plural forms. Get this right on day one.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Bootstrap
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// i18n.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;i18n&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;i18next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;initReactI18next&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-i18next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Localization&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;expo-localization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;en&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;./locales/en.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;es&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;./locales/es.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;ar&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;./locales/ar.json&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;deviceLocale&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Localization&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getLocales&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="nx"&gt;languageCode&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;i18n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;initReactI18next&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;compatibilityJSON&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;v4&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;en&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;translation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;en&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;es&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;translation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;es&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;ar&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;translation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ar&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;lng&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;deviceLocale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;fallbackLng&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;interpolation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;escapeValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;i18n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two production-important bits: &lt;code&gt;compatibilityJSON: 'v4'&lt;/code&gt; for modern plural rules, and &lt;code&gt;escapeValue: false&lt;/code&gt; because RN already escapes JSX.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Wire into Expo Router
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/_layout.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Stack&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;expo-router&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./i18n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;RootLayout&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="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Stack&lt;/span&gt; &lt;span class="o"&gt;/&amp;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;Then in any component:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&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;useTranslation&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-i18next&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="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;=&lt;/span&gt; &lt;span class="nf"&gt;useTranslation&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Text&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;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cart.itemCount&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;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="p"&gt;})}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/Text&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;span class="c1"&gt;// "3 items"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Gotchas Nobody Warns You About
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Never concatenate around &lt;code&gt;t()&lt;/code&gt;&lt;/strong&gt;. Word order changes across languages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ Breaks in Japanese, German, Arabic&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Text&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;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hello&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;user&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="o"&gt;!&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/Text&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;
&lt;span class="c1"&gt;// ✅ Whole sentence is one key&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Text&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;t&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;greeting&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;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&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="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/Text&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;RTL doesn't flip everything&lt;/strong&gt;. Flexbox auto-flips, but you'll need to fix:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;left&lt;/code&gt;/&lt;code&gt;right&lt;/code&gt;, use &lt;code&gt;start&lt;/code&gt;/&lt;code&gt;end&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Directional icons, wrap in &lt;code&gt;transform: [{ scaleX: -1 }]&lt;/code&gt; when &lt;code&gt;I18nManager.isRTL&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Hardcoded &lt;code&gt;textAlign&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Hardcoded strings in third-party UI kits&lt;/strong&gt;. Buttons in libraries like RN Elements ship English defaults. Override or pick i18n-aware libs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Push notifications aren't localized by the phone&lt;/strong&gt;. Your backend has to send the localized copy or use OS-level notification categories.&lt;/p&gt;

&lt;h2&gt;
  
  
  Formatting
&lt;/h2&gt;

&lt;p&gt;Never string-concat dates or currencies. Use &lt;code&gt;Intl&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DateTimeFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i18n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;language&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;dateStyle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;medium&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;format&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;Date&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NumberFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i18n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;language&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;currency&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;USD&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;29.99&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add &lt;code&gt;@formatjs/intl-datetimeformat&lt;/code&gt; polyfills if you need older Android or less common locales.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing Discipline
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Turn on &lt;code&gt;saveMissing&lt;/code&gt; in dev. react-i18next logs every missing key. Fail CI on any.&lt;/li&gt;
&lt;li&gt;Pseudo-localize with &lt;code&gt;en-XA&lt;/code&gt;. Surfaces hardcoded strings and 30 to 40% longer text for layout stress-tests.&lt;/li&gt;
&lt;li&gt;Snapshot per locale. French, German, and Arabic snapshots catch layout regressions.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Shortcut
&lt;/h2&gt;

&lt;p&gt;If you're starting fresh, &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=rn-i18n-playbook" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; generates the whole i18n scaffold, provider, layout wiring, translation files, RTL flags, from a natural-language prompt. You get the same setup you'd write by hand, in about 30 seconds.&lt;/p&gt;

&lt;p&gt;Otherwise: ship it. It's 2 to 3 days of setup and it opens the app to a much larger share of the market.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>i18n</category>
      <category>javascript</category>
    </item>
    <item>
      <title>How to Build a Pet Care App with React Native (2026)</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Fri, 31 Jul 2026 10:28:00 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/how-to-build-a-pet-care-app-with-react-native-2026-3i7</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/how-to-build-a-pet-care-app-with-react-native-2026-3i7</guid>
      <description>&lt;ul&gt;
&lt;li&gt;Pet care is a well-scoped React Native project: bounded features, cross-platform demand, forgiving users.&lt;/li&gt;
&lt;li&gt;MVP is five features: pet profile, health record, appointment tracker, feeding log, push notifications.&lt;/li&gt;
&lt;li&gt;Stack: Expo SDK 52+, TypeScript, Expo Router, Supabase, NativeWind, FlashList.&lt;/li&gt;
&lt;li&gt;Store UTC in the database, render local with &lt;code&gt;date-fns-tz&lt;/code&gt;, or your 8 AM reminder fires at 3 AM after a flight.&lt;/li&gt;
&lt;li&gt;Store &lt;code&gt;next_due_at&lt;/code&gt; per row. Rabies schedules differ by jurisdiction.&lt;/li&gt;
&lt;li&gt;Realistic timeline: 4 to 6 weeks solo if you know React Native, 10 to 12 if you are learning the stack alongside.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pet care market keeps growing, and mobile apps are eating an increasing share of it: vet booking, vaccination reminders, feeding schedules. If you've been looking for a well-scoped React Native project to ship, a pet care app is genuinely one of the best options. Bounded feature set, cross-platform relevance, and a user base that's forgiving of a rough v1 as long as it solves a real problem.&lt;/p&gt;

&lt;p&gt;Here's the practical build guide, from stack decisions through App Store submission.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why React Native fits pet care apps
&lt;/h2&gt;

&lt;p&gt;Pet care apps need cross-platform reach (pet owners split evenly between iOS and Android), rich UI (photo-heavy profiles), and native features like push notifications and camera. That is the exact sweet spot for React Native with Expo. They also have a naturally scoped MVP: pet profiles, health records, appointments, reminders. You can ship in weeks, not months.&lt;/p&gt;

&lt;h2&gt;
  
  
  The MVP feature set
&lt;/h2&gt;

&lt;p&gt;Ship these five features first. Adding a sixth before nailing edge cases (multi-species schedules, deleted pets, timezone-sensitive reminders) is the fastest way to never launch.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pet profile.&lt;/strong&gt; Name, species, breed, DOB, weight, photo, microchip ID&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Health record.&lt;/strong&gt; Vaccinations with next-due dates, medications, allergies&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Appointment tracker.&lt;/strong&gt; Vet visits, grooming, boarding with reminders&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feeding log.&lt;/strong&gt; Food type, portion, schedule&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Push notifications.&lt;/strong&gt; Vaccinations, medications, appointments&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;React Native&lt;/strong&gt; with &lt;strong&gt;Expo SDK 52+&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TypeScript&lt;/strong&gt; (non-negotiable for anything you ship)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expo Router&lt;/strong&gt; for file-based routing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supabase&lt;/strong&gt; for hosted Postgres + auth&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NativeWind&lt;/strong&gt; for Tailwind-style styling&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;expo-notifications&lt;/strong&gt; for local push&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FlashList&lt;/strong&gt; (not &lt;code&gt;FlatList&lt;/code&gt;) for photo-heavy timelines&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Scaffold it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx create-expo-app@latest pet-care-app &lt;span class="nt"&gt;--template&lt;/span&gt;
&lt;span class="c"&gt;# choose "Navigation (TypeScript)"&lt;/span&gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;pet-care-app
npx expo &lt;span class="nb"&gt;install &lt;/span&gt;expo-notifications expo-image-picker expo-file-system
npm &lt;span class="nb"&gt;install &lt;/span&gt;nativewind zustand @supabase/supabase-js date-fns
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Route structure
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app/
  (tabs)/
    _layout.tsx           # Pets | Schedule | Records | Settings
    index.tsx             # pet list
    schedule.tsx
    records.tsx
    settings.tsx
  pet/
    [id].tsx
    [id]/health.tsx
    [id]/appointments.tsx
  appointment/new.tsx
  vaccination/new.tsx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Decide the route tree before writing screens. Nesting everything under &lt;code&gt;(tabs)/pets/[id]/...&lt;/code&gt; will bite you when you need to push a full-screen modal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data model
&lt;/h2&gt;

&lt;p&gt;Four entities cover the MVP:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Entity&lt;/th&gt;
&lt;th&gt;Key fields&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pets&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;id, owner_id, name, species, breed, date_of_birth, weight_kg, photo_url&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vaccinations&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;id, pet_id, name, administered_at, next_due_at&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;appointments&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;id, pet_id, type, starts_at, location, notes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;feedings&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;id, pet_id, food_name, portion_grams, scheduled_at, given&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things matter more than you'd expect:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Store &lt;code&gt;next_due_at&lt;/code&gt; on the row.&lt;/strong&gt; Rabies is annual in some jurisdictions, triennial in others. Don't hardcode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store UTC in the DB, render local with &lt;code&gt;date-fns-tz&lt;/code&gt;.&lt;/strong&gt; Get this wrong and a reminder set for 8 AM triggers at 3 AM after a flight.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Push notifications the right way
&lt;/h2&gt;

&lt;p&gt;The single feature that separates useful pet apps from forgettable ones:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Notifications&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;expo-notifications&lt;/span&gt;&lt;span class="dl"&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;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scheduleNotificationAsync&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;title&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;pet&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;'s &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;vaccination&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; is due`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Book a vet appointment to stay on schedule.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;petId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pet&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="na"&gt;vaccinationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;vaccination&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="na"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;nextDueAt&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 rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Request permissions gracefully. Show a "why" screen before the OS prompt.&lt;/li&gt;
&lt;li&gt;Reschedule on &lt;code&gt;AppState.change&lt;/code&gt; when the app foregrounds. Timezone and DST drift is real.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The 4 to 6 week problem
&lt;/h2&gt;

&lt;p&gt;Honest timeline for a solo developer: 4 to 6 weeks for a shippable MVP if you're comfortable with React Native. 10 to 12 weeks if you're learning Expo, TypeScript, and Supabase alongside.&lt;/p&gt;

&lt;p&gt;Most of that time is boilerplate. Layout, navigation wiring, spacing, empty states, forms. None of it is the interesting part of building a pet care app.&lt;/p&gt;

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

&lt;p&gt;Tools like &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=how-to-build-a-pet-care-app-with-react-native" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; generate a working React Native + Expo codebase from a natural-language description. The workflow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Describe the app: &lt;em&gt;"A pet care app with pet profiles, vaccination tracking, appointment reminders, and a schedule tab across all pets."&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Get a working app in a few minutes.&lt;/li&gt;
&lt;li&gt;Scan a QR code, preview on your real phone.&lt;/li&gt;
&lt;li&gt;Click any element to describe changes ("make these cards bigger, add a soft green tint when a task is done").&lt;/li&gt;
&lt;li&gt;Export the full React Native + Expo source, or publish directly to the stores.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Same output, a real React Native app, different path. Worth trying on the boring 80% of the build so you can spend time on the interesting 20%.&lt;/p&gt;

&lt;h2&gt;
  
  
  App Store gotchas specific to pet apps
&lt;/h2&gt;

&lt;p&gt;Three things reviewers catch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Health claims.&lt;/strong&gt; Track vaccinations and meds without positioning as a medical device. Be explicit in the description.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Background location.&lt;/strong&gt; If you add walk tracking, justify the permission concretely: &lt;em&gt;"To log your dog's walk route while your phone is in your pocket."&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Screenshots.&lt;/strong&gt; Empty-state screenshots underperform dramatically. Show real pets with real data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use &lt;code&gt;eas submit&lt;/code&gt;. It handles both stores and eliminates most rejection loops.&lt;/p&gt;

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

&lt;p&gt;The features are the easy part. Reliable reminders, offline-first data, and fast photo handling are what separate 5-star pet apps from abandoned ones. Nail those, ship a focused MVP, and let owners tell you what's missing.&lt;/p&gt;

&lt;p&gt;The manual scaffold above should get you to a working v1 in about a month.&lt;/p&gt;

&lt;p&gt;If you're building something in this space, drop a comment with what you're working on. Curious whether anyone has solved multi-pet, multi-timezone reminders cleanly, because that one still feels unsolved to me.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>mobile</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>The 4 Notification Categories Users Actually Whitelist (Based on 60 App Interviews)</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Wed, 29 Jul 2026 05:06:49 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/the-4-notification-categories-users-actually-whitelist-based-on-60-app-interviews-3ao4</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/the-4-notification-categories-users-actually-whitelist-based-on-60-app-interviews-3ao4</guid>
      <description>&lt;ul&gt;
&lt;li&gt;Users whitelist only &lt;strong&gt;four&lt;/strong&gt; notification categories: someone they know did something involving them, a thing they explicitly set up, a transaction they're expecting, and a time-critical alert from a system they trust.&lt;/li&gt;
&lt;li&gt;Everything else — feature announcements, recommendations, streaks, re-engagement, promos — gets muted within two weeks.&lt;/li&gt;
&lt;li&gt;Users pattern-match to &lt;strong&gt;category&lt;/strong&gt;, not copy. Better push copy optimizes the wrong variable.&lt;/li&gt;
&lt;li&gt;Per-category in-app consent (not one system prompt) moved opt-in from 41% to 73% and dropped 30-day mute rate from 34% to 9% in one test.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Over the last six weeks I interviewed 60 people about their phone's notification settings. I asked each to open Settings → Notifications, scroll through every app, and tell me the story behind why each one was on, off, or set to silent delivery.&lt;/p&gt;

&lt;p&gt;The pattern was sharper than I expected. Users don't allow or block push app-by-app. They do it &lt;strong&gt;category-by-category&lt;/strong&gt; — and they only tolerate four specific categories. Everything else gets muted within two weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four categories users whitelist
&lt;/h2&gt;

&lt;p&gt;Across 60 interviews, people named the same four types of notification as ones they actively want:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Someone I know did something involving me.&lt;/strong&gt; A message, a comment, a mention, a friend joining, a family member sharing a photo. Nearly everyone said they never mute these.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A thing I explicitly set up.&lt;/strong&gt; Alarm, reminder, calendar event, medication, workout time. The rare exceptions had turned these off &lt;em&gt;in the app itself&lt;/em&gt;, not in system settings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. A transaction I'm expecting.&lt;/strong&gt; Delivery updates, ride arrival, payment confirmation, booking status. Widely kept on, though several used silent delivery to soften the interruption.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. A time-critical alert from a system I trust.&lt;/strong&gt; Bank fraud alert, security code, appointment 15 minutes out, flight delay. &lt;em&gt;Trust&lt;/em&gt; was the load-bearing word — several people had disabled these for specific apps that had cried wolf.&lt;/p&gt;

&lt;p&gt;Every other category — feature announcements, personalized recommendations, streak reminders, re-engagement, promos, content updates — got muted, disabled, or in a few cases triggered an uninstall.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why category beats copy
&lt;/h2&gt;

&lt;p&gt;The standard growth advice is "write better push copy." A/B test the emoji, tighten the CTA, personalize with the user's name.&lt;/p&gt;

&lt;p&gt;That optimizes the wrong variable.&lt;/p&gt;

&lt;p&gt;The interviews were unambiguous: users don't parse notification copy before deciding whether to keep push on. They pattern-match to the category. Once your app has sent two notifications from a category they don't want, they mute the whole app — no matter how good the copy was.&lt;/p&gt;

&lt;p&gt;So the design question isn't "how do we write a compelling notification?" It's &lt;strong&gt;"how do we ensure every notification we send falls into one of the four whitelisted categories?"&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The consent conversation to have in-app
&lt;/h2&gt;

&lt;p&gt;The iOS system dialog is a blunt instrument: one yes/no for all future notifications. What users actually want is category-level consent — which iOS supports through notification categories, but almost no app uses well.&lt;/p&gt;

&lt;p&gt;The pattern I now recommend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Notifications&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;expo-notifications&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Category&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;social&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reminders&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;transactions&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;security&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;setupNotificationCategories&lt;/span&gt;&lt;span class="p"&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;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setNotificationCategoryAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;social&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="na"&gt;identifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reply&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;buttonTitle&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Reply&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;opensAppToForeground&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="p"&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;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setNotificationCategoryAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reminders&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setNotificationCategoryAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;transactions&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setNotificationCategoryAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;security&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;requestConsentForCategory&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Category&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;stored&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getStoredPreferences&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;stored&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;denied&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// In-app modal that explains what this category is&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wantsIt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;showCategoryConsentModal&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;labelFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;example&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;exampleFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;frequency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;frequencyFor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;savePreference&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;wantsIt&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;granted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;denied&lt;/span&gt;&lt;span class="dl"&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;wantsIt&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;}&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;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPermissionsAsync&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;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;granted&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestPermissionsAsync&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The in-app modal does the real work. It shows a concrete example of the notification the user would receive, states roughly how often ("a few times per week," not "occasionally"), and lets them opt in per category rather than for everything at once.&lt;/p&gt;

&lt;p&gt;One app I tested this with saw opt-in rise from &lt;strong&gt;41% to 73%&lt;/strong&gt; by moving from a single system prompt to contextual per-category prompts. More importantly, 30-day mute rate dropped from &lt;strong&gt;34% to 9%&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The signal every PM misses: silent delivery
&lt;/h2&gt;

&lt;p&gt;iOS's "Deliver Quietly" lets users keep receiving your notifications but hide them from the lock screen and Notification Center. It's a soft mute — chosen when someone wants the app's data but not the interruption.&lt;/p&gt;

&lt;p&gt;Most PMs don't track it, because iOS doesn't report it. But you can infer it: if a user's open rate on pushes drops to near-zero while their app opens stay steady, they've likely moved you to Deliver Quietly.&lt;/p&gt;

&lt;p&gt;Segment those users. They're one bad broadcast away from a full mute or uninstall. For them, cut sends by 60–80% and fire only category 1 and 3 notifications (people-related and transactional). I've seen apps recover full-delivery status this way for a meaningful share of the quietly-delivered segment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redesign around user-declared intent
&lt;/h2&gt;

&lt;p&gt;The biggest shift from these interviews: the notifications users keep are tied to intent &lt;em&gt;they&lt;/em&gt; expressed inside the product. They set the reminder. They ordered the food. They followed the person. They asked the bank to alert on fraud.&lt;/p&gt;

&lt;p&gt;The notifications they mute are tied to intent the &lt;em&gt;product&lt;/em&gt; declared on their behalf. "We think you'd love this new feature." "We noticed you haven't opened us in a week." "Here's a personalized recommendation."&lt;/p&gt;

&lt;p&gt;So the implication is concrete: for every notification your app sends, you should be able to trace it back to a specific in-app action the user took that constitutes consent for &lt;em&gt;this&lt;/em&gt; notification. If you can't, it belongs in the mute pile. If you can, you're building the trust that keeps push on for the lifetime of that user.&lt;/p&gt;

&lt;p&gt;A practical way to enforce this: require a &lt;code&gt;sourceAction&lt;/code&gt; field on every notification in your schema — a machine-checkable link back to the user action that authorized the send. It's an annoying constraint at first. Then it becomes the reason your app is still on the lock screen six months in, when competitors have been muted.&lt;/p&gt;




&lt;p&gt;Run the interviews yourself. Ten users, thirty minutes each. You'll come back with a notification strategy your PM would never sign off on — and your users will actually welcome.&lt;/p&gt;

&lt;p&gt;I write more about building this kind of thing into React Native apps over at &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=notification-categories" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Which of the four categories does your app mostly send? And have you ever caught yourself sending from the mute pile?&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>ux</category>
      <category>mobile</category>
      <category>expo</category>
    </item>
    <item>
      <title>Introducing tinbase: A Docker-Free, Supabase-Compatible Backend on Real Postgres</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Mon, 27 Jul 2026 13:30:19 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/introducing-tinbase-a-docker-free-supabase-compatible-backend-on-real-postgres-7pg</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/introducing-tinbase-a-docker-free-supabase-compatible-backend-on-real-postgres-7pg</guid>
      <description>&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;tinbase&lt;/strong&gt; is an open-source (MIT) backend that speaks the Supabase API but runs as a &lt;strong&gt;single process on real Postgres 17&lt;/strong&gt; — no Docker, no container orchestration.&lt;/li&gt;
&lt;li&gt;It's &lt;strong&gt;&lt;code&gt;supabase-js&lt;/code&gt; compatible&lt;/strong&gt;, so a lot of existing Supabase client code points at it unchanged.&lt;/li&gt;
&lt;li&gt;Because it drops the container requirement, it runs where the full stack can't: lightweight CI, low-spec machines, and &lt;strong&gt;in the browser&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Built by &lt;a href="https://twitter.com/sanketsahu" rel="noopener noreferrer"&gt;Sanket Sahu&lt;/a&gt;. MIT-licensed and open to contributions.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;If you've run Supabase locally, you know the ritual: install Docker, start the stack, wait for Postgres, GoTrue, PostgREST, Realtime, and Storage to spin up, and watch your laptop fans kick in.&lt;/p&gt;

&lt;p&gt;For a lot of workflows — quick local iteration, CI, teaching, browser sandboxes — that's more machinery than the task needs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;tinbase&lt;/strong&gt; is a different bet: the Supabase API surface, on real Postgres, as one process.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually is
&lt;/h2&gt;

&lt;p&gt;tinbase is an open-source backend that provides Supabase-compatible APIs while running as a single process on &lt;strong&gt;PostgreSQL 17&lt;/strong&gt;, with &lt;strong&gt;no Docker requirement&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The design goals in one line each:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Real Postgres, not a shim.&lt;/strong&gt; It runs on Postgres 17, so SQL behavior matches production. No SQLite dialect gaps to trip over when you deploy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;supabase-js&lt;/code&gt; compatible.&lt;/strong&gt; Client code written against Supabase can, in many cases, point at a tinbase instance without changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single process, no Docker.&lt;/strong&gt; One thing to start. No container stack to orchestrate or resource-tune.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runs in the browser.&lt;/strong&gt; Because it sheds the container requirement, tinbase can run in environments a Docker stack simply can't — including in-browser sandboxes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MIT-licensed.&lt;/strong&gt; Fully open source. Inspect it, self-host it, contribute to it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why single-process, Docker-free matters
&lt;/h2&gt;

&lt;p&gt;The architecture isn't the point — what it unlocks is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Faster local loops.&lt;/strong&gt; A single process starts in a fraction of the time an orchestrated multi-container stack takes. The edit-run-test cycle gets shorter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cheaper CI.&lt;/strong&gt; CI runners are memory-constrained and billed by the minute. Spinning up one Postgres-backed process per job is dramatically lighter than booting the full Supabase container set.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Teaching and demos that just run.&lt;/strong&gt; Workshops and tutorials can hand out a real backend without first walking every participant through installing and configuring Docker. That alone removes the most common "it doesn't work on my machine" failure at the start of a session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Browser and constrained environments.&lt;/strong&gt; In-browser IDEs and low-spec machines that can't host the full container stack can still run a real Supabase-compatible backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it compares
&lt;/h2&gt;

&lt;p&gt;If you want a lighter local Supabase, you've probably weighed these:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Real Postgres?&lt;/th&gt;
&lt;th&gt;Docker-free?&lt;/th&gt;
&lt;th&gt;Supabase-compatible?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Full Supabase CLI stack&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQLite-based shim&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hand-rolled Postgres + PostgREST&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Depends&lt;/td&gt;
&lt;td&gt;You wire it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;tinbase&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The gap tinbase fills: the realism of Postgres-backed Supabase, without the Docker overhead, and without dropping to a non-Postgres engine that diverges from production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who it's for
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Developers who want a fast local Supabase without the container tax&lt;/li&gt;
&lt;li&gt;Teams trying to cut CI time and cost&lt;/li&gt;
&lt;li&gt;Educators and content authors who need a reproducible backend in a tutorial&lt;/li&gt;
&lt;li&gt;Anyone building in a browser sandbox or on constrained hardware&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Getting started
&lt;/h2&gt;

&lt;p&gt;tinbase is single-process and Docker-free, so setup is meant to be minimal: install, start the process, and point a &lt;code&gt;supabase-js&lt;/code&gt; client at it.&lt;/p&gt;

&lt;p&gt;The full documentation and source are at &lt;a href="https://www.tinbase.dev/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=tinbase-launch" rel="noopener noreferrer"&gt;tinbase.dev&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;If you've been avoiding local Supabase because of the Docker overhead, this is worth a look — especially for CI and teaching setups.&lt;/p&gt;

&lt;p&gt;Running Supabase locally today? What's your current setup — full CLI stack, a shim, or something hand-rolled? Curious what the pain points have been.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Prototype a Mobile App in Hours (Wireframe React Native)</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Fri, 24 Jul 2026 09:34:12 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/how-to-prototype-a-mobile-app-in-hours-wireframe-react-native-2mjc</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/how-to-prototype-a-mobile-app-in-hours-wireframe-react-native-2mjc</guid>
      <description>&lt;ul&gt;
&lt;li&gt;The wireframe → clickable prototype → coded prototype pipeline collapses into a single step.&lt;/li&gt;
&lt;li&gt;Four phases: &lt;strong&gt;capture the wireframe&lt;/strong&gt; (20–40 min), &lt;strong&gt;generate the base app&lt;/strong&gt; (2–3 min), &lt;strong&gt;iterate visually&lt;/strong&gt; (1–2 hrs), &lt;strong&gt;ship to real hardware&lt;/strong&gt; (30 sec).&lt;/li&gt;
&lt;li&gt;Traditional first testable build: &lt;strong&gt;2–4 weeks&lt;/strong&gt;. AI-native: &lt;strong&gt;1–3 hours&lt;/strong&gt;. Most of the compression is eliminating role handoffs.&lt;/li&gt;
&lt;li&gt;Best wireframe input isn't a sketch — it's a competitor screenshot, or a precise written paragraph.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The three artifacts most guides conflate
&lt;/h2&gt;

&lt;p&gt;One clarification that trips up most prototyping conversations:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wireframe&lt;/strong&gt; — static layout. No interactivity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clickable prototype&lt;/strong&gt; — hotspots wired between static screens (Figma, InVision). Can't run real logic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Working prototype&lt;/strong&gt; — real React Native code, real navigation, real state, installable on device via QR.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production app&lt;/strong&gt; — the working prototype plus backend, auth, analytics, store listing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most "prototyping" tutorials stop at rung two. The interesting compression is going from rung one straight to rung three.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four-phase workflow
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Capture the wireframe (20–40 min)
&lt;/h3&gt;

&lt;p&gt;Any medium that captures screens, elements, and tap behaviour works: hand sketch, whiteboard photo, Excalidraw, Figma frame, competitor screenshot, or a written paragraph.&lt;/p&gt;

&lt;p&gt;Treat the wireframe as disposable. Its job is to be &lt;em&gt;input to the generator&lt;/em&gt;, not a deliverable.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Generate the base app (2–3 min)
&lt;/h3&gt;

&lt;p&gt;Feed the wireframe plus a one-paragraph description into an AI mobile app builder. You get back a live React Native + Expo app with routing, styled screens, and stubbed data.&lt;/p&gt;

&lt;p&gt;It's about 70% right — good enough to iterate against.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Iterate visually (1–2 hours)
&lt;/h3&gt;

&lt;p&gt;The base app has ~30% wrong. Point-and-edit is the accelerator: click any element in the live preview, describe the change ("make cards rounder, move price to top-right"), and the underlying code updates. Product teams typically do 30–50 micro-iterations in the first hour.&lt;/p&gt;

&lt;p&gt;Two rules for this phase:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Don't accept the first version of any screen.&lt;/li&gt;
&lt;li&gt;Don't over-specify visuals before validating flow. &lt;strong&gt;Colours are cheap; navigation is expensive.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Ship to real hardware (30 sec)
&lt;/h3&gt;

&lt;p&gt;Scan a QR code, install into a preview client on iPhone or Android, and use the app on real hardware.&lt;/p&gt;

&lt;p&gt;This is where the bugs Figma can't show you actually surface: thumb-tap zones, safe areas, keyboard behaviour, dark mode.&lt;/p&gt;

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

&lt;p&gt;For a 5-screen MVP:&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;Traditional&lt;/th&gt;
&lt;th&gt;AI-native&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Wireframing&lt;/td&gt;
&lt;td&gt;4–8 hours&lt;/td&gt;
&lt;td&gt;20–40 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hi-fi design&lt;/td&gt;
&lt;td&gt;2–5 days&lt;/td&gt;
&lt;td&gt;Skipped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clickable prototype&lt;/td&gt;
&lt;td&gt;4–8 hours&lt;/td&gt;
&lt;td&gt;Skipped&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Coded prototype&lt;/td&gt;
&lt;td&gt;1–3 weeks&lt;/td&gt;
&lt;td&gt;2–3 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Iteration cycle&lt;/td&gt;
&lt;td&gt;1–3 days per round&lt;/td&gt;
&lt;td&gt;30 sec to 2 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real-device install&lt;/td&gt;
&lt;td&gt;4–8 hours (Xcode/TestFlight)&lt;/td&gt;
&lt;td&gt;30 sec (QR)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;First testable build&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2–4 weeks&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1–3 hours&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The compression is mostly the elimination of role handoffs. When one operator drives the whole loop, the "waiting for someone else" hours evaporate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which wireframe inputs convert best
&lt;/h2&gt;

&lt;p&gt;Ranked worst → best by signal-to-noise:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Napkin sketch photo&lt;/strong&gt; — works, but expect nuance loss. Pair it with a written description.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whiteboard photo&lt;/strong&gt; — better. Whiteboards force larger, more legible drawings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Excalidraw / FigJam&lt;/strong&gt; — excellent. Digital media produce clean shapes and labels.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Figma frame&lt;/strong&gt; — very good. Layout and hierarchy transfer cleanly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Written PRD paragraph&lt;/strong&gt; — underrated. A precise three-to-four sentence description often beats a bad sketch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Competitor screenshot&lt;/strong&gt; — highest signal. It's already a mobile UI, so layout, typography, and interaction all transfer.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Hour by hour: a food-delivery MVP in one afternoon
&lt;/h2&gt;

&lt;p&gt;Two-person team, five screens (restaurant list → detail → cart → checkout → order tracking):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;0:00–0:30&lt;/strong&gt; — Whiteboard sketch, phone photo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;0:30–0:38&lt;/strong&gt; — Upload plus a one-paragraph description. Base app generated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;0:38–1:20&lt;/strong&gt; — First iteration pass. Point-and-edit, roughly 40 changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1:20–1:35&lt;/strong&gt; — QR install on both team phones. Two nav bugs surface; fixed in 3 minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1:35–2:20&lt;/strong&gt; — Send the QR to three test users, collect feedback in a shared doc.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2:20–3:15&lt;/strong&gt; — Second iteration pass. Address the top ten UX notes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3:15–3:30&lt;/strong&gt; — Publish a shareable preview URL, drop it in Slack.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three and a half hours end to end, iOS and Android, on real phones.&lt;/p&gt;

&lt;h2&gt;
  
  
  When &lt;em&gt;not&lt;/em&gt; to use this workflow
&lt;/h2&gt;

&lt;p&gt;The AI loop isn't universal. Stay traditional when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Novel interaction patterns with no precedent.&lt;/strong&gt; Generators pattern-match; no pattern means no output quality.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Regulated industries&lt;/strong&gt; (medical, aviation, financial trading) that need documented decision trails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pixel-perfect exec-review demos&lt;/strong&gt; with no budget for a "70% then iterate" opening state.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the other 90% — CRUD apps, marketplaces, social, dashboards, internal tools — the compressed loop wins on speed, iteration count, and cost.&lt;/p&gt;




&lt;p&gt;If you want to run the loop end to end, &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=wireframe-to-app-prototyping" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; does the sketch/prompt/screenshot → React Native + Expo → QR-preview pipeline in one editor.&lt;/p&gt;

&lt;p&gt;What's your current wireframe-to-code gap? Curious whether the competitor-screenshot trick works as well for others as it has for me.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>ai</category>
      <category>mobile</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Recruiting 12 Real Testers for Your Google Play Closed Test (Without Buying Them)</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Wed, 22 Jul 2026 11:02:33 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/recruiting-12-real-testers-for-your-google-play-closed-test-without-buying-them-3ffe</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/recruiting-12-real-testers-for-your-google-play-closed-test-without-buying-them-3ffe</guid>
      <description>&lt;ul&gt;
&lt;li&gt;Buying testers fails three ways: &lt;strong&gt;Play's fraud detection flags dormant accounts&lt;/strong&gt;, &lt;strong&gt;bought testers never open the app&lt;/strong&gt; (so engagement doesn't count), and &lt;strong&gt;you're dependent on a third party&lt;/strong&gt; to satisfy a store requirement.&lt;/li&gt;
&lt;li&gt;Three source pools that work: your &lt;strong&gt;existing audience&lt;/strong&gt; (highest quality), &lt;strong&gt;community trades&lt;/strong&gt; on r/androiddev and r/indiedev (fastest), and your &lt;strong&gt;personal network&lt;/strong&gt; (last resort).&lt;/li&gt;
&lt;li&gt;A warm-list invite converts 40–50%. Anonymous forums convert 5–10% — which is why you need multiple pools.&lt;/li&gt;
&lt;li&gt;Play watches for &lt;strong&gt;meaningful engagement&lt;/strong&gt;, not just opt-ins. Four touches over 14 days keeps testers active.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;You need 12 opted-in testers for 14 days before Google Play will let you ship. Buying them looks like the shortcut.&lt;/p&gt;

&lt;p&gt;It usually isn't. Play's ML is good at spotting bought accounts, and it will silently reset your 14-day counter.&lt;/p&gt;

&lt;p&gt;Here's how to recruit 12 real humans in 3–4 days instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why paid services fail
&lt;/h2&gt;

&lt;p&gt;The tester-service business exists, and some of it works. Most of it has three failure modes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Play's fraud detection flags dormant or newly created accounts.&lt;/strong&gt; Your test "has 12 testers" but Play doesn't count them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The testers opt in and never open the app.&lt;/strong&gt; The engagement signal doesn't register.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You're now dependent on a third party to satisfy a store requirement.&lt;/strong&gt; If they turn shady, your launch is stuck.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And even when it works, you've spent $50–200 on 12 accounts that will never use your app again. You had to do the harder work anyway — you just paid extra for the privilege.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three source pools
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Your existing audience (highest quality)
&lt;/h3&gt;

&lt;p&gt;Newsletter subscribers, followers, Discord or Slack members. Even 200 subscribers converts to 10–15 testers with a good ask.&lt;/p&gt;

&lt;p&gt;If you don't have this: start building one on day one of your next project. This requirement is a recurring tax, not a one-off.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Community trades (fastest)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;r/androiddev&lt;/strong&gt; — weekly beta-swap threads&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;r/indiedev&lt;/strong&gt; — weekly "test my app" threads&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Indie Hackers&lt;/strong&gt; — milestones section&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Discord communities&lt;/strong&gt; in your product's category (game dev, productivity, etc.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trade is simple: "I'll test your app, you test mine." Both parties get closer to their 12, and communities enforce reciprocity so quality stays high.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Personal network plus amplification (last resort)
&lt;/h3&gt;

&lt;p&gt;Friends, colleagues, family — then their networks via a "know anyone with an Android who'd help?" broadcast. This usually gets you the last three or four you need.&lt;/p&gt;

&lt;h2&gt;
  
  
  The invite template
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Subject:&lt;/strong&gt; Would you spend 5 minutes to help me launch my app?&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Hey [name],&lt;/p&gt;

&lt;p&gt;I'm launching [app name] on Android next month. Google now requires 12 testers for 14 days before I can ship publicly.&lt;/p&gt;

&lt;p&gt;If you have an Android phone and 5 minutes, you'd be helping me a lot by:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Clicking this link: [tester opt-in URL]&lt;/li&gt;
&lt;li&gt;Installing when the app appears in your Play Store&lt;/li&gt;
&lt;li&gt;Opening it a couple of times over the next 2 weeks&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the whole ask. You'll get free access to the paid version as a thank you.&lt;/p&gt;

&lt;p&gt;[your name]&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This converts &lt;strong&gt;40–50% on a warm list&lt;/strong&gt;. Anonymous forums convert 5–10% — which is exactly why you need more than one pool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping them engaged for 14 days
&lt;/h2&gt;

&lt;p&gt;Play watches for meaningful engagement. If your 12 testers opt in and never open the app, the requirement isn't satisfied.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Day 1:&lt;/strong&gt; Welcome message with "here's what to try" (two or three specific flows).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 5:&lt;/strong&gt; A genuine "what's confusing so far?" ask. This gets you feedback &lt;em&gt;and&lt;/em&gt; another session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 10:&lt;/strong&gt; Preview of what's shipping in the next update. Another reason to open.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 14:&lt;/strong&gt; Thank you, plus "the app goes to production tomorrow."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Four touches, each with real value. Not spam.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning testers into first users
&lt;/h2&gt;

&lt;p&gt;Your 12 testers just spent 14 days with your app. That's the highest-quality feedback pool you will ever have. Before the closed test ends:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Send a proper survey.&lt;/strong&gt; Not "rate 1–5." Ask: &lt;em&gt;"What almost stopped you from using this?"&lt;/em&gt; and &lt;em&gt;"What would you tell a friend it does?"&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask for a testimonial&lt;/strong&gt;, with permission to quote.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask whether they'd upgrade to paid at launch.&lt;/strong&gt; Their answer is your first real customer signal.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The reframe
&lt;/h2&gt;

&lt;p&gt;You started this hunting 12 accounts to satisfy Google. You finish with 12 engaged real users, genuine feedback, a few testimonials, and possibly your first paying customers.&lt;/p&gt;

&lt;p&gt;The Play requirement is annoying. The discipline it forces is actually a launch playbook.&lt;/p&gt;




&lt;p&gt;If you're stuck at "have the app, need the testers," &lt;a href="https://letsdeploy.it/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=recruiting-12-testers" rel="noopener noreferrer"&gt;letsdeploy.it&lt;/a&gt; handles the release side so you can focus on recruiting. The 12 humans still have to come from you.&lt;/p&gt;

&lt;p&gt;How did you find your 12? I'm curious which pool actually worked — the swap threads or the personal network.&lt;/p&gt;

</description>
      <category>android</category>
      <category>googleplay</category>
      <category>indiedev</category>
      <category>startup</category>
    </item>
    <item>
      <title>7 Things That Break When You Convert a Web App to React Native</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Wed, 22 Jul 2026 09:51:38 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/7-things-that-break-when-you-convert-a-web-app-to-react-native-4bj2</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/7-things-that-break-when-you-convert-a-web-app-to-react-native-4bj2</guid>
      <description>&lt;ul&gt;
&lt;li&gt;There is no &lt;code&gt;div&lt;/code&gt;, no &lt;code&gt;span&lt;/code&gt;, and no cascading CSS. Every layout primitive changes.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;localStorage&lt;/code&gt;, &lt;code&gt;window&lt;/code&gt;, &lt;code&gt;document&lt;/code&gt;, and most browser APIs simply don't exist.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;flexDirection&lt;/code&gt; defaults to &lt;code&gt;column&lt;/code&gt;, not &lt;code&gt;row&lt;/code&gt;. This one silently breaks every layout you wrote.&lt;/li&gt;
&lt;li&gt;Routing, image handling, forms, and scrolling all need native equivalents.&lt;/li&gt;
&lt;li&gt;None of it is hard individually. It's the volume that kills weekend projects.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;AI web app builders got very good very fast. You can describe an app to Lovable, v0, or Bolt and have a working web product the same afternoon.&lt;/p&gt;

&lt;p&gt;Then you try to put it on the App Store, and you discover the thing nobody mentions: &lt;strong&gt;a web app and a native app share a language, not a platform.&lt;/strong&gt; They both run JavaScript. Almost nothing else transfers.&lt;/p&gt;

&lt;p&gt;I've done this conversion enough times to know exactly where it hurts. Here are the seven things that break, in roughly the order you'll hit them.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Every HTML element is gone
&lt;/h2&gt;

&lt;p&gt;There is no &lt;code&gt;div&lt;/code&gt;. No &lt;code&gt;span&lt;/code&gt;. No &lt;code&gt;p&lt;/code&gt;, &lt;code&gt;h1&lt;/code&gt;, &lt;code&gt;button&lt;/code&gt;, &lt;code&gt;ul&lt;/code&gt;, or &lt;code&gt;img&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;React Native has its own primitives, and the mapping isn't one-to-one:&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="c1"&gt;// Web&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"card"&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="nt"&gt;h2&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Title&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h2&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="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Body text&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&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="nt"&gt;img&lt;/span&gt; &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;url&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="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// React Native&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;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;styles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;card&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;Text&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="nx"&gt;styles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Title&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;Body text&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;Image&lt;/span&gt; &lt;span class="na"&gt;source&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;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;url&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;View&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;The important gotcha: &lt;strong&gt;all text must be wrapped in &lt;code&gt;&amp;lt;Text&amp;gt;&lt;/code&gt;.&lt;/strong&gt; A bare string inside a &lt;code&gt;&amp;lt;View&amp;gt;&lt;/code&gt; throws. On web you can drop text anywhere; here it's a hard error, and it's the single most common first crash.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. CSS doesn't cascade — and half of it doesn't exist
&lt;/h2&gt;

&lt;p&gt;React Native uses a JavaScript style object, not a stylesheet. That means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No cascade.&lt;/strong&gt; A style on a parent doesn't inherit down to children. Set &lt;code&gt;fontSize&lt;/code&gt; on a &lt;code&gt;View&lt;/code&gt; and the &lt;code&gt;Text&lt;/code&gt; inside ignores it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No selectors.&lt;/strong&gt; No classes, no descendants, no &lt;code&gt;:hover&lt;/code&gt;, no media queries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No units.&lt;/strong&gt; Numbers are density-independent pixels. &lt;code&gt;padding: 16&lt;/code&gt;, not &lt;code&gt;padding: '16px'&lt;/code&gt;.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;styles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;StyleSheet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;card&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;borderRadius&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;backgroundColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#fff&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also missing: &lt;code&gt;float&lt;/code&gt;, &lt;code&gt;grid&lt;/code&gt;, &lt;code&gt;position: fixed&lt;/code&gt;, and most of the box model shorthand you're used to. Flexbox is the layout system — essentially the only one.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. &lt;code&gt;flexDirection&lt;/code&gt; defaults to &lt;code&gt;column&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This is the one that quietly wrecks everything.&lt;/p&gt;

&lt;p&gt;On the web, &lt;code&gt;display: flex&lt;/code&gt; defaults to &lt;code&gt;flex-direction: row&lt;/code&gt;. In React Native, &lt;strong&gt;every &lt;code&gt;View&lt;/code&gt; is already flex, and the default direction is &lt;code&gt;column&lt;/code&gt;.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So every horizontal row you built on the web stacks vertically after the port, and you won't get an error — just a layout that looks wrong everywhere at once.&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="c1"&gt;// You have to be explicit&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;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;flexDirection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;row&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;alignItems&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;center&lt;/span&gt;&lt;span class="dl"&gt;'&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;Budget an afternoon just for this.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Browser APIs don't exist
&lt;/h2&gt;

&lt;p&gt;No &lt;code&gt;window&lt;/code&gt;. No &lt;code&gt;document&lt;/code&gt;. No &lt;code&gt;localStorage&lt;/code&gt;. No &lt;code&gt;fetch&lt;/code&gt; cookies, no DOM measurement, no &lt;code&gt;alert()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The replacements:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Web&lt;/th&gt;
&lt;th&gt;React Native&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;localStorage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AsyncStorage&lt;/code&gt; (or &lt;code&gt;expo-secure-store&lt;/code&gt; for anything sensitive)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;document.querySelector&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;refs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;window.location&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Expo Router / React Navigation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;alert()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Alert.alert()&lt;/code&gt; from &lt;code&gt;react-native&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CSS media queries&lt;/td&gt;
&lt;td&gt;&lt;code&gt;useWindowDimensions()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;fetch&lt;/code&gt; does survive, which is a small mercy — your API layer mostly ports as-is.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Routing is a different model
&lt;/h2&gt;

&lt;p&gt;URL-based routing doesn't exist natively. There's no address bar and no history object.&lt;/p&gt;

&lt;p&gt;Expo Router gets you closest to a web mental model — it's file-based, so &lt;code&gt;app/profile/[id].tsx&lt;/code&gt; maps to &lt;code&gt;/profile/42&lt;/code&gt; — but you still have to think in &lt;strong&gt;stacks and tabs&lt;/strong&gt; rather than pages. Back isn't "previous URL," it's "pop the stack."&lt;/p&gt;

&lt;p&gt;Deep links also need real configuration on both platforms (URL schemes, Universal Links on iOS, App Links on Android). That's a genuine chunk of work with no web equivalent.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Scrolling and lists are explicit
&lt;/h2&gt;

&lt;p&gt;On the web, content overflows and the browser gives you a scrollbar for free. In React Native, nothing scrolls unless you say so.&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;ScrollView&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;/* short 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;ScrollView&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;FlatList&lt;/span&gt;
  &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;keyExtractor&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="nx"&gt;item&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;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;renderItem&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="nx"&gt;item&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Row&lt;/span&gt; &lt;span class="na"&gt;item&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;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;And use &lt;code&gt;FlatList&lt;/code&gt; (or FlashList) for anything long — &lt;code&gt;ScrollView&lt;/code&gt; renders every child at once and will tank your frame rate on a list of 500 items.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Forms and keyboards need real work
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;&amp;lt;input&amp;gt;&lt;/code&gt; becomes &lt;code&gt;&amp;lt;TextInput&amp;gt;&lt;/code&gt;, and it needs configuration the browser handled implicitly: &lt;code&gt;keyboardType&lt;/code&gt;, &lt;code&gt;autoCapitalize&lt;/code&gt;, &lt;code&gt;autoComplete&lt;/code&gt;, &lt;code&gt;secureTextEntry&lt;/code&gt;, &lt;code&gt;returnKeyType&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then there's the keyboard itself. On mobile it physically covers the bottom of the screen, so your submit button disappears behind it unless you wrap the form in &lt;code&gt;KeyboardAvoidingView&lt;/code&gt; with different &lt;code&gt;behavior&lt;/code&gt; values per platform.&lt;/p&gt;

&lt;p&gt;There is no web equivalent to this problem, and it catches everyone once.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this actually adds up to
&lt;/h2&gt;

&lt;p&gt;Individually, none of these are hard. A competent React dev can work through any one of them in an hour.&lt;/p&gt;

&lt;p&gt;The problem is that there are seven of them, and they compound — plus certificates, provisioning profiles, screenshots at five device sizes, a privacy nutrition label, and a reviewer who might say no anyway.&lt;/p&gt;

&lt;p&gt;That's the honest reason so many AI-built web apps never reach a store. The build was a weekend. The port and the submission are a different project entirely.&lt;/p&gt;

&lt;p&gt;If you'd rather not do that project, &lt;a href="https://www.rapidnative.com/lovable-to-app-store?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=web-to-rn-conversion" rel="noopener noreferrer"&gt;RapidNative converts Lovable apps into real React Native + Expo builds and handles the full App Store and Play Store submission&lt;/a&gt; — signing, screenshots, metadata included. You get the source code and keep ownership of it, and if your app uses Supabase, the native build points at the same project with no migration.&lt;/p&gt;




&lt;p&gt;Which of these seven got you first? For me it was &lt;code&gt;flexDirection&lt;/code&gt; — I spent two hours convinced my styles weren't applying at all.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>webdev</category>
      <category>mobile</category>
      <category>javascript</category>
    </item>
    <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>
  </channel>
</rss>
