<?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: Sophie F A</title>
    <description>The latest articles on DEV Community by Sophie F A (@sophie_fa_6ed935b0601d76).</description>
    <link>https://dev.to/sophie_fa_6ed935b0601d76</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%2F4029791%2Fa9280f9a-134e-4ad5-87cb-1d0f1d6c6cc2.png</url>
      <title>DEV Community: Sophie F A</title>
      <link>https://dev.to/sophie_fa_6ed935b0601d76</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sophie_fa_6ed935b0601d76"/>
    <language>en</language>
    <item>
      <title>Every EAS Submit Failure Mode I've Hit in Six Months (and What Catches Each Pre-Submit)</title>
      <dc:creator>Sophie F A</dc:creator>
      <pubDate>Wed, 16 Sep 2026 13:44:02 +0000</pubDate>
      <link>https://dev.to/sophie_fa_6ed935b0601d76/every-eas-submit-failure-mode-ive-hit-in-six-months-and-what-catches-each-pre-submit-1cnn</link>
      <guid>https://dev.to/sophie_fa_6ed935b0601d76/every-eas-submit-failure-mode-ive-hit-in-six-months-and-what-catches-each-pre-submit-1cnn</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;eas submit&lt;/code&gt; exiting 0 does not mean your app is on track for review. It means the binary reached the store's front door&lt;/li&gt;
&lt;li&gt;Six failures I've hit in six months: wrong &lt;code&gt;ascAppId&lt;/code&gt;, IAPs not attached to the version, vanished TestFlight tester groups, incomplete Play Data Safety, &lt;code&gt;versionCode&lt;/code&gt; collisions, and screenshot resolution rejections&lt;/li&gt;
&lt;li&gt;Each one shares a shape: green CLI, red store, and roughly a day lost to the review calendar&lt;/li&gt;
&lt;li&gt;Every single one is catchable before you submit. Checklist at the bottom&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;eas submit&lt;/code&gt; is genuinely good software. That is the problem. The CLI succeeds so cleanly that it trains you to believe the release is done, when all it has actually verified is that a binary was delivered. Everything that gets apps rejected lives on the other side of that handoff, in App Store Connect and Play Console state that the CLI never sees.&lt;/p&gt;

&lt;p&gt;Here is every way that has bitten me since March, and the pre-submit check that now catches each one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure 1: Wrong ascAppId, right exit code
&lt;/h2&gt;

&lt;p&gt;Two apps under one Apple team. The submit profile in &lt;code&gt;eas.json&lt;/code&gt; carried the &lt;code&gt;ascAppId&lt;/code&gt; of the wrong one:&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;"submit"&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;"production"&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;"ios"&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;"ascAppId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1234567890"&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;The CLI went green, because the ID was valid. The build just landed under the wrong app in App Store Connect, and I spent a morning wondering why the right app showed no new build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pre-submit catch:&lt;/strong&gt; print the target before you fire. A two-line script that echoes &lt;code&gt;ascAppId&lt;/code&gt; and the app's bundle identifier side by side, and refuses to continue if they don't come from the same app record, ends this class of mistake permanently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure 2: IAPs not attached to the version
&lt;/h2&gt;

&lt;p&gt;The binary was fine. The in-app purchase products existed. But new IAPs have to be attached to the app version and submitted with it for their first review. Ship the binary without them and Apple rejects the release, because the app references products the reviewer cannot see.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pre-submit catch:&lt;/strong&gt; if this release introduces or changes any IAP, open the version page in App Store Connect and confirm the products are listed under it before submitting. This is a thirty-second look that saves a full review round trip.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure 3: The vanished TestFlight external group
&lt;/h2&gt;

&lt;p&gt;After a fresh submit, external testers stopped getting builds. The external tester group was still there, but the new build wasn't distributed to it: external distribution needs the build assigned to the group, and the first build of a new version needs beta review again. Nothing failed. Testers just quietly received nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pre-submit catch:&lt;/strong&gt; treat "build uploaded" and "build distributed" as separate checklist items. After every submit, verify the new build shows against the external group, not just in the builds list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure 4: Play Console Data Safety, the silent blocker
&lt;/h2&gt;

&lt;p&gt;On Android my release sat in "Ready to send for review" while I assumed it was in review. The Data Safety section had a newly required disclosure I hadn't filled, and Play Console will hold a release on incomplete store listing sections without anything resembling an error in your terminal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pre-submit catch:&lt;/strong&gt; open the release dashboard and look for any yellow banners before and after submitting. If your app adds an SDK, assume the Data Safety answers changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure 5: versionCode collision
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;eas submit&lt;/code&gt; delivered the AAB, Play rejected it: &lt;code&gt;versionCode&lt;/code&gt; already used. A local build from weeks earlier had burned the number.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pre-submit catch:&lt;/strong&gt; stop managing it by hand.&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;"build"&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;"production"&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;"autoIncrement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&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;Remote version management makes EAS the single source of truth for &lt;code&gt;versionCode&lt;/code&gt; and iOS build numbers, and this failure mode disappears.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure 6: Screenshot resolution mismatch
&lt;/h2&gt;

&lt;p&gt;Metadata, not binary. App Store Connect enforces exact pixel dimensions per display size class, and a set exported at the wrong scale gets the whole release bounced. The required sizes also shift as new devices ship, so a screenshot set that passed last year can fail this year untouched.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pre-submit catch:&lt;/strong&gt; script it. A tiny check that reads each PNG's dimensions and compares against the currently required sizes takes ten minutes to write and never lets a stale set through.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;All six failures share one shape: the CLI reported success, the store said no, and the cost was paid in calendar days, not compute. Review queues turn a five-minute config mistake into a one-day delay, and two mistakes into a lost week.&lt;/p&gt;

&lt;p&gt;The fix is boring: a pre-submit checklist that verifies store-side state, not just build-side state. Ours is now the first file in every release branch.&lt;/p&gt;

&lt;p&gt;If you'd rather not own that checklist at all, this is exactly the job we built &lt;a href="https://rapidnative.com/deploy?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=eas-submit-failure-modes" rel="noopener noreferrer"&gt;RapidNative Deploy&lt;/a&gt; for: it runs these store-side checks and handles the submission path so a green pipeline actually means a shipping app.&lt;/p&gt;

&lt;p&gt;Which one of these has cost you a review cycle? And what's the failure mode I haven't hit yet? Drop it in the comments.&lt;/p&gt;

</description>
      <category>expo</category>
      <category>reactnative</category>
      <category>appstore</category>
      <category>android</category>
    </item>
    <item>
      <title>Where AI-Generated Full-Stack Code Silently Rots (and How Templates Cap the Damage)</title>
      <dc:creator>Sophie F A</dc:creator>
      <pubDate>Wed, 16 Sep 2026 13:12:42 +0000</pubDate>
      <link>https://dev.to/sophie_fa_6ed935b0601d76/where-ai-generated-full-stack-code-silently-rots-and-how-templates-cap-the-damage-10ak</link>
      <guid>https://dev.to/sophie_fa_6ed935b0601d76/where-ai-generated-full-stack-code-silently-rots-and-how-templates-cap-the-damage-10ak</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AI-generated full-stack code does not fail on merge. It fails months later, at the seams between subsystems&lt;/li&gt;
&lt;li&gt;Three recurring rot sites: missing auth boundary tests, decorative RLS policies, and Stripe webhooks that only handle checkout completion&lt;/li&gt;
&lt;li&gt;The AI writes tests for the code it wrote, never for the gap it introduced&lt;/li&gt;
&lt;li&gt;Templates cap the damage because their value is debugged code, not written code&lt;/li&gt;
&lt;li&gt;Safe workflow: template for the seams, AI for the surface area, human-picked failure modes for every new route&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Six months of shipping Next.js + Supabase + Stripe apps under a mixed workflow (some human-written, some AI-scaffolded from Claude and Cursor) has surfaced a repeatable pattern. The AI-generated code doesn't fail on merge. It fails in month three. And it fails in a specific place: the seams between subsystems that the AI never wrote a test for, because it never generated the test that would have caught its own gap.&lt;/p&gt;

