<?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: Lucas Martin</title>
    <description>The latest articles on DEV Community by Lucas Martin (@lucas_martin_8cb158b9a81b).</description>
    <link>https://dev.to/lucas_martin_8cb158b9a81b</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%2F4003983%2F363e7f88-9f64-46f6-b3c1-4a5370f4205b.png</url>
      <title>DEV Community: Lucas Martin</title>
      <link>https://dev.to/lucas_martin_8cb158b9a81b</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lucas_martin_8cb158b9a81b"/>
    <language>en</language>
    <item>
      <title>React Native New Architecture in 2026: What Developers Need to Know</title>
      <dc:creator>Lucas Martin</dc:creator>
      <pubDate>Mon, 12 Oct 2026 09:14:04 +0000</pubDate>
      <link>https://dev.to/lucas_martin_8cb158b9a81b/react-native-new-architecture-in-2026-what-developers-need-to-know-4mgo</link>
      <guid>https://dev.to/lucas_martin_8cb158b9a81b/react-native-new-architecture-in-2026-what-developers-need-to-know-4mgo</guid>
      <description>&lt;p&gt;For years, "should we enable the New Architecture?" was a real question in React Native projects. In 2026 it isn't. The New Architecture is the only architecture React Native runs, the legacy code is being deleted release by release, and the JavaScript engine underneath it has been upgraded too.&lt;/p&gt;

&lt;p&gt;If you're starting an app, you get all of this by default. If you're maintaining one that's a few versions behind, the gap is getting harder to cross the longer you wait. This post covers what the New Architecture actually is, how we got here, what it changes in your day-to-day code, and a practical checklist for upgrading.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "the New Architecture" actually means
&lt;/h2&gt;

&lt;p&gt;It's four pieces that replaced the old asynchronous bridge:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;What it replaced&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;JSI&lt;/strong&gt; (JavaScript Interface)&lt;/td&gt;
&lt;td&gt;The JSON-serialized bridge&lt;/td&gt;
&lt;td&gt;Lets JavaScript hold references to C++ objects and call them directly, without serializing messages&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Fabric&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The old UI manager&lt;/td&gt;
&lt;td&gt;The new renderer: builds the UI tree in C++, supports synchronous layout and React's concurrent features&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;TurboModules&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Legacy native modules&lt;/td&gt;
&lt;td&gt;Native modules loaded lazily and called through JSI, synchronously when the method is sync&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Codegen&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Hand-written glue code&lt;/td&gt;
&lt;td&gt;Generates the typed C++ interface between your TypeScript specs and native code at build time&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Together they make React Native &lt;strong&gt;bridgeless&lt;/strong&gt;: there's no message queue between JavaScript and native any more. Calls go straight through JSI.&lt;/p&gt;

&lt;p&gt;The practical effects you'll notice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Layout can be measured synchronously.&lt;/strong&gt; &lt;code&gt;useLayoutEffect&lt;/code&gt; behaves like it does on the web, so tooltips and popovers no longer flash in the wrong position for a frame.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concurrent React works.&lt;/strong&gt; Features like &lt;code&gt;startTransition&lt;/code&gt; and Suspense behave as they do in React DOM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native modules start lazily,&lt;/strong&gt; which helps startup time in apps with many modules.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How we got here
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Release&lt;/th&gt;
&lt;th&gt;What changed&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.76 (2024)&lt;/td&gt;
&lt;td&gt;New Architecture enabled by default for new projects&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.82&lt;/td&gt;
&lt;td&gt;New Architecture becomes the only runtime option; the opt-out flags are ignored&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.83&lt;/td&gt;
&lt;td&gt;Experimental flag to strip legacy architecture code on iOS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.84 (Feb 2026)&lt;/td&gt;
&lt;td&gt;Hermes V1 becomes the default engine; legacy iOS code removed by default; precompiled iOS binaries by default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.86 (Jun 2026)&lt;/td&gt;
&lt;td&gt;More legacy APIs deprecated as part of the removal; repo moves to the &lt;code&gt;react&lt;/code&gt; GitHub organization&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few details from the official release notes are worth knowing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hermes V1 is the default as of 0.84.&lt;/strong&gt; If you were already on Hermes, which has been the default since 0.70, you get V1 automatically with no configuration changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Legacy code is actually being deleted, not just disabled.&lt;/strong&gt; 0.84 continued removing Legacy Architecture code from both iOS and Android, building on 0.82 making the New Architecture the only runtime. On Android that release removed a list of old bridge classes outright.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The compatibility shim is still there.&lt;/strong&gt; The release notes are explicit that apps already on the New Architecture shouldn't break, because the Interop Layer code required for compatibility remains in place. That's what lets many older libraries keep working, for now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;iOS builds got faster.&lt;/strong&gt; 0.84 ships precompiled iOS binaries by default, which the team says significantly reduces build times. They're downloaded automatically during &lt;code&gt;pod install&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;0.86 kept the momentum, quietly.&lt;/strong&gt; It has no user-facing breaking changes, deprecates &lt;code&gt;ViewUtil.getUIManagerType&lt;/code&gt; as part of the legacy removal, and moves 0.83 to unsupported.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this changes in your code
&lt;/h2&gt;

&lt;p&gt;For most app code written in plain React and TypeScript, almost nothing. The differences show up at the edges.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Native module errors are synchronous now (for sync methods)
&lt;/h3&gt;

&lt;p&gt;With the bridge, a native exception often got swallowed somewhere in the queue. With TurboModules, a &lt;strong&gt;synchronous&lt;/strong&gt; native method that throws unwinds straight into your JavaScript call site. Wrap sync calls that depend on user input or device state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;readSetting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;MySettingsModule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// sync TurboModule method&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&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;Setting unavailable&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&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="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;Async methods still return promises, so existing &lt;code&gt;await&lt;/code&gt; and &lt;code&gt;.catch()&lt;/code&gt; handling keeps working.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Codegen mismatches fail loudly
&lt;/h3&gt;

&lt;p&gt;If your TypeScript spec says a prop is a &lt;code&gt;string&lt;/code&gt; and the native implementation reads it as a number, you no longer get a silent no-op. You get a native crash at render. The fix is usually to regenerate and rebuild, but it's worth knowing that a hard crash on first render of a custom native component often points here.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Old timing hacks can now cause bugs
&lt;/h3&gt;

&lt;p&gt;Code that wrapped layout reads in &lt;code&gt;setTimeout(…, 0)&lt;/code&gt; or &lt;code&gt;requestAnimationFrame&lt;/code&gt; to wait for the bridge often isn't needed any more, and occasionally causes a visible flicker now that layout is synchronous. When you touch that code, try the straightforward version first.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Your debugger changed
&lt;/h3&gt;

