<?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>Your Supabase Migrations Should Survive Any Postgres. Here's How I Test That.</title>
      <dc:creator>Mark F A</dc:creator>
      <pubDate>Mon, 28 Sep 2026 14:59:24 +0000</pubDate>
      <link>https://dev.to/mark_fa/your-supabase-migrations-should-survive-any-postgres-heres-how-i-test-that-37b7</link>
      <guid>https://dev.to/mark_fa/your-supabase-migrations-should-survive-any-postgres-heres-how-i-test-that-37b7</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The only part of your local Supabase setup that should travel to production is &lt;code&gt;supabase/migrations/*.sql&lt;/code&gt;. Everything else is disposable.&lt;/li&gt;
&lt;li&gt;If a migration only works because you clicked something in a dashboard once, it's not a migration. It's a memory.&lt;/li&gt;
&lt;li&gt;I replay every migration against a bare Postgres 17 in CI, with a tiny &lt;code&gt;auth&lt;/code&gt; shim, on every PR. It catches ordering bugs, missing extensions, and edited history before they reach a real database.&lt;/li&gt;
&lt;li&gt;Once migrations are the contract, your local runtime becomes swappable. That's the real payoff.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most "Supabase local dev" posts are about the runtime: which containers, how much RAM, how fast it boots. This one is about the thing that outlives every runtime you'll ever use: the folder of SQL files.&lt;/p&gt;

&lt;h2&gt;
  
  
  The contract is the folder
&lt;/h2&gt;

&lt;p&gt;Here's the only layout that matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;supabase/
├── migrations/
│   ├── 20260901120000_create_projects.sql
│   ├── 20260905093000_projects_rls.sql
│   └── 20260912174500_add_project_members.sql
└── seed.sql          # local-only data, never runs in prod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Timestamps order the history. Each file runs once, in order, and gets recorded in a history table so it never runs again. That's the whole model, and it's the same model whether the files land on a laptop, a CI box, or hosted Supabase.&lt;/p&gt;

&lt;p&gt;You don't need any tooling to create one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;touch&lt;/span&gt; &lt;span class="s2"&gt;"supabase/migrations/&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; +%Y%m%d%H%M%S&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;_add_project_members.sql"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Five rules for migrations that travel
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Nothing exists unless a migration created it
&lt;/h3&gt;

&lt;p&gt;The classic drift: a column added through a dashboard, a policy tweaked in the UI, a function pasted into a SQL editor. It works on your database. A teammate clones the repo and it doesn't exist.&lt;/p&gt;

&lt;p&gt;If it isn't in &lt;code&gt;supabase/migrations&lt;/code&gt;, assume it doesn't exist.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Declare your extensions
&lt;/h3&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="n"&gt;extension&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="nv"&gt;"pgcrypto"&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="n"&gt;extensions&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hosted Supabase ships a lot of extensions pre-enabled. A fresh Postgres doesn't. Say what you depend on, in the migration that first needs it.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Tables and their RLS ship together
&lt;/h3&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="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;projects&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;owner_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;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;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;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="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="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;projects&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;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"owners read own projects"&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;projects&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;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;select&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;owner_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A table that exists for even one migration without RLS is a table that someone, someday, will query in that window. Same file, every time.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Never edit a migration that's already been applied
&lt;/h3&gt;

&lt;p&gt;Applied history is immutable. Want to change it? Write a new migration. Editing an old file means your laptop and production now disagree about what "migration 3" did, and nothing will tell you.&lt;/p&gt;

&lt;p&gt;A cheap guard for PRs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# scripts/no-edited-migrations.sh&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nv"&gt;changed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git diff &lt;span class="nt"&gt;--name-only&lt;/span&gt; &lt;span class="nt"&gt;--diff-filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;MR origin/main &lt;span class="nt"&gt;--&lt;/span&gt; supabase/migrations &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$changed&lt;/span&gt;&lt;span class="s2"&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;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Applied migrations were modified:"&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$changed&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Write a new migration instead."&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  5. Schema in migrations, data in seed
&lt;/h3&gt;

