<?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: Mark F A</title>
    <description>The latest articles on DEV Community by Mark F A (@mark_fa).</description>
    <link>https://dev.to/mark_fa</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%2F3809861%2Fcea830b9-a2f6-4e96-8171-71cd64b7a88b.jpg</url>
      <title>DEV Community: Mark F A</title>
      <link>https://dev.to/mark_fa</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mark_fa"/>
    <language>en</language>
    <item>
      <title>PGlite Is Great. It Still Is Not Your Local Postgres Stack.</title>
      <dc:creator>Mark F A</dc:creator>
      <pubDate>Mon, 21 Sep 2026 09:12:11 +0000</pubDate>
      <link>https://dev.to/mark_fa/pglite-is-great-it-still-is-not-your-local-postgres-stack-3kek</link>
      <guid>https://dev.to/mark_fa/pglite-is-great-it-still-is-not-your-local-postgres-stack-3kek</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PGlite is real Postgres compiled to WASM: no Docker, no install, starts in milliseconds&lt;/li&gt;
&lt;li&gt;It is brilliant for unit tests, CI, and embedded/in-browser databases&lt;/li&gt;
&lt;li&gt;It is single connection, speaks no wire protocol out of the box, and ships none of the platform layer (auth, storage, auto API) your app talks to&lt;/li&gt;
&lt;li&gt;Use PGlite for tests. Use a real Postgres server (there are single-binary options now) when your app expects a database plus the services around it&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;PGlite is Postgres compiled to WebAssembly, wrapped in a TypeScript client. Not a Linux VM running Postgres, not a lookalike engine: the actual Postgres query planner and executor, running inside your JS runtime.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PGlite&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="s2"&gt;@electric-sql/pglite&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;db&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;PGlite&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;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;select 'hello' as message;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// { rows: [ { message: "hello" } ] }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a working Postgres in two lines. In memory by default, persistable to the filesystem in Node or IndexedDB in the browser, roughly 3 MB gzipped, with extension support including pgvector.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it genuinely wins
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Unit tests and CI.&lt;/strong&gt; This is the killer use case. A fresh, isolated Postgres per test, created and destroyed in milliseconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;PGlite&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="s2"&gt;@electric-sql/pglite&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;inserts a user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;db&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;PGlite&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// brand new database, every test&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;create table users (id serial, email text)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;insert into users (email) values ($1)&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a@b.co&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="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;select count(*)::int as n from users&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&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;rows&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;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No shared test database, no truncate-between-tests hacks, no container spin-up in CI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Embedded and in-browser databases.&lt;/strong&gt; Local-first apps, demos, playgrounds: shipping Postgres inside the page is a legitimately new capability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it stops being your dev database
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;One connection. Ever.&lt;/strong&gt; PGlite runs Postgres in single-user mode because WASM cannot fork processes. Your app opens a pool of five connections? Not happening. Two services sharing one dev database? Not happening.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No wire protocol by default.&lt;/strong&gt; psql, TablePlus, your ORM's normal driver: all of them speak the Postgres protocol over TCP. PGlite is a library call, not a server. There is a socket proxy add-on that bridges this, but at that point you are assembling infrastructure, which was the thing you were avoiding.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No platform layer.&lt;/strong&gt; If your app is built against a Supabase-style stack, the database is maybe a third of what it talks to. Auth endpoints, row level security backed by real roles, storage buckets, the auto-generated REST API: none of that exists in a bare WASM Postgres. Your app boots, then dies on the first &lt;code&gt;supabase.auth&lt;/code&gt; call.&lt;/p&gt;

&lt;p&gt;This is the gap single-binary runtimes are filling. &lt;a href="https://www.tinbase.dev/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=pglite-local-dev-limits" rel="noopener noreferrer"&gt;Tinbase&lt;/a&gt;, for example, is an MIT-licensed single binary that runs a real Postgres server with a Supabase-compatible surface on top: real connections, real wire protocol, no Docker. Same "just run it" feel PGlite gives you, but for the whole stack instead of the engine alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You need&lt;/th&gt;
&lt;th&gt;Reach for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fresh DB per unit test&lt;/td&gt;
&lt;td&gt;PGlite&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Postgres in the browser&lt;/td&gt;
&lt;td&gt;PGlite&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;psql / GUI clients / connection pools&lt;/td&gt;
&lt;td&gt;Real Postgres server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Supabase-style auth, storage, REST&lt;/td&gt;
&lt;td&gt;Supabase-compatible runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI without containers&lt;/td&gt;
&lt;td&gt;Either, depending on the two rows above&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The honest framing: PGlite is not a worse local Postgres. It is a different shape of Postgres, and the shape matters more than the engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;I have moved all my unit tests to PGlite and my app-level dev environment to a single-binary runtime, and the two do not compete at all.&lt;/p&gt;

&lt;p&gt;What is your local Postgres setup right now: Docker, WASM, single binary, or "I just point dev at a cloud database and hope"? Drop it in the comments, especially if it is the last one.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>webdev</category>
      <category>supabase</category>
    </item>
    <item>
      <title>Postgres RLS in Local Dev: The Three Silent-Fail Modes That Ship to Prod</title>
      <dc:creator>Mark F A</dc:creator>
      <pubDate>Wed, 16 Sep 2026 13:47:19 +0000</pubDate>
      <link>https://dev.to/mark_fa/postgres-rls-in-local-dev-the-three-silent-fail-modes-that-ship-to-prod-4327</link>
      <guid>https://dev.to/mark_fa/postgres-rls-in-local-dev-the-three-silent-fail-modes-that-ship-to-prod-4327</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;RLS has a brutal failure property: when it is misconfigured, everything works. Queries return rows, tests pass, nothing errors&lt;/li&gt;
&lt;li&gt;Silent-fail 1: your migration tool (raw SQL, dbmate, Prisma) creates tables with RLS disabled by default&lt;/li&gt;
&lt;li&gt;Silent-fail 2: your local dev connection runs as a role with &lt;code&gt;BYPASSRLS&lt;/code&gt;, so policies are never exercised even when they exist&lt;/li&gt;
&lt;li&gt;Silent-fail 3: views execute with the view owner's privileges, so RLS on the underlying tables is skipped unless you opt in (Postgres 15+)&lt;/li&gt;
&lt;li&gt;All three are catchable with about 30 lines of SQL that run at boot in ~50ms&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The nasty thing about row level security is that it fails open in development. A missing policy doesn't throw. A bypassed policy doesn't warn. Your app happily reads and writes, your test suite goes green, and the first person to discover the gap is a curious user in production changing an ID in a URL.&lt;/p&gt;

&lt;p&gt;Here are the three ways I've seen that happen, and the boot-time check that makes all of them loud.&lt;/p&gt;

&lt;h2&gt;
  
  
  Silent-fail 1: migrations don't enable RLS
&lt;/h2&gt;

&lt;p&gt;Whatever writes your DDL, the default is the same. This table has no row security:&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="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;user_notes&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Raw SQL migrations, dbmate, Prisma's generated DDL: none of them add &lt;code&gt;enable row level security&lt;/code&gt; for you. You have to remember two lines, every table, forever:&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="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;user_notes&lt;/span&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;user_notes&lt;/span&gt; &lt;span class="k"&gt;force&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That second line matters more than people think. Without &lt;code&gt;force&lt;/code&gt;, the table's &lt;em&gt;owner&lt;/em&gt; still bypasses RLS entirely. If your app connects as the role that ran the migrations, you have policies that apply to nobody.&lt;/p&gt;

&lt;h2&gt;
  
  
  Silent-fail 2: dev is seeded (and queried) under BYPASSRLS
&lt;/h2&gt;

&lt;p&gt;Even with RLS enabled and policies written, they only apply to roles that are subject to them. Superusers and any role with the &lt;code&gt;BYPASSRLS&lt;/code&gt; attribute skip the whole mechanism.&lt;/p&gt;

&lt;p&gt;And that is exactly how most local stacks are wired: you seed and often query as &lt;code&gt;postgres&lt;/code&gt; or an equivalent admin role. So locally, every policy is a no-op. The first connection that actually exercises your policies is the production one, which is the worst possible place to run a policy for the first time.&lt;/p&gt;

&lt;p&gt;The fix is to make local dev connect the way production does: as a non-privileged role, with the admin role reserved for migrations only.&lt;/p&gt;

&lt;h2&gt;
  
  
  Silent-fail 3: views don't inherit RLS (until you ask)
&lt;/h2&gt;

&lt;p&gt;Views in Postgres execute with the privileges of the view's &lt;em&gt;owner&lt;/em&gt;, not the querying user. Create a convenience view over a policied table and you have quietly built a door around your own policies:&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="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;view&lt;/span&gt; &lt;span class="n"&gt;recent_notes&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;user_notes&lt;/span&gt; &lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt; &lt;span class="k"&gt;limit&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- runs as the owner: RLS on user_notes is not applied to callers&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Postgres 15 added the opt-in fix:&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="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;view&lt;/span&gt; &lt;span class="n"&gt;recent_notes&lt;/span&gt;
  &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;security_invoker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;user_notes&lt;/span&gt; &lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt; &lt;span class="k"&gt;limit&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;security_invoker&lt;/code&gt;, the view runs as the caller and RLS applies normally. On anything older than 15 there is no clean equivalent, which is one of several reasons local dev should run the same modern Postgres major as production.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boot-time check that catches all three
&lt;/h2&gt;

&lt;p&gt;None of these need discipline. They need a tripwire. This runs at application boot (or as the last migration) and fails loudly on all three modes:&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="k"&gt;do&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;declare&lt;/span&gt; &lt;span class="n"&gt;bad&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;begin&lt;/span&gt;
  &lt;span class="c1"&gt;-- 1: tables without RLS (or without FORCE)&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;string_agg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tablename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;', '&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="n"&gt;bad&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_tables&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;
  &lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tablename&lt;/span&gt;
  &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
    &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rowsecurity&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relforcerowsecurity&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="n"&gt;bad&lt;/span&gt; &lt;span class="k"&gt;is&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;then&lt;/span&gt;
    &lt;span class="n"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;exception&lt;/span&gt; &lt;span class="s1"&gt;'RLS not enabled/forced on: %'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;-- 2: non-system roles that bypass RLS&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;string_agg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rolname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;', '&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="n"&gt;bad&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_roles&lt;/span&gt;
  &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;rolbypassrls&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;rolname&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;like&lt;/span&gt; &lt;span class="s1"&gt;'pg&lt;/span&gt;&lt;span class="se"&gt;\_&lt;/span&gt;&lt;span class="s1"&gt;%'&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;rolname&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'postgres'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="n"&gt;bad&lt;/span&gt; &lt;span class="k"&gt;is&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;then&lt;/span&gt;
    &lt;span class="n"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;exception&lt;/span&gt; &lt;span class="s1"&gt;'BYPASSRLS roles present: %'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;-- 3: views without security_invoker&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;string_agg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;', '&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="n"&gt;bad&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
  &lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relnamespace&lt;/span&gt;
  &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relkind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'v'&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
    &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reloptions&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;like&lt;/span&gt; &lt;span class="s1"&gt;'%security_invoker=true%'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="n"&gt;bad&lt;/span&gt; &lt;span class="k"&gt;is&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;then&lt;/span&gt;
    &lt;span class="n"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;exception&lt;/span&gt; &lt;span class="s1"&gt;'Views without security_invoker: %'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;end&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;Adjust the allowlists to taste (maybe your migration role legitimately keeps &lt;code&gt;BYPASSRLS&lt;/code&gt;; exclude it explicitly so the exception is a decision, not an accident). On a normal-sized schema this runs in tens of milliseconds. It costs nothing and converts all three silent fails into a crash at boot, which is where you want them.&lt;/p&gt;

&lt;p&gt;This class of bug is also why I care about local dev running &lt;em&gt;real&lt;/em&gt; Postgres with the same defaults as prod. It is one of the reasons we built &lt;a href="https://tinbase.dev/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=postgres-rls-silent-fails" rel="noopener noreferrer"&gt;Tinbase&lt;/a&gt;: a single-binary, MIT-licensed, Supabase-compatible local runtime on actual Postgres 17, no Docker, so checks like the one above behave identically on your laptop and in prod, &lt;code&gt;security_invoker&lt;/code&gt; included.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;RLS is enforcement only when three things are true: the table has it enabled and forced, the connecting role is subject to it, and every view over it runs as the invoker. Each condition fails silently on its own. Check all three mechanically, at boot, every time.&lt;/p&gt;

&lt;p&gt;Have you been bitten by a policy that turned out to be decorative? What did the tripwire look like once you built one? Comments are open.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>security</category>
      <category>supabase</category>
    </item>
    <item>
      <title>I Replaced Docker Supabase With a 58 MB Binary and Saved 1.5 GB of RAM</title>
      <dc:creator>Mark F A</dc:creator>
      <pubDate>Fri, 11 Sep 2026 13:38:28 +0000</pubDate>
      <link>https://dev.to/rapidnative-ai/i-replaced-docker-supabase-with-a-58-mb-binary-and-saved-15-gb-of-ram-15ll</link>
      <guid>https://dev.to/rapidnative-ai/i-replaced-docker-supabase-with-a-58-mb-binary-and-saved-15-gb-of-ram-15ll</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Docker-based Supabase local dev quietly costs gigabytes of RAM, minutes of boot time, and a background VM you forget is running until your fans remind you.&lt;/li&gt;
&lt;li&gt;I swapped it for a single native binary: real Postgres 17, Supabase-compatible API surface, no Docker anywhere. The executable is 58 MB (92 MB installed).&lt;/li&gt;
&lt;li&gt;On my machine, Activity Monitor gave back roughly 1.5 GB. Your numbers will vary; the shape of the win won't.&lt;/li&gt;
&lt;li&gt;Not everyone should switch. If your local setup mirrors a self-hosted production stack container-for-container, stay where you are.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The state of local dev in 2026
&lt;/h2&gt;

&lt;p&gt;Somewhere along the way, "run the database locally" turned into "run a container orchestrator locally." The standard Supabase local setup is a Docker Compose stack: Postgres, the auth server, the REST layer, realtime, storage, the studio UI, an API gateway. Each one is a container. On macOS and Windows, all of them live inside a Linux VM that exists solely so containers have somewhere to be.&lt;/p&gt;

&lt;p&gt;None of this is wrong. It's a faithful replica of the hosted product, and for some teams that fidelity is exactly the point. But it's worth saying out loud what we normalized: to write a &lt;code&gt;SELECT&lt;/code&gt; statement against a local database, my laptop was running a virtual machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Docker's cost is invisible until it isn't
&lt;/h2&gt;

&lt;p&gt;The tax hides because you pay it in three currencies you don't track:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RAM you never see itemized.&lt;/strong&gt; The VM reserves memory whether containers are busy or idle. It doesn't show up as "Supabase" in your process list; it shows up as your machine feeling smaller than it is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Boot time you've stopped noticing.&lt;/strong&gt; Pull images, start the VM, health-check seven services into readiness. You've built a coffee ritual around it, which is how you know it's too long.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A failure mode that isn't yours.&lt;/strong&gt; When local dev breaks, the bug report is rarely about your schema. It's the VM not starting, a port collision with a container you forgot, an image version drifting from your teammate's. You end up debugging the harness instead of the app.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The moment it stops being invisible is always the same: you're on battery, on a call, with the stack "idle" in the background, and the fans spin up anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The switch: what changed on my laptop
&lt;/h2&gt;

&lt;p&gt;I moved my local stack to &lt;a href="https://tinbase.dev/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=i-replaced-docker-supabase-with-a-58-mb-binary" rel="noopener noreferrer"&gt;Tinbase&lt;/a&gt;, a single-binary, MIT-licensed local dev runtime that speaks the Supabase API surface and runs real Postgres 17 underneath. No containers, no VM, no compose file. The binary is 58 MB; the full install footprint on disk is 92 MB. (Worth being precise, because those two numbers get conflated constantly, including by us.)&lt;/p&gt;