&lt;p&gt;React Native DevTools is the debugger: press &lt;code&gt;j&lt;/code&gt; in the Metro terminal and a Chrome DevTools-based window attaches to Hermes on your device. Remote JS debugging (running your code in Chrome's V8) is gone, which also removes a whole class of "works only when the debugger is attached" bugs. If your Metro terminal went quiet after an upgrade, your &lt;code&gt;console.log&lt;/code&gt; output moved to the DevTools console.&lt;/p&gt;

&lt;h2&gt;
  
  
  Libraries: the real migration work
&lt;/h2&gt;

&lt;p&gt;Your own code is rarely the blocker. Dependencies are.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check every native dependency&lt;/strong&gt; against the React Native Directory (reactnative.directory), which marks New Architecture support for each library. For each one, you'll land in one of three buckets:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Supports the New Architecture.&lt;/strong&gt; Upgrade to the current version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Works through the Interop Layer.&lt;/strong&gt; It runs, but it's living on borrowed time as legacy code is removed. Look for an alternative or an upstream fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unmaintained, doesn't work.&lt;/strong&gt; Replace it. In 2026 there's almost always a maintained alternative, often an Expo module.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Common categories to check first: navigation, gesture and animation libraries, maps, camera, video, in-app purchases, and anything with a custom native UI component.&lt;/p&gt;

&lt;h2&gt;
  
  
  An upgrade checklist
&lt;/h2&gt;

&lt;p&gt;If you're on a version older than 0.82, this is the order that usually goes smoothest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] &lt;strong&gt;Audit native dependencies&lt;/strong&gt; against the React Native Directory and upgrade or replace the ones without New Architecture support&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Upgrade React Native in steps,&lt;/strong&gt; using the community Upgrade Helper to diff native files between versions, rather than jumping several majors at once&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;If you use Expo,&lt;/strong&gt; upgrade one SDK at a time and run &lt;code&gt;npx expo-doctor&lt;/code&gt; and &lt;code&gt;npx expo install --check&lt;/code&gt; after each step&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Check your Node version&lt;/strong&gt; before upgrading; 0.84 requires Node.js 22.11 or later&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Rebuild native projects from scratch&lt;/strong&gt; (&lt;code&gt;npx expo prebuild --clean&lt;/code&gt;, or clean Pods and Gradle caches) instead of trusting cached builds&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Test custom native modules and components&lt;/strong&gt; first; that's where Codegen and threading issues show up&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Remove &lt;code&gt;setTimeout&lt;/code&gt; layout workarounds&lt;/strong&gt; you added for the bridge, and retest those screens&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Wrap sync TurboModule calls&lt;/strong&gt; that can fail in &lt;code&gt;try/catch&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Run release builds on real devices,&lt;/strong&gt; not just debug builds in a simulator; many New Architecture issues only appear in release mode&lt;/li&gt;
&lt;li&gt;[ ] &lt;strong&gt;Watch crash reporting closely&lt;/strong&gt; for the first release after the upgrade, with source maps uploaded so stack traces are readable&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Should you upgrade now?
&lt;/h2&gt;

&lt;p&gt;If you're on 0.82 or later, you're already on the New Architecture. The remaining upgrades are mostly smooth, and staying current keeps you inside the supported window: each release moves an older one to unsupported.&lt;/p&gt;

&lt;p&gt;If you're still on an older version, the honest answer is that it doesn't get easier. Every release removes more legacy code, and every month more libraries drop support for the old architecture. Start with the dependency audit. That alone tells you whether the upgrade is a week of work or a quarter.&lt;/p&gt;




&lt;p&gt;Shipping an RN app in 2026 means shipping on Fabric and bridgeless by default. If you want to see what that generated code looks like without wiring any of it up yourself, &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=react-native-new-architecture-2026" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; generates RN + Expo projects targeting the current stack from a prompt.&lt;/p&gt;

&lt;p&gt;If you've migrated a real app to 0.82, I'd be curious what broke for you. Drop it in the comments.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>mobile</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>EAS Submit in 2026: What It Automates, What It Doesn't, and the Release Pipeline That Covers Both</title>
      <dc:creator>Lucas Martin</dc:creator>
      <pubDate>Thu, 08 Oct 2026 14:59:49 +0000</pubDate>
      <link>https://dev.to/lucas_martin_8cb158b9a81b/eas-submit-in-2026-what-it-automates-what-it-doesnt-and-the-release-pipeline-that-covers-both-1m2d</link>
      <guid>https://dev.to/lucas_martin_8cb158b9a81b/eas-submit-in-2026-what-it-automates-what-it-doesnt-and-the-release-pipeline-that-covers-both-1m2d</guid>
      <description>&lt;p&gt;&lt;code&gt;eas submit&lt;/code&gt; is one of the best things in the Expo toolchain. One command takes a production build and uploads it to App Store Connect or Google Play, from macOS, Linux, or Windows. No Xcode, no Transporter, no Play Console upload dialog.&lt;/p&gt;

&lt;p&gt;It's also the source of a very common misunderstanding: "I ran &lt;code&gt;eas submit&lt;/code&gt;, so my app is submitted." Not quite. EAS Submit &lt;em&gt;uploads&lt;/em&gt; your build. Getting it in front of users still involves a handful of steps that live in the store consoles, and those are where first launches stall.&lt;/p&gt;

&lt;p&gt;This post covers both halves: how to set up EAS Submit properly for iOS and Android (including a recent change most older tutorials get wrong), and a clear map of what it doesn't do, so you can plan for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What EAS Submit does
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;iOS:&lt;/strong&gt; uploads your &lt;code&gt;.ipa&lt;/code&gt; to App Store Connect. The build appears in TestFlight after processing, usually 10 to 15 minutes. You don't need a Mac.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Android:&lt;/strong&gt; uploads your &lt;code&gt;.aab&lt;/code&gt; to a Google Play track (internal by default) and can roll it out, leave it as a draft, or publish to production, depending on your submit profile.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automation:&lt;/strong&gt; runs the same way locally, in EAS Workflows, or in any CI with an &lt;code&gt;EXPO_TOKEN&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Setting up iOS
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Bundle identifier and a production build
&lt;/h3&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;"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.yourcompany.yourapp"&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;eas build &lt;span class="nt"&gt;--platform&lt;/span&gt; ios &lt;span class="nt"&gt;--profile&lt;/span&gt; production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. A submit profile with your App Store Connect app ID
&lt;/h3&gt;

&lt;p&gt;Create the app record in App Store Connect first. Then add its ID to &lt;code&gt;eas.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;"submit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"ios"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"ascAppId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1234567890"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You'll find the ID in App Store Connect → your app → &lt;strong&gt;App Information&lt;/strong&gt; → &lt;strong&gt;Apple ID&lt;/strong&gt; (a number, not your email).&lt;/p&gt;

&lt;h3&gt;
  
  
  3. An App Store Connect API key (for anything automated)
&lt;/h3&gt;

&lt;p&gt;Interactive &lt;code&gt;eas submit&lt;/code&gt; can prompt for your Apple ID. CI and workflows can't, so set up an API key once:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;eas credentials &lt;span class="nt"&gt;--platform&lt;/span&gt; ios
&lt;span class="c"&gt;# production → log in → App Store Connect: Manage your API Key&lt;/span&gt;
&lt;span class="c"&gt;#            → Set up your project to use an API Key for EAS Submit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you prefer to manage the key yourself, set &lt;code&gt;ascApiKeyPath&lt;/code&gt;, &lt;code&gt;ascApiKeyIssuerId&lt;/code&gt;, and &lt;code&gt;ascApiKeyId&lt;/code&gt; in the submit profile instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Submit
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;eas submit &lt;span class="nt"&gt;--platform&lt;/span&gt; ios &lt;span class="nt"&gt;--profile&lt;/span&gt; production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Setting up Android
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Package name and an &lt;code&gt;.aab&lt;/code&gt;
&lt;/h3&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;"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.yourcompany.yourapp"&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;eas build &lt;span class="nt"&gt;--platform&lt;/span&gt; android &lt;span class="nt"&gt;--profile&lt;/span&gt; production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default production profile produces an Android App Bundle (&lt;code&gt;.aab&lt;/code&gt;), which Google Play requires. An &lt;code&gt;.apk&lt;/code&gt; profile is useful for installing on devices but can't be submitted.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Create the app in Play Console
&lt;/h3&gt;

&lt;p&gt;EAS can't create the app record. Click &lt;strong&gt;Create app&lt;/strong&gt; in Play Console and choose the package name.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. A Google Service Account key
&lt;/h3&gt;

&lt;p&gt;EAS needs a service account key to upload on your behalf. Create one (Expo links a step-by-step guide from its docs), then upload it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;eas credentials &lt;span class="nt"&gt;--platform&lt;/span&gt; android
&lt;span class="c"&gt;# production → Google Service Account → Upload a Google Service Account Key&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. A submit profile
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"submit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"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;"track"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"internal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"releaseStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"draft"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;track&lt;/code&gt;&lt;/strong&gt; picks where the build lands: &lt;code&gt;internal&lt;/code&gt;, &lt;code&gt;alpha&lt;/code&gt; (closed testing), &lt;code&gt;beta&lt;/code&gt; (open testing), or &lt;code&gt;production&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;releaseStatus: "draft"&lt;/code&gt;&lt;/strong&gt; uploads without rolling out, so you can review and release in Play Console.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. Submit
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;eas submit &lt;span class="nt"&gt;--platform&lt;/span&gt; android &lt;span class="nt"&gt;--profile&lt;/span&gt; production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The change older tutorials get wrong
&lt;/h3&gt;

&lt;p&gt;For years, every Expo guide said the same thing: &lt;em&gt;you must upload your first Android build manually in Play Console before EAS Submit will work.&lt;/em&gt; Expo's current docs say otherwise. On a first submission, the default &lt;code&gt;eas submit&lt;/code&gt; works out of the box and creates the app's first release on the internal testing track, as long as the app exists in Play Console and EAS has your service account key.&lt;/p&gt;

&lt;p&gt;The app stays in draft status in Play Console until you finish the store listing and setup tasks. Which brings us to the other half.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build and submit in one step
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;eas build &lt;span class="nt"&gt;--platform&lt;/span&gt; all &lt;span class="nt"&gt;--profile&lt;/span&gt; production &lt;span class="nt"&gt;--auto-submit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--auto-submit&lt;/code&gt; hands each finished build to EAS Submit using the submit profile with the same name.&lt;/p&gt;

&lt;h2&gt;
  
  
  Automate it with EAS Workflows
&lt;/h2&gt;

&lt;p&gt;Workflows are YAML files in &lt;code&gt;.eas/workflows/&lt;/code&gt;. This one builds both platforms on every push to &lt;code&gt;main&lt;/code&gt;, sends iOS to TestFlight, and uploads Android to your configured track:&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;# .eas/workflows/release.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Release&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;main'&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;build_ios&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build iOS&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
    &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;platform&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ios&lt;/span&gt;
      &lt;span class="na"&gt;profile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;

  &lt;span class="na"&gt;build_android&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Build Android&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build&lt;/span&gt;
    &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;platform&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;android&lt;/span&gt;
      &lt;span class="na"&gt;profile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;

  &lt;span class="na"&gt;testflight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Send to TestFlight&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;build_ios&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;testflight&lt;/span&gt;
    &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;build_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ needs.build_ios.outputs.build_id }}&lt;/span&gt;

  &lt;span class="na"&gt;submit_android&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Submit to Google Play&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;build_android&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;submit&lt;/span&gt;
    &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;profile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;production&lt;/span&gt;
      &lt;span class="na"&gt;build_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ needs.build_android.outputs.build_id }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it manually the first time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;eas workflow:run release.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;testflight&lt;/code&gt; job can also share the build with internal and external tester groups. If you'd rather use GitHub Actions or another CI, run &lt;code&gt;eas submit&lt;/code&gt; there with an &lt;code&gt;EXPO_TOKEN&lt;/code&gt; set.&lt;/p&gt;

&lt;h2&gt;
  
  
  What EAS Submit doesn't do
&lt;/h2&gt;

&lt;p&gt;Here's the part that surprises people. After a successful &lt;code&gt;eas submit&lt;/code&gt;, these steps still stand between your build and the public:&lt;/p&gt;

&lt;h3&gt;
  
  
  On iOS
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Submit the build &lt;strong&gt;for App Review&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;App Store Connect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Store listing: name, subtitle, description, keywords, screenshots&lt;/td&gt;
&lt;td&gt;App Store Connect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App Privacy details (the "nutrition label")&lt;/td&gt;
&lt;td&gt;App Store Connect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Age rating questionnaire&lt;/td&gt;
&lt;td&gt;App Store Connect&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Demo account for the reviewer, if login is required&lt;/td&gt;
&lt;td&gt;App Review Information&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Beta App Review for &lt;strong&gt;external&lt;/strong&gt; TestFlight testers (first build)&lt;/td&gt;
&lt;td&gt;Automatic, but it's a queue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Responding to rejections&lt;/td&gt;
&lt;td&gt;Resolution Center&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;EAS gets the binary into TestFlight. Sending it to App Review is still a step you take in App Store Connect.&lt;/p&gt;

&lt;h3&gt;
  
  
  On Android
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Store listing, screenshots, feature graphic&lt;/td&gt;
&lt;td&gt;Play Console&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data Safety form&lt;/td&gt;
&lt;td&gt;Play Console&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content rating questionnaire&lt;/td&gt;
&lt;td&gt;Play Console&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Target audience, ads declaration, app access&lt;/td&gt;
&lt;td&gt;Play Console&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Account deletion web link (if users can sign up)&lt;/td&gt;
&lt;td&gt;Data Safety form&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;12 testers for 14 days&lt;/strong&gt; in closed testing (new personal accounts)&lt;/td&gt;
&lt;td&gt;Play Console + your testers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Applying for production access&lt;/td&gt;
&lt;td&gt;Play Console&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Promoting a release to production&lt;/td&gt;
&lt;td&gt;Play Console, or &lt;code&gt;track: "production"&lt;/code&gt; once allowed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The closed-testing requirement is the long one: new personal developer accounts must keep at least 12 testers opted in for 14 continuous days before production opens up. EAS can upload to the closed-testing track (&lt;code&gt;track: "alpha"&lt;/code&gt;) on every build, but the 14 days are calendar days.&lt;/p&gt;

&lt;h2&gt;
  
  
  A release pipeline that covers both halves
&lt;/h2&gt;

&lt;p&gt;Put together, a realistic first launch looks like this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Day 1:&lt;/strong&gt; create both app records; set up the ASC API key and Google service account key; add submit profiles.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 1:&lt;/strong&gt; first &lt;code&gt;eas build --auto-submit&lt;/code&gt;. iOS lands in TestFlight; Android lands on the internal track.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Days 1–3:&lt;/strong&gt; recruit Android testers; switch the Android profile to &lt;code&gt;track: "alpha"&lt;/code&gt; so every build goes to closed testing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Days 2–5:&lt;/strong&gt; fill in listings, screenshots, privacy details, Data Safety, ratings, and the demo account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 5 onward:&lt;/strong&gt; submit iOS for App Review from App Store Connect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 17 onward:&lt;/strong&gt; apply for Google Play production access, then promote.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After launch, the pipeline is fully automatic: every merge to &lt;code&gt;main&lt;/code&gt; builds, uploads, and lands in TestFlight and your Play track, and you promote from there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common EAS Submit errors
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"No suitable application records were found" (iOS):&lt;/strong&gt; the bundle ID doesn't match an app in App Store Connect, or &lt;code&gt;ascAppId&lt;/code&gt; is wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version code or build number already used:&lt;/strong&gt; set &lt;code&gt;"cli": { "appVersionSource": "remote" }&lt;/code&gt; and &lt;code&gt;"autoIncrement": true&lt;/code&gt; on the production build profile so EAS increments for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service account permission errors (Android):&lt;/strong&gt; the service account needs access to the app in Play Console's &lt;strong&gt;Users and permissions&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI can't authenticate with Apple:&lt;/strong&gt; interactive Apple ID login doesn't work in CI. Use the App Store Connect API key.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;EAS Submit removes the upload step from your release. It doesn't remove the store consoles. Set up the API key and service account once, automate builds and uploads with a workflow, and put the listing, privacy, testing, and review steps on your calendar from day one.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you'd rather hand off the console half (listings, privacy forms, Data Safety, testers, and the review back-and-forth), &lt;a href="https://letsdeploy.it/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=eas_submit_what_it_automates_2026" rel="noopener noreferrer"&gt;LetsDeployIt&lt;/a&gt; handles App Store and Google Play submission end to end.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What's the step that surprised you most on your first release?&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>ios</category>
      <category>android</category>
    </item>
    <item>
      <title>Google Play's 12-Tester Rule: The 21-Day Plan That Passes the First Time</title>
      <dc:creator>Lucas Martin</dc:creator>
      <pubDate>Wed, 30 Sep 2026 06:52:14 +0000</pubDate>
      <link>https://dev.to/lucas_martin_8cb158b9a81b/google-plays-12-tester-rule-the-21-day-plan-that-passes-the-first-time-3p3k</link>
      <guid>https://dev.to/lucas_martin_8cb158b9a81b/google-plays-12-tester-rule-the-21-day-plan-that-passes-the-first-time-3p3k</guid>
      <description>&lt;p&gt;You finished the app. You paid the $25 for a Google Play developer account. You uploaded the build. And then Play Console told you production is locked until &lt;strong&gt;12 testers stay opted in to a closed test for 14 continuous days&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Most solo developers hit this wall the day they think they're done, and most of them fail the first attempt: not because the rule is hard, but because the clock has rules nobody told them about. This is the plan I give people so they pass once.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule, precisely