&lt;p&gt;Test users, demo rows, and fixtures go in &lt;code&gt;seed.sql&lt;/code&gt;. Mixing them into migrations means production eventually gets &lt;code&gt;alice@example.com&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The replay check: bare Postgres 17 + a tiny auth shim
&lt;/h2&gt;

&lt;p&gt;Here's the part I actually rely on. On every PR, CI spins up a vanilla Postgres 17 and replays every migration in order. If a file depends on something no migration created, it fails loudly.&lt;/p&gt;

&lt;p&gt;Supabase migrations reference &lt;code&gt;auth.users&lt;/code&gt;, &lt;code&gt;auth.uid()&lt;/code&gt;, and the &lt;code&gt;anon&lt;/code&gt; / &lt;code&gt;authenticated&lt;/code&gt; roles, which a bare Postgres doesn't have. So the check loads a minimal shim first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- scripts/auth-shim.sql&lt;/span&gt;
&lt;span class="c1"&gt;-- NOT Supabase Auth. Just enough surface for migrations to apply.&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;schema&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;auth&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;schema&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;extensions&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;begin&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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&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;rolname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'anon'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt; &lt;span class="n"&gt;nologin&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="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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&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;rolname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'authenticated'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt; &lt;span class="n"&gt;nologin&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="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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&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;rolname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'service_role'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt; &lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;service_role&lt;/span&gt; &lt;span class="n"&gt;nologin&lt;/span&gt; &lt;span class="n"&gt;bypassrls&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;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;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="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;email&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;raw_user_meta_data&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;jsonb&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;or&lt;/span&gt; &lt;span class="k"&gt;replace&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="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;returns&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;
&lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt; &lt;span class="k"&gt;stable&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;nullif&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current_setting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'request.jwt.claim.sub'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;true&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="n"&gt;uuid&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;And the replay script:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# scripts/replay-migrations.sh&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

psql &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;ON_ERROR_STOP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; scripts/auth-shim.sql

&lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;ls &lt;/span&gt;supabase/migrations/&lt;span class="k"&gt;*&lt;/span&gt;.sql | &lt;span class="nb"&gt;sort&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"→ &lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  psql &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;ON_ERROR_STOP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;--single-transaction&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done

&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"✅ All migrations replayed cleanly on a fresh Postgres"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wire it into GitHub Actions:&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="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;migrations&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replay&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&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;postgres&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:17&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
        &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5432:5432"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;-&lt;/span&gt;
          &lt;span class="s"&gt;--health-cmd pg_isready --health-interval 5s --health-retries 10&lt;/span&gt;
    &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;PGHOST&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;localhost&lt;/span&gt;
      &lt;span class="na"&gt;PGUSER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
      &lt;span class="na"&gt;PGPASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
      &lt;span class="na"&gt;PGDATABASE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;fetch-depth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash scripts/no-edited-migrations.sh&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash scripts/replay-migrations.sh&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What this catches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A migration that references a table created in a &lt;em&gt;later&lt;/em&gt; file (timestamp mistakes happen)&lt;/li&gt;
&lt;li&gt;A missing &lt;code&gt;create extension&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Objects that only ever existed in someone's dashboard&lt;/li&gt;
&lt;li&gt;Edited history&lt;/li&gt;
&lt;li&gt;Plain syntax errors, before a human reviews the PR&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What it doesn't catch: real auth behavior, JWT claims, or whether your policies return the right rows. The shim is scaffolding, not Supabase. Test policy behavior against a runtime that actually implements the API.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this makes your local runtime swappable
&lt;/h2&gt;

&lt;p&gt;Once the SQL folder is the contract, the local runtime is just "something that applies these files to real Postgres and serves the Supabase APIs on top." You can pick whichever one fits your machine today, and the choice stops being a commitment.&lt;/p&gt;