&lt;p&gt;The before state, for context, is the standard compose stack everyone recognizes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# the shape of what I deleted&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;        &lt;span class="c1"&gt;# postgres&lt;/span&gt;
  &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;      &lt;span class="c1"&gt;# gotrue&lt;/span&gt;
  &lt;span class="na"&gt;rest&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;      &lt;span class="c1"&gt;# postgrest&lt;/span&gt;
  &lt;span class="na"&gt;realtime&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;kong&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;      &lt;span class="c1"&gt;# api gateway&lt;/span&gt;
  &lt;span class="na"&gt;studio&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The after state is one process in my process list. My app code didn't change: it's the same connection string pattern and the same client SDK talking to a local URL. That's the whole trick of keeping the Supabase-compatible surface; the swap happens under the app, not in it.&lt;/p&gt;

&lt;p&gt;Two honest notes on the migration itself. First, I treated it as a local-dev swap only; production still runs where it always ran. Second, I re-ran my migrations from scratch against the fresh Postgres 17 instance rather than trying to transplant a data directory out of a container volume. Cleaner, and it doubles as a test of whether your migrations actually stand alone.&lt;/p&gt;

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

&lt;p&gt;I'll give you the one number I actually measured and stand behind: &lt;strong&gt;about 1.5 GB of RAM back&lt;/strong&gt;, per Activity Monitor, comparing my machine with the Docker stack idle versus the native binary idle. That lines up with what we found when we broke down why Supabase local dev needs 2.3 GB of Docker in the first place: most of the cost was never the database.&lt;/p&gt;

&lt;p&gt;Boot time went from "start the stack, go do something else" to "it's a native process; it's just on." CI is the same story in a different costume: the job that used to pull and boot images now downloads one binary and runs. I'm deliberately not quoting you a seconds-saved figure for CI because it depends entirely on your runner's cache behavior. Measure yours; the direction is not in doubt.&lt;/p&gt;

&lt;p&gt;One benchmark I'd push back on if you see it anywhere, including from me: synthetic query throughput comparisons. It's real Postgres 17 in both cases. The win is everything wrapped around the database, not the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke, what didn't
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Didn't break:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Schema migrations. It's Postgres 17; they ran unmodified.&lt;/li&gt;
&lt;li&gt;The client SDK. Same calls, local URL.&lt;/li&gt;
&lt;li&gt;Auth flows in dev, RLS policies, the day-to-day query loop.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Needed attention:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Anything in my scripts that assumed Docker existed: &lt;code&gt;docker exec&lt;/code&gt; for a psql shell, container-name health checks in a Makefile. All replaceable with direct equivalents, but grep for &lt;code&gt;docker&lt;/code&gt; in your repo before you switch, because those assumptions hide in tooling, not app code.&lt;/li&gt;
&lt;li&gt;Scheduled jobs and edge functions are the two areas where I'd tell you to verify behavior against your own use case before leaning on them, rather than take a blog post's word for it, mine included. Run your actual workload for a week locally before you delete the compose file for good.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Genuinely different:&lt;/strong&gt; the debugging experience. When something misbehaves, there's one process and one log. No "is it the container, the network bridge, or the VM" triage tree.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who should not switch
&lt;/h2&gt;

&lt;p&gt;Honesty section. Stay on Docker if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Your production is self-hosted Supabase in containers&lt;/strong&gt; and you want local to mirror it container-for-container. Fidelity to prod is a legitimate reason to pay the tax.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You depend on studio-adjacent tooling in your daily loop&lt;/strong&gt; and your team's muscle memory is built around the full stack UI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your team standardized on devcontainers&lt;/strong&gt; for everything. One native binary inside a containerized-everything policy is friction, not simplification.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need multi-service parity tests locally&lt;/strong&gt;, where the gateway and service boundaries are part of what you're testing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If none of those describe you, and for most app developers using Supabase as a hosted backend they don't, you're paying a replication-fidelity tax for fidelity you never use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the ecosystem is going
&lt;/h2&gt;

&lt;p&gt;The bigger pattern is that local dev is swinging back toward native. We spent a decade making environments reproducible by making them heavier, and the pendulum is now returning with the reproducibility kept: single static binaries, real databases instead of mocks, the production API surface without the production topology. SQLite's renaissance is part of it. Single-binary Postgres wrappers are part of it. "Compatible surface, native process" is a shape you're going to see a lot more of.&lt;/p&gt;

&lt;p&gt;Docker won because it made "works on my machine" a solvable problem. The next round is making it solvable without the VM.&lt;/p&gt;

&lt;p&gt;Have you measured what your local stack actually costs while idle? Open your resource monitor right now with everything "not doing anything" and drop the number in the comments. I have a theory the median is worse than anyone thinks.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>docker</category>
      <category>devops</category>
    </item>
    <item>
      <title>AI React Native Form Builder: The Complete Data-Entry Stack in 2026</title>
      <dc:creator>Mark F A</dc:creator>
      <pubDate>Mon, 07 Sep 2026 12:39:13 +0000</pubDate>
      <link>https://dev.to/rapidnative-ai/ai-react-native-form-builder-the-complete-data-entry-stack-in-2026-191o</link>
      <guid>https://dev.to/rapidnative-ai/ai-react-native-form-builder-the-complete-data-entry-stack-in-2026-191o</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every mobile app is forms underneath: signup, checkout, onboarding, KYC. The UI is an afternoon; the invisible stack (keyboard geometry, validation, migrations, RLS, typed writes) is where weeks disappear.&lt;/li&gt;
&lt;li&gt;Most AI form builders generate a pretty &lt;code&gt;&amp;lt;TextInput&amp;gt;&lt;/code&gt; and stop. The useful pattern is generating the whole pipeline from one prompt: SQL migration, RLS policies, regenerated types, controlled state, visible errors, and a real Supabase insert.&lt;/li&gt;
&lt;li&gt;Five silent-failure patterns ship broken forms constantly: &lt;code&gt;Alert.alert&lt;/code&gt; on web, unchecked &lt;code&gt;{ error }&lt;/code&gt;, RLS with no policy, stale generated types, and guard clauses that swallow crashes.&lt;/li&gt;
&lt;li&gt;Iterate additively (point-and-edit, follow-up prompts) instead of regenerating. Full regenerations lose per-field polish.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why "just add a form" is never just a form
&lt;/h2&gt;

&lt;p&gt;Ask any React Native developer what's slow about mobile development and forms will be near the top of the list. Not for the reasons the UI suggests. The visible part (labels, inputs, a submit button) is an afternoon. The invisible part is where the calendar goes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keyboard geometry.&lt;/strong&gt; iOS pushes content up; Android resizes; the submit button ends up under the keyboard on one platform and floats wrong on the other. Every screen with a &lt;code&gt;TextInput&lt;/code&gt; needs a &lt;code&gt;KeyboardAvoidingView&lt;/code&gt; with the correct &lt;code&gt;behavior&lt;/code&gt; prop and a &lt;code&gt;ScrollView&lt;/code&gt; with &lt;code&gt;keyboardShouldPersistTaps="handled"&lt;/code&gt;, or it ships broken.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Controlled state.&lt;/strong&gt; Every field wants a &lt;code&gt;useState&lt;/code&gt; slice, an &lt;code&gt;onChangeText&lt;/code&gt; handler, a &lt;code&gt;value&lt;/code&gt; prop, and a clean way to reset. Formik and react-hook-form abstract this, but they add a dependency graph, and neither handles the mobile-specific ergonomics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validation with visible errors.&lt;/strong&gt; A validator that fails silently is worse than none. Errors have to render on the correct field, at the correct time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The database half.&lt;/strong&gt; A form that doesn't persist is a demo. Persisting means a table, columns of the right type, RLS policies (or every query returns zero rows with no error), a typed client, and error handling on the mutation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failure modes on web.&lt;/strong&gt; React Native for Web is not React Native. &lt;code&gt;Alert.alert&lt;/code&gt; is a no-op on web. An unhandled promise rejection surfaces on native and disappears on web. AI-generated forms trip over both constantly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What generation looks like when the whole stack is in scope
&lt;/h2&gt;

&lt;p&gt;Here's the difference between a UI-only form generator and a data-entry generator. Say the prompt is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Add a customer intake form to my services app. Fields: full name, phone (US format), email, service type (single-select from three options), notes. Save to the database, show it in an admin list, and only let each user see their own submissions.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A UI-only tool gives you a screen with five styled inputs and a submit button that logs to console. Beautiful, useless.&lt;/p&gt;

&lt;p&gt;A fullstack AI app builder treats the prompt as an end-to-end contract. In &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=react-native-form-builder-ai-fullstack-data-entry-2026" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt;'s fullstack-supabase template it produces, in one pass:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A SQL migration&lt;/strong&gt; creating &lt;code&gt;intake_submissions&lt;/code&gt; with the right column types, an &lt;code&gt;updated_at&lt;/code&gt; trigger, &lt;code&gt;enable row level security&lt;/code&gt;, and two policies (&lt;code&gt;select&lt;/code&gt; and &lt;code&gt;insert&lt;/code&gt;) scoped to &lt;code&gt;auth.uid() = user_id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Regenerated TypeScript types&lt;/strong&gt; in &lt;code&gt;src/db/types.ts&lt;/code&gt; so &lt;code&gt;client.from('intake_submissions')&lt;/code&gt; autocompletes the exact columns you just created.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A form screen&lt;/strong&gt; wrapped in &lt;code&gt;KeyboardAvoidingView&lt;/code&gt;, with controlled &lt;code&gt;TextInput&lt;/code&gt; fields, keyboard types set per field (&lt;code&gt;email-address&lt;/code&gt;, &lt;code&gt;phone-pad&lt;/code&gt;), autofill hints, on-blur validation with inline error text, a spinner-managed submit, and a Supabase &lt;code&gt;insert()&lt;/code&gt; call whose &lt;code&gt;{ error }&lt;/code&gt; is checked and surfaced.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An admin list screen&lt;/strong&gt; that reads via &lt;code&gt;useQuery&lt;/code&gt; from TanStack Query, keyed as &lt;code&gt;['intake_submissions', userId]&lt;/code&gt; so it invalidates cleanly on write.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Prompting for a full-stack form: a five-minute walkthrough
&lt;/h2&gt;

&lt;p&gt;Open a new project with the fullstack-supabase template and drop this prompt into the chat:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Build a "Customer Feedback" screen. Fields: &lt;code&gt;full_name&lt;/code&gt; (required), &lt;code&gt;email&lt;/code&gt; (required, valid email), &lt;code&gt;rating&lt;/code&gt; (integer 1–5, required), &lt;code&gt;message&lt;/code&gt; (optional, up to 500 chars). Submit inserts into a &lt;code&gt;feedback&lt;/code&gt; table scoped to the current user via RLS. After submit, clear the form and show a green success toast for 2 seconds. Also add an admin list screen that shows the current user's own feedback rows, newest first.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Behind the scenes, the generator runs a four-step LLM pipeline: plan the schema, write the migration, apply it against an in-browser PGlite instance (real Postgres in WASM, not a mock), regenerate types, then write the screens. The reason PGlite matters: pg-mem's Postgres subset used to accept &lt;code&gt;uuid = text&lt;/code&gt; comparisons that real Postgres refuses at &lt;code&gt;create policy&lt;/code&gt;. The migration would look green while the whole RLS chain quietly failed and every screen came up empty. Switching to PGlite killed a whole class of "works locally, breaks in production" bugs.&lt;/p&gt;

&lt;p&gt;What actually lands in your project:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The migration&lt;/strong&gt; (&lt;code&gt;supabase/migrations/20260904_add_feedback.sql&lt;/code&gt;):&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="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="n"&gt;feedback&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="n"&gt;uuid&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;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;cascade&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;full_name&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;email&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;rating&lt;/span&gt; &lt;span class="nb"&gt;int&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;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rating&lt;/span&gt; &lt;span class="k"&gt;between&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_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="n"&gt;updated_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="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="n"&gt;feedback_user_id_idx&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;feedback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;feedback&lt;/span&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;drop&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="n"&gt;feedback_select_own&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;feedback&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="n"&gt;feedback_select_own&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;feedback&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;drop&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="n"&gt;feedback_insert_own&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;feedback&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="n"&gt;feedback_insert_own&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;feedback&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every piece is deliberate. &lt;code&gt;if not exists&lt;/code&gt; on the table and index so a rebuild doesn't throw &lt;code&gt;42P07&lt;/code&gt;. &lt;code&gt;drop policy if exists&lt;/code&gt; above each &lt;code&gt;create policy&lt;/code&gt; because Postgres has no &lt;code&gt;create policy if not exists&lt;/code&gt;. RLS enabled &lt;strong&gt;and&lt;/strong&gt; two policies: enabling RLS without a policy makes every query return zero rows, and the app looks broken with no error anywhere. An index on the foreign key because Postgres doesn't create one and lookups seq-scan without it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The screen&lt;/strong&gt; (&lt;code&gt;app/(app)/feedback.tsx&lt;/code&gt;), condensed to the shape of what ships:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;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;FeedbackScreen&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;client&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useApp&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;qc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useQueryClient&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;fullName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setFullName&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&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;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setEmail&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&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;rating&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setRating&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&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;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setMessage&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&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;errors&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setErrors&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;({});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;submitting&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setSubmitting&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setSuccess&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;validate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;fullName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fullName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Required&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\S&lt;/span&gt;&lt;span class="sr"&gt;+@&lt;/span&gt;&lt;span class="se"&gt;\S&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\.\S&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Enter a valid email&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;rating&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rating&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Pick 1–5&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Max 500 characters&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;setErrors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onSubmit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;validate&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nf"&gt;setSubmitting&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="k"&gt;try&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;error&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;client&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;feedback&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="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;full_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fullName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rating&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;null&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;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nf"&gt;setErrors&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;form&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nf"&gt;setFullName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nf"&gt;setEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nf"&gt;setRating&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="nf"&gt;setMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;setSuccess&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="nx"&gt;qc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invalidateQueries&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;queryKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;feedback&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="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setSuccess&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="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setSubmitting&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="p"&gt;};&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;KeyboardAvoidingView&lt;/span&gt;
      &lt;span class="na"&gt;behavior&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;Platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OS&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ios&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;padding&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;height&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ScrollView&lt;/span&gt;
        &lt;span class="na"&gt;keyboardShouldPersistTaps&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"handled"&lt;/span&gt;
        &lt;span class="na"&gt;contentContainerStyle&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;paddingBottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"bg-background"&lt;/span&gt;
      &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* Field JSX with keyboardType, autoComplete, and inline &amp;lt;Text&amp;gt; errors */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;ScrollView&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;KeyboardAvoidingView&lt;/span&gt;&lt;span class="p"&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;Notice what's there: a controlled state slice per field, a validator that runs on submit and populates a per-field error map, &lt;code&gt;KeyboardAvoidingView&lt;/code&gt; with the correct per-platform &lt;code&gt;behavior&lt;/code&gt;, &lt;code&gt;ScrollView&lt;/code&gt; with &lt;code&gt;keyboardShouldPersistTaps="handled"&lt;/code&gt;, and (the piece most generators miss) the &lt;code&gt;{ error }&lt;/code&gt; from the Supabase insert is checked and rendered into on-screen state. Not &lt;code&gt;Alert.alert&lt;/code&gt;. Not &lt;code&gt;console.error&lt;/code&gt;. Visible text the user actually sees.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hidden 60%: silent-failure patterns that ship broken forms
&lt;/h2&gt;