&lt;p&gt;This post is a field report on where the rot happens, and why starting from a template collapses most of the failure surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three places AI-generated code rots
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Auth boundary tests are always missing.&lt;/strong&gt; Every Next.js + Supabase project I've reviewed that was generated top-to-bottom by an AI had tests. They were also generated by the AI. Every test verified that a signed-in user could read their own data. Zero tests verified that an anonymous user got a 401. Zero tests verified that user A couldn't read user B's data by manipulating the URL. The AI wrote the tests it thought about, which was the tests for the code it wrote.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RLS policies are decorative.&lt;/strong&gt; Related to the above. AI will generate a &lt;code&gt;create table users_data&lt;/code&gt; migration and skip &lt;code&gt;enable row level security&lt;/code&gt;. If your dev environment uses the Supabase local stack, RLS is off by default on your custom tables. The auth policy is documentation, not enforcement, and the missing test never catches it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stripe webhooks fire on the wrong event, then no-op silently.&lt;/strong&gt; The pattern the AI reaches for is &lt;code&gt;if (event.type === 'checkout.session.completed')&lt;/code&gt;. That covers the most common flows and misses subscription upgrades, downgrades, cancellations, and dunning. The webhook is wired, the code is present, the user's subscription state drifts silently over weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why templates cap the damage
&lt;/h2&gt;

&lt;p&gt;A template is not obsolete because AI can generate code. A template is a codebase that has already been debugged by a human against real production traffic. The value isn't the 200 lines the AI could regenerate in a minute. The value is the hours of debugging those 200 lines that someone already paid.&lt;/p&gt;

&lt;p&gt;Concretely, the &lt;a href="https://www.applighter.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=ai-code-rot-templates" rel="noopener noreferrer"&gt;Applighter&lt;/a&gt; Next.js + Stripe + Supabase starter ships with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;All migrations enable RLS by default. Skip it explicitly if you don't want it; the default catches you if you forget.&lt;/li&gt;
&lt;li&gt;A Stripe webhook handler that dispatches on the full event set (&lt;code&gt;customer.subscription.updated&lt;/code&gt;, &lt;code&gt;.deleted&lt;/code&gt;, &lt;code&gt;invoice.payment_failed&lt;/code&gt;, etc.), not just checkout completion.&lt;/li&gt;
&lt;li&gt;A test suite that verifies auth boundaries (anon requests, cross-user access attempts, expired sessions) before any happy-path tests.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can still let AI generate the app on top. The template ensures the seams already have the tests the AI won't write for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to use AI on top of a template safely
&lt;/h2&gt;

&lt;p&gt;The workflow that has worked for us:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Start from a template that has the seams solved.&lt;/li&gt;
&lt;li&gt;Let AI generate feature code: new routes, new components, new business logic.&lt;/li&gt;
&lt;li&gt;Do not let AI generate tests for the seams. It cannot see the failure mode it just introduced.&lt;/li&gt;
&lt;li&gt;Human-write a smoke test per new route: anon access, wrong-user access, malformed input. The AI can help you write the assertions, but you should be picking the failure modes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The rule of thumb: AI generates the code it thought of. Humans think of the code the AI didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Concluding thought
&lt;/h2&gt;

&lt;p&gt;Most of the "AI-generated codebase failure" postmortems I read describe the same shape: everything worked, tests passed, then a specific class of user request revealed data leaking through a policy that was written but not enforced. This is not a Claude problem or a Cursor problem. It is a workflow problem. And the workflow fix is trivially available: start from a codebase where the seams are already solid, and use the AI to fill in the surface area on top.&lt;/p&gt;

&lt;p&gt;Applighter's templates exist for this reason.&lt;/p&gt;

&lt;p&gt;Where has AI-generated code rotted on you? Drop the failure mode in the comments.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>saas</category>
      <category>nextjs</category>
      <category>supabase</category>
    </item>
    <item>
      <title>Google Play's 12-Tester Rule: The Playbook Solo Indie Devs Need Before They Hit It</title>
      <dc:creator>Sophie F A</dc:creator>
      <pubDate>Wed, 09 Sep 2026 13:25:05 +0000</pubDate>
      <link>https://dev.to/sophie_fa_6ed935b0601d76/google-plays-12-tester-rule-the-playbook-solo-indie-devs-need-before-they-hit-it-475g</link>
      <guid>https://dev.to/sophie_fa_6ed935b0601d76/google-plays-12-tester-rule-the-playbook-solo-indie-devs-need-before-they-hit-it-475g</guid>
      <description>&lt;p&gt;You built the app. You bought the developer account. You upload the build, hunt for the publish button, and instead find a wall: run a closed test with 12 testers for 14 days before you can even &lt;em&gt;apply&lt;/em&gt; to publish.&lt;/p&gt;