&lt;/h2&gt;

&lt;p&gt;Developers with personal accounts created after November 13, 2023 must run a closed test with at least 12 testers who have been opted in continuously for at least 14 days. Google originally required 20 testers and reduced it to 12 on December 11, 2024.&lt;/p&gt;

&lt;p&gt;Three details that fail people:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Continuous means continuous.&lt;/strong&gt; A tester who opts in, tests for fewer than 14 days, and opts out doesn't count. If they opt out and back in, the 14 days restart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;All 12 have to overlap.&lt;/strong&gt; The 14-day window starts when the twelfth tester is opted in, not when the first one is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Passing the numbers isn't approval.&lt;/strong&gt; When you apply, Google asks questions about your test, and reviewers can reject applications for insufficient testing engagement.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Organization accounts (registered with a D-U-N-S number) are exempt. For a solo developer, though, forming a company just to avoid testing costs more time and money than the test itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why 12 is the wrong number to aim for
&lt;/h2&gt;

&lt;p&gt;Testers get busy. Someone opts out on day 9 to free up storage. Someone's phone breaks. If you recruit exactly 12, one dropout resets everything.&lt;/p&gt;

&lt;p&gt;Recruit &lt;strong&gt;18 to 20&lt;/strong&gt;. Tell every one of them, explicitly: stay opted in for the full 14 days, even if you stop using the app. That sentence prevents more failures than anything else in this article.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 21-day plan
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Day 0: Set up the track.&lt;/strong&gt;&lt;br&gt;
In Play Console → Testing → Closed testing, create a track, upload your AAB, and fill in the store listing, content rating, Data Safety form, and privacy policy URL. Google won't publish a closed-test release with an incomplete listing, and every day you spend fixing that is a day the clock isn't running.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Days 0–3: Recruit 18–20 real people.&lt;/strong&gt;&lt;br&gt;
The best sources, in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Friends, family, coworkers with Android phones&lt;/li&gt;
&lt;li&gt;Your existing audience: newsletter, Discord, LinkedIn, a "help me launch" post&lt;/li&gt;
&lt;li&gt;Communities built for this: r/AndroidClosedTesting and similar tester-swap groups where developers test each other's apps&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Collect their &lt;strong&gt;Google account emails&lt;/strong&gt;. Add them to the tester list on the track, or create a Google Group and add the group.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Day 3: Confirm all 12+ are opted in.&lt;/strong&gt;&lt;br&gt;
Send the opt-in link. Then check the track's tester count in Play Console. The 14-day clock starts when the twelfth person is in, so don't assume; verify.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Days 3–17: Keep them engaged.&lt;/strong&gt;&lt;br&gt;
Google looks at engagement, not just enrollment. Ship at least one update during the window (a real one: a fix or a small feature) so testers have a reason to open the app. Send a short message on day 1, day 7, and day 13: "Thanks, please stay opted in until [date]."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Day 17: Apply for production access.&lt;/strong&gt;&lt;br&gt;
Play Console shows an "Apply for production" button once the requirement is met. The application asks how you recruited testers, what feedback you got, and what you changed. Answer honestly and specifically: "12 friends and 6 members of a tester community; feedback on onboarding copy and a crash on Android 12; fixed both in build 1.0.3." Vague answers get rejected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Days 17–21: Wait for review.&lt;/strong&gt;&lt;br&gt;
Approval usually takes a few days. Once granted, you can release to production and use open testing for future betas.&lt;/p&gt;

&lt;h2&gt;
  
  
  What not to do
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't buy fake testers.&lt;/strong&gt; Emulators, bot accounts, and "12 testers for $10" services risk permanent account suspension, and you lose the $25, the app, and the ability to open a new account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't start the clock before your listing is complete.&lt;/strong&gt; A rejected closed-test release doesn't count toward anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't assume a second app skips this.&lt;/strong&gt; Play Console shows the requirement per app for affected accounts; plan for it every time until you see otherwise in your console.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't recruit exactly 12.&lt;/strong&gt; See above.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The pre-flight checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Personal account created after Nov 13, 2023? (If yes, this applies.)&lt;/li&gt;
&lt;li&gt;[ ] Store listing, content rating, Data Safety, and privacy policy complete&lt;/li&gt;
&lt;li&gt;[ ] AAB uploaded to a closed testing track&lt;/li&gt;
&lt;li&gt;[ ] 18–20 testers recruited with Google account emails&lt;/li&gt;
&lt;li&gt;[ ] All testers told: stay opted in for 14 days&lt;/li&gt;
&lt;li&gt;[ ] Tester count in Console ≥ 12 before you start counting days&lt;/li&gt;
&lt;li&gt;[ ] At least one update shipped during the window&lt;/li&gt;
&lt;li&gt;[ ] Specific answers drafted for the production access application&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  If you'd rather not run this yourself
&lt;/h2&gt;

&lt;p&gt;The 14 days can't be shortened, but everything around them can be handled: the listing, the Data Safety form, the track setup, the tester coordination, and the application. That's what &lt;a href="https://letsdeploy.it/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=google_play_12_testers_2026&amp;amp;utm_content=footer" rel="noopener noreferrer"&gt;LetsDeployIt&lt;/a&gt; does for AI-built and indie apps: done-for-you App Store and Google Play submission, including the closed-testing gate.&lt;/p&gt;

&lt;p&gt;If you've been through this already: what got your testers to stay opted in for the full two weeks? I've seen everything from pizza to a shared leaderboard, and I'm curious what actually worked.&lt;/p&gt;

</description>
      <category>android</category>
      <category>googleplay</category>
      <category>mobile</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>Self-Hosting Supabase Won't Save You Money on Local Dev (and What Will)</title>
      <dc:creator>Lucas Martin</dc:creator>
      <pubDate>Mon, 28 Sep 2026 13:40:03 +0000</pubDate>
      <link>https://dev.to/lucas_martin_8cb158b9a81b/self-hosting-supabase-wont-save-you-money-on-local-dev-and-what-will-4jhf</link>
      <guid>https://dev.to/lucas_martin_8cb158b9a81b/self-hosting-supabase-wont-save-you-money-on-local-dev-and-what-will-4jhf</guid>
      <description>&lt;p&gt;Every few weeks someone in a Supabase Discord asks the same question: &lt;em&gt;"Should I self-host to save money?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The answer depends on which problem they're actually trying to solve. About half the time, what they mean is "the hosted free tier is too small for my side project." The other half, they mean "I want to run this on my laptop without paying for a cloud project." Those are different problems, and self-hosting only solves one of them.&lt;/p&gt;

&lt;p&gt;This post is the honest breakdown: what self-hosted Supabase really is, when it's the right call, and why for local dev and previews you probably want something much smaller.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "self-hosted Supabase" actually means
&lt;/h2&gt;

&lt;p&gt;Supabase is not one server. The official self-hosting path is Docker Compose, and it starts PostgreSQL plus ten services around it: PostgREST, GoTrue for auth, Realtime, Storage, Studio, the API gateway, the Supavisor pooler, imgproxy, the meta service, and the edge runtime. Eleven containers, and a quiet instance idles at around 2 GB of RAM before the database has a working set.&lt;/p&gt;

&lt;p&gt;Then there's the part the quickstart glosses over: the default &lt;code&gt;.env&lt;/code&gt; ships demo secrets and a dashboard password literally named &lt;code&gt;this_password_is_insecure_and_should_be_updated&lt;/code&gt;. Every key has to be regenerated before you put real data in it. After that you own TLS termination, SMTP for auth emails, backups that you've actually tested restoring, and image upgrades across all eleven services.&lt;/p&gt;

&lt;p&gt;None of that is a criticism. It's a full production backend, and self-hosting it is a real, supported path. Supabase's own docs frame it as suitable for small-to-medium production workloads. But it's ops work, and it's community-supported rather than covered by a support plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  When self-hosting is the right call
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Data residency.&lt;/strong&gt; You need the database in a specific jurisdiction or on hardware you control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost at scale.&lt;/strong&gt; Once you're paying for compute add-ons, a dedicated box can be cheaper, &lt;em&gt;if&lt;/em&gt; you have someone to run it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Extensions or config the hosted platform doesn't allow.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You already run infrastructure&lt;/strong&gt; and adding eleven containers to it is Tuesday.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If none of those apply, the hosted platform's free tier and $25/mo Pro plan are almost always cheaper than the hours you'll spend on Compose.&lt;/p&gt;

&lt;h2&gt;
  
  
  When self-hosting is the wrong tool
&lt;/h2&gt;

&lt;p&gt;Here's the case that trips people up: &lt;strong&gt;you want a backend on your laptop, in CI, or per preview environment.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Self-hosting doesn't help there. You'd be running the same eleven containers, just on your own machine, which is exactly what &lt;code&gt;supabase start&lt;/code&gt; already does. On a 16 GB laptop it's tolerable. On the 8 GB machine most students and indie devs actually own, it's the reason they stop before writing a line of app code. And in CI, spinning up that stack on every pull request is the slowest job in the pipeline.&lt;/p&gt;

&lt;p&gt;This is a different problem from production hosting, and it wants a different shape of solution: not "run all of Supabase yourself," but "run enough of Supabase, in one process, wherever you are."&lt;/p&gt;

&lt;h2&gt;
  
  
  The third option: one process
&lt;/h2&gt;

&lt;p&gt;That's what &lt;a href="https://tinbase.dev/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=self_hosting_vs_local_2026&amp;amp;utm_content=intro" rel="noopener noreferrer"&gt;tinbase&lt;/a&gt; is. An open-source (MIT), Supabase-compatible backend that runs as a single process with no Docker:&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;That boots embedded native Postgres 17 with RLS, plus REST (PostgREST grammar), Auth (GoTrue-shaped), Storage, Realtime, in-process Edge Functions, and a Studio-style dashboard. The official &lt;code&gt;@supabase/supabase-js&lt;/code&gt; works unchanged. It reads your existing &lt;code&gt;supabase/migrations/*.sql&lt;/code&gt; and &lt;code&gt;supabase/seed.sql&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The numbers from the README, against the Docker-based local stack:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Supabase local (Docker)&lt;/th&gt;
&lt;th&gt;tinbase (native engine)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Processes&lt;/td&gt;
&lt;td&gt;12 containers&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAM at boot&lt;/td&gt;
&lt;td&gt;~1.4 GB&lt;/td&gt;
&lt;td&gt;~59 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cold boot&lt;/td&gt;
&lt;td&gt;~1 min&lt;/td&gt;
&lt;td&gt;~2 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;There's also a WASM engine (PGlite) that runs the whole backend inside a browser tab, and an ultralight pure-JS engine for previews. Same handlers, different database underneath.&lt;/p&gt;

&lt;h2&gt;
  
  
  What tinbase is not
&lt;/h2&gt;

&lt;p&gt;This is the part I'd rather say now than have you find out later.&lt;/p&gt;