&lt;p&gt;If a generated form doesn't work and you can't see why, it's almost always one of these five. They compile, they ship, and they produce a button that appears inert with nothing in the console.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;Alert.alert&lt;/code&gt; as the only feedback path.&lt;/strong&gt; &lt;code&gt;Alert&lt;/code&gt; from &lt;code&gt;react-native&lt;/code&gt; does nothing on web, and most editor previews are Expo Web. A submit handler whose error branch is &lt;code&gt;Alert.alert('Error', msg); return;&lt;/code&gt; is completely invisible in preview. The button just doesn't do anything. Render errors into on-screen state. If you truly want a modal on web, branch on &lt;code&gt;Platform.OS === 'web'&lt;/code&gt; and use &lt;code&gt;window.alert&lt;/code&gt; or a custom in-app dialog there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Unchecked &lt;code&gt;{ error }&lt;/code&gt; from the Supabase call.&lt;/strong&gt; The client returns &lt;code&gt;{ data, error }&lt;/code&gt;; it does not throw. If you write &lt;code&gt;await client.from('feedback').insert(...)&lt;/code&gt; and never destructure &lt;code&gt;error&lt;/code&gt;, PostgREST failures (missing column, RLS denial, constraint violation) vanish silently and the UI moves on as if the write succeeded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. RLS enabled with no policy.&lt;/strong&gt; The single most common cause of "the form submits but the list is empty." &lt;code&gt;alter table ... enable row level security&lt;/code&gt; without a matching &lt;code&gt;create policy&lt;/code&gt; makes every &lt;code&gt;select&lt;/code&gt; return zero rows and every &lt;code&gt;insert&lt;/code&gt; fail with an ambiguous permission error. Always ship RLS and at least one &lt;code&gt;select&lt;/code&gt; policy in the same migration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Stale generated types.&lt;/strong&gt; &lt;code&gt;src/db/types.ts&lt;/code&gt; is generated from the applied migrations. If it drifts (someone edited the migration but didn't regenerate) and &lt;code&gt;client.from('feedback')&lt;/code&gt; starts typing every column as &lt;code&gt;never&lt;/code&gt;, the fix is to regenerate. Never cast past it with &lt;code&gt;client as any&lt;/code&gt;, which buries a real drift between code and database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Guard clauses around things that always exist.&lt;/strong&gt; &lt;code&gt;if (!client) return;&lt;/code&gt; turns what should be a loud crash into a no-op. Only guard on values that are genuinely optional (an unauthenticated user, an empty input), and when you do, &lt;code&gt;setError(...)&lt;/code&gt; on the way out so the user sees why nothing happened.&lt;/p&gt;

&lt;p&gt;The reason these matter more in AI-generated code than in human-written code is that the model is optimising for "compiles and looks reasonable." A silent failure is, from the model's perspective, indistinguishable from success. The generator has to be trained (or system-prompted) to write the visible-failure form of every one of these patterns, and to refuse the silent form.&lt;/p&gt;

&lt;h2&gt;
  
  
  Iterating without regenerating
&lt;/h2&gt;

&lt;p&gt;Generation is the start. What matters after is iteration speed, because the second prompt is always "make it look better," and the third is always "add a field."&lt;/p&gt;

&lt;p&gt;Two ways to iterate that don't require regenerating the whole screen:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Point-and-edit.&lt;/strong&gt; Click any element in the preview (a label, an input, the submit button) and describe the change in natural language. The AI edits only that node's props or its style class, so the rest of the file is untouched. Much faster than "regenerate the whole file with X changed," which risks losing edits you already made.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Follow-up prompts.&lt;/strong&gt; "Add a &lt;code&gt;company&lt;/code&gt; field between &lt;code&gt;email&lt;/code&gt; and &lt;code&gt;rating&lt;/code&gt;, optional, autocomplete=organization." The generator reads the current file, adds the state, adds the JSX, updates the validator, writes an &lt;code&gt;add column if not exists company text&lt;/code&gt; migration, and regenerates types. What you don't get is a rewrite of everything else.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Full regenerations lose per-field polish. Additive edits preserve it. Learn to prompt in additive language and you keep the iteration cost near zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  Beyond the single-screen form: three patterns worth knowing
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Multi-step wizards.&lt;/strong&gt; For onboarding, KYC, or checkout, split a long form across screens with progress. The pattern: one route per step under &lt;code&gt;app/(auth)/onboarding/[step].tsx&lt;/code&gt;, state lifted to a React context, and a single &lt;code&gt;insert()&lt;/code&gt; at the end. Prompt: &lt;em&gt;"Break this signup into three steps (account, profile, preferences) with a progress bar at the top and a back button on every step except the first."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;File uploads (with the web gotcha).&lt;/strong&gt; &lt;code&gt;ImagePicker&lt;/code&gt; returns a &lt;code&gt;blob:&lt;/code&gt; or &lt;code&gt;data:&lt;/code&gt; URI on web, and &lt;code&gt;expo-file-system&lt;/code&gt; cannot read either. Any generated form that uploads a file has to branch on &lt;code&gt;Platform.OS === 'web'&lt;/code&gt;: on web use &lt;code&gt;await (await fetch(uri)).blob()&lt;/code&gt; and take the extension from &lt;code&gt;blob.type&lt;/code&gt;; on native, keep the &lt;code&gt;FileSystem&lt;/code&gt; base64 path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Optimistic writes.&lt;/strong&gt; For chat, likes, reviews, anywhere latency shows: wrap the write in a TanStack Query &lt;code&gt;useMutation&lt;/code&gt; with &lt;code&gt;onMutate&lt;/code&gt; that updates the cache immediately and &lt;code&gt;onError&lt;/code&gt; that rolls back. Prompt: &lt;em&gt;"Make the submit optimistic. Show the new row in the list instantly, and roll back if the write fails."&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparison: three ways to build a React Native form in 2026
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Setup time&lt;/th&gt;
&lt;th&gt;Backend included&lt;/th&gt;
&lt;th&gt;Web-safe&lt;/th&gt;
&lt;th&gt;Ownership&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Hand-written with &lt;code&gt;TextInput&lt;/code&gt; + Formik + Supabase SDK&lt;/td&gt;
&lt;td&gt;2–5 days&lt;/td&gt;
&lt;td&gt;You build it&lt;/td&gt;
&lt;td&gt;Only if you branch &lt;code&gt;Alert.alert&lt;/code&gt; yourself&lt;/td&gt;
&lt;td&gt;Full code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Boilerplate/template + hand-wiring&lt;/td&gt;
&lt;td&gt;1–2 days&lt;/td&gt;
&lt;td&gt;Partial (scaffold only)&lt;/td&gt;
&lt;td&gt;Sometimes&lt;/td&gt;
&lt;td&gt;Full code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI form builder (fullstack-supabase template)&lt;/td&gt;
&lt;td&gt;~5 minutes to a working form&lt;/td&gt;
&lt;td&gt;Yes: migration, RLS, types, mutation&lt;/td&gt;
&lt;td&gt;Yes: silent-failure patterns blocked at generation&lt;/td&gt;
&lt;td&gt;Full code, exportable&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A dedicated form library like &lt;a href="https://formik.org/" rel="noopener noreferrer"&gt;Formik&lt;/a&gt; is a fine choice if you're building a small number of forms by hand. The tradeoff shifts the moment you have more than a handful of forms, or the moment "backend" is part of the definition.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;How does an AI React Native form builder handle validation?&lt;/strong&gt;&lt;br&gt;
Validation lives inside the generated component as a &lt;code&gt;validate()&lt;/code&gt; function that populates an &lt;code&gt;errors&lt;/code&gt; map keyed by field name, rendered as inline &lt;code&gt;&amp;lt;Text&amp;gt;&lt;/code&gt; under each input. Fields validate on submit by default; add "validate on blur" to the prompt and the generator wires per-field &lt;code&gt;onBlur&lt;/code&gt; handlers. For schema-based validation, prompt for Zod and the generator adds the schema and a &lt;code&gt;safeParse&lt;/code&gt; call in &lt;code&gt;validate()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can AI-generated forms write to a real database?&lt;/strong&gt;&lt;br&gt;
Yes. In a fullstack template, the generator writes a SQL migration for the target table, enables RLS, creates policies scoped to &lt;code&gt;auth.uid()&lt;/code&gt;, regenerates the TypeScript schema, and inserts a &lt;code&gt;client.from('table').insert(...)&lt;/code&gt; call in the submit handler with error handling. The write hits a real Postgres in preview (PGlite in WASM), so what you see in the editor is what ships.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about accessibility on generated form screens?&lt;/strong&gt;&lt;br&gt;
Generated forms include &lt;code&gt;accessibilityLabel&lt;/code&gt; on inputs, keyboard types set per field (&lt;code&gt;email-address&lt;/code&gt;, &lt;code&gt;phone-pad&lt;/code&gt;, &lt;code&gt;numeric&lt;/code&gt;), autocomplete hints (&lt;code&gt;autoComplete="email"&lt;/code&gt;, &lt;code&gt;"tel"&lt;/code&gt;, &lt;code&gt;"name"&lt;/code&gt;), and inline error text that screen readers surface.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to go from here
&lt;/h2&gt;

&lt;p&gt;The takeaway isn't "AI writes forms now." AI has written forms for two years. The takeaway is that the useful surface has moved: from generating the visible pretty layer to generating &lt;strong&gt;the whole data-entry pipeline&lt;/strong&gt; (migration, RLS, typed schema, controlled state, keyboard behaviour, visible errors, and a mutation that actually persists) from one natural-language description, in seconds, into code you own. All on the &lt;a href="https://docs.expo.dev/" rel="noopener noreferrer"&gt;Expo&lt;/a&gt; + &lt;a href="https://reactnative.dev/" rel="noopener noreferrer"&gt;React Native&lt;/a&gt; stack you already know.&lt;/p&gt;

&lt;p&gt;What's the form pattern that's burned you the most: keyboard geometry, silent RLS failures, or something worse? Drop it in the comments.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>supabase</category>
      <category>ai</category>
      <category>mobile</category>
    </item>
    <item>
      <title>The Non-Technical Founder's Guide to Mobile App Development in 2026</title>
      <dc:creator>Mark F A</dc:creator>
      <pubDate>Mon, 31 Aug 2026 11:02:00 +0000</pubDate>
      <link>https://dev.to/mark_fa/the-non-technical-founders-guide-to-mobile-app-development-in-2026-3j0j</link>
      <guid>https://dev.to/mark_fa/the-non-technical-founders-guide-to-mobile-app-development-in-2026-3j0j</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Four realistic paths in 2026: agency, freelancer, no-code platform, AI-native builder.&lt;/li&gt;
&lt;li&gt;Agencies: $45,000–$150,000, 4–7 months. Freelancers: $15,000–$60,000, 2–5 months. No-code: cheap, but you hit a ceiling and never own code. AI-native: real React Native output, days not months.&lt;/li&gt;
&lt;li&gt;Three decisions are yours before you hire anyone: cross-platform vs native, launch sequencing, and what you refuse to build in v1.&lt;/li&gt;
&lt;li&gt;The 30-day playbook at the bottom assumes the AI-native path. Stretch it a lot for the others.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are the engineer someone is about to ask "can you build my app idea," this is the piece to send them. It is written for the founder, but the useful part for you is the section on what they should decide before they land in your inbox.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually changed between 2023 and 2026
&lt;/h2&gt;

&lt;p&gt;Three things landed at once.&lt;/p&gt;

&lt;p&gt;AI code generation crossed from demo to production output. Models now write React Native and Expo code that boots on TestFlight without a human rewriting it from scratch. Expo tooling (OTA updates, EAS Build, universal links) stopped requiring a senior mobile engineer to configure. And a category emerged between no-code and code generation: describe the app, real code comes out, you own it.&lt;/p&gt;

&lt;p&gt;The consequence is that a solo non-technical founder can put a working iOS and Android app in front of paying users in days, on budgets that used to cover only design.&lt;/p&gt;

&lt;p&gt;That does not kill the old paths. It means there are four, and picking wrong is the expensive part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Path 1: agency
&lt;/h2&gt;

&lt;p&gt;Brief them, they design and build, they hand over a codebase. Typically 4–7 months for an MVP. $45,000–$150,000 in 2026 for auth, a backend, and one core workflow. Higher in North America, lower with Eastern European or Latin American shops.&lt;/p&gt;

&lt;p&gt;Real upside: polished output, they handle App Store submission, they own the responsibility for shipping. Real downside: you are the least-informed person in every technical meeting, change requests cost money, and if the agency wobbles at month four you are stuck.&lt;/p&gt;

&lt;p&gt;Pick this when you have capital, a fixed deadline, and requirements that genuinely need five specialists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Path 2: freelancer
&lt;/h2&gt;

&lt;p&gt;A senior React Native freelancer runs $60–$120 per hour in the US, less abroad. A lean MVP is 200–500 hours, so $15,000–$60,000 depending on location and scope discipline.&lt;/p&gt;

&lt;p&gt;This works when you find the right person. It also generates the most founder horror stories, because "the right person" is hard to source without a technical network, and if you cannot read code you will not find out you inherited a mess until you try to hand it to someone else.&lt;/p&gt;

&lt;p&gt;Pick this if you have a specific recommendation, or a technical advisor who can review the code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Path 3: no-code
&lt;/h2&gt;

&lt;p&gt;Bubble, Adalo, Glide, FlutterFlow. Drag components, configure logic through visual rules. No code output. The app lives in the platform runtime permanently.&lt;/p&gt;

&lt;p&gt;You can genuinely build with zero technical background. You also cannot leave without a rewrite, complex logic turns into nested visual conditions, performance trails a native app, and App Store rejections for wrapped apps keep happening.&lt;/p&gt;

&lt;p&gt;Pick this if the app is genuinely simple (directory, form-heavy internal tool, booking flow) and will not scale past a few thousand users.&lt;/p&gt;

&lt;h2&gt;
  
  
  Path 4: AI-native builders
&lt;/h2&gt;

&lt;p&gt;Describe the app in plain English, sketch it, or paste a PRD. An AI generates React Native and Expo code. You own it from day one, preview on your actual phone, and iterate by describing changes.&lt;/p&gt;

&lt;p&gt;This is what broke the cost math. Tens of dollars in credits instead of tens of thousands in labor, and evenings instead of quarters. Because the output is standard React Native on Expo, if the thing takes off you hand it to an engineer and they extend it like any other project.&lt;/p&gt;

&lt;p&gt;The trade-off is real: these tools are strongest on the common surface area (auth, lists, forms, media, standard navigation) and need human judgment for custom device APIs, complex real-time systems, and novel UI. For a first app, the common surface area is usually the entire product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost table
&lt;/h2&gt;

&lt;p&gt;Comparable MVP in every row: auth, a backend, one core workflow, shipped to iOS and Android. These are ranges, not quotes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;Upfront&lt;/th&gt;
&lt;th&gt;Time to v1&lt;/th&gt;
&lt;th&gt;Ongoing/mo&lt;/th&gt;
&lt;th&gt;Own the code&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Agency&lt;/td&gt;
&lt;td&gt;$45,000–$150,000&lt;/td&gt;
&lt;td&gt;4–7 months&lt;/td&gt;
&lt;td&gt;$0 (or retainer)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Freelancer&lt;/td&gt;
&lt;td&gt;$15,000–$60,000&lt;/td&gt;
&lt;td&gt;2–5 months&lt;/td&gt;
&lt;td&gt;$0 (or hourly)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No-code&lt;/td&gt;
&lt;td&gt;$0–$500&lt;/td&gt;
&lt;td&gt;2–6 weeks&lt;/td&gt;
&lt;td&gt;$50–$500&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI-native&lt;/td&gt;
&lt;td&gt;$0–$200&lt;/td&gt;
&lt;td&gt;1–5 days&lt;/td&gt;
&lt;td&gt;$20–$100&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The low numbers are not typos. An AI-native builder like &lt;a href="https://rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=non-technical-founders-guide-mobile-app-development-2026" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; produces a functional MVP for the cost of dinner, because the labor cost collapsed. What still costs money is everything you did not write: $99/year Apple, $25 one-time Google, $0–$50/month backend at MVP scale on Supabase or similar, and paid acquisition.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three decisions a founder should make before hiring anyone
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Cross-platform vs native.&lt;/strong&gt; Native means Swift plus Kotlin, two codebases, roughly double the cost. Cross-platform means one codebase on React Native or Flutter. In 2026 the answer for almost every startup is cross-platform. The performance gap has effectively closed for standard functionality, and Instagram, Discord, Shopify, and Coinbase all run significant parts of their mobile products on React Native. Legitimate reasons to go native: hardcore graphics, a platform feature with no wrapper yet, or a strategic showcase play. If someone tells a founder otherwise, ask them to name the specific device API they cannot reach.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Launch sequencing.&lt;/strong&gt; Cross-platform gets you both stores, so this is mostly about order. TestFlight for iOS first, 20–50 users, tune the flow, then submit to both together. Apple's review is stricter, so clearing it early prevents a launch-day surprise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What you refuse to build.&lt;/strong&gt; The most valuable technical decision a non-technical founder makes is the cut list. The MVP should do one thing well enough that a user recommends it despite everything missing. If you can name three core workflows, you have too many. Push notifications, IAP, social login past Google and Apple, offline mode: all cheap in v2, all expensive forced into v1.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 30-day playbook
&lt;/h2&gt;

&lt;p&gt;Assumes the AI-native path. Stretch the timeline substantially for the others.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Days 1–3.&lt;/strong&gt; One-page PRD: who is the user, what is the single workflow they will thank you for, what does month-one success look like. Show five potential users. Rewrite. Do not skip this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Days 4–6.&lt;/strong&gt; Feed the PRD in. Get the core workflow working end to end, polish absent. Test on your own phone via the preview QR. Click, describe the change, watch it happen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Days 7–10.&lt;/strong&gt; The unglamorous parts. Auth. Settings screen. Empty states. Error states. This is the difference between a demo and something that feels real.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Days 11–15.&lt;/strong&gt; Ten people using it. Not user tests, actual usage. Watch them. Note every hesitation. Do not defend your app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Days 16–20.&lt;/strong&gt; TestFlight. Same ten plus twenty new ones from the target market.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Days 21–25.&lt;/strong&gt; Fix the crashes, confusing flows, and typos TestFlight surfaces. Add nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Days 26–30.&lt;/strong&gt; Submit to both stores. Apple typically clears in under two days, Google is often same-day, both vary. Screenshots and the first line of the description carry most of the conversion weight.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part engineers actually care about
&lt;/h2&gt;

&lt;p&gt;The reason AI-native builders are worth taking seriously and no-code platforms mostly are not is portability. There is no proprietary runtime and no visual-logic language that exists inside one tool. The exported project is a normal Expo app. You can open it, read it, and extend it without a migration project.&lt;/p&gt;

&lt;p&gt;That means the founder who shipped without you in month one is not handing you a rewrite in month six. They are handing you a codebase.&lt;/p&gt;

&lt;p&gt;Which is a much better conversation to have.&lt;/p&gt;




&lt;p&gt;If you have inherited an app a non-technical founder shipped solo, how did that handoff actually go? Curious whether the "no rewrite required" claim holds up in practice or whether you all have war stories. Drop them in the comments.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>startup</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Reanimated vs Moti vs Skia: stop picking one</title>
      <dc:creator>Mark F A</dc:creator>
      <pubDate>Mon, 31 Aug 2026 11:01:49 +0000</pubDate>
      <link>https://dev.to/mark_fa/reanimated-vs-moti-vs-skia-stop-picking-one-4n7m</link>
      <guid>https://dev.to/mark_fa/reanimated-vs-moti-vs-skia-stop-picking-one-4n7m</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reanimated&lt;/strong&gt; runs animation math on the UI thread via worklets. It is the foundation, and you will end up with it installed regardless.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Moti&lt;/strong&gt; is a declarative wrapper over Reanimated with a Framer Motion-style API. Great for enter/exit, skeletons, staggered lists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skia&lt;/strong&gt; is Shopify's binding to Google's 2D graphics engine. Paths, shaders, blurs. Not an animation library.&lt;/li&gt;
&lt;li&gt;These are not competitors. They are three layers of one stack, and most production apps use at least two.&lt;/li&gt;
&lt;li&gt;Skia props accept Reanimated shared values directly, with no wrapper component. That single fact is why the combination is everywhere.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The framing problem
&lt;/h2&gt;

&lt;p&gt;Every "X vs Y vs Z" post assumes you are picking one. With these three that assumption is wrong, and it produces two specific failure modes: teams that write 40 lines of Reanimated hooks to fade in a card, and teams that reach for Moti on a pan gesture and then cannot bind it to anything.&lt;/p&gt;

&lt;p&gt;Here is what each one actually is at the runtime level.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reanimated: worklets on the UI thread
&lt;/h2&gt;

&lt;p&gt;Animations that run on the JS thread stutter whenever your app does work. Parsing JSON, rendering a list, handling touch. Reanimated moves the math onto the UI thread using worklets, small JS functions the runtime serializes and executes natively through JSI.&lt;/p&gt;

&lt;p&gt;Three pieces:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Animated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;useSharedValue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;useAnimatedStyle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;withSpring&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native-reanimated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Card&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;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useSharedValue&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useAnimatedStyle&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="na"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;translateX&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;offset&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;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Animated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;View&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onTouchEnd&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;withSpring&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="si"&gt;}&lt;/span&gt; &lt;span class="p"&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;&lt;code&gt;useSharedValue&lt;/code&gt; is a reactive primitive living on the UI thread, readable and writable from both sides. &lt;code&gt;useAnimatedStyle&lt;/code&gt; is a worklet that re-runs whenever a shared value it reads changes. &lt;code&gt;withTiming&lt;/code&gt; and &lt;code&gt;withSpring&lt;/code&gt; drive the value over time.&lt;/p&gt;