&lt;p&gt;For my own laptop I've been using &lt;a href="https://tinbase.dev/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=portable-supabase-migrations-local-postgres" rel="noopener noreferrer"&gt;tinbase&lt;/a&gt;, an open-source, Supabase-compatible runtime on real Postgres 17 that runs as a single process without Docker. It reads the same &lt;code&gt;supabase/migrations/*.sql&lt;/code&gt; files the Supabase CLI does and tracks them in the same way, so the folder above works unchanged:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx tinbase start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And app code doesn't change either. It's the regular client pointed at the local URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&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;@supabase/supabase-js&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://127.0.0.1:54321&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SUPABASE_ANON_KEY&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth being upfront: it's alpha software built for local dev and prototypes, not production. Which is exactly the point of this workflow. The runtime is disposable. The migrations aren't.&lt;/p&gt;

&lt;p&gt;When it's time to ship, the same files go to hosted Supabase with the CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;supabase &lt;span class="nb"&gt;link&lt;/span&gt; &lt;span class="nt"&gt;--project-ref&lt;/span&gt; &amp;lt;your-project-ref&amp;gt;
supabase db push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No rewrite, no export, no "which version of the schema is real."&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Migrations&lt;/strong&gt; are source code. Reviewed, immutable once applied, replayed in CI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Seed data&lt;/strong&gt; is a local convenience.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The local runtime&lt;/strong&gt; is a tool you can swap on a Tuesday afternoon without anyone noticing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If swapping your local runtime &lt;em&gt;would&lt;/em&gt; break something, that's not a runtime problem. It's a migration that isn't telling the whole truth.&lt;/p&gt;

&lt;p&gt;What's the worst schema drift bug you've hit between local and production? Dashboard edits, edited migrations, something weirder? Drop it in the comments. 👇&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>supabase</category>
      <category>database</category>
      <category>devops</category>
    </item>
    <item>
      <title>The Bugs That Only Exist Because Your Local DB Isn't Real Postgres</title>
      <dc:creator>Mark F A</dc:creator>
      <pubDate>Wed, 23 Sep 2026 11:04:57 +0000</pubDate>
      <link>https://dev.to/mark_fa/the-bugs-that-only-exist-because-your-local-db-isnt-real-postgres-4547</link>
      <guid>https://dev.to/mark_fa/the-bugs-that-only-exist-because-your-local-db-isnt-real-postgres-4547</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If your local dev database is an emulated or WASM Postgres and you deploy to real Postgres, you're testing against a different engine than you ship on.&lt;/li&gt;
&lt;li&gt;The divergences that bite: single-connection tools hide every concurrency bug, WASM builds ship only some extensions, and emulated engines approximate RLS and Postgres-specific SQL instead of running it.&lt;/li&gt;
&lt;li&gt;These bugs are invisible locally by definition. They surface in staging or prod, which is the worst place to find them.&lt;/li&gt;
&lt;li&gt;The fix isn't Docker's 12-container stack. It's running &lt;em&gt;actual&lt;/em&gt; Postgres locally, cheaply.&lt;/li&gt;
&lt;li&gt;Match the local engine to the job: embedded/local-first → WASM is perfect; "does this behave like prod" → you need real Postgres.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You wrote the query, it passed locally, tests were green, you shipped. Then prod throws a deadlock, or a &lt;code&gt;CREATE EXTENSION&lt;/code&gt; fails, or a row-level-security policy lets through a row it shouldn't. None of it reproduced on your machine.&lt;/p&gt;

&lt;p&gt;Here's the uncomfortable reason: your local "Postgres" wasn't Postgres. It was an emulation, a WASM build, or an in-memory reimplementation. Fast and convenient, and subtly different from the engine you deploy on. Those differences are a whole class of bugs that are &lt;em&gt;structurally invisible&lt;/em&gt; in local dev.&lt;/p&gt;