&lt;p&gt;tinbase is &lt;strong&gt;alpha&lt;/strong&gt;, built for local dev, CI, prototypes, and embedded use. It is not a self-hosted Supabase and not a production database. Specifically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Single connection.&lt;/strong&gt; Writes serialize. Fine for one developer or one preview; not for concurrent users.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Coverage is roughly 80%&lt;/strong&gt; of the supabase-js surface. MFA, SSO, phone auth, and pgvector aren't there yet. The gaps are listed in the repo's coverage table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Realtime DELETE events aren't filtered per row&lt;/strong&gt; the way INSERT and UPDATE are.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge Functions with &lt;code&gt;npm:&lt;/code&gt; or &lt;code&gt;jsr:&lt;/code&gt; imports&lt;/strong&gt; still need bundling.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your question is "how do I host my production Supabase myself," tinbase is the wrong answer. Use Docker Compose, regenerate every secret, set up backups, and read the self-hosting docs twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision, compressed
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You want to…&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Ship to production with zero ops&lt;/td&gt;
&lt;td&gt;Supabase hosted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ship to production on your own hardware or region&lt;/td&gt;
&lt;td&gt;Self-hosted Supabase (Docker Compose)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run a backend on a laptop, in CI, or per preview&lt;/td&gt;
&lt;td&gt;tinbase (or &lt;code&gt;supabase start&lt;/code&gt; if RAM is no object)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run a backend inside a browser tab&lt;/td&gt;
&lt;td&gt;tinbase WASM engine&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The migration files are the same across all four rows. That's the whole point: develop against the small one, deploy to the big one, and never edit app code in between.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&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;Source, roadmap, and the coverage table are on &lt;a href="https://github.com/tinbase/tinbase?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=self_hosting_vs_local_2026&amp;amp;utm_content=cta" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. If you hit a supabase-js call that doesn't work, open an issue; those reports are what move the table.&lt;/p&gt;

&lt;p&gt;Which row are you in? I'm curious how many people asking "should I self-host" are actually in row three.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>docker</category>
      <category>devops</category>
    </item>
    <item>
      <title>The Privacy Policy Trap: The App Store Rejection Nobody Warns You About</title>
      <dc:creator>Lucas Martin</dc:creator>
      <pubDate>Wed, 23 Sep 2026 05:49:18 +0000</pubDate>
      <link>https://dev.to/lucas_martin_8cb158b9a81b/the-privacy-policy-trap-the-app-store-rejection-nobody-warns-you-about-27hh</link>
      <guid>https://dev.to/lucas_martin_8cb158b9a81b/the-privacy-policy-trap-the-app-store-rejection-nobody-warns-you-about-27hh</guid>
      <description>&lt;h2&gt;
  
  
  The two rejections that eat your first submit
&lt;/h2&gt;

&lt;p&gt;Two data-safety rejections show up more than everything else combined on first submissions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Your Privacy Policy URL loads but doesn't mention the data types you listed in App Store Connect.&lt;/strong&gt; The reviewer clicks your policy, ctrl-F's for "email" or "device identifier", finds nothing. Held.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You claim "Data Not Linked to You" for analytics while your SDK sends a persistent device ID.&lt;/strong&gt; Any default install of Firebase, Amplitude, or RevenueCat lands here.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both are 30-minute fixes. Both cost a full review cycle. The whole game is eliminating them before you submit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Match the policy verbatim to the Connect form
&lt;/h2&gt;

&lt;p&gt;Apple's data-safety reviewer isn't reading your policy. They're diffing it against the table you filled in App Store Connect. So the policy needs to be structured the same way:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One row per data type&lt;/li&gt;
&lt;li&gt;Three columns: what, why, third parties&lt;/li&gt;
&lt;li&gt;Language that maps 1:1 to the Connect field names ("Product Interaction", "Device or Other IDs", "Coarse Location")&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Legalese from a generic policy generator won't match. Rewrite it as a table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build-time drift check
&lt;/h2&gt;

&lt;p&gt;Drift between what your app actually collects and what your Connect form declares is the number one hold reason. Catch it in CI:&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;# .github/workflows/data-safety.yml&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Data-safety drift check&lt;/span&gt;
  &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
    &lt;span class="s"&gt;python scripts/data_safety_diff.py \&lt;/span&gt;
      &lt;span class="s"&gt;--ios-plist ios/App/Info.plist \&lt;/span&gt;
      &lt;span class="s"&gt;--android-manifest android/app/src/main/AndroidManifest.xml \&lt;/span&gt;
      &lt;span class="s"&gt;--connect-declared connect-data-safety.yml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A 40-line script parses your &lt;code&gt;Info.plist&lt;/code&gt; and &lt;code&gt;AndroidManifest.xml&lt;/code&gt; permission lists and compares them against a &lt;code&gt;connect-data-safety.yml&lt;/code&gt; committed alongside the app. Any drift means a red build. You never submit a mismatch again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Notes for Reviewer is the field that unblocks you
&lt;/h2&gt;

&lt;p&gt;Reviewers spend about 90 seconds on your submission. The field they actually read is &lt;strong&gt;Notes for Reviewer&lt;/strong&gt;. Use it to answer the three questions they always ask:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Test credentials to log in.&lt;/strong&gt; Real, working, not disabled.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Personal vs device data.&lt;/strong&gt; One sentence: "We collect X for Y purpose; SDK Z sends a device ID, which is why we declare Data Linked."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IAP unlocks.&lt;/strong&gt; What the tester sees after purchase.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Skip these and you get held. Include them and your review cycle gets a day shorter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one toggle that fixes 40%
&lt;/h2&gt;

&lt;p&gt;In App Store Connect &amp;gt; App Privacy &amp;gt; Data Collection: if any SDK (analytics, ads, crash reporting) sends a device ID or IP, you must declare &lt;strong&gt;"Data Linked to You"&lt;/strong&gt; for that category. Not "Not Linked".&lt;/p&gt;

&lt;p&gt;This single toggle is where 40% of the data-safety rejections we've seen land. Get it right on the first submission and you save two review cycles.&lt;/p&gt;

&lt;h2&gt;
  
  
  What passes vs what gets held
&lt;/h2&gt;

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

&lt;ul&gt;
&lt;li&gt;Table-format policy matching Connect verbatim&lt;/li&gt;
&lt;li&gt;A static &lt;code&gt;/privacy&lt;/code&gt; page, indexed on your marketing site&lt;/li&gt;
&lt;li&gt;Notes for Reviewer with real test creds and a data explanation&lt;/li&gt;
&lt;li&gt;ATT prompt shown after a value-explaining action, using Apple's exact string, with a deny path that doesn't paywall&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;Legalese policy from a generic generator&lt;/li&gt;
&lt;li&gt;ATT prompt on splash&lt;/li&gt;
&lt;li&gt;"Data Not Linked" claim with any device-ID-collecting SDK&lt;/li&gt;
&lt;li&gt;Missing Notes for Reviewer&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The fastest wins
&lt;/h2&gt;

&lt;p&gt;If you're prepping an App Store submission this week: match your Privacy Policy table to Connect verbatim, add the drift check to CI, and write Notes for Reviewer for real. We built &lt;a href="https://letsdeploy.it/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=privacy-policy-trap" rel="noopener noreferrer"&gt;LetsDeployIt&lt;/a&gt; to automate this pipeline for indie teams, but the checklist matters more than the tool.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>mobile</category>
      <category>privacy</category>
      <category>appstore</category>
    </item>
    <item>
      <title>Every EAS Submit Failure Mode I've Hit in Six Months (and What Catches Each Pre-Submit)</title>
      <dc:creator>Lucas Martin</dc:creator>
      <pubDate>Wed, 16 Sep 2026 05:23:13 +0000</pubDate>
      <link>https://dev.to/lucas_martin_8cb158b9a81b/every-eas-submit-failure-mode-ive-hit-in-six-months-and-what-catches-each-pre-submit-1b3h</link>
      <guid>https://dev.to/lucas_martin_8cb158b9a81b/every-eas-submit-failure-mode-ive-hit-in-six-months-and-what-catches-each-pre-submit-1b3h</guid>
      <description>&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;eas submit&lt;/code&gt; succeeding tells you the upload worked. It tells you nothing about whether the store will accept it&lt;/li&gt;
&lt;li&gt;Six failures in six months, every one of them green in the terminal and red in App Store Connect or Play Console the next morning&lt;/li&gt;
&lt;li&gt;Each one cost roughly a calendar day, because store review and processing don't run on your clock&lt;/li&gt;
&lt;li&gt;All six are catchable before you run submit. The checks are at the bottom&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I've submitted something like forty builds through EAS this year across a handful of Expo apps. The CLI has failed on me maybe twice. The store has bounced me six different ways, and in every case the terminal said "Submitted" and I went to bed.&lt;/p&gt;

&lt;p&gt;That gap, green CLI and red store, is the whole story. &lt;code&gt;eas submit&lt;/code&gt; validates that your credentials work and the binary uploaded. Everything else lives in App Store Connect or Play Console, and neither of those tells you anything until a human or a batch job gets to it, usually the next day.&lt;/p&gt;

&lt;p&gt;Here's each failure in the order I hit them, and the pre-submit check that would have caught it.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Wrong &lt;code&gt;ascAppId&lt;/code&gt;: the build went to the wrong app
&lt;/h2&gt;

&lt;p&gt;I had two apps in the same Apple team, one production and one white-label. Both &lt;code&gt;eas.json&lt;/code&gt; files were copy-pasted. Both had the same &lt;code&gt;ascAppId&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;"submit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"ios"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"appleId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"me@example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"ascAppId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1234567890"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"appleTeamId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ABCDE12345"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The white-label build uploaded cleanly into the production app's TestFlight. Bundle identifier mismatch? Apple doesn't check that at upload time against &lt;code&gt;ascAppId&lt;/code&gt;. It processed the build, attached it to the wrong app record, and I found out when a production tester asked why the icon had changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What catches it:&lt;/strong&gt; compare &lt;code&gt;ascAppId&lt;/code&gt; against the bundle identifier before submitting. The App Store Connect API gives you this in one call:&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;# returns bundleId for the app record ascAppId points at&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$ASC_JWT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://api.appstoreconnect.apple.com/v1/apps/&lt;/span&gt;&lt;span class="nv"&gt;$ASC_APP_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.data.attributes.bundleId'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that doesn't equal &lt;code&gt;ios.bundleIdentifier&lt;/code&gt; in &lt;code&gt;app.json&lt;/code&gt;, stop. I run this as the first line of a &lt;code&gt;presubmit.sh&lt;/code&gt; now.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The two things only App Store Connect knows about
&lt;/h2&gt;

&lt;p&gt;Two separate failures, same root cause: the store needed something the binary can't carry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In-app purchases not attached to the version.&lt;/strong&gt; The IAP products existed. They were approved. They weren't attached to the app version I submitted for review, because that's a checkbox on the version page in App Store Connect, not anything in the build. The reviewer opened the paywall, tapped a product, got nothing, and rejected under 2.1.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Missing export compliance.&lt;/strong&gt; The build sat in TestFlight with a yellow "Missing Compliance" badge and wouldn't distribute externally until someone answered the encryption question. That's a manual step in the TestFlight UI on every build, unless you declare it in the binary:&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;"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;"config"&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;"usesNonExemptEncryption"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What catches it:&lt;/strong&gt; &lt;code&gt;usesNonExemptEncryption&lt;/code&gt; in &lt;code&gt;app.json&lt;/code&gt; removes the compliance prompt permanently (assuming you're only using standard HTTPS, which is what the &lt;code&gt;false&lt;/code&gt; means). For IAP, there's no config-side fix; it's a pre-submit checklist item: open the version in ASC, confirm every product is attached, then submit. Every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. TestFlight external testers stopped getting builds
&lt;/h2&gt;

&lt;p&gt;I had an external tester group of about thirty people. I submitted a new build, it processed, and nobody got it. The group hadn't vanished, but the build wasn't in it.&lt;/p&gt;

&lt;p&gt;External groups don't automatically receive new builds unless "Automatic distribution" is on for the group, and each build still has to clear beta review first. I'd been manually adding builds to the group for months without realizing it, then forgot once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What catches it:&lt;/strong&gt; turn on automatic distribution for the group in TestFlight, once. Or, if you want it explicit, add the build to the group from the pipeline after submit using the ASC API's &lt;code&gt;betaGroups/{id}/relationships/builds&lt;/code&gt; endpoint. Either way, don't rely on remembering.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Play Console blocked the release on Data Safety
&lt;/h2&gt;

&lt;p&gt;The AAB uploaded. The release page showed one error: "Your Data safety section is incomplete." That section is a questionnaire about every category of data the app collects, and it has to be completed before the first production release. It's also easy to invalidate: add an analytics SDK, change your answers, or the next release is blocked again.&lt;/p&gt;