&lt;p&gt;Updates are scheduled against display frames rather than the JS event loop. That is why the animation stays smooth while your JS thread renders a 50-item list.&lt;/p&gt;

&lt;p&gt;Wire it to a gesture and you get the thing Reanimated is actually for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Gesture&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;GestureDetector&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native-gesture-handler&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;pan&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Gesture&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Pan&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;changeX&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="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;GestureDetector&lt;/span&gt; &lt;span class="na"&gt;gesture&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pan&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Animated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;View&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;style&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;GestureDetector&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;runOnJS&lt;/code&gt;, no bridge hop. The gesture callback is a worklet and it mutates a UI-thread value in the same frame.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cost:&lt;/strong&gt; ceremony. Shared values, worklet directives on nested functions, &lt;code&gt;useAnimatedStyle&lt;/code&gt; for every animated view. It is more machinery than a fade-in deserves.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moti: the same thing, four lines
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;MotiView&lt;/span&gt;
  &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;opacity&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="na"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;animate&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;opacity&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;translateY&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="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;opacity&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="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;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;timing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under the hood this is Reanimated. Same worklets, same UI-thread execution. You just never touch &lt;code&gt;useSharedValue&lt;/code&gt; for the common cases. You also get &lt;code&gt;AnimatePresence&lt;/code&gt; for exits, &lt;code&gt;MotiText&lt;/code&gt; and &lt;code&gt;MotiImage&lt;/code&gt;, a &lt;code&gt;&amp;lt;Skeleton /&amp;gt;&lt;/code&gt; loader, and &lt;code&gt;&amp;lt;Sequence /&amp;gt;&lt;/code&gt; for chaining.&lt;/p&gt;

&lt;p&gt;Staggering a list is where the ergonomics really show:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;items&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;item&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;MotiView&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;opacity&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="na"&gt;translateY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;animate&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;opacity&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;translateY&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="si"&gt;}&lt;/span&gt;
    &lt;span class="na"&gt;transition&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;))}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The cost:&lt;/strong&gt; the moment you need a live gesture driving the animation, you drop back to raw Reanimated. There is no way to bind a &lt;code&gt;MotiView&lt;/code&gt; prop to an ongoing pan translation. &lt;code&gt;useDynamicAnimation&lt;/code&gt; exists, but using it puts you back in imperative code and the abstraction stops paying rent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Skia: not an animation library at all
&lt;/h2&gt;

&lt;p&gt;It is the 2D engine behind Chrome, Flutter, and Android's rendering pipeline, exposed to RN through a declarative component API. You use it for what the view tree cannot express: paths, SkSL shaders, blurs, blend modes, gradients beyond &lt;code&gt;LinearGradient&lt;/code&gt;, text on curves.&lt;/p&gt;

&lt;p&gt;The detail that matters for this comparison:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Canvas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Circle&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;@shopify/react-native-skia&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;useSharedValue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;withTiming&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native-reanimated&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;cx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useSharedValue&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;cx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;withTiming&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;600&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Canvas&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Circle&lt;/span&gt; &lt;span class="na"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;cx&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;r&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"hotpink"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Canvas&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Skia props accept Reanimated shared values directly. No &lt;code&gt;createAnimatedComponent&lt;/code&gt;, no &lt;code&gt;useAnimatedProps&lt;/code&gt;. The canvas re-renders on the UI thread as the value changes. That is the pattern under Victory Native XL, Reanimated Carousel, and basically every good animated chart in the ecosystem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The cost:&lt;/strong&gt; no Expo Go, so you need a dev client. Roughly 2 MB of native binary. And SkSL is its own idiom with a real learning curve.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Reanimated&lt;/th&gt;
&lt;th&gt;Moti&lt;/th&gt;
&lt;th&gt;Skia&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Type&lt;/td&gt;
&lt;td&gt;Animation runtime&lt;/td&gt;
&lt;td&gt;Declarative wrapper&lt;/td&gt;
&lt;td&gt;2D graphics engine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runs on&lt;/td&gt;
&lt;td&gt;UI thread (worklets)&lt;/td&gt;
&lt;td&gt;UI thread (via Reanimated)&lt;/td&gt;
&lt;td&gt;UI thread (JSI)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Gestures, sequences&lt;/td&gt;
&lt;td&gt;Enter/exit, transitions&lt;/td&gt;
&lt;td&gt;Drawings, shaders&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Depends on&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Reanimated 2/3/4&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Approx. cost&lt;/td&gt;
&lt;td&gt;~150 KB JS + native&lt;/td&gt;
&lt;td&gt;~30 KB JS + Reanimated&lt;/td&gt;
&lt;td&gt;~2 MB native binary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expo Go&lt;/td&gt;
&lt;td&gt;Yes (SDK 47+)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;No, dev client&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Maintainer&lt;/td&gt;
&lt;td&gt;Software Mansion&lt;/td&gt;
&lt;td&gt;Fernando Rojo&lt;/td&gt;
&lt;td&gt;Shopify&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What to reach for
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Library&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fade in a card on mount&lt;/td&gt;
&lt;td&gt;Moti&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Drive a value from a pan or pinch&lt;/td&gt;
&lt;td&gt;Reanimated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chain coordinated view animations&lt;/td&gt;
&lt;td&gt;Moti&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Animated chart with line and dots&lt;/td&gt;
&lt;td&gt;Skia + Reanimated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blur a background as a sheet drags up&lt;/td&gt;
&lt;td&gt;Skia + Reanimated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Layout animation on list add/remove&lt;/td&gt;
&lt;td&gt;Reanimated 4 &lt;code&gt;LinearTransition&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Skeleton loader while fetching&lt;/td&gt;
&lt;td&gt;Moti &lt;code&gt;&amp;lt;Skeleton /&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shader-based splash screen&lt;/td&gt;
&lt;td&gt;Skia&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The dependency graph makes composition cheap. Moti pulls in Reanimated, so the incremental cost of "adding" Reanimated once you have Moti is zero. Skia is the only real commit, and you pay for it once.&lt;/p&gt;

&lt;h2&gt;
  
  
  The performance thing nobody mentions
&lt;/h2&gt;

&lt;p&gt;All three run on the UI thread over the same JSI bridge, so raw frame timing is comparable for comparable work. Where it actually breaks in production is almost never the library. It is this pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// This will drop frames&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useAnimatedStyle&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="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;h&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="c1"&gt;// triggers layout every frame&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;

&lt;span class="c1"&gt;// This will not&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;style&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useAnimatedStyle&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="na"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;scaleY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;h&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="c1"&gt;// compositor only&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Animating a layout property schedules a layout pass on every frame and the "60fps animation" ends up queued behind it. All three libraries let you do this. If you are chasing a stutter, profile before you refactor.&lt;/p&gt;

&lt;h2&gt;
  
  
  What building an AI generator taught us
&lt;/h2&gt;

&lt;p&gt;We build &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=react-native-animation-libraries-reanimated-vs-moti-vs-skia" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt;, which turns natural-language prompts into React Native and Expo code. Picking the animation primitive without a human in the loop surfaced two things worth stealing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Users never say "gesture."&lt;/strong&gt; They say "make it feel snappy" or "add some polish." We default to Moti for appearance verbs (fade, slide, pop, stagger), escalate to Reanimated the moment a prompt implies user-driven motion (drag, swipe, pull to refresh, pinch), and only pull in Skia for charts, shaders, gradients, or explicitly artistic phrasing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Composition beats picking a tool.&lt;/strong&gt; The worst early bug was choosing one library per screen. A screen with a draggable card and an animated hero wants Reanimated for the drag and Moti for the hero. Separate concerns, separate primitives.&lt;/p&gt;

&lt;p&gt;That generalizes past AI. Even when a human is choosing, the either/or framing costs you.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is Moti just Reanimated with less code?&lt;/strong&gt; Mostly. Same worklets, same UI-thread execution, same shared values internally. Moti adds prop-based config, &lt;code&gt;AnimatePresence&lt;/code&gt;, sequence orchestration, and prebuilt components.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use Skia without Reanimated?&lt;/strong&gt; Yes, for static scenes and animations driven by its own value primitives. Almost nobody does, because shared values are the standard driver everywhere else in the app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does Reanimated 4 replace Moti?&lt;/strong&gt; No. The CSS-like syntax narrows the ergonomic gap, but &lt;code&gt;AnimatePresence&lt;/code&gt; and the declarative prop API are still more concise for common transitions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about the built-in &lt;code&gt;Animated&lt;/code&gt; API?&lt;/strong&gt; Wrong default for new code. JS thread by default, &lt;code&gt;useNativeDriver&lt;/code&gt; covers only a subset of transforms and opacity, verbose interpolation, painful gesture integration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;Install Reanimated and Moti. Leave Skia until the design brief demands it. If you are auditing an existing codebase, the two things you will find are Moti calls that should be Reanimated (wired to a gesture later, never rewritten) and Reanimated hooks that should be Moti (fading a card in, 30 unnecessary lines).&lt;/p&gt;

&lt;p&gt;Pick the primitive per interaction, not per app.&lt;/p&gt;

&lt;p&gt;What is your current animation stack, and have you hit the point where you needed to mix them? Drop a comment with what you are building.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>animation</category>
      <category>javascript</category>
    </item>
    <item>
      <title>The Complete Guide to Push Notifications in React Native (2026)</title>
      <dc:creator>Mark F A</dc:creator>
      <pubDate>Wed, 19 Aug 2026 05:16:15 +0000</pubDate>
      <link>https://dev.to/rapidnative-ai/the-complete-guide-to-push-notifications-in-react-native-2026-5bi0</link>
      <guid>https://dev.to/rapidnative-ai/the-complete-guide-to-push-notifications-in-react-native-2026-5bi0</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Use &lt;code&gt;expo-notifications&lt;/code&gt; in 2026, even in a bare React Native workflow. CNG makes the managed/bare distinction mostly irrelevant.&lt;/li&gt;
&lt;li&gt;The token identifies a &lt;strong&gt;device plus install&lt;/strong&gt;, not a user. Plan for rotation from day one.&lt;/li&gt;
&lt;li&gt;On Android you must create a notification channel &lt;strong&gt;before&lt;/strong&gt; requesting permission, or notifications silently never display.&lt;/li&gt;
&lt;li&gt;Android 13+ needs the &lt;code&gt;POST_NOTIFICATIONS&lt;/code&gt; runtime permission, and only if your target SDK is 33 or higher.&lt;/li&gt;
&lt;li&gt;Never call the system permission prompt cold. It's one-shot. Put a screen you own in front of it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;react-native-push-notification&lt;/code&gt; (Zo0r) is dead. Don't start a new project on it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Push notifications are the single most reliable way to bring a user back into a mobile app. Localytics data pegs day-90 retention at roughly 190 percent higher for apps that use them well, and Airship's 2024 benchmarks show median direct-open rates north of 4 percent across most verticals. Yet notifications are also the feature most React Native teams postpone the longest, because the surface spans two operating systems, two transport providers (APNs and FCM), a set of iOS entitlements that require an Apple Developer account, an Android notification-channel model that changed twice in the last five years, and at least three distinct app states you have to handle differently.&lt;/p&gt;