&lt;p&gt;Let me name the three that cost the most time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Divergence 1: single-connection tools hide every concurrency bug
&lt;/h2&gt;

&lt;p&gt;This is the big one, and it's structural, not a config you can fix.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://pglite.dev/" rel="noopener noreferrer"&gt;pglite&lt;/a&gt; (Postgres compiled to WASM, under 3 MB gzipped) is explicitly &lt;strong&gt;single-user, single-connection&lt;/strong&gt;. That's not a knock. It's inherent to the design: Emscripten can't fork processes, so pglite runs Postgres in single-user mode. Brilliant for embedding a SQL engine in the browser or a Node process. But it means that locally, there is only ever one connection.&lt;/p&gt;

&lt;p&gt;Which means the entire category of concurrency bugs cannot appear:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deadlocks between transactions that grab locks in different orders.&lt;/li&gt;
&lt;li&gt;Lost updates from two writers racing on the same row.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SELECT ... FOR UPDATE SKIP LOCKED&lt;/code&gt; job-queue logic that only reveals its bug under contention.&lt;/li&gt;
&lt;li&gt;Serialization failures under &lt;code&gt;SERIALIZABLE&lt;/code&gt; isolation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You can write a broken job worker, run it against a single-connection local DB, watch every test pass, and ship a queue that double-processes rows the moment two workers run in prod. The local engine never had a second connection to expose it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- A classic worker pattern. Correct behavior depends entirely on&lt;/span&gt;
&lt;span class="c1"&gt;-- what happens when TWO workers run this at the same time.&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'pending'&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;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;SKIP&lt;/span&gt; &lt;span class="n"&gt;LOCKED&lt;/span&gt;   &lt;span class="c1"&gt;-- silently meaningless with one connection&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- ... process, then:&lt;/span&gt;
&lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;jobs&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'done'&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a single-connection engine, &lt;code&gt;SKIP LOCKED&lt;/code&gt; never has anything to skip. The query works, so you assume the logic works. It doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Divergence 2: the extension is in the docs, not in the build
&lt;/h2&gt;

&lt;p&gt;Real Postgres has a huge extension ecosystem. WASM and emulated builds ship a curated subset, because each extension has to be compiled in or emulated.&lt;/p&gt;

&lt;p&gt;pglite includes some (pgvector, PostGIS) but you have to verify anything else is in the WASM build before you rely on it. Single-binary tools make different calls too: some Postgres extensions get skipped or emulated rather than truly run. So this passes locally and fails in CI or prod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Fine on hosted Postgres if the extension is available there.&lt;/span&gt;
&lt;span class="c1"&gt;-- Locally: works, silently no-ops, or errors, depending on your engine.&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&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;pg_trgm&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;idx_name_trgm&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="n"&gt;gin_trgm_ops&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Same story for uuid-ossp, pg_cron, pg_net, http, hypopg...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your search relies on &lt;code&gt;pg_trgm&lt;/code&gt;, your scheduled jobs on &lt;code&gt;pg_cron&lt;/code&gt;, or your vector search on a specific &lt;code&gt;pgvector&lt;/code&gt; version, "it worked locally" tells you nothing about whether it works where you deploy. You need the local engine to either run the real extension or fail loudly the same way prod would.&lt;/p&gt;

&lt;h2&gt;
  
  
  Divergence 3: RLS and Postgres-specific SQL get approximated
&lt;/h2&gt;

&lt;p&gt;Row-level security is where "approximately Postgres" gets dangerous, because the failure mode is a &lt;strong&gt;security&lt;/strong&gt; failure, not a crash.&lt;/p&gt;