&lt;p&gt;There are four of these gates on the Play side and none of them are in the build: Data Safety, Content Rating questionnaire, Target Audience, and the Ads declaration. Miss any one and the release button is disabled.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What catches it:&lt;/strong&gt; open the Policy → App content page before you build, not after. Everything on that page with a "Start" or "Update" button is going to block you. New Play Console accounts also have to run a 14-day closed test with at least 12 testers before production access is granted, which isn't a submit failure so much as a submit-two-weeks-later.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Android &lt;code&gt;versionCode&lt;/code&gt; already used
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Version code 42 has already been used. Try another version code.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one is the most CLI-adjacent of the six, and it still comes from the store, not from EAS. It happened because &lt;code&gt;versionCode&lt;/code&gt; was set in &lt;code&gt;app.json&lt;/code&gt; and I'd built twice without bumping it. It also happens when a build goes to internal testing and then a rebuild goes to production with the same code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What catches it:&lt;/strong&gt; let EAS own the number.&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;"cli"&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;"appVersionSource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"remote"&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;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"autoIncrement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;appVersionSource: remote&lt;/code&gt;, the version lives on EAS servers and &lt;code&gt;autoIncrement&lt;/code&gt; bumps it per build. &lt;code&gt;eas build:version:get&lt;/code&gt; shows you the current value if you want to sanity-check it against Play Console before submitting.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. iOS screenshots at the wrong pixel size
&lt;/h2&gt;

&lt;p&gt;App Store Connect rejects screenshot uploads whose dimensions don't match one of Apple's exact device sizes. Not "roughly a phone." Exact. My designer exported at 1284×2778 (an older 6.5" size) when the required set for the listing was 6.9" at 1320×2868. The submission was blocked on the version page until every slot was filled with the right size.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What catches it:&lt;/strong&gt; check dimensions before uploading. On macOS:&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="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in &lt;/span&gt;screenshots/ios/&lt;span class="k"&gt;*&lt;/span&gt;.png&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;sips &lt;span class="nt"&gt;-g&lt;/span&gt; pixelWidth &lt;span class="nt"&gt;-g&lt;/span&gt; pixelHeight &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="nb"&gt;awk&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;f&lt;/span&gt;&lt;span class="o"&gt;=&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="s1"&gt;'NR&amp;gt;1{printf "%s ", $2} END{print f}'&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then compare the output against Apple's current screenshot specifications for the device sizes you're targeting. The required sizes change when new devices ship, so don't hardcode them; link to the spec page in the checklist.&lt;/p&gt;

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

&lt;p&gt;Green CLI, red store, one day each. Six days of calendar time lost to things that took under an hour to fix once I knew what they were. None of them are Expo's fault. &lt;code&gt;eas submit&lt;/code&gt; does exactly what it says. The failures all live in the layer between the upload and the review, which nothing in the codebase can see.&lt;/p&gt;

&lt;p&gt;The pre-submit checklist that came out of this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;ascAppId&lt;/code&gt; resolves to the same bundle ID as &lt;code&gt;app.json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;usesNonExemptEncryption&lt;/code&gt; set; IAP products attached to the version in ASC&lt;/li&gt;
&lt;li&gt;TestFlight external groups on automatic distribution&lt;/li&gt;
&lt;li&gt;Play Console App content page has zero pending items&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;appVersionSource: remote&lt;/code&gt; and &lt;code&gt;autoIncrement&lt;/code&gt; on&lt;/li&gt;
&lt;li&gt;Screenshot dimensions verified against Apple's current spec&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Steps 1, 2 (compliance), 5, and 6 are scriptable. Steps 2 (IAP), 3, and 4 are a human opening a browser tab, which is exactly why they get skipped.&lt;/p&gt;

&lt;p&gt;That human-in-the-browser layer is what &lt;a href="https://www.letsdeploy.it/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=eas-submit-failure-modes" rel="noopener noreferrer"&gt;LetsDeployIt&lt;/a&gt; exists for: they take Expo apps through the store side, with a person checking each of these gates, so the submit you run at 11pm is the last one. If you'd rather own it yourself, the six checks above are the whole list, at least until I hit number seven.&lt;/p&gt;

&lt;p&gt;What's the one that got you? I've heard rumors about Play's pre-launch report flagging a crash in the sample app that isn't reachable from the real UI, but I haven't seen it myself yet.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>ios</category>
      <category>android</category>
    </item>
    <item>
      <title>Docker Alternatives for Local Postgres Dev: A 2026 Comparison</title>
      <dc:creator>Lucas Martin</dc:creator>
      <pubDate>Mon, 07 Sep 2026 13:53:34 +0000</pubDate>
      <link>https://dev.to/lucas_martin_8cb158b9a81b/docker-alternatives-for-local-postgres-dev-a-2026-comparison-2f1p</link>
      <guid>https://dev.to/lucas_martin_8cb158b9a81b/docker-alternatives-for-local-postgres-dev-a-2026-comparison-2f1p</guid>
      <description>&lt;ul&gt;
&lt;li&gt;Podman and Colima swap the container runtime; RAM stays ~1.4-1.6 GB because the 12 containers stay&lt;/li&gt;
&lt;li&gt;PGlite drops to ~80 MB but you rebuild auth/realtime/storage yourself&lt;/li&gt;
&lt;li&gt;A single-process Supabase-compatible stack (Tinbase) lands at ~100 MB with the supabase-js SDK unchanged&lt;/li&gt;
&lt;li&gt;Pick by which trade-off hurts: prod parity, laptop RAM, or SDK compatibility&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Docker Desktop pricing changes and RAM overhead have pushed the "Docker alternatives" conversation from niche to mainstream. For Postgres-backed local development specifically (Supabase-style stacks, Prisma + Postgres, or any full-stack app with a real DB), the choices in 2026 are richer than they were a year ago. This is a hands-on comparison of the five approaches I tested, with real numbers on RAM, boot time, and dev ergonomics.&lt;/p&gt;

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

&lt;p&gt;All measurements from a 16 GB MacBook Pro M2, same test workload: a Next.js app with 12 tables, auth, and a realtime subscription. Each backend booted cold, the app started, one user created via auth, one INSERT made against a subscribed table. RAM via &lt;code&gt;docker stats&lt;/code&gt; (container approaches) or Activity Monitor (host processes). Boot time is wall-clock from start command to the dev server successfully querying the database. Your numbers will differ; the ratios probably won't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 1: Docker Compose + Supabase (baseline)
&lt;/h2&gt;

&lt;p&gt;The standard: &lt;code&gt;supabase start&lt;/code&gt; spins up 12 containers.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RAM:&lt;/strong&gt; ~1626 MB across the containers&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Boot:&lt;/strong&gt; 45s cold, 12s warm&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disk:&lt;/strong&gt; ~2.3 GB of images
&lt;/li&gt;
&lt;/ul&gt;

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

&lt;/div&gt;



&lt;p&gt;Works out of the box, mirrors prod behavior, has the whole ecosystem behind it. The RAM cost is the price of admission.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 2: Podman + Supabase
&lt;/h2&gt;

&lt;p&gt;Same compose file, different runtime.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RAM:&lt;/strong&gt; ~1580 MB&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Boot:&lt;/strong&gt; 42s cold&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disk:&lt;/strong&gt; ~2.3 GB
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;podman
podman machine init &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; podman machine start
&lt;span class="nv"&gt;DOCKER_HOST&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;unix:///tmp/podman.sock supabase start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Docker Desktop licensing question goes away, but the RAM and boot story barely moves. Podman is a "get Docker Desktop off my machine" solution, not a "make local dev faster" solution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 3: Colima + Supabase
&lt;/h2&gt;

&lt;p&gt;Colima wraps Lima for a Docker-compatible daemon with a lighter host VM.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RAM:&lt;/strong&gt; ~1420 MB&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Boot:&lt;/strong&gt; 38s cold&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disk:&lt;/strong&gt; ~2.3 GB
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;colima docker
colima start &lt;span class="nt"&gt;--cpu&lt;/span&gt; 4 &lt;span class="nt"&gt;--memory&lt;/span&gt; 4
supabase start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Better than Docker Desktop on RAM, still 12 containers. The savings come from a lighter VM, not a different architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 4: PGlite + custom auth (no containers)
&lt;/h2&gt;

&lt;p&gt;PGlite is Postgres compiled to WASM, running in-process. Great for tests and prototyping, but you build the rest of the stack yourself.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RAM:&lt;/strong&gt; ~80 MB&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Boot:&lt;/strong&gt; ~2s&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disk:&lt;/strong&gt; ~50 MB
&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="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="s1"&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./data&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;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 (...)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No auth, no realtime, no storage: you build or stub each. Perfect for a solo prototype where you aren't testing the auth flow. Painful the moment you need Supabase's actual features.&lt;/p&gt;

&lt;h2&gt;
  
  
  Approach 5: Single-process Supabase-compatible (Tinbase)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://tinbase.dev/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=docker-alternatives-local-postgres-2026" rel="noopener noreferrer"&gt;Tinbase&lt;/a&gt; runs Postgres semantics plus auth, storage, realtime, and edge functions as one process that speaks the supabase-js wire protocol. It's built on PGlite under the hood, started with one command, and it's young: the project itself labels it alpha, aimed at local dev, prototypes, and embedded use rather than production.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RAM:&lt;/strong&gt; ~100 MB&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Boot:&lt;/strong&gt; ~3s cold&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disk:&lt;/strong&gt; ~58 MB
&lt;/li&gt;
&lt;/ul&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;Then in the app, no client changes:&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;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="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;LOCAL_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signUp&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;password&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;todos&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hi&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;RLS policies, &lt;code&gt;auth.uid()&lt;/code&gt;, triggers, and foreign keys behave like Postgres because underneath it is Postgres.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary table
&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;RAM&lt;/th&gt;
&lt;th&gt;Boot cold&lt;/th&gt;
&lt;th&gt;Disk&lt;/th&gt;
&lt;th&gt;SDK compat&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Docker Supabase&lt;/td&gt;
&lt;td&gt;1626 MB&lt;/td&gt;
&lt;td&gt;45s&lt;/td&gt;
&lt;td&gt;2.3 GB&lt;/td&gt;
&lt;td&gt;Native&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Podman Supabase&lt;/td&gt;
&lt;td&gt;1580 MB&lt;/td&gt;
&lt;td&gt;42s&lt;/td&gt;
&lt;td&gt;2.3 GB&lt;/td&gt;
&lt;td&gt;Native&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Colima Supabase&lt;/td&gt;
&lt;td&gt;1420 MB&lt;/td&gt;
&lt;td&gt;38s&lt;/td&gt;
&lt;td&gt;2.3 GB&lt;/td&gt;
&lt;td&gt;Native&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PGlite&lt;/td&gt;
&lt;td&gt;80 MB&lt;/td&gt;
&lt;td&gt;2s&lt;/td&gt;
&lt;td&gt;50 MB&lt;/td&gt;
&lt;td&gt;None (raw Postgres)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tinbase&lt;/td&gt;
&lt;td&gt;100 MB&lt;/td&gt;
&lt;td&gt;3s&lt;/td&gt;
&lt;td&gt;58 MB&lt;/td&gt;
&lt;td&gt;Native&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  When to pick what
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Docker Supabase:&lt;/strong&gt; team-shared staging, maximum prod parity, when RAM isn't your bottleneck.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Podman:&lt;/strong&gt; you specifically want off Docker Desktop for licensing; everything else stays.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Colima:&lt;/strong&gt; same as Podman with slightly better macOS ergonomics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PGlite:&lt;/strong&gt; unit tests, browser-side Postgres, prototypes without auth requirements.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tinbase:&lt;/strong&gt; local dev where you need Supabase's actual feature set (auth, realtime, RLS) without Docker's weight. Solo work, laptop-bound teams, workshop demos.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If prod parity is non-negotiable, stay on Docker. If your laptop is the bottleneck, the single-process end of the spectrum is dramatically cheaper for equivalent SDK compatibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The RAM delta between the two ends here (1626 MB vs 100 MB) is not a marginal optimization; it's the difference between a laptop that thermal-throttles at 11am and one that doesn't. Try the swap for a week and keep Docker Compose around for when you need it. Tinbase is open source under MIT; the repo is github.com/tinbase/tinbase if you want to read the code or the caveats first.&lt;/p&gt;

&lt;p&gt;What's your local Postgres setup, and what's it actually costing you in RAM? Run the same five-minute measurement and drop your numbers in the comments; I'll add interesting configs to the table.&lt;/p&gt;

</description>
      <category>docker</category>
      <category>postgres</category>
      <category>supabase</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why PGlite Is Quietly Fixing the Open-Source Contributor Onboarding Problem</title>
      <dc:creator>Lucas Martin</dc:creator>
      <pubDate>Fri, 04 Sep 2026 13:32:48 +0000</pubDate>
      <link>https://dev.to/lucas_martin_8cb158b9a81b/why-pglite-is-quietly-fixing-the-open-source-contributor-onboarding-problem-1oc3</link>
      <guid>https://dev.to/lucas_martin_8cb158b9a81b/why-pglite-is-quietly-fixing-the-open-source-contributor-onboarding-problem-1oc3</guid>
      <description>&lt;ul&gt;
&lt;li&gt;First-time contributors bounce off docker-compose setups; maintainers never see it happen&lt;/li&gt;
&lt;li&gt;PGlite (WASM Postgres) makes dev setup three commands: clone, install, run&lt;/li&gt;
&lt;li&gt;For shared local staging or CI, single-binary Postgres runtimes are the middle path&lt;/li&gt;
&lt;li&gt;If clone-to-running takes more than 15 minutes, your project has a cliff&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I watched a first-time open-source contributor try to run a Supabase-based project locally last week. They wanted to fix a small typo in the auth UI. Simple issue. Two-line PR.&lt;/p&gt;

&lt;p&gt;Here's what actually happened:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Clone repo. Run docker-compose up.&lt;/li&gt;
&lt;li&gt;Port 5432 in use (their local Postgres). Change to 5433.&lt;/li&gt;
&lt;li&gt;Port 3000 in use (their Grafana). Change to 3001.&lt;/li&gt;
&lt;li&gt;Docker Desktop asks to update. Update.&lt;/li&gt;
&lt;li&gt;Studio container fails: outdated image. Pull.&lt;/li&gt;
&lt;li&gt;kong container OOM-kills. Bump memory.&lt;/li&gt;
&lt;li&gt;Auth container can't reach db. Wait for readiness probe.&lt;/li&gt;
&lt;li&gt;Realtime container just... never starts.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;45 minutes in, they closed the tab and worked on a different project.&lt;/p&gt;

&lt;p&gt;This is not a hypothetical. It's the modal experience of a first-time contributor to any Supabase-based (or generally, any docker-compose-heavy) project in 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  The contributor cliff
&lt;/h2&gt;

&lt;p&gt;For maintainers, the cost of this is invisible. You already have the containers cached, the ports settled, the memory bumped. docker-compose up takes 30 seconds on your machine. You never see the contributor who bounces because their laptop is 8 GB and Docker Desktop already eats 4 of them.&lt;/p&gt;

&lt;p&gt;But the contributor cliff is real, and it's the single biggest factor in how fast a project's contributor base grows.&lt;/p&gt;

&lt;h2&gt;
  
  
  PGlite changes the first-commit experience
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://pglite.dev" rel="noopener noreferrer"&gt;PGlite&lt;/a&gt; is Postgres compiled to WebAssembly. Full Postgres semantics (types, constraints, transactions, RLS, a supported extension set) running in the browser or in Node with no server to spin up. It boots in milliseconds.&lt;/p&gt;

&lt;p&gt;For OSS projects that need Postgres in dev, PGlite is a legitimate onboarding fix. Instead of a 45-minute docker-compose adventure, the contributor experience becomes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone repo
npm &lt;span class="nb"&gt;install
&lt;/span&gt;npm run dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole thing. The dev server boots PGlite in-process, runs migrations, seeds fixtures, and the app is running against real Postgres.&lt;/p&gt;

&lt;h2&gt;
  
  
  Case study: a real migration
&lt;/h2&gt;

&lt;p&gt;A project I contributed to recently made this shift. The prod stack is Supabase Cloud. The dev stack used to be docker-compose Supabase. Now the dev stack is PGlite for the DB + a lightweight mock for auth.&lt;/p&gt;

&lt;p&gt;Observable results after two months:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;First-time contributor merge rate&lt;/strong&gt;: 12% → 34%&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Median time-to-first-PR after clone&lt;/strong&gt;: 4 hours → 45 minutes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contributor questions in Discord about setup&lt;/strong&gt;: dropped ~80%&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The auth mock is the honest trade-off. It doesn't perfectly mirror Supabase's session model, so some auth-specific PRs still need the full stack. But for the 80% of PRs that touch UI, DB schemas, or business logic, the local dev experience is transformed.&lt;/p&gt;

&lt;h2&gt;
  
  
  When PGlite isn't enough: single-binary Postgres
&lt;/h2&gt;

&lt;p&gt;PGlite is per-client. It can't back a multi-user local staging environment, and long-lived browser persistence has practical limits.&lt;/p&gt;

&lt;p&gt;For projects that need a shared local backend (a designer and an engineer both hitting the same DB, or CI running e2e tests against persistent state), the emerging middle path is single-binary Postgres runtimes. &lt;a href="https://tinbase.dev/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=pglite-oss-contributor-onboarding" rel="noopener noreferrer"&gt;Tinbase&lt;/a&gt; is one: real Postgres plus auth, realtime, and edge functions in a single executable (~58 MB, ~100 MB RAM), booting in a couple of seconds, no Docker.&lt;/p&gt;

&lt;p&gt;The shape of the local stack becomes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PGlite → embedded in the dev server for the fastest first-commit path&lt;/li&gt;
&lt;li&gt;Tinbase (or similar) → for shared local staging, CI, or when features need auth/realtime that PGlite doesn't provide&lt;/li&gt;
&lt;li&gt;Hosted Postgres → for prod&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same SQL, same wire, same auth semantics across all three tiers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What OSS maintainers should do this quarter
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Instrument your contributor drop-off.&lt;/strong&gt; How long between clone and first successful npm run dev? If it's more than 15 minutes, you have a cliff.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consider PGlite for dev.&lt;/strong&gt; Even if prod is hosted Postgres, dev doesn't need to be Docker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Document the local-dev path in the README as three commands, not thirty.&lt;/strong&gt; If it can't be three commands, fix the tooling until it can.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The contributor experience is the product for OSS. PGlite, and the single-binary Postgres wave following it, is one of the biggest DX unlocks in years for anyone building on Postgres. Quietly, it might be the biggest OSS-growth lever of 2026.&lt;/p&gt;

&lt;p&gt;Maintainers: how long does your project take from clone to running? Time it honestly and drop the number in the comments. Curious where the cliff actually sits across projects.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>opensource</category>
      <category>webdev</category>
      <category>devtools</category>
    </item>
    <item>
      <title>pglite vs Tinbase: different tools for different jobs</title>
      <dc:creator>Lucas Martin</dc:creator>
      <pubDate>Wed, 02 Sep 2026 11:16:28 +0000</pubDate>
      <link>https://dev.to/lucas_martin_8cb158b9a81b/pglite-vs-tinbase-different-tools-for-different-jobs-57p3</link>
      <guid>https://dev.to/lucas_martin_8cb158b9a81b/pglite-vs-tinbase-different-tools-for-different-jobs-57p3</guid>
      <description>&lt;ul&gt;
&lt;li&gt;pglite: Postgres as an embeddable library (browser, Node, Bun, Deno). No network layer, no auth stack.&lt;/li&gt;
&lt;li&gt;Tinbase: single-binary local Supabase drop-in (Postgres + auth + realtime + edge functions). Supabase-JS works unchanged.&lt;/li&gt;
&lt;li&gt;Neither is a production database. Both make local dev, CI, and self-hosting cheaper.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The local-Postgres tooling landscape in 2026 has quietly become interesting. pglite (Supabase's WASM Postgres) has seen adoption take off. Tinbase (Shaper Studio's single-binary Supabase-compatible stack) launched recently. Docker Postgres is still the incumbent. And there's a low-grade Twitter debate about which one "wins".&lt;/p&gt;

&lt;p&gt;Having used all three in production for different projects, I think "wins" is the wrong framing. Here's how I actually pick.&lt;/p&gt;

&lt;h2&gt;
  
  
  The landscape
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Docker Postgres&lt;/strong&gt;: real Postgres in a container. Well-understood. Reliable. Heavy (~500 MB image, ~200 MB RAM idle). Requires Docker itself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;pglite&lt;/strong&gt;: Postgres compiled to WASM, embeddable in Node, browser, Bun, Deno. Under 3 MB gzipped. Perfect for scenarios where you want the SQL engine without a network layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tinbase&lt;/strong&gt;: single-file executable (~58 MB, ~100 MB RAM) that runs real Postgres plus auth, realtime, edge functions, and webhooks. Supabase-JS SDK works unchanged. Positioned as "local Supabase without Docker".&lt;/p&gt;

&lt;h2&gt;
  
  
  pglite's sweet spot
&lt;/h2&gt;

&lt;p&gt;Use pglite when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need Postgres semantics in a browser (interactive SQL tutorials, in-app query playgrounds, offline-first web apps with a real DB).&lt;/li&gt;
&lt;li&gt;You're building a Node/Bun tool that ships with an embedded DB (CLI tools, static-site generators, migration tools).&lt;/li&gt;
&lt;li&gt;Your use case is "DB embedded in the process". You don't want networked clients, you want the SQL engine as a library.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What pglite doesn't do: it's just Postgres, so no auth, no RLS enforcement (RLS is a Postgres feature but requires role setup pglite doesn't manage for you), no realtime, no PostgREST wrapper. If you're using the Supabase SDK's &lt;code&gt;.from('table').select()&lt;/code&gt; calls, pglite alone doesn't run those. You need to also run PostgREST or write a shim.&lt;/p&gt;