&lt;p&gt;This guide is the version I wish I'd had when I first shipped push in a React Native app. It walks through the full stack (permissions, tokens, sending, receiving, deep linking, rich content, and testing) using the Expo Notifications SDK, which works for both Expo-managed and bare React Native projects on modern Expo SDK versions. Every code sample runs. Every gotcha is one I've actually hit in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "push notifications" actually means
&lt;/h2&gt;

&lt;p&gt;A push notification is a message your &lt;strong&gt;server&lt;/strong&gt; hands to a &lt;strong&gt;push service&lt;/strong&gt; (APNs for Apple, FCM for Google) which then delivers it to a specific device using a device-specific token. The device wakes up even if your app is killed, OS-level code decodes the payload, and either displays a system UI or fires an event into your app process.&lt;/p&gt;

&lt;p&gt;Three things follow from that definition, and they cause most of the confusion:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;You cannot send a push directly from your React Native app to another user.&lt;/strong&gt; You need a server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The token identifies a device plus app install&lt;/strong&gt;, not a user. When a user reinstalls, logs into a second device, or clears app data, you get a new token. Tokens also rotate for other reasons and can be invalidated by the push service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What runs inside your app is only half the story.&lt;/strong&gt; The other half is Apple's or Google's OS-level notification presentation logic, which you configure through payload keys, notification channels, and iOS entitlements.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Local notifications, the kind you schedule from inside the app without a server, use most of the same APIs but skip the token and the push service. This guide covers both, since real apps almost always need both.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 2026 landscape: which library
&lt;/h2&gt;

&lt;p&gt;There are three real options for React Native in 2026, and the honest recommendation is: &lt;strong&gt;use &lt;code&gt;expo-notifications&lt;/code&gt;&lt;/strong&gt;, even if you're not on Expo Go and even if you use a bare React Native workflow.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Library&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;th&gt;Trade-offs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;expo-notifications&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Almost every app&lt;/td&gt;
&lt;td&gt;First-class support in both Expo-managed and bare RN via CNG (Continuous Native Generation). Handles APNs and FCM transparently. Actively maintained.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;@react-native-firebase/messaging&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Apps that already use other Firebase products (Firestore, Auth, Analytics) heavily&lt;/td&gt;
&lt;td&gt;Google-only transport story; you still need a separate iOS setup for APNs credentials.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;&lt;code&gt;react-native-notifications&lt;/code&gt; (Wix)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Advanced native-side customization needs&lt;/td&gt;
&lt;td&gt;Smaller community; more manual native config.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The legacy &lt;code&gt;react-native-push-notification&lt;/code&gt; package (Zo0r) is unmaintained and should not be used in a new project.&lt;/p&gt;

&lt;p&gt;The rest of this guide uses &lt;code&gt;expo-notifications&lt;/code&gt;. Because Expo now supports prebuild (&lt;code&gt;npx expo prebuild&lt;/code&gt;) and CNG, you get the same experience whether your project is Expo-managed or bare: the config plugin generates the correct native code for both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing and configuring
&lt;/h2&gt;

&lt;p&gt;Assuming an Expo SDK 52+ project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx expo &lt;span class="nb"&gt;install &lt;/span&gt;expo-notifications expo-device expo-constants
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;expo-device&lt;/code&gt; is used to skip token registration on simulators. Apple's push service does not deliver to the iOS simulator on Xcode versions below 14, and even on newer Xcode you need a paid developer account and the Simulator's push testing tools. &lt;code&gt;expo-constants&lt;/code&gt; gives you access to &lt;code&gt;easConfig.projectId&lt;/code&gt;, which the Expo Push Service needs to route tokens.&lt;/p&gt;

&lt;p&gt;Add the config plugin in &lt;code&gt;app.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"plugins"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"expo-notifications"&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;"icon"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./assets/notification-icon.png"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"color"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"#111827"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"sounds"&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="s2"&gt;"./assets/notification-sound.wav"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="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;"bundleIdentifier"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"com.yourco.yourapp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"infoPlist"&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;"UIBackgroundModes"&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="s2"&gt;"remote-notification"&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="nl"&gt;"android"&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;"package"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"com.yourco.yourapp"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"googleServicesFile"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./google-services.json"&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;Two things to know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The notification icon must be a &lt;strong&gt;monochrome PNG with a transparent background&lt;/strong&gt;. Android's icon guidelines are strict, and a full-color icon renders as a white square.&lt;/li&gt;
&lt;li&gt;On iOS, you need to enable the &lt;strong&gt;Push Notifications capability&lt;/strong&gt; and the &lt;strong&gt;Background Modes to Remote notifications&lt;/strong&gt; capability in your Apple Developer account. If you're using EAS Build, &lt;code&gt;expo-notifications&lt;/code&gt; and the &lt;code&gt;UIBackgroundModes&lt;/code&gt; config above handle this automatically.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're standing up a new project rather than retrofitting an existing one, starting from a config that already builds saves an afternoon. &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=react-native-push-notifications-complete-guide-2026" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; generates Expo projects with &lt;code&gt;app.json&lt;/code&gt;, the bundle identifier, and the Android package name already in place, so you're editing a working config instead of assembling one from an empty file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Requesting permission the right way
&lt;/h2&gt;

&lt;p&gt;This is where a lot of implementations lose would-be opt-ins. Apple's system prompt is a one-shot event: if the user taps "Don't Allow," you cannot re-prompt from your app. They would have to go into Settings.&lt;/p&gt;

&lt;p&gt;The pattern that works is a &lt;strong&gt;pre-permission screen&lt;/strong&gt;: a screen you own, explaining the value, with a "Turn on notifications" button. Only when the user taps that button do you call the system API.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-notifications&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;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;Device&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-device&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;Constants&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-constants&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;Platform&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;registerForPushNotifications&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;Device&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isDevice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Push notifications require a physical device.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OS&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;android&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setNotificationChannelAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;default&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;default&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;importance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AndroidImportance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;vibrationPattern&lt;/span&gt;&lt;span class="p"&gt;:&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="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;lightColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#111827&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;existing&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPermissionsAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;finalStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;existing&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;existing&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;granted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestPermissionsAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nx"&gt;finalStatus&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;finalStatus&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;granted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&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;projectId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Constants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expoConfig&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;extra&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;eas&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;projectId&lt;/span&gt;
    &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;Constants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;easConfig&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;projectId&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;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getExpoPushTokenAsync&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;projectId&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;token&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;A few details that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Android 13+ (API 33) requires runtime permission&lt;/strong&gt; via &lt;code&gt;POST_NOTIFICATIONS&lt;/code&gt;. &lt;code&gt;expo-notifications&lt;/code&gt; handles this for you when you call &lt;code&gt;requestPermissionsAsync()&lt;/code&gt;, but only if your target SDK is 33 or higher.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On Android, you must create a notification channel&lt;/strong&gt; before you request permission. Otherwise notifications will silently fail to display, even though your token is valid. Channels group notifications so users can mute categories independently in system settings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You want the Expo push token, not the native APNs or FCM token&lt;/strong&gt;, unless you're skipping Expo's push service. The Expo token is a single string of the form &lt;code&gt;ExponentPushToken[xxxxxxxxxx]&lt;/code&gt; that Expo's service translates to either APNs or FCM at send time.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where to go next
&lt;/h2&gt;

&lt;p&gt;The setup above gets you a valid token and a permission prompt users actually accept. The next layer is the one that decides whether push works in production: sending from your server, handling the payload in all three app states (foreground, background, killed), routing a tap to the right screen, and testing all of it on real hardware.&lt;/p&gt;

&lt;p&gt;What broke first when you shipped push? My money is on the Android channel. Drop yours in the comments.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>javascript</category>
      <category>mobile</category>
    </item>
    <item>
      <title>From Side Project to App Store: A Non-Technical Founder's Guide (2026)</title>
      <dc:creator>Mark F A</dc:creator>
      <pubDate>Fri, 14 Aug 2026 06:59:12 +0000</pubDate>
      <link>https://dev.to/mark_fa/from-side-project-to-app-store-a-non-technical-founders-guide-2026-387i</link>
      <guid>https://dev.to/mark_fa/from-side-project-to-app-store-a-non-technical-founders-guide-2026-387i</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The distance between "I have an app idea" and "it's live on the App Store" collapsed in 2026.&lt;/li&gt;
&lt;li&gt;Stack: AI mobile app builder producing real exportable React Native code, Supabase for data, TestFlight and Play Internal Testing for QA, self-submission to both stores.&lt;/li&gt;
&lt;li&gt;Validate with 10 real interviews and a landing page &lt;em&gt;before&lt;/em&gt; you build anything.&lt;/li&gt;
&lt;li&gt;Budget one week for submission. Most rejections are mechanical: privacy label mismatches, placeholder content, first-minute crashes.&lt;/li&gt;
&lt;li&gt;Retention beats downloads. Day 30 above 20% means you have something.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Somewhere between 70 and 80 percent of side projects never leave a Notion doc. Not because the ideas are bad. Because the distance between "I have an idea for an app" and "my app is live on the App Store" has always been enormous for anyone who can't code. Hiring a developer costs $20,000 to $80,000. Learning React Native takes six months. Cloning boilerplate templates leaves you stuck the moment you want anything custom.&lt;/p&gt;

&lt;p&gt;That distance collapsed in 2026. A non-technical founder can now go from a napkin sketch to a live App Store listing in a matter of weeks, working alone, without writing a line of code, and without shipping a fragile prototype held together with duct tape.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack has finally shifted
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is the fastest way for a non-technical founder to build a mobile app in 2026?&lt;/strong&gt; Use an AI mobile app builder to generate production-ready React Native code from a prompt, sketch, or PRD; back it with a managed database like Supabase; test with TestFlight and Google Play Internal Testing; and submit to both stores yourself. The whole cycle takes weeks, not months, and the code you own is real code, not a walled-garden template.&lt;/p&gt;

&lt;p&gt;The old paths were expensive, slow, or extractive. Hire an agency and you were out $30k before a single user tapped the icon. Hire a "technical co-founder" and you gave away 40% of your company for someone who could've been replaced by tooling that didn't exist yet. Learn to code and you spent your first year fighting Xcode installation errors instead of talking to users.&lt;/p&gt;

&lt;p&gt;The new stack does three things the old one couldn't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Generates working, exportable code.&lt;/strong&gt; React Native and Expo apps you can own, edit, and ship, not throwaway prototypes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Handles the full stack.&lt;/strong&gt; Screens, navigation, forms, auth, database, and API integration from one generation pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runs a real preview.&lt;/strong&gt; Hold your phone up to a QR code and use your app on iOS the same day you started.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The bottleneck used to be code. Now the bottleneck is your ability to make product decisions, which is exactly the work a founder should be doing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 1: Validate without writing a line of code
&lt;/h2&gt;

&lt;p&gt;One to two weeks. Three artifacts: a landing page with a signup form, 10 completed user interviews, and a written product spec.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 10-user test.&lt;/strong&gt; Pick 10 people who would actually pay for or use your app. Not friends. Not "the whole world." Ten specific humans. Ask them: "Walk me through the last time you tried to solve [problem]. What did you use? What did you hate about it?" If you can't find 10 people who describe the problem the same way you do, you don't have a product. You have a hunch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Landing page and waitlist.&lt;/strong&gt; Spin up a one-pager on Framer, Carrd, or Webflow. Value prop, three screenshots (mock them in Figma if you have to), and a "get early access" form. Push it in one Slack community, one subreddit, and one Twitter thread. If nobody signs up, that's your answer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Write a real PRD.&lt;/strong&gt; Not a novel. One page listing: the target user, the top three screens, the data each screen shows, the actions the user can take, and what happens after each action. This becomes the input for the next phase.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 2: Build the first working version
&lt;/h2&gt;

&lt;p&gt;This is where non-technical founders used to get stuck for six months. Now it takes days.&lt;/p&gt;

&lt;p&gt;Open an AI mobile app builder. Describe your app in plain English: "A workout tracker where users log sets and reps, see weekly volume, and get streak badges." Watch it generate a working Expo app with screens, navigation, forms, and a database schema. Scan the QR code with your phone. Use the app. Notice five things you want to change. Describe them. Regenerate. Done in an afternoon what would have taken a contractor a month.&lt;/p&gt;

&lt;p&gt;Under the hood, the good builders run a multi-step pipeline: interpret the prompt, plan a component tree, generate React Native code, wire up state with Redux or Context, apply theming with NativeWind, and stream the result into a live preview you can interact with in real time. You don't need to know any of that, but knowing it exists is what separates "toy prototype" from "actual app I can submit to Apple."&lt;/p&gt;

&lt;p&gt;Four input modes are worth knowing about:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mode&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Prompt to app&lt;/td&gt;
&lt;td&gt;Describe it, get it&lt;/td&gt;
&lt;td&gt;Founders who know what they want&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sketch to app&lt;/td&gt;
&lt;td&gt;Draw wireframes, get working screens&lt;/td&gt;
&lt;td&gt;Ideas more visual than verbal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PRD to app&lt;/td&gt;
&lt;td&gt;Paste your spec, get a full prototype&lt;/td&gt;
&lt;td&gt;Founders who already wrote the doc&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Screenshot to app&lt;/td&gt;
&lt;td&gt;Upload a UX you like, generate something similar&lt;/td&gt;
&lt;td&gt;Cloning proven patterns&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=side-project-to-app-store-non-technical-founders-guide" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; supports all four and exports a real Expo project. Pick the mode that matches how the idea actually lives in your head.&lt;/p&gt;

&lt;p&gt;The trick in this phase is &lt;strong&gt;ruthless scope-cutting&lt;/strong&gt;. Every non-technical founder wants push notifications, in-app purchases, social login, offline mode, and dark mode on day one. Build none of that. Notifications and IAP are two-week rabbit holes each, and every screen you add is a screen you'll redesign after your first ten users.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 3: Test on TestFlight and Play Internal Testing
&lt;/h2&gt;

&lt;p&gt;Most founders skip this. Don't. A demo in the simulator is not a real test.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Apple TestFlight.&lt;/strong&gt; Free, ships from your Apple Developer account, distributes to up to 10,000 external testers via email or a public link. Upload a build (EAS handles this), fill out a short test information form, invite testers. Apple runs a brief review on your first build, usually under 24 hours. After that, updates are near-instant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Google Play Internal Testing.&lt;/strong&gt; Also free, ships from Play Console, supports up to 100 internal testers, distributed by Google account email. More forms than Apple up front, but review is essentially zero. Internal builds go live within minutes.&lt;/p&gt;

&lt;p&gt;Send invites to your 10 interviewees from Phase 1. After a week of use, ask each of them: &lt;em&gt;What did you use it for? What was confusing? Would you pay $5/month for this?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Instrument analytics before you send it out. Expo has PostHog and Mixpanel SDKs that take five minutes to integrate. You want to know which screens users open, where they drop off, and how long sessions last. This data is worth ten times what people tell you in interviews.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 4: The submission gauntlet
&lt;/h2&gt;

&lt;p&gt;The phase founders fear most and, frankly, the one that trips them up least. Budget one week for both stores.&lt;/p&gt;