&lt;p&gt;Most solo devs discover this rule at the worst possible moment, launch week. This is the playbook for hitting it on purpose instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Personal Play Console accounts created after November 13, 2023 must run a closed test with at least 12 testers opted in continuously for 14 days before applying for production access. Per app.&lt;/li&gt;
&lt;li&gt;The 14 days are the &lt;em&gt;most recent&lt;/em&gt; 14 days. Drop below 12 testers and your window breaks.&lt;/li&gt;
&lt;li&gt;Only the closed testing track counts. Internal testing does not.&lt;/li&gt;
&lt;li&gt;Passing the count is not passing the test: Google also looks at whether testers actually used the app, and you answer a questionnaire when you apply.&lt;/li&gt;
&lt;li&gt;Recruit 18 to 20, not 12. Attrition is the rule, not the exception.&lt;/li&gt;
&lt;li&gt;Budget three weeks minimum from "build ready" to "live on the store."&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What the rule actually is (and what it isn't)
&lt;/h2&gt;

&lt;p&gt;The rule, from Google's own testing requirements: before applying for production access, a personal developer account created after November 13, 2023 must run a closed test with a minimum of 12 testers who have been opted in continuously for the last 14 days.&lt;/p&gt;

&lt;p&gt;Unpack the load-bearing words:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Personal account, created after Nov 13, 2023.&lt;/strong&gt; Organization accounts are exempt, and so are older personal accounts. If your account predates the cutoff, you can stop reading and go publish.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Opted in.&lt;/strong&gt; A tester counts when they click your opt-in link and join the test through the Play Store with their Google account. Twelve email addresses in your tester list that never clicked count as zero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Continuously, last 14 days.&lt;/strong&gt; Measured backwards from now. Not "14 days total, eventually."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Closed testing.&lt;/strong&gt; Internal testing (up to 100 testers, no review) is great for your own devices but contributes nothing to this requirement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per app.&lt;/strong&gt; Passing once does not unlock the account forever. Each new app runs its own closed test.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What the rule is &lt;em&gt;not&lt;/em&gt;: a guarantee. Hitting 12x14 makes you eligible to apply for production access. When you apply, you answer questions about your testing process, what feedback you got, and what you changed. Google has also been checking that testers genuinely engaged with the app, not just opted in and vanished. Twelve ghosts can pass the counter and still sink the application.&lt;/p&gt;

&lt;p&gt;One more historical footnote worth knowing: the requirement launched at 20 testers and was later reduced to 12. Any tutorial telling you "20 testers" is just old, not describing a different rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Google added it
&lt;/h2&gt;

&lt;p&gt;The Play Store had a spam problem: low-effort apps churned out from throwaway accounts, published in bulk, abandoned. A two-week test with a dozen real humans is a speed bump that costs a spammer more than it costs you. It also front-loads a thing you should be doing anyway: watching real people use your app before strangers do.&lt;/p&gt;

&lt;p&gt;Understanding the intent matters tactically. Google is not counting heads for fun; it is looking for evidence of a real testing process. Everything below is built around producing that evidence honestly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 14-day counter: resets, gotchas, real elapsed time
&lt;/h2&gt;

&lt;p&gt;This is where launches die, so be precise about the mechanics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The meaningful clock runs while you have &lt;strong&gt;12 or more&lt;/strong&gt; opted-in testers. Practically, treat it as starting when tester #12 opts in, not when you create the track.&lt;/li&gt;
&lt;li&gt;Because the requirement reads "the last 14 days continuously," a dip below 12 on day 10 does not pause your progress, it breaks it. You are back to rebuilding a clean 14-day window.&lt;/li&gt;
&lt;li&gt;Testers can opt out silently. A tester who uninstalls the app but stays opted in still counts toward the opt-in number, but contributes nothing to the engagement picture. Both numbers matter.&lt;/li&gt;
&lt;li&gt;Real elapsed time is longer than 14 days. Add: Google's review of the closed-test release itself before testers can join (often a few days for a first release from a new account), recruiting time, and the production access review after you apply. This is why the honest runway is three weeks, not two.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Write these two dates down the moment tester #12 opts in: the opt-in date, and opt-in date + 14. Everything between them is a "do not drop below 12" zone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to actually find 12 real testers
&lt;/h2&gt;

&lt;p&gt;In rough order of quality:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;People who want the app to exist.&lt;/strong&gt; If you built a climbing log, post in a climbing community. These testers open the app because they care, which is exactly the engagement signal you need.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Friends, family, colleagues.&lt;/strong&gt; Reliable opt-ins, mediocre engagement. Give each one a specific job ("use it to track one real thing this week") or they will install and forget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dev communities and mutual-testing groups.&lt;/strong&gt; Indie devs testing each other's apps (Discord servers, subreddits, forums). It works, with two caveats: reciprocity costs you time testing other apps, and a track full of developers who open the app once is thin engagement evidence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paid testing services.&lt;/strong&gt; They exist in volume. If you go this route, understand what you are buying: opted-in accounts, not necessarily the genuine-usage story Google increasingly wants to see. Treat it as a top-up, not a strategy.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The move that consistently works for solo devs: recruit from 1 and 2 first, then backfill from 3. And recruit &lt;em&gt;before&lt;/em&gt; the build is ready, so testers are waiting on you instead of the reverse.&lt;/p&gt;

&lt;h2&gt;
  
  
  The opt-in URL: sharing it without looking desperate
&lt;/h2&gt;

&lt;p&gt;The mechanics: create the closed track, upload the build, add an email list (or Google Group, which is far easier to manage), and share the opt-in link Google generates. Testers open it, join, and install from the Play Store.&lt;/p&gt;

&lt;p&gt;The pitch matters more than the link. What flops: "please help me, Google makes me do this." What works is a one-liner that gives people a reason and a bounded ask:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;I'm launching &amp;lt;app&amp;gt; for &amp;lt;audience&amp;gt; and it's in a 2-week
pre-release test. If you'd use something like this, you can
get it early here: &amp;lt;link&amp;gt;. One ask: open it a couple of
times a week and tell me the first thing that annoys you.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That framing does three jobs at once: it filters for people who might actually use the app, it sets the engagement expectation, and it primes the feedback you will need for the production access questionnaire.&lt;/p&gt;

&lt;p&gt;Use a Google Group for the tester list. Adding and removing individual emails in the Console gets old by tester #6.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attrition planning: over-recruit, track, react
&lt;/h2&gt;

&lt;p&gt;Plan for a third of your testers to flake. Not because people are bad, but because "install a stranger's app and keep it for two weeks" is a genuinely annoying favor.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Recruit 18 to 20 for a 12 floor.&lt;/strong&gt; Every one above 12 is insurance against a broken window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Track opt-ins daily.&lt;/strong&gt; The Console shows your tester count. Thirty seconds each morning, that's the whole habit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Have 2 to 3 warm spares.&lt;/strong&gt; People who said yes but whom you have not sent the link. If the count reads 13 and trending down, deploy a spare the same day, because a new opt-in today protects a window that a new opt-in on the day you drop below 12 cannot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nudge once, mid-window.&lt;/strong&gt; A single day-7 message ("halfway, anything broken? anything missing?") revives idle testers and generates the feedback trail you want. More than one nudge and you are spam.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What if you fail the test
&lt;/h2&gt;

&lt;p&gt;Two distinct failure modes, two remediations:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The counter broke.&lt;/strong&gt; You dropped below 12 mid-window. Nothing is lost except time: keep the track, backfill testers, and let a fresh 14-day window accumulate. This is annoying, not fatal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Production access was denied.&lt;/strong&gt; You hit the numbers, applied, and Google said no. This almost always means the testing &lt;em&gt;story&lt;/em&gt; was weak: no evidence of real usage, questionnaire answers that read like nobody actually tested anything, or an app with obvious unfinished parts. The fix is a genuinely better test cycle: engaged testers, a change you shipped in response to feedback, and questionnaire answers that name both. Then reapply.&lt;/p&gt;

&lt;p&gt;Then there is the third option: deciding your time is worth more than fighting the process. This gate is one of several reasons a done-for-you route exists; a service like &lt;a href="https://www.letsdeploy.it/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=google-play-12-tester-rule-playbook" rel="noopener noreferrer"&gt;LetsDeployIt&lt;/a&gt; handles store submission end to end, which for a lot of solo devs (especially the wave shipping AI-built apps who never planned to learn Play Console at all) is the difference between launching and stalling at the paperwork.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 3-week release runway
&lt;/h2&gt;

&lt;p&gt;The rule quietly converts "publish to Google Play" from an afternoon into a project phase. Plan it like one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Week 0 (before the build is final)
  - Recruit 18-20 testers, park them in a Google Group
  - Create the closed track early; first releases can sit
    in review for days

Week 1-2 (the window)
  - Day 0: 12+ opted in, write down the target date
  - Daily: 30-second tester count check
  - Day 7: single nudge + collect feedback
  - Ship at least one visible fix from that feedback

Week 3
  - Apply for production access; answer the questionnaire
    with specifics (feedback received, changes made)
  - Production review + staged rollout
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The devs who resent this rule fight it during launch week and lose a month. The devs who absorb it into the plan run the test while they polish the store listing and lose nothing.&lt;/p&gt;

&lt;p&gt;Solo devs who've been through it: how long did your window actually take, and where did you find your 12? Drop it in the comments, the tester-sourcing answers genuinely help the next person who lands here from the Play Console wall.&lt;/p&gt;

</description>
      <category>android</category>
      <category>googleplay</category>
      <category>mobile</category>
      <category>indiedev</category>
    </item>
    <item>
      <title>Claude Code on a React Native Codebase: Guardrails That Actually Change the Output</title>
      <dc:creator>Sophie F A</dc:creator>
      <pubDate>Wed, 09 Sep 2026 13:19:12 +0000</pubDate>
      <link>https://dev.to/sophie_fa_6ed935b0601d76/claude-code-on-a-react-native-codebase-guardrails-that-actually-change-the-output-3pd2</link>
      <guid>https://dev.to/sophie_fa_6ed935b0601d76/claude-code-on-a-react-native-codebase-guardrails-that-actually-change-the-output-3pd2</guid>
      <description>&lt;p&gt;Every React Native developer has run the same experiment by now: point an AI agent at the codebase, ask for a feature, watch it produce something that compiles, and then quietly rewrite half of it before merging. The demo works. The diff does not.&lt;/p&gt;

&lt;p&gt;The gap is not model quality. It is that an agent with no constraints makes the statistically average choice at every fork, and the statistically average React Native choice is frequently wrong for &lt;em&gt;your&lt;/em&gt; app: wrong navigation pattern, wrong state library, a fresh &lt;code&gt;useState&lt;/code&gt; where you have a store, an inline style where you have a design system.&lt;/p&gt;

&lt;p&gt;Guardrails close that gap. Here are the ones that measurably changed what Claude Code hands me on RN projects.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A short CLAUDE.md beats a long one. Two paragraphs on stack and conventions do most of the work.&lt;/li&gt;
&lt;li&gt;Fence off &lt;code&gt;ios/&lt;/code&gt;, &lt;code&gt;android/&lt;/code&gt;, and native config with permission rules AND an instruction. Belt and suspenders.&lt;/li&gt;
&lt;li&gt;One screen or one hook per task. If you cannot describe the diff in a sentence, split it.&lt;/li&gt;
&lt;li&gt;Make the agent run typecheck, lint, and tests itself before showing you anything.&lt;/li&gt;
&lt;li&gt;Agents are great at gluing and modifying. For standard app skeletons, a template is still faster.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The gap between "writes RN code" and "writes RN code you'd merge"
&lt;/h2&gt;

&lt;p&gt;Unguided agent output on an RN codebase fails in predictable ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It invents a second way to do something you already do one way (a new fetch wrapper next to your API client).&lt;/li&gt;
&lt;li&gt;It reaches for dependencies you do not have, or worse, installs them.&lt;/li&gt;
&lt;li&gt;It edits generated or native files that should never be touched by hand.&lt;/li&gt;
&lt;li&gt;It styles components from scratch instead of using your primitives.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are intelligence failures. They are context failures, and context is exactly the thing you control.&lt;/p&gt;

&lt;h2&gt;
  
  
  CLAUDE.md: the two paragraphs that change the defaults
&lt;/h2&gt;

&lt;p&gt;Claude Code reads a &lt;code&gt;CLAUDE.md&lt;/code&gt; file from your project root at the start of every session and treats it as standing instructions. Most people either skip it or write a 300-line novel the model has to dig through. The high-leverage version is short and opinionated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# CLAUDE.md&lt;/span&gt;

This is an Expo (managed workflow) app. TypeScript strict mode.
Navigation: expo-router, file-based. State: zustand stores in src/stores/.
Data fetching goes through src/api/client.ts only. Never call fetch directly.
UI: use the components in src/components/ui/. Do not write inline styles;
use the theme tokens in src/theme/.

Never modify anything in ios/, android/, or app.json without asking first.
Before finishing any task, run: npx tsc --noEmit &amp;amp;&amp;amp; npm run lint &amp;amp;&amp;amp; npm test.
If any of those fail, fix them before presenting the diff.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole file. Every line kills one category of "compiles but wrong" output. The first paragraph replaces the model's generic defaults with your actual stack. The second paragraph sets the boundaries and the exit criteria.&lt;/p&gt;

&lt;p&gt;The test for whether a line belongs in CLAUDE.md: have you corrected the agent for this twice? Then it goes in. Otherwise it is noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guardrail: keep the agent out of native land
&lt;/h2&gt;

&lt;p&gt;On RN specifically, the highest-damage failure mode is the agent "helpfully" editing native projects: a Podfile tweak here, a gradle change there, an Info.plist permission it decided you needed. These changes are invisible in a JS-focused review and surface as build failures days later.&lt;/p&gt;

&lt;p&gt;I fence this at two layers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 1: permission rules.&lt;/strong&gt; Claude Code supports allow/deny permission rules in &lt;code&gt;.claude/settings.json&lt;/code&gt; at the project root, so the constraint ships with the repo:&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;"permissions"&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;"deny"&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;"Edit(ios/**)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Edit(android/**)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Write(ios/**)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Write(android/**)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(pod:*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(npx expo prebuild:*)"&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;&lt;strong&gt;Layer 2: the instruction.&lt;/strong&gt; The "never modify ios/, android/, or app.json without asking" line in CLAUDE.md. This is not redundant. Permission rules are enforcement; the instruction changes &lt;em&gt;planning&lt;/em&gt;. Without it, the agent plans a native change, hits the wall, and flails. With it, the agent routes around native work from the start or stops and asks, which is what you actually want.&lt;/p&gt;

&lt;p&gt;If you are on Expo managed workflow, you get this guardrail almost for free: keep the agent in JS, keep config in &lt;code&gt;app.json&lt;/code&gt; under human control, and let EAS handle the native layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task shape: small, verifiable, one screen at a time
&lt;/h2&gt;

&lt;p&gt;The size of the task changes the quality of the output more than any prompt wording. My working rule: &lt;strong&gt;if you cannot describe the expected diff in one sentence, the task is too big.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Bad: "Add a profile feature."&lt;/p&gt;

&lt;p&gt;Good, as a sequence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Add a ProfileScreen at app/profile.tsx using the ui/ components.
   Static layout only, hardcoded data. No navigation changes yet.
2. Add a useProfile hook in src/hooks/ that loads the profile
   via src/api/client.ts. Include a loading and error state.
3. Wire useProfile into ProfileScreen. Handle loading/error with
   our existing &amp;lt;Spinner /&amp;gt; and &amp;lt;ErrorView /&amp;gt;.
4. Add the profile tab to the router layout.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each step is one screen, one hook, or one wiring job. Each is verifiable on its own: you can open the diff, look at one file, and say yes or no in under a minute. The agent also self-corrects better at this size because failures are local.&lt;/p&gt;

&lt;p&gt;The compounding benefit: step 2's output is written against step 1's real code, not against an imagined version of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The testing loop: what the agent runs before you see a diff
&lt;/h2&gt;

&lt;p&gt;An agent that hands you untested code has done half a task. The loop I make explicit (in CLAUDE.md and in the task prompt for anything nontrivial):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx tsc &lt;span class="nt"&gt;--noEmit&lt;/span&gt;   &lt;span class="c"&gt;# strict TS catches most RN agent mistakes&lt;/span&gt;
npm run lint       &lt;span class="c"&gt;# convention drift, unused imports, hook rules&lt;/span&gt;
npm &lt;span class="nb"&gt;test&lt;/span&gt;           &lt;span class="c"&gt;# whatever exists; even thin coverage catches regressions&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Strict TypeScript is doing the heaviest lifting here. A big share of agent errors in RN are shape errors: wrong prop types, wrong navigation params, a store selector returning the wrong slice. &lt;code&gt;tsc --noEmit&lt;/code&gt; converts those from runtime surprises into a loop the agent resolves on its own before you ever look.&lt;/p&gt;

&lt;p&gt;What the agent cannot do is &lt;em&gt;see&lt;/em&gt; the screen. So my manual review step for UI work is deliberately narrow: run it, look at the screen on one device size, tap through the flow once. Logic correctness has already been machine-checked; I am only reviewing what machines cannot check.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the agent is faster than a template, and when it isn't
&lt;/h2&gt;

&lt;p&gt;Honest accounting, because "AI writes the whole app" is the claim and it is only half true.&lt;/p&gt;

&lt;p&gt;The agent wins when the work is &lt;strong&gt;specific to your app&lt;/strong&gt;: modifying existing screens, wiring a new flow into established patterns, refactors, integrating that one weird SDK, writing tests against your actual code. This is where templates cannot help you by definition.&lt;/p&gt;

&lt;p&gt;The agent loses on &lt;strong&gt;standard app infrastructure&lt;/strong&gt;. Auth flows, onboarding, navigation shells, Supabase wiring, settings screens: an agent will generate these from scratch in an hour and you will spend another two reviewing boilerplate that thousands of apps share. That is what starter templates are for; a catalog like &lt;a href="https://www.applighter.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=claude-code-react-native-guardrails" rel="noopener noreferrer"&gt;AppLighter&lt;/a&gt; exists precisely so the React Native + Expo + Supabase skeleton is a solved problem you download instead of a week of agent diffs you review.&lt;/p&gt;

&lt;p&gt;The pattern that actually compounds: template for the skeleton, agent for everything that makes the app &lt;em&gt;yours&lt;/em&gt;. The template also quietly improves the agent, because now every task starts from consistent, conventional code, which is exactly the context agents extrapolate from best.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern that beats "just ship what it wrote"
&lt;/h2&gt;

&lt;p&gt;Putting it together, the loop that has consistently produced mergeable RN diffs for me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. CLAUDE.md: stack, conventions, boundaries, exit criteria (short)
2. Permission rules: native dirs and dangerous commands fenced
3. Tasks sized to one-sentence diffs, sequenced
4. Agent runs tsc + lint + tests before presenting
5. Human review narrowed to: screen looks right, flow feels right
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of this is sophisticated. That is the point. The teams getting real throughput from agents on RN codebases are not writing clever prompts; they are removing the degrees of freedom where the agent's average choice diverges from their codebase, and letting it sprint inside the fence.&lt;/p&gt;

&lt;p&gt;What is in your CLAUDE.md? I am collecting RN-specific guardrails, so drop yours in the comments, especially the rule you added after an agent burned you.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>ai</category>
      <category>expo</category>
      <category>productivity</category>
    </item>
    <item>
      <title>We Killed Our Yearly Plan. The Lifetime License Converted 2.4x Better.</title>
      <dc:creator>Sophie F A</dc:creator>
      <pubDate>Mon, 17 Aug 2026 08:38:15 +0000</pubDate>
      <link>https://dev.to/sophie_fa_6ed935b0601d76/we-killed-our-yearly-plan-the-lifetime-license-converted-24x-better-pje</link>
      <guid>https://dev.to/sophie_fa_6ed935b0601d76/we-killed-our-yearly-plan-the-lifetime-license-converted-24x-better-pje</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;We A/B tested a yearly subscription against a one-time lifetime license on three React Native templates for a full quarter.&lt;/li&gt;
&lt;li&gt;Lifetime converted at &lt;strong&gt;2.4x&lt;/strong&gt; the yearly plan, cut the 7-day refund rate from 6.8% to 3.1%, and dropped support tickets per 100 sales from 41 to 17.&lt;/li&gt;
&lt;li&gt;Repeat purchase went &lt;strong&gt;up&lt;/strong&gt; under lifetime (~2% to ~11%). Every advisor we asked predicted the opposite.&lt;/li&gt;
&lt;li&gt;Root cause is product shape, not pricing fashion. Our marginal cost per buyer after delivery is ~0, so recurring billing had no cost curve to match.&lt;/li&gt;
&lt;li&gt;The actual code change was two lines in the Stripe session and one column in Postgres. The comms rewrite took a week.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;We sell downloadable React Native templates. Full Expo + Supabase codebases you clone, rebrand, and ship. There is no server we keep alive for you, no per-seat metering, no proxy in front of your OpenAI key.&lt;/p&gt;

&lt;p&gt;We still launched with a yearly plan, because that is what every pricing-page template on the internet says to do.&lt;/p&gt;

&lt;p&gt;Two variants, three products (a $49, a $79, and an $89 control), traffic split ~50/50 by product page visit, swapped weekly to kill weekday effects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Variant A:&lt;/strong&gt; yearly subscription at roughly one-third the lifetime price. Source access revoked on cancel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Variant B:&lt;/strong&gt; one payment, lifetime updates, keep the code forever.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Identical code, identical Supabase migrations, identical Expo config. The only difference was what happened to your access when you stopped paying.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Yearly&lt;/th&gt;
&lt;th&gt;Lifetime&lt;/th&gt;
&lt;th&gt;Delta&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Product-page to paid conversion&lt;/td&gt;
&lt;td&gt;1.0x&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.4x&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;+140%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Refund rate (7-day window)&lt;/td&gt;
&lt;td&gt;6.8%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3.1%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;-54%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Support tickets per 100 sales&lt;/td&gt;
&lt;td&gt;41&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;17&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;-59%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cart abandonment at pricing&lt;/td&gt;
&lt;td&gt;68%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;44%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;-24 pts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Repeat customer rate (60 days)&lt;/td&gt;
&lt;td&gt;~2%&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~11%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;+9 pts&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three things stood out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repeat purchase went up.&lt;/strong&gt; The "if they own it they'll never come back" prediction was just wrong. A happy lifetime buyer came back for a second template. A subscription buyer treated the whole category as expensive rented software and never returned.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Refunds halved.&lt;/strong&gt; A big share of "refund me" tickets were not dissatisfaction. They were anxiety about the &lt;em&gt;next&lt;/em&gt; bill. Remove the next bill, remove the reason to hedge.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Support load collapsed.&lt;/strong&gt; Subscription tickets were dominated by four questions that literally cannot exist under lifetime: how do I cancel, will you charge me if I don't ship, what happens to my code if I stop paying, can I export before renewal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it broke: the economics didn't line up
&lt;/h2&gt;

&lt;p&gt;The subscription playbook assumes the vendor keeps paying an ongoing cost to keep your use of the product alive. Check what we actually pay for after checkout:&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;// Our per-buyer ongoing cost, honestly enumerated&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ongoingCostPerBuyer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;hosting&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="c1"&gt;// you deploy it&lt;/span&gt;
  &lt;span class="na"&gt;database&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="c1"&gt;// you connect your own Supabase project&lt;/span&gt;
  &lt;span class="na"&gt;llmInference&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="c1"&gt;// you drop in your own API key&lt;/span&gt;
  &lt;span class="na"&gt;appleDeveloper&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="c1"&gt;// your account, your $99/yr&lt;/span&gt;
  &lt;span class="na"&gt;bandwidth&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="c1"&gt;// one tarball at purchase time&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="c1"&gt;// =&amp;gt; sum is 0. There is no recurring cost to fund with recurring revenue.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You connect &lt;a href="https://supabase.com/docs/guides/getting-started" rel="noopener noreferrer"&gt;your own Supabase instance&lt;/a&gt; with the migrations we ship. You drop your own key into &lt;a href="https://docs.expo.dev/guides/environment-variables/" rel="noopener noreferrer"&gt;Expo's env config&lt;/a&gt;:&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="c"&gt;# .env.local: your keys, your quota, your bill&lt;/span&gt;
&lt;span class="nv"&gt;EXPO_PUBLIC_SUPABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://your-project.supabase.co
&lt;span class="nv"&gt;EXPO_PUBLIC_SUPABASE_ANON_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;your-anon-key
&lt;span class="nv"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sk-your-own-key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the buyer's cost is recurring and the vendor's cost is not, buyers hedge. They cancel early, they refund, and they open tickets about the next bill instead of the current build.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual code change
&lt;/h2&gt;

&lt;p&gt;This is the part people assume is hard. It isn't. The Stripe side is a mode swap:&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;// Before: subscription&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&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;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;checkout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessions&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;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subscription&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;line_items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;price_yearly_fitness&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;quantity&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="na"&gt;subscription_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;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fitness-app&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;revoke_on_cancel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&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;success_url&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;SITE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/download?session_id={CHECKOUT_SESSION_ID}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// After: one-time payment&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&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;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;checkout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessions&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;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;payment&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;line_items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;price_lifetime_fitness&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;quantity&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="na"&gt;payment_intent_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;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fitness-app&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;license_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;single&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;success_url&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;SITE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/download?session_id={CHECKOUT_SESSION_ID}`&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;Read the &lt;a href="https://docs.stripe.com/billing/subscriptions/overview" rel="noopener noreferrer"&gt;Stripe billing modes doc&lt;/a&gt; before you pick one. &lt;code&gt;payment&lt;/code&gt; versus &lt;code&gt;subscription&lt;/code&gt; is not a small config toggle, it reshapes the entire buyer relationship.&lt;/p&gt;

&lt;p&gt;The schema side is one column:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- supabase/migrations/20260114_product_licenses.sql&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;license_type&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nb"&gt;enum&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'single'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'multiple'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'team'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'enterprise'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- note: no 'monthly_recurring'. it does not exist anymore.&lt;/span&gt;

&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_licenses&lt;/span&gt;
  &lt;span class="k"&gt;add&lt;/span&gt; &lt;span class="k"&gt;column&lt;/span&gt; &lt;span class="n"&gt;license_type&lt;/span&gt; &lt;span class="n"&gt;license_type&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="s1"&gt;'single'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;add&lt;/span&gt; &lt;span class="k"&gt;column&lt;/span&gt; &lt;span class="n"&gt;purchased_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;-- entitlement check, post-switch&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="k"&gt;replace&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;has_template_access&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;p_user_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;p_template&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt; &lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt; &lt;span class="k"&gt;stable&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;product_licenses&lt;/span&gt;
    &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_user_id&lt;/span&gt;
      &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;template_slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_template&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="c1"&gt;-- no expiry comparison. that clause is what we deleted.&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That deleted &lt;code&gt;and expires_at &amp;gt; now()&lt;/code&gt; clause is the whole post in one line of SQL.&lt;/p&gt;

&lt;h2&gt;
  
  
  What buyers actually asked for
&lt;/h2&gt;

&lt;p&gt;Tagged every sales conversation, support ticket, and abandoned-cart survey for the quarter. Three themes dominated:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;"Can I just buy it?"&lt;/strong&gt; By far the most frequent. Developers wanted a purchase order, not a payment relationship.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"What happens to the code if I cancel?"&lt;/strong&gt; Our subscription answer was "access revoked." Correct, and hostile-sounding to anyone shipping a real product.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"I need this for a client project, can I keep the fork after it ships?"&lt;/strong&gt; Agency and freelancer use, a much bigger slice than we expected. A yearly plan does not serve "I need this for six weeks, then I'm done."&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The rule I'd give you
&lt;/h2&gt;

&lt;p&gt;Not "lifetime always wins." Match pricing to product shape:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Product shape&lt;/th&gt;
&lt;th&gt;Pricing&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hosted SaaS with per-user server cost&lt;/td&gt;
&lt;td&gt;Subscription&lt;/td&gt;
&lt;td&gt;Recurring cost matches recurring value&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API-metered service&lt;/td&gt;
&lt;td&gt;Usage-based&lt;/td&gt;
&lt;td&gt;Cost scales with call volume&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Downloadable tool / plugin / template&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Lifetime&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Marginal cost after delivery is zero&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Continuously-curated feed or library&lt;/td&gt;
&lt;td&gt;Hybrid&lt;/td&gt;
&lt;td&gt;Ongoing editorial work justifies ongoing revenue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hardware + optional cloud&lt;/td&gt;
&lt;td&gt;Perpetual + optional sub&lt;/td&gt;
&lt;td&gt;Two cost curves, two prices&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The honest test: &lt;strong&gt;does my cost of serving this customer continue after they sign up?&lt;/strong&gt; If yes, subscription. If no, you are selling a lifetime asset dressed up as a rental.&lt;/p&gt;

&lt;p&gt;We would go back to subscription tomorrow for a hosted managed backend, a continuously-curated prompt library, an agency retainer, or an enterprise plan with a real SLA. For static, self-hosted, source-available code, subscription is a category error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it landed
&lt;/h2&gt;

&lt;p&gt;Every template in the &lt;a href="https://www.applighter.com/apps?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=yearly-vs-lifetime-licenses-what-our-data-showed" rel="noopener noreferrer"&gt;Applighter catalog&lt;/a&gt; is now a one-time purchase between $49 and $99, including full TypeScript source, Supabase migrations, Expo config, and every future update to that template. Agencies get a &lt;code&gt;multiple&lt;/code&gt; license upgrade instead of a seat subscription. Refund window is 7 days, no questions asked.&lt;/p&gt;

&lt;p&gt;The FAQ line that took a week to write:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;One payment, all future updates to that template, including Expo upgrades, new screens, and security patches. No subscription, no seat tax.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every word in it was argued over. "All future updates" kills rug-pull fear. "Expo upgrades" makes the promise specific. "No seat tax" preempts the next question every dev asks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Your turn
&lt;/h2&gt;

&lt;p&gt;If you sell something downloadable and you're still on a yearly plan, I'd genuinely like to know what your refund rate looks like. And if you switched and it went badly, that's the more useful comment. Drop it below.&lt;/p&gt;

</description>
      <category>pricing</category>
      <category>saas</category>
      <category>indiehackers</category>
      <category>reactnative</category>
    </item>
    <item>
      <title>The 4 AI Patterns That Actually Ship in Mobile</title>
      <dc:creator>Sophie F A</dc:creator>
      <pubDate>Mon, 17 Aug 2026 08:37:32 +0000</pubDate>
      <link>https://dev.to/sophie_fa_6ed935b0601d76/the-4-ai-patterns-that-actually-ship-in-mobile-4i30</link>
      <guid>https://dev.to/sophie_fa_6ed935b0601d76/the-4-ai-patterns-that-actually-ship-in-mobile-4i30</guid>
      <description>&lt;p&gt;Most AI features in mobile apps die before ship. The ones that make it reduce to four patterns. Here they are with real numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three constraints that kill 80% of AI features
&lt;/h2&gt;

&lt;p&gt;Before the patterns, the filter. An AI feature ships in a mobile app if and only if it clears these:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;P95 latency under 2s&lt;/strong&gt; (or streamed with TTFT &amp;lt; 500ms)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost per active user under $0.10/mo&lt;/strong&gt; (unless paid tier is live day one)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A degradation story&lt;/strong&gt; — offline, wrong answer, model down&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every pattern below clears all three. Every pattern that doesn't is why your last AI feature never left staging.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pattern 1 — Transcribe → Structure
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Shape:&lt;/strong&gt; voice in → structured artifact out (summary, action items, journal entry).&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;// 1. capture audio (expo-av / expo-audio)&lt;/span&gt;
&lt;span class="c1"&gt;// 2. stream to Whisper / Deepgram / AssemblyAI&lt;/span&gt;
&lt;span class="c1"&gt;// 3. LLM call with a JSON schema&lt;/span&gt;
&lt;span class="c1"&gt;// 4. render + persist&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;P50&lt;/th&gt;
&lt;th&gt;P95&lt;/th&gt;
&lt;th&gt;Cost / 5-min recording&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Upload&lt;/td&gt;
&lt;td&gt;400ms&lt;/td&gt;
&lt;td&gt;1.2s&lt;/td&gt;
&lt;td&gt;free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whisper transcription&lt;/td&gt;
&lt;td&gt;3s&lt;/td&gt;
&lt;td&gt;7s&lt;/td&gt;
&lt;td&gt;$0.03&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gpt-4o-mini structuring&lt;/td&gt;
&lt;td&gt;800ms&lt;/td&gt;
&lt;td&gt;2.1s&lt;/td&gt;
&lt;td&gt;$0.002&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The UX trick:&lt;/strong&gt; stream the transcript in &lt;em&gt;during&lt;/em&gt; upload. The user never waits on the wall clock. Perceived latency stays under 1s because the UI is animating continuously.&lt;/p&gt;

&lt;p&gt;Reference implementation: the AI Voice Notes template on Applighter ships this end-to-end — 16+ screens, waveform visualizer, pluggable transcription backend.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pattern 2 — Chat over your data (RAG)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Shape:&lt;/strong&gt; documents + question → grounded answer.&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;// ingest (once per doc)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chunks&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;chunkPdf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;overlap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&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;embeddings&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;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;embeddings&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;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text-embedding-3-small&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;text&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;supabase&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;doc_chunks&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;chunks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&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="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;embedding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;embedding&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// query (every message)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;q&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;embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userQuestion&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="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;matches&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;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rpc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;match_chunks&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;query_embedding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;match_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;6&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;answer&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;streamChat&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;messages&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;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;system&lt;/span&gt;&lt;span class="dl"&gt;'&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="s2"&gt;`Answer using only:\n&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="nx"&gt;matches&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&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;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userQuestion&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;&lt;strong&gt;Cost profile — 1,000 users × 20 msg/day:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Embeddings: ~$4/mo&lt;/li&gt;
&lt;li&gt;LLM (gpt-4o-mini): ~$18/mo&lt;/li&gt;
&lt;li&gt;pgvector on Supabase free tier: $0&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Total: ~$22/mo → $0.022/user&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Charge $4.99. Print money.&lt;/p&gt;

&lt;p&gt;Supabase's &lt;code&gt;pgvector&lt;/code&gt; is the low-ops choice — same Postgres you already use for auth. Pinecone/Weaviate/Chroma only make sense above ~100k docs.&lt;/p&gt;

&lt;p&gt;Reference: &lt;a href="https://supabase.com/docs/guides/ai/vector-columns" rel="noopener noreferrer"&gt;Supabase vector columns docs&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pattern 3 — Streaming generation
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Shape:&lt;/strong&gt; prompt → streamed text/JSON.&lt;/p&gt;

&lt;p&gt;The transport is the whole game. Same model, same prompt, but token-by-token output shows meaningful content in &amp;lt;300ms. Non-streamed on cellular feels broken; streamed feels instant.&lt;/p&gt;

&lt;p&gt;React Native gotcha: default &lt;code&gt;fetch&lt;/code&gt; doesn't reliably stream on Android. Use &lt;code&gt;expo/fetch&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;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;fetch&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/fetch&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;res&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;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/chat&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;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&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="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;messages&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;reader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getReader&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;decoder&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;TextDecoder&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;while &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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;done&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;value&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;reader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&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;done&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;setStreamedText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;prev&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;parseSSE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;decoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&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;Three things that break in prod:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Backgrounding.&lt;/strong&gt; iOS suspends the stream. Buffer server-side, reconnect on resume.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FlatList reflow.&lt;/strong&gt; Appending chars re-measures rows every keystroke. Memoize, or use a fixed-height container.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interruption.&lt;/strong&gt; Users tap to type mid-stream. Need cancel-and-restart, not a modal.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Pattern 4 — Multimodal vision extraction
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Shape:&lt;/strong&gt; photo → structured JSON.&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;image&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;ImagePicker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launchCameraAsync&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;base64&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&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;openai&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;completions&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;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gpt-4o&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;messages&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;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;system&lt;/span&gt;&lt;span class="dl"&gt;'&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Extract data as JSON matching this schema...&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;role&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;content&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;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Extract line items&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;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image_url&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;image_url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`data:image/jpeg;base64,&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;base64&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="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;response_format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;json_schema&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;json_schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RECEIPT_SCHEMA&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;Gotchas:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Resize first.&lt;/strong&gt; iPhone photos are 3–5MB base64. Resize to 1024×1024 max, save 80% of tokens, no accuracy loss.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The model will guess.&lt;/strong&gt; Always show the extraction with editable fields before saving.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;P50 is ~3s.&lt;/strong&gt; Show a skeleton, don't block the camera UI.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Side by side
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;P50 latency&lt;/th&gt;
&lt;th&gt;Cost/event&lt;/th&gt;
&lt;th&gt;Ships in&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Transcribe → structure&lt;/td&gt;
&lt;td&gt;Whisper + gpt-4o-mini&lt;/td&gt;
&lt;td&gt;4s&lt;/td&gt;
&lt;td&gt;$0.03&lt;/td&gt;
&lt;td&gt;1 weekend&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAG&lt;/td&gt;
&lt;td&gt;gpt-4o-mini + pgvector&lt;/td&gt;
&lt;td&gt;1.5s streamed&lt;/td&gt;
&lt;td&gt;$0.001/msg&lt;/td&gt;
&lt;td&gt;3–5 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Streaming generation&lt;/td&gt;
&lt;td&gt;gpt-4o-mini&lt;/td&gt;
&lt;td&gt;300ms TTFT&lt;/td&gt;
&lt;td&gt;$0.0005/reply&lt;/td&gt;
&lt;td&gt;2 days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vision extraction&lt;/td&gt;
&lt;td&gt;gpt-4o&lt;/td&gt;
&lt;td&gt;3s&lt;/td&gt;
&lt;td&gt;$0.01&lt;/td&gt;
&lt;td&gt;2–3 days&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  The pattern that never ships: agents
&lt;/h2&gt;

&lt;p&gt;Autonomous agents fail &lt;em&gt;silently and expensively&lt;/em&gt;. A stuck agent burns $2 of API before anyone notices. A hallucinating agent sends the wrong Slack. On desktop, fine — you're watching a terminal. On mobile — backgrounded, killed, lost network mid-run — it's a support ticket factory.&lt;/p&gt;

&lt;p&gt;The four patterns above ship because they're &lt;strong&gt;bounded&lt;/strong&gt;: user sees input, user sees output, loop closes in seconds.&lt;/p&gt;

&lt;p&gt;Agents will get there. Not in 2026.&lt;/p&gt;




&lt;h2&gt;
  
  
  Picking your pattern
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Users speak more than they type → &lt;strong&gt;Pattern 1&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Users have their own data → &lt;strong&gt;Pattern 2&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Users compose text → &lt;strong&gt;Pattern 3&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Users capture from the physical world → &lt;strong&gt;Pattern 4&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ship one. Compose later. Never all four at once — that's the roadmap slide of a startup that no longer exists.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.applighter.com/blog/ai-features-in-mobile-apps-four-patterns-that-actually-ship" rel="noopener noreferrer"&gt;Canonical version with more code + template links&lt;/a&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>ai</category>
      <category>expo</category>
      <category>supabase</category>
    </item>
    <item>
      <title>Cross-Platform Design Tokens — Getting Web + Mobile to Feel Like One Product</title>
      <dc:creator>Sophie F A</dc:creator>
      <pubDate>Thu, 06 Aug 2026 09:13:40 +0000</pubDate>
      <link>https://dev.to/sophie_fa_6ed935b0601d76/cross-platform-design-tokens-getting-web-mobile-to-feel-like-one-product-2ibh</link>
      <guid>https://dev.to/sophie_fa_6ed935b0601d76/cross-platform-design-tokens-getting-web-mobile-to-feel-like-one-product-2ibh</guid>
      <description>&lt;p&gt;Most full-stack SaaS templates ship a Next.js web app and a React Native mobile app in the same repo. Fewer share design tokens between them. And that's the difference between an app that feels like one product on two surfaces and an app that feels like two companies wearing the same t-shirt.&lt;/p&gt;

&lt;h2&gt;
  
  
  The visual drift problem
&lt;/h2&gt;

&lt;p&gt;Open a random full-stack SaaS template. Compare the primary button color on the web dashboard to the primary button color on the mobile app. Are they exactly the same? Same shade, same weight, same radius, same padding? In most templates, no — they're close but not identical, because the web team picked one hex and the mobile team picked another, and there's no shared source of truth.&lt;/p&gt;

&lt;p&gt;That drift compounds. Six months later, the web app has ten shades of blue, the mobile app has eight, and neither team can tell you which is the "brand" blue.&lt;/p&gt;

&lt;h2&gt;
  
  
  What shared tokens actually mean
&lt;/h2&gt;

&lt;p&gt;Shared design tokens for a full-stack SaaS means one JSON (or TypeScript) file that defines every visual value the app uses, and both the web app and the mobile app import from it.&lt;/p&gt;

&lt;p&gt;tokens/&lt;br&gt;
  colors.ts      // brand, surface, text, border, semantic (success/warning/error)&lt;br&gt;
  typography.ts  // heading scale, body scale, mono&lt;br&gt;
  spacing.ts     // 4px base unit, exposed as space.1, space.2, space.4, space.8&lt;br&gt;
  radius.ts      // sm, md, lg, full&lt;br&gt;
  shadow.ts      // sm, md, lg&lt;/p&gt;

&lt;p&gt;The web app consumes them via Tailwind config or a CSS-in-JS theme. The mobile app consumes them via a theme provider that resolves platform-appropriate values. Same source, different runtimes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where web and mobile should diverge
&lt;/h2&gt;

&lt;p&gt;Sharing tokens does not mean web and mobile should look identical. There are good reasons to diverge:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Typography:&lt;/strong&gt; iOS has SF Pro, Android has Roboto (or Noto), the web can have anything. Shared tokens define the scale (heading.xl = 32px); platform code picks the family.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Touch targets:&lt;/strong&gt; mobile needs a 44pt minimum tap target, web can be smaller. Shared spacing tokens, different application.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Elevation:&lt;/strong&gt; mobile uses subtle shadows or none (Material) plus platform blur effects; web can use heavier shadows because there's more visual noise to compete with.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Corner radius:&lt;/strong&gt; iOS trends smaller (8-12px), Android trends larger (16-24px), web is a taste call.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A well-designed shared tokens system exposes the primitives (color, spacing, scale) and lets the platform layer pick the composition. Templates that force pixel-identical layouts across web and mobile end up making both surfaces feel wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Auditing a template's design layer
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Search the codebase for hex codes.&lt;/strong&gt; If there are more than ~20 outside the tokens file, the template has drifted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Look at the tokens file.&lt;/strong&gt; Is it semantic (text.subtle) or raw (gray800)? Semantic wins for theming.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check dark mode.&lt;/strong&gt; Is it token-based (each token has a light + dark value, resolved at the provider) or if (isDark) scattered through screens? The former scales, the latter doesn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check for a component gallery&lt;/strong&gt; (Storybook, or a /design route). Templates that shipped one thought about design as a system, not screens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check for component variant definitions in the theme,&lt;/strong&gt; not the component. Button.variants.primary in theme &amp;gt;  with hardcoded styles.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The handoff pattern that scales
&lt;/h2&gt;

&lt;p&gt;[7:02 AM]The design handoff pattern that works long-term for full-stack SaaS templates:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Designer works in Figma with a token library that mirrors the code tokens.&lt;/li&gt;
&lt;li&gt;When tokens change (a new brand color, an updated radius scale), the designer edits Figma and files a PR against the tokens file with the same names.&lt;/li&gt;
&lt;li&gt;Engineering merges the PR, tokens flow to both web and mobile instantly, no screen touched.&lt;/li&gt;
&lt;li&gt;Component names in Figma match component names in code — PrimaryButton in both places.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Templates that ship this pattern from day one save the design team weeks of translation work per project. Templates that don't force every design change to become an engineering task.&lt;/p&gt;

&lt;h2&gt;
  
  
  Applighter's approach
&lt;/h2&gt;

&lt;p&gt;At &lt;a href="https://applighter.com" rel="noopener noreferrer"&gt;Applighter&lt;/a&gt;, the cross-platform tokens layer is treated as first-class — one tokens file consumed by both the Next.js web app and the Expo mobile app, semantic naming throughout, platform-appropriate composition at the render layer, Figma library that mirrors code component names. Not because we're perfect on this — because we watched too many full-stack templates ship with a drift problem baked in.&lt;/p&gt;

&lt;p&gt;Whatever template you evaluate, ask this one question: is there a single tokens file that both surfaces import? If yes, the design work will scale. If no, budget the retrofit.&lt;/p&gt;

</description>
      <category>saas</category>
      <category>designsystems</category>
      <category>react</category>
      <category>reactnative</category>
    </item>
    <item>
      <title>React Native i18n in 2026: A Practical Expo Router Playbook</title>
      <dc:creator>Sophie F A</dc:creator>
      <pubDate>Mon, 03 Aug 2026 07:35:54 +0000</pubDate>
      <link>https://dev.to/sophie_fa_6ed935b0601d76/react-native-i18n-in-2026-a-practical-expo-router-playbook-2k8f</link>
      <guid>https://dev.to/sophie_fa_6ed935b0601d76/react-native-i18n-in-2026-a-practical-expo-router-playbook-2k8f</guid>
      <description>&lt;p&gt;If your React Native app only speaks English, you're leaving ~75% of 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;: translation management (6M+ weekly downloads, TS-first)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;expo-localization&lt;/strong&gt;: 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;: dates, numbers, currencies (built into Hermes)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I18nManager&lt;/strong&gt;: 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 6 plural forms, so 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;. This surfaces hardcoded strings and 30–40% longer text for layout stress-tests.&lt;/li&gt;
&lt;li&gt;Snapshot per locale. French/German/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=mobile-app-localization-react-native" 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–3 days of setup and it opens up 3 billion more users.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>i18n</category>
      <category>javascript</category>
    </item>
    <item>
      <title>How to Build a Video Calling App with React Native and WebRTC</title>
      <dc:creator>Sophie F A</dc:creator>
      <pubDate>Wed, 15 Jul 2026 07:22:22 +0000</pubDate>
      <link>https://dev.to/sophie_fa_6ed935b0601d76/how-to-build-a-video-calling-app-with-react-native-and-webrtc-2e3o</link>
      <guid>https://dev.to/sophie_fa_6ed935b0601d76/how-to-build-a-video-calling-app-with-react-native-and-webrtc-2e3o</guid>
      <description>&lt;p&gt;The WebRTC pipeline is where the interesting engineering lives. The UI around it — call setup screen, contact list, in-call controls, history log — is boilerplate you've built a hundred times. That's exactly the layer that &lt;a href="https://www.rapidnative.com/?utm_source=blog&amp;amp;utm_medium=content&amp;amp;utm_campaign=how-to-build-a-video-calling-app-with-react-native-and-webrtc" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; handles well: describe the screens you need, get production-ready React Native + Expo code you can drop straight into your Metro bundle.&lt;/p&gt;

&lt;p&gt;For a video calling app, that's the pre-call lobby ("Start Call" / "Join Call" / recent contacts), the in-call overlay (avatar, timer, mute/video/end buttons, participant list for group calls), and the post-call summary. You can &lt;a href="https://www.rapidnative.com/prd-to-app?utm_source=blog&amp;amp;utm_medium=content&amp;amp;utm_campaign=how-to-build-a-video-calling-app-with-react-native-and-webrtc" rel="noopener noreferrer"&gt;start from a PRD&lt;/a&gt; or &lt;a href="https://www.rapidnative.com/whiteboard?utm_source=blog&amp;amp;utm_medium=content&amp;amp;utm_campaign=how-to-build-a-video-calling-app-with-react-native-and-webrtc" rel="noopener noreferrer"&gt;sketch the flow&lt;/a&gt; and get a working Expo project in minutes, then wire in the &lt;code&gt;react-native-webrtc&lt;/code&gt; layer described above.&lt;/p&gt;

&lt;p&gt;The pattern generalizes: use AI to move fast on UI-heavy layers where you're implementing patterns everyone recognizes, and spend your engineering time on the parts that are genuinely hard — like the ICE candidate gathering that just failed on your test call. If you're building anything real-time, our writeups on &lt;a href="https://www.rapidnative.com/blogs/how-rapidnative-delivers-real-time-react-native-live-preview-across-every-device?utm_source=blog&amp;amp;utm_medium=content&amp;amp;utm_campaign=how-to-build-a-video-calling-app-with-react-native-and-webrtc" rel="noopener noreferrer"&gt;how RapidNative delivers real-time React Native previews&lt;/a&gt; and &lt;a href="https://www.rapidnative.com/blogs/how-we-built-real-time-collaboration-for-team-app-building?utm_source=blog&amp;amp;utm_medium=content&amp;amp;utm_campaign=how-to-build-a-video-calling-app-with-react-native-and-webrtc" rel="noopener noreferrer"&gt;how we built real-time collaboration for team app building&lt;/a&gt; cover the WebSocket patterns that pair nicely with WebRTC signaling.&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. You need to 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. The &lt;a href="https://github.com/expo/config-plugins" rel="noopener noreferrer"&gt;&lt;code&gt;@config-plugins/react-native-webrtc&lt;/code&gt;&lt;/a&gt; plugin handles the required permissions automatically.&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 roughly 15–20% of calls that fall back to relayed mode. Budget $0.40 per GB of TURN traffic. Group calls with an SFU move more media through your servers and cost proportionally more.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use WebRTC for one-to-many streaming?
&lt;/h3&gt;

&lt;p&gt;WebRTC is optimized for two-way conversation. For one-to-many broadcast (like a webinar or Twitch-style stream), you can use an SFU, but at scale you'll pay more than a protocol like HLS or LL-HLS designed for that pattern. WebRTC shines when latency matters more than viewer count.&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 address 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. You cannot send unencrypted media. 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 &lt;strong&gt;react native video calling app&lt;/strong&gt;: 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 that's the part AI is happiest to write for you.&lt;/p&gt;

&lt;p&gt;Ready to build? &lt;a href="https://www.rapidnative.com/?utm_source=blog&amp;amp;utm_medium=content&amp;amp;utm_campaign=how-to-build-a-video-calling-app-with-react-native-and-webrtc" rel="noopener noreferrer"&gt;Start with RapidNative&lt;/a&gt; to spin up the app shell around your WebRTC layer in minutes, or &lt;a href="https://www.rapidnative.com/blogs?utm_source=blog&amp;amp;utm_medium=content&amp;amp;utm_campaign=how-to-build-a-video-calling-app-with-react-native-and-webrtc" rel="noopener noreferrer"&gt;browse more React Native tutorials&lt;/a&gt; on the blog.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>webrtc</category>
      <category>mobile</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