&lt;p&gt;Real use case for us: RapidNative's marketing site has an in-browser SQL playground where visitors can query a demo schema. pglite is the entire backend for that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tinbase's sweet spot
&lt;/h2&gt;

&lt;p&gt;Use &lt;a href="https://tinbase.dev/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=pglite-vs-tinbase-different-tools" rel="noopener noreferrer"&gt;Tinbase&lt;/a&gt; when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your production is hosted Supabase and you want your local dev to behave identically.&lt;/li&gt;
&lt;li&gt;You want a Supabase-compatible dev environment on machines that can't spare Docker's RAM footprint (design laptops, older machines, CI runners where you're spinning up per-test).&lt;/li&gt;
&lt;li&gt;You want to run integration tests against a real Supabase-compatible stack without paying for a hosted preview branch per PR.&lt;/li&gt;
&lt;li&gt;You're demoing at a workshop or conference and need Postgres + auth + realtime running on a laptop with no internet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What Tinbase doesn't do: it's not designed as a production runtime. No multi-node replication, no built-in backup infrastructure, no HA. It's optimized for local dev, CI, and single-node self-hosting.&lt;/p&gt;

&lt;p&gt;Real use case for us: local dev + CI for a shipping mobile app. Every PR gets a spun-up Tinbase for integration tests. Contributor onboarding is &lt;code&gt;git clone &amp;amp;&amp;amp; tinbase start &amp;amp;&amp;amp; npm test&lt;/code&gt;. No Docker required.&lt;/p&gt;

&lt;h2&gt;
  
  
  The overlap zone
&lt;/h2&gt;

&lt;p&gt;Both tools can serve as "local dev Postgres for a small side project". If your app is Supabase-SDK-based, Tinbase is the shorter path. If your app talks directly to Postgres (via pg / postgres.js / node-postgres), pglite is the smaller footprint.&lt;/p&gt;

&lt;p&gt;They're also composable. pglite for browser-side experiments in an app whose backend runs Tinbase is a legitimate architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  What neither replaces
&lt;/h2&gt;

&lt;p&gt;Hosted Supabase in production. Both tools are local-dev, self-host, or embedded oriented. If you need multi-region replication, managed backups, or a support SLA, you're still paying Supabase (or another hosted provider). Tinbase and pglite are "local matches production behavior" tools; they're not "production" tools.&lt;/p&gt;

&lt;p&gt;Don't run either as your prod DB for a real product. Do use both to make local dev, CI, and self-host use cases much cheaper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deciding: a quick rubric
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;"I want Postgres inside my Node/browser/Bun process" → pglite.&lt;/li&gt;
&lt;li&gt;"I want my local dev to behave exactly like hosted Supabase" → Tinbase.&lt;/li&gt;
&lt;li&gt;"I want the smallest possible dep for a demo" → pglite.&lt;/li&gt;
&lt;li&gt;"I want auth + realtime + edge functions locally" → Tinbase.&lt;/li&gt;
&lt;li&gt;"I want CI to run integration tests against a real Supabase-compatible stack" → Tinbase.&lt;/li&gt;
&lt;li&gt;"I want a browser-based SQL playground" → pglite.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both tools are good. The right one depends on the shape of the problem, which is usually a better answer than "the one on the frontpage of HN this week".&lt;/p&gt;

&lt;p&gt;Curious what your local Postgres setup looks like in 2026. Drop a comment with what you're running and why.&lt;/p&gt;




&lt;p&gt;Disclosure: I work on Tinbase. That's why the article exists. It's also why I'm confident the honest framing "here's when NOT to use us" is a stronger sales pitch than a comparison that pretends there's a single winner. Postgres tooling has always been better as an ecosystem than a competition.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>supabase</category>
      <category>database</category>
      <category>webdev</category>
    </item>
    <item>
      <title>What App Store Reviewers Actually Do in Six Minutes</title>
      <dc:creator>Lucas Martin</dc:creator>
      <pubDate>Wed, 19 Aug 2026 07:16:43 +0000</pubDate>
      <link>https://dev.to/lucas_martin_8cb158b9a81b/what-app-store-reviewers-actually-do-in-six-minutes-pak</link>
      <guid>https://dev.to/lucas_martin_8cb158b9a81b/what-app-store-reviewers-actually-do-in-six-minutes-pak</guid>
      <description>&lt;p&gt;App Store review is opaque by design. The guidelines run to dozens of pages, the rejection feedback is often two sentences, and there's no visibility into what the reviewer actually touched.&lt;/p&gt;

&lt;p&gt;This is what we've been able to reconstruct: the shape of a first-pass review, minute by minute, and what each step is really checking.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A first pass is roughly six to eight minutes, bounded by reviewer throughput&lt;/li&gt;
&lt;li&gt;Install and first screen, screenshot comparison, account create and delete, primary flow, permissions scan&lt;/li&gt;
&lt;li&gt;The account deletion loop eats a quarter of that budget on its own&lt;/li&gt;
&lt;li&gt;Your description sets the expectation the reviewer then tests against&lt;/li&gt;
&lt;li&gt;Most rejections are one of these five steps failing, not a judgement about your product&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Sources and caveats
&lt;/h2&gt;

&lt;p&gt;Two former App Store reviewers, both contract, both departed within the last eighteen months, interviewed anonymously. Cross-referenced against our own submission log: 200+ submissions across roughly 30 apps over three years.&lt;/p&gt;

&lt;p&gt;Treat the reviewer accounts as single-source. The patterns line up with what we see at scale, but review procedure varies over time and between reviewers, and nothing here is Apple policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minutes 1 to 2: install and first screen
&lt;/h2&gt;

&lt;p&gt;They install the build, launch it, and wait for the first meaningful screen.&lt;/p&gt;

&lt;p&gt;What's being checked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does it crash on launch&lt;/li&gt;
&lt;li&gt;Does it reach a functional screen in a reasonable time&lt;/li&gt;
&lt;li&gt;Does it behave sensibly without a network connection&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one catches people. Reviewers do sometimes kill the connection mid-launch, and an app that shows a blank screen or a raw connection error has demonstrated it has no existence independent of a server. That reads as a wrapped website even when it isn't one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Design for it:&lt;/strong&gt; cache the last known good state. Don't require network on first launch if you can avoid it. Ship an offline state you designed rather than one you inherited.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minutes 2 to 3: screenshots versus reality
&lt;/h2&gt;

&lt;p&gt;They open your store listing on a second device and compare each screenshot to what the app actually shows.&lt;/p&gt;

&lt;p&gt;What's being checked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Are these screenshots from &lt;em&gt;this&lt;/em&gt; build&lt;/li&gt;
&lt;li&gt;Does the UI match the listing copy&lt;/li&gt;
&lt;li&gt;Is anything shown that doesn't exist in the app&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Design for it:&lt;/strong&gt; regenerate screenshots every release, even when the UI barely changed. Automate it. And never reuse assets from a different app, however similar, because a screenshot that appears in two listings is the kind of signal that pulls the whole submission into a slower queue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minutes 3 to 5: create an account, then delete it
&lt;/h2&gt;

&lt;p&gt;If your app allows account creation, they create one, use it briefly, and then try to remove it.&lt;/p&gt;

&lt;p&gt;What's being checked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Can they sign up with a test email, without phone verification or obscure required fields blocking them&lt;/li&gt;
&lt;li&gt;Is there an in-app account deletion flow, which Apple requires under Guideline 5.1.1&lt;/li&gt;
&lt;li&gt;Is deletion findable quickly from a signed-in state&lt;/li&gt;
&lt;li&gt;Does deletion actually delete, which they may verify by attempting to sign back in&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This step is worth a quarter of the entire review budget, which is wildly disproportionate to how much attention it usually gets during development.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Design for it:&lt;/strong&gt; put the delete button in Settings, one tap deep, labelled "Delete Account" rather than buried under "Manage". Confirm intent, then actually delete. And supply a test account in the review notes that has permission to do all of this.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minutes 5 to 7: the primary flow against your own description
&lt;/h2&gt;

&lt;p&gt;They read the first paragraph of your App Store description and then try to do the thing it says.&lt;/p&gt;

&lt;p&gt;What's being checked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does the app do what the listing claims&lt;/li&gt;
&lt;li&gt;Is the primary feature discoverable without help&lt;/li&gt;
&lt;li&gt;Does a paywall or upsell block the flow the description promised&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Design for it:&lt;/strong&gt; the first sentence of your description should describe what a user can do in their first thirty seconds. If the copy promises an AI writing assistant and the first screen is a login wall, the reviewer has found a mismatch before they've evaluated anything else. If authentication is unavoidable, ship a demo mode or a reviewer bypass and say so in the notes.&lt;/p&gt;

&lt;p&gt;This is also the step where an overwritten description costs you. Your listing sets the expectation that the next two minutes test against, and nothing else in the review is graded against a bar you set yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Minutes 7 to 8: permissions and signals
&lt;/h2&gt;

&lt;p&gt;A scan of what the app asks for and anything that looks off.&lt;/p&gt;

&lt;p&gt;What's being checked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Are requested permissions justified by what the app does&lt;/li&gt;
&lt;li&gt;Do the usage description strings explain &lt;em&gt;why&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Any entitlements that don't belong in a consumer app&lt;/li&gt;
&lt;li&gt;Any sign this is a template with placeholder content still in it&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Design for it:&lt;/strong&gt; prune permissions ruthlessly. A note-taking app requesting contacts access is a question you don't want asked. Write usage strings that name the feature: "To let you add photos to your notes" tells a reviewer something, "Photos" doesn't.&lt;/p&gt;

&lt;p&gt;And search your build for placeholder content before submitting. &lt;code&gt;Lorem ipsum&lt;/code&gt; and &lt;code&gt;Sample Item 1&lt;/code&gt; in a shipped app suggest nobody opened the screens, which is the impression that colours everything else.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this changes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Reviewer attention is a scarce resource.&lt;/strong&gt; Every second they spend confused moves you closer to a rejection, and there are only about four hundred of those seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The account loop is disproportionate.&lt;/strong&gt; Two of eight minutes on sign-up and deletion, for a flow most teams treat as an afterthought. If your app has accounts, that flow deserves the same care as your primary feature.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your description is the rubric.&lt;/strong&gt; You write it, then get tested against it. Underpromise in the listing and overdeliver in the app, not the reverse.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing reviewers actually complain about
&lt;/h2&gt;

&lt;p&gt;Asked what frustrates them most, both said a version of the same thing: apps where you can tell nobody on the team ever used the app end to end.&lt;/p&gt;

&lt;p&gt;The fix is unglamorous. Walk the whole thing yourself before submitting. Then hand it to someone who didn't build it and watch them try, without helping. Fix everything they get stuck on. Then submit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;Review isn't adversarial and isn't magic. It's a few hundred seconds of a person answering a small number of specific questions about your app.&lt;/p&gt;

&lt;p&gt;Design so those questions answer themselves, and most of the rejection surface disappears.&lt;/p&gt;

&lt;p&gt;For teams shipping repeatedly, moving screenshots, metadata, and review notes into the release pipeline rather than reassembling them each submission removes the most common source of drift between the listing and the build. That's part of what &lt;a href="https://www.letsdeploy.it/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=app-store-review-six-minutes" rel="noopener noreferrer"&gt;letsdeploy.it&lt;/a&gt; handles.&lt;/p&gt;




&lt;p&gt;What got you rejected, and how long did it take to work out why? The gap between the rejection text and the actual cause is the part nobody warns you about.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>mobile</category>
      <category>appstore</category>
      <category>startup</category>
    </item>
    <item>
      <title>I published my digital product's refund rate</title>
      <dc:creator>Lucas Martin</dc:creator>
      <pubDate>Fri, 14 Aug 2026 07:00:50 +0000</pubDate>
      <link>https://dev.to/lucas_martin_8cb158b9a81b/i-published-my-digital-products-refund-rate-5735</link>
      <guid>https://dev.to/lucas_martin_8cb158b9a81b/i-published-my-digital-products-refund-rate-5735</guid>
      <description>&lt;ul&gt;
&lt;li&gt;We sell React Native templates at $79&lt;/li&gt;
&lt;li&gt;Refund policy: 7 days, no questions asked, processed in 2 business days&lt;/li&gt;
&lt;li&gt;Refund rate over 6 months: &lt;strong&gt;3.8%&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Blended cost of the policy: &lt;strong&gt;~4% of gross revenue&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Would ship it identically on day one again&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The policy, verbatim
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- 7-day money-back guarantee, no questions asked
- Window starts from the receipt date
- Email support@shaper.studio with your order ID
- Processed within 2 business days
- Excluded: requests after 7 days, third-party API costs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire policy. There is no "unless you cloned the repo" clause. There is no "unless you used support" clause. There is no cooling-off period, no store-credit-first flow, no retention pitch. If you email us the word "refund" and your order ID within seven days, you get your money back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we chose this
&lt;/h2&gt;

&lt;p&gt;Three arguments won:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The buyer cannot inspect the product pre-purchase.&lt;/strong&gt; With source code, the demo video and screenshots are all you get until the credit card clears. A generous refund policy is the only honest way to say "verify for yourself" in a market where verification requires purchase.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. If we are right about the product, the policy is cheap.&lt;/strong&gt; Low refund rate = low policy cost. If we are wrong, the refund rate tells us fast. The only losing scenario is one where our product is bad, and in that scenario we deserve to lose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Refund friction is negative marketing.&lt;/strong&gt; Every "no refunds" post triggers a Reddit thread. Not being on the wrong side of that conversation is worth something.&lt;/p&gt;

&lt;h2&gt;
  
  
  Competitor comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Vendor&lt;/th&gt;
&lt;th&gt;Window&lt;/th&gt;
&lt;th&gt;Conditions&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Applighter&lt;/td&gt;
&lt;td&gt;7 days&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Instamobile&lt;/td&gt;
&lt;td&gt;Case-by-case&lt;/td&gt;
&lt;td&gt;Bundles non-refundable, lose rights, no refund if support was used&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typical CodeCanyon&lt;/td&gt;
&lt;td&gt;14 days&lt;/td&gt;
&lt;td&gt;"Not as described" (unfalsifiable in practice)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typical premium boilerplate&lt;/td&gt;
&lt;td&gt;0 days&lt;/td&gt;
&lt;td&gt;All sales final&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The refund rate math
&lt;/h2&gt;

&lt;p&gt;At $79 with a 3.8% refund rate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;per-order refund cost = 0.038 * $79 = $3.00
plus Stripe fixed fee retained on refund = $0.30
= ~$3.30 lost per paid order
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For context: a Dev.to writeup last year reported ~40% refund rate at $29 vs ~4% at $79 for a similar product. Our number matches that curve. Price is a filter. Cheap templates get impulse buyers who impulse-refund.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually cost us
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Refunded revenue:&lt;/strong&gt; four-figure number over six months&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stripe fixed fees:&lt;/strong&gt; ~30¢ per refund, kept by Stripe&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Support time:&lt;/strong&gt; ~90 min/month (5 min email + 1 click Stripe)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Opportunity cost:&lt;/strong&gt; buyers who would have taken store credit but got cash instead&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Total blended cost:&lt;/strong&gt; ~4% of gross revenue&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What it did not cost us
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Reddit threads: zero&lt;/li&gt;
&lt;li&gt;Public complaints: zero&lt;/li&gt;
&lt;li&gt;Chargebacks: zero (chargebacks are worse than refunds — fees, disputes, reputation)&lt;/li&gt;
&lt;li&gt;Hostile support emails: zero&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Counter-intuitive benefits
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Refund requests are the highest-signal feedback we get.&lt;/strong&gt; Happy customers say nothing. Refund requests come with implicit info — which template, whether they opened the docs, whether they cloned the repo. Two refunds in one week from buyers who never opened the setup docs told us the docs were too easy to miss. We fixed the onboarding email.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generous refunds pre-qualify buyers.&lt;/strong&gt; "Seven days is plenty" = the buyer we want. "Seven days isn't enough" = the buyer who will not evaluate the code for months, will not ship, and will not be happy regardless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The support inbox stays calm.&lt;/strong&gt; "I want a refund" as a two-word conversation instead of a negotiation drops the emotional temperature. Vendors with strict policies deal with disputes, chargebacks, and public complaints. Generous refunds head off most chargebacks before they start.&lt;/p&gt;

&lt;h2&gt;
  
  
  When not to do this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Product under $30:&lt;/strong&gt; impulse buyers push refund rate toward 40%. Fix: raise the price or tighten the policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real marginal cost per unit:&lt;/strong&gt; source code is zero marginal cost, so we can absorb 3.8%. Physical goods cannot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Value fully consumed in the refund window:&lt;/strong&gt; video courses need anti-abuse (e.g., &amp;gt;50% watched voids the refund) or refund rate approaches 100%.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cannot deliver the turnaround:&lt;/strong&gt; a 7-day policy that takes 30 days to process is worse than no policy at all.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The part I did not expect
&lt;/h2&gt;

&lt;p&gt;The policy changed how we write marketing. When the buyer can call your bluff with one email and their money, you stop reaching for weasel words. Every landing page, every screenshot, every code excerpt has to hold up under "would this survive the refund window?" It is the closest thing to product-market fit pressure I have felt in a bootstrapped business.&lt;/p&gt;

&lt;h2&gt;
  
  
  Full breakdown
&lt;/h2&gt;

&lt;p&gt;Full write-up with the internal numbers, the policy design tradeoffs, and the exact refund-request template we use: &lt;a href="https://www.applighter.com/blog/our-react-native-template-refund-policy-what-it-cost" rel="noopener noreferrer"&gt;on the Applighter blog&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>indiehackers</category>
      <category>startup</category>
      <category>saas</category>
    </item>
    <item>
      <title>What Users Actually Feel When Your AI-Generated Code Is 'Fine': A UX Autopsy of 3 Shipped Features</title>
      <dc:creator>Lucas Martin</dc:creator>
      <pubDate>Wed, 29 Jul 2026 04:26:33 +0000</pubDate>
      <link>https://dev.to/lucas_martin_8cb158b9a81b/what-users-actually-feel-when-your-ai-generated-code-is-fine-a-ux-autopsy-of-3-shipped-features-2emc</link>
      <guid>https://dev.to/lucas_martin_8cb158b9a81b/what-users-actually-feel-when-your-ai-generated-code-is-fine-a-ux-autopsy-of-3-shipped-features-2emc</guid>
      <description>&lt;ul&gt;
&lt;li&gt;AI-generated code is often &lt;strong&gt;technically correct and experientially broken&lt;/strong&gt; — it solves the ticket, and the ticket rarely describes the feeling.&lt;/li&gt;
&lt;li&gt;Three real examples: a confirmation modal with no friction (14 accidental account deletions), a naked payment spinner (−4.1% completion), and an error message that blamed the user (2x refund requests).&lt;/li&gt;
&lt;li&gt;Three prompt clauses — &lt;strong&gt;emotional stakes&lt;/strong&gt;, &lt;strong&gt;latency honesty&lt;/strong&gt;, &lt;strong&gt;blame avoidance&lt;/strong&gt; — caught ~70% of these before review.&lt;/li&gt;
&lt;li&gt;A 4-item UX regression checklist for AI-authored PRs, none of which a linter can catch.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Three features shipped last quarter that passed every automated check, got merged by senior engineers, and quietly dragged our activation rate down by 6 percentage points before we caught it.&lt;/p&gt;

&lt;p&gt;All three were AI-generated. None had a bug in the traditional sense. Here's what actually went wrong.&lt;/p&gt;

&lt;p&gt;The uncomfortable truth: &lt;strong&gt;most AI-generated code is technically correct and experientially broken.&lt;/strong&gt; The model doesn't know what a user feels at 11pm on a phone with 40% battery trying to check out. It writes the code that solves the ticket. The ticket almost never describes the feeling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature 1: the confirmation modal that never confirmed
&lt;/h2&gt;

&lt;p&gt;Ticket: &lt;em&gt;"Add a delete-account button in settings with a confirmation modal."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What shipped:&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Dialog&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;DialogTitle&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Delete account?&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;DialogTitle&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;DialogDescription&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    This action cannot be undone.
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;DialogDescription&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;DialogFooter&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;Button&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"outline"&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;close&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Cancel&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&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;Button&lt;/span&gt; &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"destructive"&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;deleteAccount&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Delete&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&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;DialogFooter&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;Dialog&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;Technically perfect. Passed a11y checks. Matched the design system. Support tickets over the next three weeks: &lt;strong&gt;14 users accidentally deleted their accounts.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What was missing: the type-your-username-to-confirm friction step. GitHub does it. Stripe does it. Every consequential delete flow on the internet does it. The model wrote what the ticket asked for, and the ticket didn't ask for friction — because the PM assumed "confirmation modal" implied it. It doesn't. To a model, a confirmation modal is exactly two buttons.&lt;/p&gt;

&lt;p&gt;User interviews afterward:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I thought Cancel was the red one because it was the destructive action."&lt;/p&gt;

&lt;p&gt;"My thumb just... hit it. I don't even remember reading the modal."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The fix was 8 lines. The lesson was bigger: &lt;strong&gt;the model needs the emotional stakes in the prompt, not just the functional requirements.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature 2: the loading state that stole 800ms of trust
&lt;/h2&gt;

&lt;p&gt;Ticket: &lt;em&gt;"Add a loading spinner while the payment processes."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Shipped code, paraphrased:&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;isProcessing&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;Spinner&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pay&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Pay $49&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&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;Technically correct. But what the user saw:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Click "Pay $49"&lt;/li&gt;
&lt;li&gt;Button disappears, spinner appears&lt;/li&gt;
&lt;li&gt;~1.2 seconds of a spinning circle with &lt;strong&gt;no context&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Success screen&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We A/B tested it against this:&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;isProcessing&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex items-center gap-2 text-sm text-muted-foreground"&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;Spinner&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;stage&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pay&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Pay $49&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// stage cycles: "Contacting your bank..." → "Confirming payment..." → "Almost done..."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The staged labels don't reflect what's happening in the backend at all — they're just paced text. Result: &lt;strong&gt;completion rate on the payment step went up 4.1%.&lt;/strong&gt; Users who felt informed didn't bounce; users who watched a naked spinner did.&lt;/p&gt;

&lt;p&gt;The model didn't know a spinner is a UX cliff. It knew a spinner was the canonical solution for a loading state. Those are different things.&lt;/p&gt;

&lt;h2&gt;
  
  
  Feature 3: the error message that blamed the user
&lt;/h2&gt;

&lt;p&gt;Most AI-generated error handling has this shape:&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;try&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;createOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;toast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Invalid input. Please check your details and try again.&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;Every word in that message pushes the failure onto the user. "Invalid input." "Check your details." "Try again."&lt;/p&gt;

&lt;p&gt;One user in a session recording:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I checked. Twice. It's not my card. Forget this." (closes tab)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The actual error, from the server logs: our idempotency key collided because they'd clicked the button twice in 300ms. Nothing was invalid. The order had gone through on their first click.&lt;/p&gt;

&lt;p&gt;Rewrite:&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;try&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;createOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;idempotency_conflict&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;toast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Looks like this went through already — refreshing your order list.&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="nf"&gt;refetchOrders&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="nx"&gt;toast&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Something on our end didn't work. Your card wasn't charged. Try again or ping support — we'll fix it.&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;Two things changed: we handled the specific error case the model glossed over, and the generic fallback puts the blame on us, not the user, while telling them their card is safe. Refund requests on that flow dropped by half.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the model can't feel — and how to compensate in the prompt
&lt;/h2&gt;

&lt;p&gt;The pattern across all three: the model wrote &lt;em&gt;sufficient&lt;/em&gt; code, not &lt;em&gt;humane&lt;/em&gt; code. It optimizes for spec-completion, and specs are a lossy compression of what a good product feels like.&lt;/p&gt;

&lt;p&gt;What we added to our prompts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Emotional-stakes clause:&lt;/strong&gt; "This flow is a delete action. Real users will lose real data if they mis-click. Design the friction accordingly."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency-honesty clause:&lt;/strong&gt; "Any async call over 400ms must show a labelled progress indicator, not a naked spinner. Prefer 3 pacing labels over a single 'Loading...'."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blame-avoidance clause:&lt;/strong&gt; "Error messages must not use 'Invalid', 'Wrong', or 'You'. They must state what won't happen (charge, save, send) and what the user can do next."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those three lines in a system prompt caught roughly 70% of the UX regressions we'd been finding in review after the fact.&lt;/p&gt;

&lt;h2&gt;
  
  
  A UX regression bar for AI-authored PRs
&lt;/h2&gt;

&lt;p&gt;We now run every user-facing AI PR through a 4-item checklist before merge:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Every destructive action has a friction step proportional to its consequence.&lt;/li&gt;
&lt;li&gt;Every async op over 400ms has a labelled progress state, not just a spinner.&lt;/li&gt;
&lt;li&gt;Every error message names what won't happen and offers a next step.&lt;/li&gt;
&lt;li&gt;Every empty state has a first-run tip, not just "No items yet."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of these are things a linter can catch. They're things a person who's watched 50 hours of user-session recordings can catch. Until the model has watched those recordings too, that person stays in the loop — but the checklist makes them 4x faster.&lt;/p&gt;




&lt;p&gt;If you're building a new SaaS and don't want to relearn these the hard way, most of the patterns above — typed-confirm modals, paced loading states, human error copy — are the kind of thing a mature template bakes in by default. The &lt;a href="https://www.applighter.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=ai-code-ux-autopsy" rel="noopener noreferrer"&gt;Applighter&lt;/a&gt; starters ship with them because they were extracted from exactly this kind of production autopsy.&lt;/p&gt;

&lt;p&gt;Which of the four checklist items would your last AI-authored PR have failed? Mine was the naked spinner — twice.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>ux</category>
      <category>webdev</category>
      <category>react</category>
    </item>
  </channel>
</rss>