&lt;h3&gt;
  
  
  Apple App Store
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Apple Developer Program&lt;/strong&gt;: $99/year. Individual account is fine to start.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;App Store Connect listing&lt;/strong&gt;: name (30 chars), subtitle (30 chars), keywords (100 chars, comma-separated, invisible to users), description, screenshots (6.7" and 6.5" iPhone minimum), promotional text, support URL, privacy policy URL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privacy nutrition label&lt;/strong&gt;: declare what data you collect, what links to the user, and what's used for tracking. This is the #1 rejection reason for solo founders.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;App Tracking Transparency&lt;/strong&gt;: required if you use third-party analytics that fingerprints devices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review time&lt;/strong&gt;: around 24 hours for 90% of submissions in 2026. Complex or first-time submissions can take 2–4 days.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Google Play Store
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Developer account&lt;/strong&gt;: $25 one-time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Play Console listing&lt;/strong&gt;: title (30 chars), short description (80 chars), full description (4000 chars), feature graphic, screenshots, category, content rating questionnaire.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data Safety form&lt;/strong&gt;: Google's equivalent of Apple's privacy label. Mismatches with actual behavior get apps pulled later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Target API level&lt;/strong&gt;: last two API levels required. Modern Expo apps handle this automatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review time&lt;/strong&gt;: a few hours to a couple of days for new apps, faster for updates.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three most common rejections for solo-founder apps: placeholder content still visible ("Lorem ipsum" screens are an instant reject), crashes in the first minute, and misleading metadata. Ship a build you've used yourself for a full week.&lt;/p&gt;

&lt;p&gt;Your builder should export a complete Expo project you can submit yourself with EAS or hand to a developer for final packaging. This is where owning real, exportable code matters. You're not trapped inside a proprietary builder that can't produce an actual &lt;code&gt;.ipa&lt;/code&gt; or &lt;code&gt;.aab&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Phase 5: Launch and beyond
&lt;/h2&gt;

&lt;p&gt;Getting into the store is not the launch. It's the starting line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Soft launch first.&lt;/strong&gt; Go live in one small market (Canada and Australia are common) for two weeks before opening globally. Watch crash rates, retention, and reviews. Fix what breaks. Then flip the geo restriction off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick one launch channel and go deep.&lt;/strong&gt; Product Hunt, a niche subreddit, an X thread, or an outreach email to 50 users. Not all four in the same day. Product Hunt in particular rewards launches that already have engagement, so warm your list before you post.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Instrument retention, not vanity metrics.&lt;/strong&gt; Downloads are meaningless. Day 1, Day 7, and Day 30 retention are the numbers that matter. Day 30 above 20% for a consumer app means you have something worth pushing. Below 10% and you iterate on the product, not the marketing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Iterate fast.&lt;/strong&gt; Every piece of feedback becomes a prompt. "The signup flow is confusing" becomes a two-minute edit and a new build on TestFlight the same afternoon. Founders who take a week to ship a fix lose to founders who take two hours.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ASO basics.&lt;/strong&gt; Optimize your app name and subtitle around one high-intent keyword each. Use all 100 characters of the App Store keyword field. Ship screenshots that show the value in the first two frames, since that's what users see on the search page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five pitfalls to avoid
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Building too much before showing anyone.&lt;/strong&gt; Every screen you build without a user in front of it is a screen you'll rebuild. Ship five screens to ten users before you build the sixth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ignoring review guidelines.&lt;/strong&gt; Read them once before you submit. The mechanical rejections (privacy label mismatches, missing account-deletion flow, sign-in-with-Apple requirement when you have social login) are easy to avoid and painful to hit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No maintenance plan.&lt;/strong&gt; Apple and Google both push OS updates yearly. If you can't ship at least one build every 6 months, your app will start breaking. A maintenance update in 2026 is a 30-minute regeneration, not a week of contractor work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Undervaluing yourself on price.&lt;/strong&gt; Free apps monetize on scale you don't have yet. $4.99/month or $29/year is a common indie starting price and it filters out users who were never going to convert.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skipping the legal basics.&lt;/strong&gt; Both stores require a privacy policy URL. Termly or Iubenda cost $10–$20 and take 15 minutes. Do it before you submit, not after.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What shipping alone actually looks like now
&lt;/h2&gt;

&lt;p&gt;The old founder journey: idea, then learn to code or raise money or find a co-founder, then build for a year, then ship a bad version, then run out of runway.&lt;/p&gt;

&lt;p&gt;The new one: idea, validate in a week, build in a week, test in two weeks, submit in a week, iterate forever. The whole cycle from opening a browser to a live App Store listing can compress into a single month.&lt;/p&gt;

&lt;p&gt;You still need judgement: what to build, what to cut, who to talk to, how to price. Those are the parts that make you a founder. Everything else has become tooling.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What phase are you stuck in right now?&lt;/strong&gt; Validation, building, testing, or submission? Drop it in the comments. Curious how many of you are sitting on a Notion doc that's been there over a year.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>startup</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>How to Build a HIPAA-Compliant App: A Founder's Guide for 2026</title>
      <dc:creator>Mark F A</dc:creator>
      <pubDate>Wed, 12 Aug 2026 07:09:30 +0000</pubDate>
      <link>https://dev.to/rapidnative-ai/how-to-build-a-hipaa-compliant-app-a-founders-guide-for-2026-ha0</link>
      <guid>https://dev.to/rapidnative-ai/how-to-build-a-hipaa-compliant-app-a-founders-guide-for-2026-ha0</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;There is no HIPAA certification.&lt;/strong&gt; No stamp, no badge. It's a continuous state you maintain through contracts, controls, and documentation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Six technical pillars carry most of the weight:&lt;/strong&gt; encryption at rest and in transit, unique auth with MFA, server-enforced RBAC, tamper-evident audit logs, automatic session logoff, and a signed BAA with every vendor that touches PHI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing BAAs are the most common root cause of HIPAA breaches.&lt;/strong&gt; They're also the easiest thing for an auditor to check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You can use LLMs with PHI in 2026&lt;/strong&gt;, but only with providers that offer a BAA, on the right plan, with identifiers redacted before the call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The $60k-$300k agency number is real but not the only number.&lt;/strong&gt; A React Native and Supabase stack can hit a compliant beta in eight to twelve weeks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every healthcare founder eventually hits the same wall. You have a real idea, sometimes even paying pilot customers, and then a hospital CTO or a payer's legal team asks the question that stops the conversation: "Is your app HIPAA-compliant?"&lt;/p&gt;

&lt;p&gt;Suddenly the six-week MVP plan collides with a body of law from 1996, agencies quoting $150,000 to $300,000 for a "compliant build," and a vendor stack full of tools you love that quietly cannot legally touch patient data.&lt;/p&gt;

&lt;p&gt;Here's how you'd actually do it in 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "HIPAA-compliant" actually means
&lt;/h2&gt;

&lt;p&gt;HIPAA-compliant app development means every point where Protected Health Information (PHI) is created, stored, transmitted, or accessed is covered by administrative, physical, and technical safeguards defined by the HIPAA Security Rule. And every third-party vendor that touches PHI has signed a Business Associate Agreement (BAA) with you.&lt;/p&gt;

&lt;p&gt;Compliance is not a certification you buy. There is no "HIPAA-certified" stamp issued by HHS. What exists is the HIPAA Security Rule, the Privacy Rule, and the Breach Notification Rule, plus the Office for Civil Rights that investigates breaches and complaints. Your job is to demonstrate, with documentation, contracts, and technical controls, that you meet every applicable requirement on the day someone asks.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three rules that shape your architecture
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The Privacy Rule&lt;/strong&gt; governs how PHI can be used and disclosed. It introduces the concept every product designer bumps into first: the &lt;strong&gt;minimum necessary standard&lt;/strong&gt;. Your app collects, displays, and transmits only the PHI required to do its job. If your telehealth app doesn't need a full address to run a video visit, don't ask for it. It also defines patient rights (access, amendment, accounting of disclosures) and every one of those maps to a screen or an endpoint you have to build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The Security Rule&lt;/strong&gt; is the one engineers live in. It covers electronic PHI and requires three safeguard categories:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Administrative:&lt;/strong&gt; risk assessments, workforce training, access management policies, incident response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Physical:&lt;/strong&gt; facility access controls, workstation security, device and media disposal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Technical:&lt;/strong&gt; access controls, audit logs, integrity controls, transmission security. This is the layer your code implements.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The Breach Notification Rule&lt;/strong&gt; gives you a legal duty to notify affected individuals within 60 days, notify HHS, and in some cases notify media. "Unsecured" is the operative word. Properly encrypted PHI that gets stolen is generally treated as a much lower-risk event, which is the strongest practical argument that encryption is not optional.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 6 technical requirements that actually matter
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Encryption at rest and in transit.&lt;/strong&gt; AES-256 server-side and on device. TLS 1.2+ with modern ciphers in flight. Hardware-backed key storage for anything the client persists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unique user authentication with MFA.&lt;/strong&gt; Unique identifier per user, no shared logins, MFA for anyone accessing PHI. Biometrics count as a second factor on the client.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Role-based access control.&lt;/strong&gt; Patient sees their own record. Nurse sees their unit. Billing clerk sees charge codes but not clinical notes. Enforced on the server, never only in the UI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tamper-evident audit logging.&lt;/strong&gt; Every read and write of PHI logged with who, what, when, and from where. Logs protected from modification, retained six years.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatic logoff and session controls.&lt;/strong&gt; Inactive sessions terminate. On mobile this means a background timer that clears the session and forces re-auth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Signed BAAs with every vendor that touches PHI.&lt;/strong&gt; The one most first-time healthcare founders miss.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;On mobile, requirement 1 mostly comes down to not persisting PHI in the wrong place:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// DON'T: AsyncStorage is plaintext on disk.&lt;/span&gt;
&lt;span class="c1"&gt;// On Android it's SharedPreferences XML, readable with adb.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;AsyncStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;patient_mrn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mrn&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// DO: hardware-backed keychain/keystore, device-bound.&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;SecureStore&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-secure-store&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;SecureStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItemAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;session_token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;keychainAccessible&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SecureStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;WHEN_UNLOCKED_THIS_DEVICE_ONLY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;keychainService&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;com.yourcompany.health.auth&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;requireAuthentication&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="c1"&gt;// gates read behind Face ID / biometric&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Better still: don't persist PHI on the device at all. Keep it in memory, fetch it per session, and let the server be the only durable store. That turns a lost-phone incident into a non-event.&lt;/p&gt;

&lt;p&gt;Requirement 5 is the one people forget until an auditor asks. A minimal version:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// sessionTimer.js&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;AppState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native&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;TIMEOUT_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 15 min inactivity&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;backgroundedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;AppState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;change&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;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;background&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;backgroundedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;backgroundedAt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;backgroundedAt&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;TIMEOUT_MS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;clearSession&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;       &lt;span class="c1"&gt;// wipe in-memory PHI&lt;/span&gt;
      &lt;span class="nf"&gt;navigateToLogin&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;    &lt;span class="c1"&gt;// force re-authentication&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;backgroundedAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&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;Pair that with a foreground inactivity timer reset on user interaction, and log both the timeout and the re-auth to your audit trail.&lt;/p&gt;

&lt;h2&gt;
  
  
  The vendor stack that will actually sign a BAA
&lt;/h2&gt;

&lt;p&gt;This is where most guides fail founders. They say "sign a BAA with your vendors" without saying which vendors will sign one, on which plan, at what price.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;HIPAA-eligible option in 2026&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cloud infrastructure&lt;/td&gt;
&lt;td&gt;AWS (BAA free via AWS Artifact, ~150 eligible services), Google Cloud, Azure&lt;/td&gt;
&lt;td&gt;AWS is the most-used HIPAA cloud in 2026&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database + auth + storage&lt;/td&gt;
&lt;td&gt;Supabase Team ($599/mo) + HIPAA add-on ($350/mo), or self-host&lt;/td&gt;
&lt;td&gt;Check current Supabase HIPAA docs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frontend (mobile)&lt;/td&gt;
&lt;td&gt;React Native / Expo, no PHI in the bundle&lt;/td&gt;
&lt;td&gt;Most common mobile framework for HIPAA apps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email (transactional)&lt;/td&gt;
&lt;td&gt;AWS SES (BAA available), Paubox, LuxSci&lt;/td&gt;
&lt;td&gt;Not SendGrid on standard plans&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SMS / voice&lt;/td&gt;
&lt;td&gt;Twilio (HIPAA-eligible products only)&lt;/td&gt;
&lt;td&gt;Sign a BAA and configure specifically for HIPAA&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Push notifications&lt;/td&gt;
&lt;td&gt;OneSignal (HIPAA plan), or AWS SNS&lt;/td&gt;
&lt;td&gt;Never put PHI in the notification body&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error tracking&lt;/td&gt;
&lt;td&gt;Sentry (HIPAA tier), Datadog (HIPAA tier)&lt;/td&gt;
&lt;td&gt;Standard tiers do not qualify&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Analytics&lt;/td&gt;
&lt;td&gt;Server-side only, PHI-free events&lt;/td&gt;
&lt;td&gt;Google Analytics does not sign BAAs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI / LLM&lt;/td&gt;
&lt;td&gt;Anthropic (BAA available), AWS Bedrock, Vertex AI, Azure OpenAI&lt;/td&gt;
&lt;td&gt;See below&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two rules of thumb. If a vendor's marketing site doesn't say "HIPAA eligible" and their sales team can't produce a template BAA within a day, assume they can't. And HIPAA eligibility is almost always plan-specific, so the free tier you prototyped on almost certainly doesn't qualify.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can you use LLMs with PHI?
&lt;/h2&gt;

&lt;p&gt;Yes, but only with specific providers, on specific plans, under a signed BAA.&lt;/p&gt;

&lt;p&gt;The major model providers now offer HIPAA-eligible tiers. Anthropic offers BAAs for enterprise customers using Claude. AWS Bedrock, Google Cloud Vertex AI, and Azure OpenAI all extend the underlying platform BAA to the models hosted on them. What you cannot do is ship PHI to a consumer API endpoint with no BAA. That's a breach the moment the request is sent, regardless of what the model does with the data.&lt;/p&gt;

&lt;p&gt;Even under a BAA, minimize what you send:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Redact identifiers server-side BEFORE the model call.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SAFE_FIELDS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ageBand&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sex&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;conditionCodes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;medications&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;toModelPayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;patient&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;SAFE_FIELDS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;acc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;patient&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;acc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;patient&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;acc&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="c1"&gt;// Allow-list, not deny-list. A deny-list silently leaks&lt;/span&gt;
&lt;span class="c1"&gt;// every new field someone adds to the patient record.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Allow-list beats deny-list every time here. A deny-list quietly leaks whatever field a teammate adds next sprint. And log every prompt and response as part of your audit trail. If your AI features are purely non-PHI (educational content, appointment reminders with no diagnosis), you can often keep them on your regular non-BAA stack, as long as you can prove PHI never crosses that boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  The build checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Data classification.&lt;/strong&gt; List every data element. Tag each PHI, non-PHI, or de-identified. Remove fields and prefer tokens over raw values.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Written risk assessment.&lt;/strong&gt; A Security Rule requirement, not a nice-to-have. Threats, likelihoods, mitigations. Update annually and on material architecture changes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick your HIPAA-eligible stack.&lt;/strong&gt; Sign BAAs before a single byte of PHI reaches any vendor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Implement the six pillars.&lt;/strong&gt; Encryption, MFA, server-side RBAC, audit logs with six-year retention, session logoff, BAAs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secure SDLC.&lt;/strong&gt; Code review, SAST/DAST, dependency pinning, supply chain controls. First thing a serious buyer's security team asks about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Train your workforce.&lt;/strong&gt; Security Awareness Training for anyone who could touch PHI. Keep records: dates, curricula, attendees.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Policies and incident response plan.&lt;/strong&gt; Breach notification, sanctions, contingency, business continuity. Start from a template, then customize.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Third-party pen test before launch.&lt;/strong&gt; Not required by HIPAA, but every hospital and enterprise buyer will ask for a recent report.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privacy policy and Notice of Privacy Practices.&lt;/strong&gt; User-facing documents, not just legal artifacts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Continuous compliance.&lt;/strong&gt; Quarterly access reviews, monthly log reviews, annual risk reassessment.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What it costs and how long it takes
&lt;/h2&gt;