&lt;p&gt;An emulated engine that reimplements Postgres in JavaScript has to reimplement RLS policy evaluation, &lt;code&gt;auth.uid()&lt;/code&gt;, &lt;code&gt;SECURITY DEFINER&lt;/code&gt; semantics, and the exact order policies combine. Get any of it slightly wrong and a policy that blocks a row locally lets it through in prod, or vice versa. The same applies to the long tail of Postgres-specific behavior: &lt;code&gt;jsonb&lt;/code&gt; operator edge cases, generated columns, trigger firing order, type coercion, and &lt;code&gt;NULL&lt;/code&gt; handling in composite keys.&lt;/p&gt;

&lt;p&gt;The only way to actually test an RLS policy is to run it on the same engine that will enforce it in production. Approximation is not a test.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real fix: actual Postgres, locally, without the Docker tax
&lt;/h2&gt;

&lt;p&gt;The historical reason people reached for emulated engines is that the "correct" alternative was heavy. Supabase local dev is a 12-container Docker stack that runs around 2.3 GB on disk and over a gigabyte of RAM under load. On a 16 GB laptop, on a train, that tax is real, so people traded fidelity for lightness and inherited the divergence bugs above.&lt;/p&gt;

&lt;p&gt;That trade isn't necessary anymore. &lt;a href="https://tinbase.dev/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=local-db-isnt-real-postgres-fidelity-gap" rel="noopener noreferrer"&gt;Tinbase&lt;/a&gt; runs a Supabase-compatible backend on &lt;strong&gt;real Postgres 17&lt;/strong&gt; as a single process, no Docker. Because it's the actual Postgres engine, RLS, &lt;code&gt;auth.uid()&lt;/code&gt;, &lt;code&gt;jsonb&lt;/code&gt;, triggers, and foreign keys behave the way they do on hosted Supabase, and the official &lt;code&gt;supabase-js&lt;/code&gt; SDK works unchanged. The migration is a connection-string swap:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before: Docker Supabase local (12 containers, ~2.3 GB)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:54321&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;anonKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// After: Tinbase (one process, real Postgres 17)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost:4000&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;anonKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// same SDK, same anon key format, zero app-code refactor&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx tinbase start
&lt;span class="c"&gt;# boots in a couple of seconds; reads your supabase/migrations/*.sql&lt;/span&gt;
&lt;span class="c"&gt;# and seed.sql the same way the Supabase CLI does&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's open source (MIT) and built by Sanket Sahu at Shaper Studio. It's honest about its edges too: a few extensions it can't compile in (&lt;code&gt;pg_cron&lt;/code&gt;, &lt;code&gt;pg_net&lt;/code&gt;, &lt;code&gt;http&lt;/code&gt;, &lt;code&gt;hypopg&lt;/code&gt;) are skipped or emulated rather than silently faked, which is exactly the kind of loud divergence you &lt;em&gt;want&lt;/em&gt; to see locally instead of discovering in prod.&lt;/p&gt;

&lt;h2&gt;
  
  
  So which engine when?
&lt;/h2&gt;

&lt;p&gt;This isn't "emulated bad, real good." It's matching the tool to the question:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Local-first / embedded / in-browser SQL:&lt;/strong&gt; pglite. Being WASM and single-connection is the feature, not a bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A throwaway unit test that just needs SQL to parse:&lt;/strong&gt; an in-memory engine is fine and fast.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Does this behave like production?"&lt;/strong&gt; You need real Postgres locally, whether that's Tinbase's single binary, a native install, or Docker if you're already invested in it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mistake isn't using pglite or an in-memory engine. It's using one to answer a question it structurally cannot answer, and calling a green test suite proof.&lt;/p&gt;

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

&lt;p&gt;What's the worst "worked locally, broke in prod" database bug you've shipped? I'm betting at least one of you has a &lt;code&gt;SKIP LOCKED&lt;/code&gt; story. Drop it in the comments, along with what your current local setup is.****&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>supabase</category>
      <category>database</category>
      <category>webdev</category>
    </item>
    <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>
  </channel>
</rss>