&lt;p&gt;Every agency guide says $60,000 to $300,000 and six to twelve months. That's real for a bespoke, agency-built telemedicine app with EHR integration. It's not the only number.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Baseline:&lt;/strong&gt; whatever a non-HIPAA build of the same feature set would cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add 15-25%&lt;/strong&gt; for compliance-specific engineering: audit logging, RBAC, session management, encryption plumbing, admin console for access reviews.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vendor upcharge:&lt;/strong&gt; roughly $1,000-$3,000/month at MVP scale once you're on HIPAA tiers across the board.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compliance program:&lt;/strong&gt; policies, risk assessment, training, pen test, BAA legal review. Budget $10,000-$30,000 year one, roughly half that annually after.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Timelines compress the same way. A modern React Native and Supabase HIPAA stack, with the front-end scaffolded in an AI-assisted tool like &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=how-to-build-hipaa-compliant-app" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt;, can get you from zero to a working compliant beta in eight to twelve weeks. Worth being precise about the boundary, though: a code generation tool accelerates your front-end and scaffolding, not your compliance program. No AI code tool signs a BAA with you, because it doesn't touch your production PHI. And you should not paste PHI into any prompt during development. Scaffold with realistic-but-synthetic data and wire the real backend in your own environment.&lt;/p&gt;

&lt;p&gt;What no tool can compress is the compliance program itself: writing your risk assessment, executing your BAAs, getting the pen test scheduled. Start those in parallel with engineering, not after.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is React Native suitable for HIPAA-compliant apps?&lt;/strong&gt; Yes. Single codebase to native iOS and Android, access to Keychain and Keystore for hardware-backed encryption, biometric APIs for MFA, same TLS stack as native. HIPAA compliance is a property of your architecture and vendor stack, not your mobile framework.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need a BAA with Apple or Google to publish?&lt;/strong&gt; No. They don't process PHI on your behalf when they distribute your app. You do need to follow their health data policies, and you need BAAs with every cloud, analytics, notification, and backend vendor that actually handles PHI.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use Firebase?&lt;/strong&gt; Partially. Firebase Auth, Cloud Firestore, and Cloud Functions on Blaze are covered under the Google Cloud BAA when configured correctly. Firebase Analytics, Crashlytics on defaults, and Cloud Messaging with PHI payloads are not. Confirm each service against Google Cloud's current list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens in a breach?&lt;/strong&gt; Notify affected individuals within 60 days, notify HHS (immediately for 500+ people, otherwise annually), and in some cases notify media in the affected state. OCR fines range from $137 to over $2 million per violation depending on culpability. Encrypted PHI that's lost is generally a much lower-risk event.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need HIPAA if only patients use my app?&lt;/strong&gt; If you collect health data directly from consumers and aren't acting on behalf of a covered entity, you may not technically be subject to HIPAA. But you're almost certainly subject to the FTC Health Breach Notification Rule, state laws (California's CMIA, Washington's My Health My Data Act), and user expectations. Most direct-to-consumer health apps build to HIPAA-equivalent standards anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part most teams get wrong
&lt;/h2&gt;

&lt;p&gt;HIPAA has a reputation for being expensive and slow because most teams treat compliance as a phase at the end of the project. It isn't. It's an architectural constraint you build in from day one, and once it's in your foundation the incremental cost is much smaller than the guides suggest.&lt;/p&gt;

&lt;p&gt;Scaffold fast so you get to testable UI in days. Choose a HIPAA-eligible stack from the start. Get BAAs signed while engineers are still building. Write the risk assessment early, not the week before your first customer's security review.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What's your stack?&lt;/strong&gt; Drop it in the comments and I'll flag which pieces will fail an audit. And if you've shipped a HIPAA app already, I want to know which vendor surprised you most by refusing (or agreeing) to sign a BAA.&lt;/p&gt;

</description>
      <category>hipaa</category>
      <category>reactnative</category>
      <category>healthcare</category>
      <category>startup</category>
    </item>
    <item>
      <title>React Native Authentication in 2026: The Refresh-Token Pattern That Actually Scales</title>
      <dc:creator>Mark F A</dc:creator>
      <pubDate>Wed, 12 Aug 2026 06:03:55 +0000</pubDate>
      <link>https://dev.to/mark_fa/react-native-authentication-in-2026-the-refresh-token-pattern-that-actually-scales-4i74</link>
      <guid>https://dev.to/mark_fa/react-native-authentication-in-2026-the-refresh-token-pattern-that-actually-scales-4i74</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Access token in memory, refresh token in &lt;code&gt;expo-secure-store&lt;/code&gt;.&lt;/strong&gt; AsyncStorage is plaintext on disk. Stop using it for credentials.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Access token: 15 minutes. Refresh token: 30 days.&lt;/strong&gt; Different signing secrets. The split is about blast radius.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One singleton &lt;code&gt;refreshPromise&lt;/code&gt;&lt;/strong&gt; stops five concurrent 401s from firing five refresh calls (four of which will fail once rotation kicks in).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always pass an explicit &lt;code&gt;keychainService&lt;/code&gt;.&lt;/strong&gt; Omit it and users get logged out after every TestFlight update, iOS only.&lt;/li&gt;
&lt;li&gt;Total cost: roughly 200 lines of app code, no managed auth vendor required.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most React Native auth tutorials show you how to log a user in. Almost none of them show you how the app behaves at 3 AM when five in-flight requests all hit an expired token at the same time, or how your carefully-stored token silently disappears after a TestFlight update. This post is the pattern that survived three production apps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why most tutorials fail
&lt;/h2&gt;

&lt;p&gt;You've seen this pattern a hundred times:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&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;AsyncStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;access_token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three problems compound in production:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;AsyncStorage is not secure.&lt;/strong&gt; It's plaintext on disk. Any process that can read your app's sandbox can read the token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No refresh handling.&lt;/strong&gt; The token expires, users get 401s, and your fix is "log them out and back in." That's a leaky bucket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Race conditions on refresh.&lt;/strong&gt; When multiple requests fire while the token is expiring, they all try to refresh at once and only one wins.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The pattern below solves all three.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two-token model
&lt;/h2&gt;

&lt;p&gt;Issue two tokens from your API on login:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Access token:&lt;/strong&gt; short-lived (15 minutes), signed with &lt;code&gt;ACCESS_SECRET&lt;/code&gt;. Sent with every authenticated request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh token:&lt;/strong&gt; long-lived (30 days), signed with a different &lt;code&gt;REFRESH_SECRET&lt;/code&gt;. Used only to mint new access tokens.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The point of the split is blast radius. If your access token leaks (network sniffer, log line), the attacker has 15 minutes. The refresh token never leaves the keychain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to store each token
&lt;/h2&gt;

&lt;p&gt;The rule that took me two apps to internalize:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Access token → in-memory only.&lt;/strong&gt; A module-level variable. It dies with the JS bundle. That's fine. The app will refresh from disk if needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh token → &lt;code&gt;expo-secure-store&lt;/code&gt;.&lt;/strong&gt; Backed by iOS Keychain / Android Keystore. Never AsyncStorage.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// tokens.js&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;SecureStore&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-secure-store&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;accessToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getAccessToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;setAccessToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;accessToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;getRefreshToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;SecureStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItemAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;refresh_token&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;keychainAccessible&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SecureStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;WHEN_UNLOCKED_THIS_DEVICE_ONLY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;keychainService&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;com.yourcompany.yourapp.auth&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;setRefreshToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;SecureStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItemAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;refresh_token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;keychainAccessible&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SecureStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;WHEN_UNLOCKED_THIS_DEVICE_ONLY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;keychainService&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;com.yourcompany.yourapp.auth&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The two options on &lt;code&gt;SecureStore&lt;/code&gt; matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;WHEN_UNLOCKED_THIS_DEVICE_ONLY&lt;/code&gt;: the token is gone if the device is restored onto a new device. Prevents session cloning.&lt;/li&gt;
&lt;li&gt;Explicit &lt;code&gt;keychainService&lt;/code&gt;: survives TestFlight builds (more on this below).&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Wrap every authenticated fetch in a middleware that handles 401s transparently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// authFetch.js&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;getAccessToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;setAccessToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;getRefreshToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;setRefreshToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./tokens&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;API&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://your-api.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;refreshPromise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;performRefresh&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;rt&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;getRefreshToken&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;rt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NO_REFRESH_TOKEN&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;r&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;API&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/auth/refresh`&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;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&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="na"&gt;refresh_token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rt&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Refresh token invalid, user must log in again&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;setRefreshToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;setAccessToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;REFRESH_FAILED&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;access_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;refresh_token&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nf"&gt;setAccessToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;access_token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;setRefreshToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;refresh_token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// rotation: new RT every refresh&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;access_token&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;authFetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;doRequest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="o"&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;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;{}),&lt;/span&gt;
      &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;getAccessToken&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;response&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;doRequest&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;refreshPromise&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;refreshPromise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;performRefresh&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;refreshPromise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;refreshPromise&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// all in-flight 401'd requests await the same refresh&lt;/span&gt;
    &lt;span class="nx"&gt;response&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;doRequest&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;strong&gt;singleton &lt;code&gt;refreshPromise&lt;/code&gt;&lt;/strong&gt; is the piece most tutorials miss. Without it, five concurrent requests hitting an expired token cause five refresh calls. Four of them will 401, because the refresh-token rotation on the first call already invalidated the shared refresh token.&lt;/p&gt;

&lt;h2&gt;
  
  
  TestFlight and iOS keychain: the accessGroup trap
&lt;/h2&gt;

&lt;p&gt;Here's a bug I've watched three teams hit:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Users are getting logged out after every TestFlight update, but only on iOS."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The cause: iOS keychain items are scoped by &lt;code&gt;accessGroup&lt;/code&gt;. When TestFlight installs a new build, it &lt;em&gt;may&lt;/em&gt; preserve keychain access, but only if the &lt;code&gt;keychainService&lt;/code&gt; string is consistent across builds. If your code omits &lt;code&gt;keychainService&lt;/code&gt;, &lt;code&gt;expo-secure-store&lt;/code&gt; generates a random-ish default that changes across builds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; always pass an explicit &lt;code&gt;keychainService&lt;/code&gt; (as in the &lt;code&gt;tokens.js&lt;/code&gt; above). Once set, it survives TestFlight promotions, production releases, and version bumps.&lt;/p&gt;

&lt;p&gt;While you're at it, add a startup check that re-validates the token against your API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;bootstrapAuth&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getRefreshToken&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;authenticated&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="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Try any authenticated endpoint that returns quickly&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;r&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;authFetch&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;API&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/me`&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;user&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;authenticated&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;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;authenticated&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This way a truly-invalidated session (user was banned server-side, refresh token was revoked) doesn't sit stale in the app UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Anti-patterns
&lt;/h2&gt;

&lt;p&gt;Stop doing these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Storing tokens in AsyncStorage.&lt;/strong&gt; It's not encrypted. On Android it's SharedPreferences XML, readable with &lt;code&gt;adb&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bearer tokens in URL query strings.&lt;/strong&gt; They end up in server logs, analytics captures, and browser history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A single fetch wrapper per API call.&lt;/strong&gt; You'll forget one, and that endpoint will silently break auth. Wrap it once at the module level.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manual logout that only clears the access token.&lt;/strong&gt; If the refresh token is still in the keychain, the next &lt;code&gt;bootstrapAuth()&lt;/code&gt; re-authenticates the user.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refresh-token rotation without server-side invalidation.&lt;/strong&gt; If your API doesn't invalidate the old refresh token on rotation, an attacker who steals it can refresh indefinitely.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to ship first
&lt;/h2&gt;

&lt;p&gt;If you're starting from a fresh React Native app:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Set up the two-token model in your API. &lt;code&gt;djangorestframework-simplejwt&lt;/code&gt;, or &lt;code&gt;jose&lt;/code&gt; for Node, both handle rotation out of the box.&lt;/li&gt;
&lt;li&gt;Copy the &lt;code&gt;tokens.js&lt;/code&gt; and &lt;code&gt;authFetch.js&lt;/code&gt; above.&lt;/li&gt;
&lt;li&gt;Call &lt;code&gt;bootstrapAuth()&lt;/code&gt; in your root component's &lt;code&gt;useEffect(() =&amp;gt; {}, [])&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add a global 401 → redirect-to-login handler for the terminal case (refresh token invalid).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's roughly 200 lines of app code. It'll outlive whichever managed auth service you were considering, and the security posture is stricter than most of them. If you'd rather not wire it by hand, &lt;a href="https://rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=react-native-refresh-token-pattern" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; scaffolds Expo apps with this pattern preconfigured, though the pattern itself is straightforward enough that most teams should own it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related patterns worth reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Biometric-gated refresh:&lt;/strong&gt; use &lt;code&gt;expo-local-authentication&lt;/code&gt; to require Face ID before the refresh call. Great for financial and health apps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent token rotation on app-foreground:&lt;/strong&gt; refresh preemptively when the app comes back from background. Catches stale-token edge cases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encrypted-at-rest offline queue:&lt;/strong&gt; for apps that need to work offline and sync later, queue requests in encrypted storage until the token refreshes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Auth done right is invisible. Ship it once, ship it well, and don't touch it again.&lt;/p&gt;

&lt;p&gt;What's your current setup? Drop a comment with how you're handling refresh in production, especially if you've found a cleaner way to dedupe concurrent refresh calls.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>security</category>
      <category>mobile</category>
    </item>
    <item>
      <title>The 2026 React Native Performance Playbook (New Arch, FlashList, Reanimated 4)</title>
      <dc:creator>Mark F A</dc:creator>
      <pubDate>Mon, 10 Aug 2026 07:01:59 +0000</pubDate>
      <link>https://dev.to/mark_fa/the-2026-react-native-performance-playbook-new-arch-flashlist-reanimated-4-48g2</link>
      <guid>https://dev.to/mark_fa/the-2026-react-native-performance-playbook-new-arch-flashlist-reanimated-4-48g2</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Measure first. A before number and an after number in every PR beats every trick in this post.&lt;/li&gt;
&lt;li&gt;Turn on the New Architecture. Biggest single win available: ~40% faster cold start, 35 to 43% faster list rendering, ~25% lower memory.&lt;/li&gt;
&lt;li&gt;Confirm Hermes is on in your &lt;strong&gt;release&lt;/strong&gt; build, not just dev, and upload your source maps.&lt;/li&gt;
&lt;li&gt;Swap &lt;code&gt;FlatList&lt;/code&gt; for &lt;code&gt;FlashList&lt;/code&gt; past ~50 items.&lt;/li&gt;
&lt;li&gt;Every animation goes to Reanimated 4 worklets. No exceptions.&lt;/li&gt;
&lt;li&gt;Put a budget in CI with Flashlight so regressions break the build instead of your App Store rating.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most React Native apps do not need more code to feel fast. They need less of the wrong code, in the right places.&lt;/p&gt;

&lt;p&gt;The platform moved. New Architecture is default. Hermes is default. FlashList is stable. Reanimated 4 is stable. So the advice you memorized in 2022 (memoize everything, throw &lt;code&gt;shouldComponentUpdate&lt;/code&gt; at every list item, dread the bridge) is now either handled for you or actively counterproductive.&lt;/p&gt;

&lt;p&gt;This is the current playbook, ordered by impact. It assumes one thing: &lt;strong&gt;you measured the problem before you changed any code.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What "fast" actually means
&lt;/h2&gt;

&lt;p&gt;"It feels slow" is not a spec. On a mid-tier Android device (Pixel 6a, Samsung A34), a shippable app in 2026 hits:&lt;/p&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;Target&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Cold start (Android)&lt;/td&gt;
&lt;td&gt;Under 2.0s, tap to first interactive frame&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cold start (iPhone 13)&lt;/td&gt;
&lt;td&gt;Under 1.2s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sustained scroll&lt;/td&gt;
&lt;td&gt;58+ fps on 500+ item lists with images&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Interaction latency&lt;/td&gt;
&lt;td&gt;Under 100ms, touch to visible state change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JS heap&lt;/td&gt;
&lt;td&gt;Under 180MB, no monotonic growth over 10 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Install size&lt;/td&gt;
&lt;td&gt;Under 30MB base binary, App Store thin variant&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you cannot state your current numbers against these, that is the first fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Profile first, stop guessing
&lt;/h2&gt;

&lt;p&gt;React Native's performance surface spans three worlds: the JS thread, the UI/main thread, and native modules. A bug in one looks identical to a bug in another until you profile.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hermes Sampling Profiler.&lt;/strong&gt; Ships with RN, near-zero overhead, flame graphs open in Chrome DevTools or Perfetto. Start here every time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;React DevTools Profiler.&lt;/strong&gt; Commit-by-commit view of what rendered and why. The "Highlight updates when components render" toggle finds re-render storms in about ten seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flashlight.&lt;/strong&gt; CLI for FPS, CPU, memory and JS thread health during scripted runs. Puts real numbers on regressions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Perfetto (Android) / Instruments (iOS).&lt;/strong&gt; For anything crossing the native boundary: cold start, module init, layout, gestures.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expo's perf inspector.&lt;/strong&gt; FPS meter and JS heap sampler in the dev menu. Good enough for 80% of day-to-day work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rule that matters more than any tool: &lt;strong&gt;before number, after number, both in the PR description.&lt;/strong&gt; No numbers, no merge. This single policy will make your app faster than any optimization on this list.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Be on the New Architecture
&lt;/h2&gt;

&lt;p&gt;RN 0.76 made it the default. Expo SDK 52 followed. If you are still opted out you are leaving 30 to 40% of your cold start and roughly all of your bridge overhead on the table.&lt;/p&gt;

&lt;p&gt;Three pieces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JSI&lt;/strong&gt; replaces the async JSON bridge with direct synchronous calls. Serialize, queue, deserialize, return, deserialize is now a function pointer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fabric&lt;/strong&gt; is the new renderer. Concurrent-aware, layout on the UI thread, no more commit storms during scroll-plus-fetch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TurboModules&lt;/strong&gt; load lazily on first use. That alone can shave 200 to 400ms off cold start in apps with a lot of linked modules.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Enabling it is a flag:&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="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;app.json&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(Expo)&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;"expo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"newArchEnabled"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight properties"&gt;&lt;code&gt;&lt;span class="c"&gt;# gradle.properties (bare)
&lt;/span&gt;&lt;span class="py"&gt;newArchEnabled&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;
&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="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;ios/Podfile.properties.json&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(bare)&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;"newArchEnabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What breaks is third-party native modules that never got upgraded. Check each dependency's issue tracker before you flip it.&lt;/p&gt;

&lt;p&gt;Teams that finished the migration in 2025 consistently report cold start down ~40%, list rendering up ~35 to 43%, memory down ~25%, and frame rates going from the high 40s to a steady 58 to 59 fps. That is not incremental. It is the largest performance change RN has ever shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Hermes is default, so do not break it
&lt;/h2&gt;

&lt;p&gt;Hermes is the engine on both platforms now. The value is not just runtime speed, it is ahead-of-time bytecode compilation at build time. You ship precompiled bytecode instead of raw JS, which is why cold starts land 20 to 40% ahead of JSC.&lt;/p&gt;

&lt;p&gt;Three things to actually do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Confirm Hermes is on in release&lt;/strong&gt;, not just dev. Easy to miss when migrating off JSC. &lt;code&gt;hermesEnabled=true&lt;/code&gt; in &lt;code&gt;gradle.properties&lt;/code&gt;, &lt;code&gt;:hermes_enabled =&amp;gt; true&lt;/code&gt; in the Podfile, or the Expo equivalent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Precompile bytecode in CI&lt;/strong&gt; so it is baked at build time instead of first launch on device.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upload your source maps.&lt;/strong&gt; Hermes bytecode makes Sentry and Bugsnag stack traces unreadable without them. This is the footgun that bites teams in production, every time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You do not need to tune the engine. You just need to not accidentally disable it.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. FlashList for every list that matters
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;FlatList&lt;/code&gt; is fine for short lists. Past ~50 items, images, or variable row heights, use &lt;strong&gt;FlashList&lt;/strong&gt;. This is the highest-leverage optimization that requires you to write actual code.&lt;/p&gt;

&lt;p&gt;FlashList recycles cells instead of unmounting them and does not allocate a new view per item. On a Pixel 6a with 1,000 image-plus-text rows:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;FlatList&lt;/th&gt;
&lt;th&gt;FlashList&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Sustained FPS&lt;/td&gt;
&lt;td&gt;30 to 40, with freezes&lt;/td&gt;
&lt;td&gt;58 to 60&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cell handling&lt;/td&gt;
&lt;td&gt;Unmount and remount&lt;/td&gt;
&lt;td&gt;Recycled&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Views allocated&lt;/td&gt;
&lt;td&gt;One per item&lt;/td&gt;
&lt;td&gt;Pooled&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&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;FlashList&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="s2"&gt;@shopify/flash-list&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;Row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;memo&lt;/span&gt;&lt;span class="p"&gt;(({&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onPress&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt; &lt;span class="na"&gt;onPress&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;onPress&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;styles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;FastImage&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;thumb&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;styles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;thumb&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;styles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;));&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Feed&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;onSelect&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="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;FlashList&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;estimatedItemSize&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;88&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;keyExtractor&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;renderItem&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Row&lt;/span&gt; &lt;span class="na"&gt;item&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;onPress&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;onSelect&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two rules to actually get the speedup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Set &lt;code&gt;estimatedItemSize&lt;/code&gt;.&lt;/strong&gt; Off by 30% and you still get most of the win. Missing entirely and FlashList falls back to slow measurement passes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No dynamic per-item work inside &lt;code&gt;renderItem&lt;/code&gt; closures.&lt;/strong&gt; Memoize row components and their handlers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Variable heights you cannot estimate? Use the median. Do not overthink it.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Kill re-renders, but only the ones that matter
&lt;/h2&gt;

&lt;p&gt;Re-renders are the most over-optimized problem in React Native. Wrapping everything in &lt;code&gt;React.memo&lt;/code&gt; bloats your bundle and slows mounting. Profile first, memoize where the profiler shows a hot path.&lt;/p&gt;

&lt;p&gt;That said, four anti-patterns cause 80%+ of real re-render bugs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Inline object props.&lt;/strong&gt; New object identity every parent render, which invalidates every downstream &lt;code&gt;React.memo&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Bad&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Card&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// Good&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;styles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;StyleSheet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;card&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Card&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;styles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;card&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Inline handler props.&lt;/strong&gt; &lt;code&gt;&amp;lt;Button onPress={() =&amp;gt; doThing(id)} /&amp;gt;&lt;/code&gt; is a new function every render. &lt;code&gt;useCallback&lt;/code&gt;, or better, move the handler into a memoized child that owns the id.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Context provider sprawl.&lt;/strong&gt; Every value change re-renders every consumer. Split by update frequency: identity (rare), theme (rare), live data (constant). Do not put them in one provider.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Selector reference issues.&lt;/strong&gt; Redux/Zustand selectors returning fresh object references re-render every consumer even when values are identical.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Bad: new object every store update&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;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;avatar&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useStore&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&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="na"&gt;avatar&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;avatar&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;

&lt;span class="c1"&gt;// Good&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;useShallow&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="s2"&gt;zustand/react/shallow&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;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;avatar&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useStore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nf"&gt;useShallow&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&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="na"&gt;avatar&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;avatar&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;code&gt;why-did-you-render&lt;/code&gt; is still the fastest way to find these in dev.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Get animations off the JS thread
&lt;/h2&gt;

&lt;p&gt;An animation on the JS thread drops frames the moment JS is busy, which is always, because JS is where your business logic lives.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reanimated 4&lt;/strong&gt; for state, gesture, or timing driven animation. Worklets run on the UI thread as native functions. A 60fps spring stays at 60fps while the JS thread parses a 500KB JSON response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gesture Handler&lt;/strong&gt; for drags, swipes, pinches. &lt;code&gt;PanResponder&lt;/code&gt; is JS-thread-bound and will jank.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skia&lt;/strong&gt; for anything painterly: charts, custom drawing, complex transitions. Bypasses the React view tree, renders straight to a GPU canvas.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are writing &lt;code&gt;Animated.Value&lt;/code&gt; with &lt;code&gt;useNativeDriver: false&lt;/code&gt;, stop. Convert it, or find out why &lt;code&gt;useNativeDriver: true&lt;/code&gt; is not an option (usually it is a prop Reanimated supports and old Animated does not).&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Cold start, the number users actually notice
&lt;/h2&gt;

&lt;p&gt;Native init + bundle load + bundle execute + first render. Also the metric App Store reviewers judge you on. In order of impact:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;New Architecture on.&lt;/strong&gt; Biggest win.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hermes bytecode precompiled.&lt;/strong&gt; Second biggest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bundle size.&lt;/strong&gt; Every 100KB of JS costs 20 to 40ms of parse-plus-execute on mid-tier Android. Run &lt;code&gt;npx react-native-bundle-visualizer&lt;/code&gt; and delete what surprises you. &lt;code&gt;moment.js&lt;/code&gt;, full &lt;code&gt;lodash&lt;/code&gt;, and five date-picker libraries you forgot about are the usual suspects.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lazy-load routes.&lt;/strong&gt; &lt;code&gt;React.lazy&lt;/code&gt; + &lt;code&gt;Suspense&lt;/code&gt; at route boundaries. Your login screen does not need to parse the dashboard's 400KB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native module audit.&lt;/strong&gt; Every linked module runs init at startup. Removing the JS import does not unlink the native code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Font loading.&lt;/strong&gt; Preload only the weights on the first screen. &lt;code&gt;Font.loadAsync&lt;/code&gt; for 12 weights blocks first paint for hundreds of ms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Splash strategy.&lt;/strong&gt; Hold the native splash until the first interactive screen is ready. Cross-fade early and you get a flash of empty content.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Instrument with &lt;code&gt;AppRegistry.setWrapperComponentProvider&lt;/code&gt; plus a manual mark at first meaningful paint, log to analytics, and watch the median. Not the mean. The mean lies.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Memory, the silent killer
&lt;/h2&gt;

&lt;p&gt;Leaks rarely crash a React Native app. They just make it slower, and slower, until the OS kills it in the background and the user thinks "the app forgot me." Three sources cover nearly every leak in the wild:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Uncleaned listeners.&lt;/strong&gt; Every &lt;code&gt;addEventListener&lt;/code&gt;, &lt;code&gt;subscribe&lt;/code&gt;, and &lt;code&gt;AppState&lt;/code&gt; handler needs cleanup in the &lt;code&gt;useEffect&lt;/code&gt; return. exhaustive-deps will not catch these.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unbounded image cache.&lt;/strong&gt; &lt;code&gt;react-native-fast-image&lt;/code&gt; caches aggressively. Set a &lt;code&gt;maxMemoryPolicy&lt;/code&gt; or hold hundreds of MB of thumbnails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Uncleared timers.&lt;/strong&gt; &lt;code&gt;setInterval&lt;/code&gt; in a component that mounts and unmounts on navigation is the classic.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;AppState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;change&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onChange&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;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;poll&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;clearInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;onChange&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;poll&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Heap snapshot at 10 minutes, another at 30, compare. Delta not roughly flat means you have a leak. Instruments Allocations on iOS, Android Studio Memory Profiler on Android.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Network, the invisible half of "fast"
&lt;/h2&gt;

&lt;p&gt;Users do not distinguish "the app is slow" from "the network is slow." Hide the network from them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;TanStack Query&lt;/strong&gt; for every fetch with a cache key. Dedup, background refetch, stale-while-revalidate for free. The single library that most improved perceived RN performance in the last two years.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP/2 or HTTP/3.&lt;/strong&gt; On HTTP/1.1 you pay a full round trip per request. Cloudflare, Fastly, AWS ALB all default to HTTP/2 now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image CDN with responsive sizing.&lt;/strong&gt; Serve 400x400 to a phone, not 4000x4000.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Promise.all&lt;/code&gt;&lt;/strong&gt; for anything that does not depend on a previous result.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optimistic updates&lt;/strong&gt; for any action that succeeds 99% of the time. Users feel every millisecond of spinner.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  10. Ship a performance budget in CI
&lt;/h2&gt;

&lt;p&gt;All of the above is worthless if someone ships a regression next Tuesday.&lt;/p&gt;

&lt;p&gt;Flashlight runs a scripted E2E test on a real or emulated Android device, records CPU/FPS/memory, and fails the build when a metric exceeds budget. Point it at five flows (cold start, login, main list scroll, detail view, checkout), set budgets 10 to 20% above current numbers, and now the regression conversation happens in code review instead of in your App Store reviews six weeks later.&lt;/p&gt;

&lt;h2&gt;
  
  
  When NOT to optimize
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The profiler shows no hotspot.&lt;/strong&gt; You are not fixing anything, you are writing code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The user cannot perceive it.&lt;/strong&gt; Nobody notices 12ms to 8ms on a screen already at 60fps. They notice 30fps to 60fps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It removes a real feature.&lt;/strong&gt; Slower with the feature beats faster without it, unless the feature is optional.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where AI-generated code fits
&lt;/h2&gt;

&lt;p&gt;Here is the part that surprised me. Generating a React Native project with an AI builder like &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=react-native-performance-optimization-guide-2026" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; now gives you the New Architecture enabled, Hermes on, FlashList on meaningful lists, Reanimated wired up, and TanStack Query for data, because those are the current defaults and the model trained on the current stack. Steps 2, 3, 4 and 6 of this playbook come pre-done.&lt;/p&gt;

&lt;p&gt;What it cannot do is your measurement work. It does not know your product screen renders 400 image cards and needs a memoized row, or that your context provider grew three orders of magnitude too much state. You get the correct baseline for free. The profiling and tuning is still yours.&lt;/p&gt;

&lt;p&gt;So the fast path in 2026: generate the correct baseline, ship v1, measure real user performance, apply this playbook to whatever the profiler actually flags. Skip the six weeks of boilerplate and spend them on the parts of the app only you understand.&lt;/p&gt;




&lt;p&gt;What is the biggest perf win you have shipped this year? Drop it in the comments, especially the ones that surprised you after profiling. I want to know which of these ten actually moved your numbers and which did nothing.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>performance</category>
      <category>javascript</category>
    </item>
    <item>
      <title>How to Build a Pet Care App with React Native (2026)</title>
      <dc:creator>Mark F A</dc:creator>
      <pubDate>Fri, 31 Jul 2026 09:29:50 +0000</pubDate>
      <link>https://dev.to/rapidnative-ai/how-to-build-a-pet-care-app-with-react-native-2026-69i</link>
      <guid>https://dev.to/rapidnative-ai/how-to-build-a-pet-care-app-with-react-native-2026-69i</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pet care is a well-scoped React Native project: bounded MVP, cross-platform users, forgiving early adopters.&lt;/li&gt;
&lt;li&gt;Stack: Expo SDK 52+, TypeScript, Expo Router, Supabase, NativeWind, &lt;code&gt;expo-notifications&lt;/code&gt;, FlashList.&lt;/li&gt;
&lt;li&gt;Store &lt;code&gt;next_due_at&lt;/code&gt; on the vaccination row, not derived — vaccination schedules vary by jurisdiction.&lt;/li&gt;
&lt;li&gt;Store UTC in the DB, render local with &lt;code&gt;date-fns-tz&lt;/code&gt;, and reschedule reminders on &lt;code&gt;AppState.change&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Honest timeline: 4–6 weeks solo, most of it boilerplate.&lt;/li&gt;
&lt;/ul&gt;

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

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

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

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

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

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

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

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

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

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



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

&lt;/div&gt;



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



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

&lt;/div&gt;



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

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

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

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

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

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

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

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-notifications&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;scheduleNotificationAsync&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;'s &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;vaccination&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; is due`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Book a vet appointment to stay on schedule.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;petId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;vaccinationId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;vaccination&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;trigger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;date&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;nextDueAt&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two rules:&lt;/p&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

&lt;p&gt;If you've shipped a pet app — or any app where reminders are the core value — how did you handle timezone drift on scheduled notifications? Drop a comment, I'd like to hear what actually held up in production.&lt;/p&gt;

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