<?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: Jules Sarah</title>
    <description>The latest articles on DEV Community by Jules Sarah (@jules_sarah_0718e958f0d24).</description>
    <link>https://dev.to/jules_sarah_0718e958f0d24</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%2F4004010%2Ff34f3a46-3625-4d59-a933-3e49876494b9.png</url>
      <title>DEV Community: Jules Sarah</title>
      <link>https://dev.to/jules_sarah_0718e958f0d24</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jules_sarah_0718e958f0d24"/>
    <language>en</language>
    <item>
      <title>App Store and Google Play Screenshots for Expo Apps: Sizes, Clean Captures, and Automation</title>
      <dc:creator>Jules Sarah</dc:creator>
      <pubDate>Fri, 16 Oct 2026 12:34:38 +0000</pubDate>
      <link>https://dev.to/jules_sarah_0718e958f0d24/app-store-and-google-play-screenshots-for-expo-apps-sizes-clean-captures-and-automation-3cc1</link>
      <guid>https://dev.to/jules_sarah_0718e958f0d24/app-store-and-google-play-screenshots-for-expo-apps-sizes-clean-captures-and-automation-3cc1</guid>
      <description>&lt;p&gt;Screenshots are the last thing most developers think about before submitting an app, and they're the first thing users see in the store. They also cause avoidable trouble: wrong sizes block the upload, a messy status bar looks unfinished, and screenshots that don't match the app can get you rejected.&lt;/p&gt;

&lt;p&gt;This guide covers the sizes each store needs, how to capture clean screenshots from an Expo app, how to automate them so you're not redoing them by hand every release, and what to put in them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What sizes do the stores need?
&lt;/h2&gt;

&lt;p&gt;Requirements change, so double-check the current App Store Connect and Play Console help pages before you export. As of this writing:&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Device&lt;/th&gt;
&lt;th&gt;Required?&lt;/th&gt;
&lt;th&gt;Size (portrait)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;iPhone 6.9"&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1320 × 2868 (1290 × 2796 also accepted)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iPad 13"&lt;/td&gt;
&lt;td&gt;Only if your app supports iPad&lt;/td&gt;
&lt;td&gt;2064 × 2752&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;Upload &lt;strong&gt;1 to 10&lt;/strong&gt; screenshots per device size.&lt;/li&gt;
&lt;li&gt;Apple scales your largest iPhone screenshots down for smaller devices, so one set usually covers every iPhone.&lt;/li&gt;
&lt;li&gt;If your Expo app has &lt;code&gt;"supportsTablet": true&lt;/code&gt;, you need iPad screenshots too. If you don't plan to design for iPad, set it to &lt;code&gt;false&lt;/code&gt; in &lt;code&gt;app.json&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Asset&lt;/th&gt;
&lt;th&gt;Required?&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Phone screenshots&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Yes, at least 2&lt;/strong&gt; (up to 8)&lt;/td&gt;
&lt;td&gt;16:9 or 9:16; each side 320–3840 px&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Feature graphic&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1024 × 500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App icon&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;512 × 512&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tablet screenshots&lt;/td&gt;
&lt;td&gt;Recommended for tablet visibility&lt;/td&gt;
&lt;td&gt;7" and 10"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A 1080 × 1920 or 1080 × 2400 phone screenshot works well for Play.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you capture clean screenshots?
&lt;/h2&gt;

&lt;h3&gt;
  
  
  iOS Simulator
&lt;/h3&gt;

&lt;p&gt;Boot a simulator that matches the 6.9" size (an iPhone "Pro Max" model), run a release-like build, and clean up the status bar first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;xcrun simctl status_bar booted override &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--time&lt;/span&gt; &lt;span class="s2"&gt;"9:41"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--batteryState&lt;/span&gt; charged &lt;span class="nt"&gt;--batteryLevel&lt;/span&gt; 100 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cellularBars&lt;/span&gt; 4 &lt;span class="nt"&gt;--wifiBars&lt;/span&gt; 3

xcrun simctl io booted screenshot home.png
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clear the override when you're done:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;xcrun simctl status_bar booted clear
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Android emulator
&lt;/h3&gt;

&lt;p&gt;Android has a "demo mode" that cleans the status bar:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;adb shell settings put global sysui_demo_allowed 1
adb shell am broadcast &lt;span class="nt"&gt;-a&lt;/span&gt; com.android.systemui.demo &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nb"&gt;command &lt;/span&gt;enter
adb shell am broadcast &lt;span class="nt"&gt;-a&lt;/span&gt; com.android.systemui.demo &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nb"&gt;command &lt;/span&gt;clock &lt;span class="nt"&gt;-e&lt;/span&gt; hhmm 0941
adb shell am broadcast &lt;span class="nt"&gt;-a&lt;/span&gt; com.android.systemui.demo &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nb"&gt;command &lt;/span&gt;battery &lt;span class="nt"&gt;-e&lt;/span&gt; level 100 &lt;span class="nt"&gt;-e&lt;/span&gt; plugged &lt;span class="nb"&gt;false
&lt;/span&gt;adb shell am broadcast &lt;span class="nt"&gt;-a&lt;/span&gt; com.android.systemui.demo &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nb"&gt;command &lt;/span&gt;notifications &lt;span class="nt"&gt;-e&lt;/span&gt; visible &lt;span class="nb"&gt;false

&lt;/span&gt;adb exec-out screencap &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; home.png

adb shell am broadcast &lt;span class="nt"&gt;-a&lt;/span&gt; com.android.systemui.demo &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nb"&gt;command exit&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Before you capture, prepare the app
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use realistic sample data.&lt;/strong&gt; Not "test test asdf." Seed a demo account with content that shows the app at its best.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hide developer UI.&lt;/strong&gt; No dev menus, debug banners, or warning overlays. Use a release or preview build.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick one theme.&lt;/strong&gt; Light or dark, consistently across all screenshots.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How do you automate screenshots?
&lt;/h2&gt;

&lt;p&gt;Retaking screenshots by hand every release gets old fast. &lt;a href="https://maestro.mobile.dev" rel="noopener noreferrer"&gt;Maestro&lt;/a&gt; can drive your app through a flow and capture each screen:&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;# .maestro/screenshots.yaml&lt;/span&gt;
&lt;span class="na"&gt;appId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;com.yourcompany.yourapp&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;launchApp&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;clearState&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;tapOn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Sign&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;in"&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;inputText&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;demo@example.com"&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;tapOn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Password"&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;inputText&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;demo-password"&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;tapOn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Continue"&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;takeScreenshot&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;01-home&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;tapOn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Insights"&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;takeScreenshot&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;02-insights&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;tapOn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Settings"&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;takeScreenshot&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;03-settings&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;maestro &lt;span class="nb"&gt;test&lt;/span&gt; .maestro/screenshots.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the status bar commands first, then the flow, once on the iOS Simulator and once on the Android emulator. You get the same set of screens for both stores in a couple of minutes, and you can rerun it whenever the UI changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should go in the screenshots?
&lt;/h2&gt;

&lt;p&gt;Raw screenshots work, but most successful listings add a short caption and a background. A few principles:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first three matter most.&lt;/strong&gt; In search results, users often see only the first few screenshots. Make sure those three explain what the app does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One idea per screenshot.&lt;/strong&gt; A short caption, a few words, that states a benefit: "Track every workout in seconds," not "Workout logging screen."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Order them like a story.&lt;/strong&gt; Start with the core value, then key features, then extras like settings or integrations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep text readable on a phone.&lt;/strong&gt; If you can't read the caption at thumbnail size, it's too small.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Show the real app.&lt;/strong&gt; Apple's guidelines (2.3) require screenshots to show the app in use. Mockups of features that don't exist, or paid features presented as free, can get the app rejected. Label paid features clearly.&lt;/p&gt;

&lt;p&gt;A simple workflow: export raw screenshots from the Maestro run, drop them into one design file with a template for each store size, add captions, and export. Next release, replace the raw screenshots and export again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist before upload
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] iPhone 6.9" set, 1 to 10 screenshots&lt;/li&gt;
&lt;li&gt;[ ] iPad 13" set, or &lt;code&gt;supportsTablet: false&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Google Play: 2+ phone screenshots, 1024 × 500 feature graphic, 512 × 512 icon&lt;/li&gt;
&lt;li&gt;[ ] Clean status bar (9:41, full battery, no notifications)&lt;/li&gt;
&lt;li&gt;[ ] Realistic demo data, no debug UI&lt;/li&gt;
&lt;li&gt;[ ] First three screenshots explain the app&lt;/li&gt;
&lt;li&gt;[ ] Captions readable at thumbnail size&lt;/li&gt;
&lt;li&gt;[ ] Paid features labeled; everything shown actually exists&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Screenshots aren't hard, but they're easy to leave until the night before submission. Get the sizes right, capture clean frames, automate the capture once with Maestro, and treat the first three screenshots as your pitch.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Want someone else to handle screenshots, store listings, and submission? &lt;a href="https://letsdeploy.it/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=store_screenshots_expo" rel="noopener noreferrer"&gt;LetsDeployIt&lt;/a&gt; takes your app from build to live on the App Store and Google Play.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What tool do you use for store screenshots? Share it in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>ios</category>
      <category>android</category>
    </item>
    <item>
      <title>Google Play's 12 Testers for 14 Days Rule: A Day-by-Day Plan That Gets You to Production</title>
      <dc:creator>Jules Sarah</dc:creator>
      <pubDate>Thu, 15 Oct 2026 11:47:02 +0000</pubDate>
      <link>https://dev.to/jules_sarah_0718e958f0d24/google-plays-12-testers-for-14-days-rule-a-day-by-day-plan-that-gets-you-to-production-2acg</link>
      <guid>https://dev.to/jules_sarah_0718e958f0d24/google-plays-12-testers-for-14-days-rule-a-day-by-day-plan-that-gets-you-to-production-2acg</guid>
      <description>&lt;p&gt;You've built the app. The iOS build is in review. Then Google Play Console tells you that before you can publish to production, you need to run a closed test with &lt;strong&gt;at least 12 testers, opted in for at least 14 days in a row&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For a solo developer, this is often the longest step of the whole launch. Not because it's technically hard, but because finding 12 real people and keeping them opted in for two weeks takes planning.&lt;/p&gt;

&lt;p&gt;Here's a day-by-day plan that gets you through it without restarting the clock.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who does this apply to?
&lt;/h2&gt;

&lt;p&gt;The requirement applies to &lt;strong&gt;personal developer accounts created after November 13, 2023&lt;/strong&gt;. Organization accounts (registered as a business, with a D-U-N-S number) don't have this requirement.&lt;/p&gt;

&lt;p&gt;If you're starting a new account and can register as an organization, that's worth considering. Otherwise, plan for the test.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;It has to be a &lt;strong&gt;closed&lt;/strong&gt; test. Internal testing doesn't count.&lt;/li&gt;
&lt;li&gt;At least &lt;strong&gt;12 testers&lt;/strong&gt; must be &lt;strong&gt;opted in&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;They must have been opted in for the &lt;strong&gt;last 14 days, continuously&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;After that, you &lt;strong&gt;apply for production access&lt;/strong&gt;, which includes a short questionnaire about your test. Google then reviews the application.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The word that trips people up is &lt;em&gt;continuously&lt;/em&gt;. If testers drop out and you fall below 12, you're no longer meeting the requirement for those days.&lt;/p&gt;

&lt;h2&gt;
  
  
  Day -7 to 0: Recruit before you need them
&lt;/h2&gt;

&lt;p&gt;Start recruiting &lt;strong&gt;before&lt;/strong&gt; your build is ready. This is the single biggest time saver.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Aim for 15 to 20 people,&lt;/strong&gt; not 12. Someone always leaves, switches phones, or never opts in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everyone needs an Android phone and a Google account.&lt;/strong&gt; iPhone friends can't help here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask people who'd actually use the app.&lt;/strong&gt; Google's questionnaire asks about feedback and changes you made, so real engagement makes the application easier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Collect Gmail addresses,&lt;/strong&gt; or set up a &lt;strong&gt;Google Group&lt;/strong&gt; that testers join. A group is easier: add the group to the test once, and anyone who joins it becomes eligible.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Good places to find testers: friends and family with Android phones, your mailing list or waitlist, a community related to your app's topic, and other developers in the same situation (swap testing with them, and actually use each other's apps).&lt;/p&gt;

&lt;h2&gt;
  
  
  Day 0: Set up the closed test
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Create the app and complete the setup tasks
&lt;/h3&gt;

&lt;p&gt;In Play Console, create your app and work through the dashboard's setup tasks: privacy policy, app access (demo account), ads declaration, content rating, target audience, and Data Safety. Some of these must be done before testing tracks can be reviewed.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Upload a build to the closed testing track
&lt;/h3&gt;

&lt;p&gt;With EAS, configure a submit profile that targets closed testing. In &lt;code&gt;eas.json&lt;/code&gt;, the closed testing track is called &lt;code&gt;alpha&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;"closed"&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;"serviceAccountKeyPath"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"./play-service-account.json"&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;"alpha"&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;Then build and submit:&lt;br&gt;
&lt;/p&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
eas submit &lt;span class="nt"&gt;--platform&lt;/span&gt; android &lt;span class="nt"&gt;--profile&lt;/span&gt; closed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the submission is rejected because the app has never had a build uploaded, upload the first &lt;code&gt;.aab&lt;/code&gt; manually in Play Console, then use EAS Submit for everything after. Check the current EAS Submit docs, since first-upload support has been improving.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Add testers and share the opt-in link
&lt;/h3&gt;

&lt;p&gt;In the closed testing track, add your email list or Google Group, then copy the &lt;strong&gt;opt-in link&lt;/strong&gt;. Send it to every tester with clear instructions:&lt;/p&gt;

&lt;blockquote&gt;
&lt;ol&gt;
&lt;li&gt;Open this link on your Android phone, signed in with the Google account you gave me.&lt;/li&gt;
&lt;li&gt;Tap "Become a tester."&lt;/li&gt;
&lt;li&gt;Install the app from the Play Store link on that page.&lt;/li&gt;
&lt;li&gt;Please stay opted in for the next two weeks, and use the app a few times.&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;

&lt;p&gt;Step 4 matters. Say it explicitly.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Wait for the track to be reviewed
&lt;/h3&gt;

&lt;p&gt;The first closed test release goes through review. The 14 days only start counting once testers can actually opt in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Days 1 to 3: Confirm everyone is in
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Check the tester count&lt;/strong&gt; in Play Console. It shows how many testers have opted in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chase the stragglers&lt;/strong&gt; personally. The most common problem is someone opting in with a different Google account than the one on their phone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't stop at 12.&lt;/strong&gt; Keep inviting until you have a buffer of three or more.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Days 4 to 13: Keep them engaged
&lt;/h2&gt;

&lt;p&gt;Opted in is the requirement, but engagement makes the production application stronger and the app better.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Ship one or two updates&lt;/strong&gt; to the closed track based on feedback. Each update is something concrete to mention in the questionnaire.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask a specific question&lt;/strong&gt; every few days: "Did the sign-up flow make sense?" gets more answers than "Any feedback?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep a simple log&lt;/strong&gt;: who reported what, and what you changed. You'll use it on day 14.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-check the tester count&lt;/strong&gt; a couple of times. Catch drop-offs before you fall under 12.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Day 14+: Apply for production access
&lt;/h2&gt;

&lt;p&gt;Once 12 or more testers have been opted in for 14 continuous days, Play Console lets you apply for production access. The questionnaire asks things like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How you recruited testers, and how easy it was&lt;/li&gt;
&lt;li&gt;How testers engaged with the app, and what feedback they gave&lt;/li&gt;
&lt;li&gt;What changes you made based on that feedback&lt;/li&gt;
&lt;li&gt;Who the app is for, and what value it provides&lt;/li&gt;
&lt;li&gt;Whether the app is ready for production&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Answer from your log. Specific beats vague: &lt;em&gt;"Five testers said the onboarding skipped explaining notifications, so we added a permission explainer screen in build 1.0.3"&lt;/em&gt; is much stronger than &lt;em&gt;"We got good feedback and made improvements."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Then wait for the review. Use the time to finish your store listing, screenshots, and release notes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What resets or delays the process?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mistake&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Using internal testing&lt;/td&gt;
&lt;td&gt;Doesn't count toward the requirement&lt;/td&gt;
&lt;td&gt;Use the closed testing track&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recruiting exactly 12&lt;/td&gt;
&lt;td&gt;One dropout drops you below the minimum&lt;/td&gt;
&lt;td&gt;Recruit 15 to 20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Testers using the wrong Google account&lt;/td&gt;
&lt;td&gt;They're not counted as opted in&lt;/td&gt;
&lt;td&gt;Send clear instructions; confirm the count&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Waiting to recruit until the build is ready&lt;/td&gt;
&lt;td&gt;Adds a week or more&lt;/td&gt;
&lt;td&gt;Recruit while building&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vague questionnaire answers&lt;/td&gt;
&lt;td&gt;Weaker application&lt;/td&gt;
&lt;td&gt;Keep a feedback log from day 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Paying for fake testers&lt;/td&gt;
&lt;td&gt;Low engagement and risk to your account&lt;/td&gt;
&lt;td&gt;Use real people who'll try the app&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The whole timeline at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;When&lt;/th&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Day -7&lt;/td&gt;
&lt;td&gt;Start recruiting 15 to 20 Android users&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Day 0&lt;/td&gt;
&lt;td&gt;Finish setup tasks, submit to closed testing, share opt-in link&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Days 1–3&lt;/td&gt;
&lt;td&gt;Confirm 12+ opted in; chase stragglers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Days 4–13&lt;/td&gt;
&lt;td&gt;Ship updates, collect feedback, keep a log&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Day 14+&lt;/td&gt;
&lt;td&gt;Apply for production access&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;After review&lt;/td&gt;
&lt;td&gt;Release to production&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Plan on roughly three weeks from first upload to production for a brand-new personal account, and start recruiting early to keep it there.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Don't want to manage testers, tracks, and the production access questions yourself? &lt;a href="https://letsdeploy.it/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=google_play_12_testers" rel="noopener noreferrer"&gt;LetsDeployIt&lt;/a&gt; handles App Store and Google Play submission end to end, including the closed testing setup.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What's been the hardest part of the 14-day test for you? Share in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>android</category>
      <category>reactnative</category>
      <category>expo</category>
      <category>googleplay</category>
    </item>
    <item>
      <title>Plan, Build, Review, Verify: A Claude Code Workflow for Shipping Expo + Supabase Features</title>
      <dc:creator>Jules Sarah</dc:creator>
      <pubDate>Thu, 08 Oct 2026 14:52:01 +0000</pubDate>
      <link>https://dev.to/jules_sarah_0718e958f0d24/plan-build-review-verify-a-claude-code-workflow-for-shipping-expo-supabase-features-55l1</link>
      <guid>https://dev.to/jules_sarah_0718e958f0d24/plan-build-review-verify-a-claude-code-workflow-for-shipping-expo-supabase-features-55l1</guid>
      <description>&lt;p&gt;Most Claude Code setups for React Native stop at a &lt;code&gt;CLAUDE.md&lt;/code&gt; file. That's the foundation: it tells the agent your conventions. But conventions alone don't stop the two failures I see most often in agent-built Expo features:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The agent starts writing before it understands the codebase&lt;/strong&gt;, and you get a feature that works but ignores how the rest of the app is built.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The agent says "done" when it isn't&lt;/strong&gt;: types don't check, a Supabase table has no RLS, and the screen has never rendered an error state.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This post is the workflow I use to fix both: four stages, each backed by a Claude Code feature, with the files to set it up. It assumes you already have a &lt;code&gt;CLAUDE.md&lt;/code&gt; with your commands and conventions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four stages
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Claude Code feature&lt;/th&gt;
&lt;th&gt;What it prevents&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Plan&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Plan mode&lt;/td&gt;
&lt;td&gt;Code written before the codebase is understood&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Build&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A project skill (&lt;code&gt;/new-feature&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Inconsistent structure across features&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Review&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Read-only subagents&lt;/td&gt;
&lt;td&gt;Security and convention mistakes the builder can't see&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Verify&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A verifier subagent + a Stop hook&lt;/td&gt;
&lt;td&gt;"Done" that isn't done&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Stage 1: Plan before any edit
&lt;/h2&gt;

&lt;p&gt;Start every non-trivial feature in &lt;strong&gt;plan mode&lt;/strong&gt;. Press &lt;code&gt;Shift+Tab&lt;/code&gt; to cycle permission modes until plan mode is active, or type &lt;code&gt;/plan&lt;/code&gt;. In plan mode Claude explores and proposes but can't edit files. When it needs to research the codebase, it delegates to a built-in read-only Plan subagent, so the exploration output stays out of your main conversation.&lt;/p&gt;

&lt;p&gt;A good planning prompt for an Expo + Supabase feature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Plan a "favorites" feature: users can heart a workout and see a Favorites tab.

Before planning, find:
- how existing features are structured under features/
- how data fetching is done (queries/ hooks)
- the most recent migration in supabase/migrations/ and its RLS pattern
- how tabs are defined in app/(tabs)/_layout.tsx

The plan must list: the migration (with RLS and policies), the query hooks,
the route files, the components, and the tests. Don't write code yet.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then read the plan. This is the cheapest moment to catch a wrong assumption: a plan that puts &lt;code&gt;supabase.from()&lt;/code&gt; calls inside a screen component costs ten seconds to correct now and an hour to refactor later. When the plan is right, approve it and let Claude build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 2: Build with a skill, not a paragraph
&lt;/h2&gt;

&lt;p&gt;If you build features the same way every time, stop retyping the instructions. A &lt;strong&gt;skill&lt;/strong&gt; is a folder with a &lt;code&gt;SKILL.md&lt;/code&gt; that you invoke as a slash command. Put project skills in &lt;code&gt;.claude/skills/&lt;/code&gt; and commit them so the whole team gets the same workflow.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;&amp;lt;!-- .claude/skills/new-feature/SKILL.md --&amp;gt;
---
&lt;/span&gt;name: new-feature
description: Scaffold a new feature in this Expo + Supabase app following project conventions.
&lt;span class="gh"&gt;disable-model-invocation: true
---
&lt;/span&gt;
Build the feature described by the user, following this order:
&lt;span class="p"&gt;
1.&lt;/span&gt; &lt;span class="gs"&gt;**Migration**&lt;/span&gt; in &lt;span class="sb"&gt;`supabase/migrations/&amp;lt;timestamp&amp;gt;_&amp;lt;name&amp;gt;.sql`&lt;/span&gt;:
&lt;span class="p"&gt;   -&lt;/span&gt; &lt;span class="sb"&gt;`create table`&lt;/span&gt; and &lt;span class="sb"&gt;`enable row level security`&lt;/span&gt; in the same file
&lt;span class="p"&gt;   -&lt;/span&gt; policies scoped to &lt;span class="sb"&gt;`(select auth.uid())`&lt;/span&gt;; never &lt;span class="sb"&gt;`using (true)`&lt;/span&gt; on user data
&lt;span class="p"&gt;   -&lt;/span&gt; an allow test and a deny test in &lt;span class="sb"&gt;`supabase/tests/`&lt;/span&gt;
&lt;span class="p"&gt;2.&lt;/span&gt; &lt;span class="gs"&gt;**Query hooks**&lt;/span&gt; in &lt;span class="sb"&gt;`queries/&amp;lt;feature&amp;gt;.ts`&lt;/span&gt; using TanStack Query.
   No data fetching inside components.
&lt;span class="p"&gt;3.&lt;/span&gt; &lt;span class="gs"&gt;**Feature folder**&lt;/span&gt; &lt;span class="sb"&gt;`features/&amp;lt;feature&amp;gt;/`&lt;/span&gt; for screens and components.
&lt;span class="p"&gt;4.&lt;/span&gt; &lt;span class="gs"&gt;**Routes**&lt;/span&gt; in &lt;span class="sb"&gt;`app/`&lt;/span&gt; that only import and render the feature screen.
&lt;span class="p"&gt;5.&lt;/span&gt; Every screen that fetches data has loading, empty, and error states.

Use theme tokens from &lt;span class="sb"&gt;`theme/tokens.ts`&lt;/span&gt;. Navigation comes from &lt;span class="sb"&gt;`expo-router`&lt;/span&gt; only.
When finished, run &lt;span class="sb"&gt;`npm run typecheck`&lt;/span&gt; and the tests, and report the results.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now building a feature is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/new-feature favorites: heart a workout, Favorites tab lists hearted workouts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;disable-model-invocation: true&lt;/code&gt; means the skill only runs when you call it, which is what you want for a workflow that creates files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 3: Review with read-only subagents
&lt;/h2&gt;

&lt;p&gt;The agent that wrote the code is the worst reviewer of it: it has the same blind spots it had while writing. A &lt;strong&gt;subagent&lt;/strong&gt; runs in its own context window with its own system prompt and tool restrictions, which makes it a fresh pair of eyes that can't accidentally "fix" things during review.&lt;/p&gt;

&lt;p&gt;Subagents are Markdown files with YAML frontmatter in &lt;code&gt;.claude/agents/&lt;/code&gt;. Two are worth having in every Expo + Supabase repo.&lt;/p&gt;

&lt;h3&gt;
  
  
  An RLS reviewer
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;&amp;lt;!-- .claude/agents/rls-reviewer.md --&amp;gt;
---
&lt;/span&gt;name: rls-reviewer
description: Reviews Supabase migrations and policies for RLS mistakes. Use proactively after any change in supabase/.
tools: Read, Grep, Glob
&lt;span class="gh"&gt;model: sonnet
---
&lt;/span&gt;
You review Supabase SQL for security problems. You never edit files.

Check every migration changed on this branch for:
&lt;span class="p"&gt;-&lt;/span&gt; tables created without &lt;span class="sb"&gt;`enable row level security`&lt;/span&gt; in the same file
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`using (true)`&lt;/span&gt; or &lt;span class="sb"&gt;`with check (true)`&lt;/span&gt; on tables holding user data
&lt;span class="p"&gt;-&lt;/span&gt; update policies that let users change privileged columns (role, plan, is_admin, credits)
&lt;span class="p"&gt;-&lt;/span&gt; views without &lt;span class="sb"&gt;`security_invoker = true`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`security definer`&lt;/span&gt; functions without an &lt;span class="sb"&gt;`auth.uid()`&lt;/span&gt; check
&lt;span class="p"&gt;-&lt;/span&gt; storage policies that don't scope paths to the user's folder
&lt;span class="p"&gt;-&lt;/span&gt; missing deny tests in supabase/tests/

Report findings as Critical / Warning / OK, with file and line for each.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because &lt;code&gt;tools&lt;/code&gt; lists only &lt;code&gt;Read, Grep, Glob&lt;/code&gt;, this subagent can't edit or run anything. It reads and reports.&lt;/p&gt;

&lt;h3&gt;
  
  
  A mobile conventions reviewer
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;&amp;lt;!-- .claude/agents/rn-reviewer.md --&amp;gt;
---
&lt;/span&gt;name: rn-reviewer
description: Reviews React Native changes for project conventions and mobile pitfalls. Use proactively after UI changes.
tools: Read, Grep, Glob, Bash
&lt;span class="gh"&gt;model: sonnet
---
&lt;/span&gt;
You review React Native code. You never edit files.

Run &lt;span class="sb"&gt;`git diff main...HEAD`&lt;/span&gt; and check the changed files for:
&lt;span class="p"&gt;-&lt;/span&gt; data fetching inside useEffect or components instead of queries/ hooks
&lt;span class="p"&gt;-&lt;/span&gt; secrets or keys in EXPO_PUBLIC_ variables
&lt;span class="p"&gt;-&lt;/span&gt; imports from @react-navigation/&lt;span class="err"&gt;*&lt;/span&gt; instead of expo-router
&lt;span class="p"&gt;-&lt;/span&gt; lists rendered with .map in ScrollView instead of FlatList/FlashList
&lt;span class="p"&gt;-&lt;/span&gt; icon-only buttons without accessibilityLabel
&lt;span class="p"&gt;-&lt;/span&gt; forms without keyboard handling
&lt;span class="p"&gt;-&lt;/span&gt; hardcoded colors or spacing instead of theme tokens
&lt;span class="p"&gt;-&lt;/span&gt; missing loading, empty, or error states

Report Critical / Warning / Suggestion with file and line.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the build stage, ask for both reviews:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Use the rls-reviewer and rn-reviewer subagents to review this branch in parallel,
then fix every Critical finding.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reviews run in their own contexts, only their summaries come back, and the main conversation does the fixing with full context of what it built.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage 4: Verify, and make "done" mean done
&lt;/h2&gt;

&lt;p&gt;Two layers here: one the agent uses on purpose, one that runs whether it remembers or not.&lt;/p&gt;

&lt;h3&gt;
  
  
  A verifier subagent for noisy output
&lt;/h3&gt;

&lt;p&gt;Test and typecheck output is long, and you rarely need all of it in your main context. A subagent that runs the checks and returns only failures keeps the main conversation clean:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;&amp;lt;!-- .claude/agents/verifier.md --&amp;gt;
---
&lt;/span&gt;name: verifier
description: Runs typecheck, lint, and tests, and reports only failures. Use before reporting any task as complete.
tools: Bash, Read
&lt;span class="gh"&gt;model: haiku
---
&lt;/span&gt;
Run, in order:
&lt;span class="p"&gt;1.&lt;/span&gt; npm run typecheck
&lt;span class="p"&gt;2.&lt;/span&gt; npx expo lint
&lt;span class="p"&gt;3.&lt;/span&gt; npx vitest run
&lt;span class="p"&gt;4.&lt;/span&gt; npx supabase test db   (if supabase/ changed)

Report only failures: the command, the file, the line, and the error.
If everything passes, say so in one line.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Routing a mechanical task like this to a smaller, cheaper model is one of the cost controls subagents are designed for.&lt;/p&gt;

&lt;h3&gt;
  
  
  A Stop hook as the backstop
&lt;/h3&gt;

&lt;p&gt;Instructions can be forgotten; hooks can't. Add a &lt;code&gt;Stop&lt;/code&gt; hook in &lt;code&gt;.claude/settings.json&lt;/code&gt; that typechecks whenever Claude finishes a turn:&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;"hooks"&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;"Stop"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx tsc --noEmit"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;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;If types fail, you see it immediately, before you read "Done!" and move on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a full run looks like
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;Shift+Tab&lt;/code&gt; into plan mode. Describe the feature and what to research. Review and approve the plan.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/new-feature favorites: …&lt;/code&gt; to build it in your conventions.&lt;/li&gt;
&lt;li&gt;"Use rls-reviewer and rn-reviewer in parallel, then fix every Critical."&lt;/li&gt;
&lt;li&gt;"Use the verifier." Fix anything it reports.&lt;/li&gt;
&lt;li&gt;Read the diff yourself before committing.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Step 5 isn't optional. The workflow makes agent output reliably &lt;em&gt;reviewable&lt;/em&gt;, not reliably &lt;em&gt;correct&lt;/em&gt;. You're still the last reviewer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three rules that keep this working
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Keep subagent descriptions short and specific.&lt;/strong&gt; Claude uses the &lt;code&gt;description&lt;/code&gt; field to decide when to delegate, and all descriptions sit in context. "Reviews Supabase migrations for RLS mistakes. Use proactively after any change in supabase/." routes well. A paragraph doesn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read-only reviewers should stay read-only.&lt;/strong&gt; Don't give review subagents &lt;code&gt;Edit&lt;/code&gt; or &lt;code&gt;Write&lt;/code&gt;. A reviewer that fixes things isn't reviewing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Commit &lt;code&gt;.claude/&lt;/code&gt;.&lt;/strong&gt; Skills, subagents, and settings in the repo mean every teammate (and every CI run) gets the same workflow. Personal tweaks go in your user-level &lt;code&gt;~/.claude/&lt;/code&gt; folder.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part no workflow fixes
&lt;/h2&gt;

&lt;p&gt;All four stages work better when the codebase itself is consistent. An agent copies the patterns it finds: if three features fetch data in &lt;code&gt;useEffect&lt;/code&gt;, the fourth will too, whatever your skill says. Reviewers catch some of that drift; a clean starting structure prevents it.&lt;/p&gt;

&lt;p&gt;That's the idea behind &lt;a href="https://www.applighter.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=claude_code_expo_workflow_2026" rel="noopener noreferrer"&gt;AppLighter&lt;/a&gt;: Expo + Supabase templates where the conventions this workflow enforces (query hooks, RLS in every migration, theme tokens, thin routes) are already in place, so Claude Code extends a codebase that's consistent from day one.&lt;/p&gt;

&lt;p&gt;What's in your &lt;code&gt;.claude/agents/&lt;/code&gt; folder? I'm always looking for the next reviewer worth adding.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Claude Code features change quickly. The file formats here follow the current &lt;a href="https://code.claude.com/docs/en/sub-agents" rel="noopener noreferrer"&gt;Claude Code docs&lt;/a&gt;; check them if something doesn't load.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>ai</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Postgres Local Dev in 2026: The Numbers, and Lighter Alternatives to Docker Supabase</title>
      <dc:creator>Jules Sarah</dc:creator>
      <pubDate>Mon, 05 Oct 2026 11:44:50 +0000</pubDate>
      <link>https://dev.to/jules_sarah_0718e958f0d24/postgres-local-dev-in-2026-the-numbers-and-lighter-alternatives-to-docker-supabase-1m5n</link>
      <guid>https://dev.to/jules_sarah_0718e958f0d24/postgres-local-dev-in-2026-the-numbers-and-lighter-alternatives-to-docker-supabase-1m5n</guid>
      <description>&lt;p&gt;Running &lt;em&gt;Postgres&lt;/em&gt; locally has never been easier. Running a &lt;em&gt;backend&lt;/em&gt; locally has quietly become one of the heaviest things on your laptop.&lt;/p&gt;

&lt;p&gt;That distinction matters. If your app only talks to a database, &lt;code&gt;brew install postgresql&lt;/code&gt; is as light as it was ten years ago. But if your app uses Supabase (or any backend-as-a-service), it expects auth, a REST API, realtime, storage, and edge functions too. Locally, that's a stack of containers, and it's where the RAM goes.&lt;/p&gt;

&lt;p&gt;Here are the numbers, the reason they got this way, and the alternatives, with commands to measure everything on your own machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The baseline: Docker Supabase
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;supabase start
docker stats &lt;span class="nt"&gt;--no-stream&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The official local stack runs about a dozen containers: Postgres, PostgREST, GoTrue (auth), Realtime, Storage, imgproxy, the edge runtime, Studio, the API gateway, the meta service, and supporting services. The tinbase project's README benchmarks the stack at &lt;strong&gt;about 1.4 GB of RAM at boot and about a minute to boot cold&lt;/strong&gt;, before Docker Desktop's own VM overhead.&lt;/p&gt;

&lt;p&gt;Your numbers will differ with your Docker Desktop version, CPU architecture, and how many projects you run. That's why the measurement commands are at the end of this post: run them and see what your machine actually pays.&lt;/p&gt;

&lt;h2&gt;
  
  
  The old baseline: just Postgres
&lt;/h2&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;postgresql@17
brew services start postgresql@17
createdb myproject
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A native Postgres server idles in tens of megabytes and starts in about a second. Nothing about Postgres got heavier. What changed is what we expect "the local database" to include.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the stack got heavy
&lt;/h2&gt;

&lt;p&gt;Backend-as-a-service platforms made a reasonable packaging choice: ship the local environment as the same set of services that runs in production, one container per service. Supabase, Appwrite, and Nhost all lean on Docker Compose for local development.&lt;/p&gt;

&lt;p&gt;For parity with production, that's correct. In the cloud those services run as separate deployments, and mirroring that locally catches configuration differences early.&lt;/p&gt;

&lt;p&gt;For a laptop, it's expensive. Every auxiliary service carries its own runtime, its own memory floor, and its own startup time, even when it sits idle for the whole session. Locally, most of those services could share one process, but the packaging doesn't make that distinction.&lt;/p&gt;

&lt;p&gt;(The notable exception is PocketBase, which has always been a single Go binary with SQLite. It proved developers love the single-binary model; it just isn't Postgres.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Three lighter alternatives
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Native Postgres, and stub the rest
&lt;/h3&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;postgresql@17
brew services start postgresql@17
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Footprint:&lt;/strong&gt; tens of megabytes, about a second to start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trade-off:&lt;/strong&gt; you lose auth, realtime, storage, and edge functions. Either stub them in your code or point those calls at a hosted project. That works if your app barely uses them; it breaks the first time your code calls &lt;code&gt;supabase.auth&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. PGlite (Postgres compiled to WASM)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; @electric-sql/pglite
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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="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="s1"&gt;create table todos (id serial primary key, text text)&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;&lt;strong&gt;Footprint:&lt;/strong&gt; the package is small and an empty database starts fast, but memory is the catch. Under real workloads the WASM heap grows to hundreds of megabytes (the tinbase README measures roughly 575–650 MB) and doesn't shrink.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trade-off:&lt;/strong&gt; it's a library inside your process, not a server, and it runs Postgres in single-user mode. That makes it excellent for tests (a fresh database per test file), and awkward for day-to-day work: &lt;code&gt;psql&lt;/code&gt;, GUI clients, and connection pools need extra plumbing or won't work.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. A single-process, Supabase-compatible backend: tinbase
&lt;/h3&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;&lt;strong&gt;Footprint (from the README):&lt;/strong&gt; 2 processes, about 59 MB of RAM at boot, about 2 seconds to boot, with embedded native Postgres 17 on macOS and Linux.&lt;/p&gt;

&lt;p&gt;It runs REST, auth, storage, realtime, and edge functions in one process, on port &lt;code&gt;54321&lt;/code&gt; like the Supabase CLI, and the official &lt;code&gt;supabase-js&lt;/code&gt; works unchanged. It reads your existing &lt;code&gt;supabase/migrations/&lt;/code&gt; folder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trade-off:&lt;/strong&gt; it's alpha. It covers roughly 80% of the supabase-js surface; MFA, SSO, phone auth, and pgvector aren't supported yet; realtime DELETE events aren't filtered per row; and writes go through a single connection. It's for laptops, CI, and prototypes, not production. Full disclosure: I'm on the team.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;RAM at boot&lt;/th&gt;
&lt;th&gt;Cold boot&lt;/th&gt;
&lt;th&gt;Auth / realtime / storage&lt;/th&gt;
&lt;th&gt;Client&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;~1.4 GB + Docker VM&lt;/td&gt;
&lt;td&gt;~1 min&lt;/td&gt;
&lt;td&gt;Yes, full fidelity&lt;/td&gt;
&lt;td&gt;supabase-js&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Native Postgres&lt;/td&gt;
&lt;td&gt;tens of MB&lt;/td&gt;
&lt;td&gt;~1 s&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;pg, Drizzle, Prisma&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PGlite&lt;/td&gt;
&lt;td&gt;small at start; hundreds of MB under load&lt;/td&gt;
&lt;td&gt;under 1 s&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;In-process&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tinbase&lt;/td&gt;
&lt;td&gt;~59 MB&lt;/td&gt;
&lt;td&gt;~2 s&lt;/td&gt;
&lt;td&gt;Yes, ~80% coverage&lt;/td&gt;
&lt;td&gt;supabase-js&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Docker Supabase and tinbase figures are from the tinbase README benchmarks. Treat all of these as ballparks and measure your own.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Need full Supabase fidelity, and Docker runs fine on your machine&lt;/strong&gt; → Docker Supabase.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only need Postgres; no auth, realtime, or storage&lt;/strong&gt; → native Postgres.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Writing tests that need a fresh database each time&lt;/strong&gt; → PGlite.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Want the Supabase stack without Docker's footprint&lt;/strong&gt; → tinbase, with a Docker Supabase or hosted check before deploy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most teams end up with two: PGlite for tests, and one of the others for development.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this is heading
&lt;/h2&gt;

&lt;p&gt;Hosted platforms have little reason to optimize for laptop RAM. Local development has every reason to. I expect more backend tools to ship two packagings: the production-shaped deployment, and a lightweight dev-time runtime. PGlite becoming the local engine for Prisma Postgres is an early sign. A lighter official local story from Supabase would be a big win for its community, and I'd welcome it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measure it yourself
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Docker Supabase: per-container memory&lt;/span&gt;
supabase start
docker stats &lt;span class="nt"&gt;--no-stream&lt;/span&gt;

&lt;span class="c"&gt;# Native Postgres: resident memory (KB)&lt;/span&gt;
brew services start postgresql@17
ps &lt;span class="nt"&gt;-axo&lt;/span&gt; rss,command | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'[p]ostgres'&lt;/span&gt;

&lt;span class="c"&gt;# tinbase: resident memory (KB) of its processes&lt;/span&gt;
npx tinbase start
ps &lt;span class="nt"&gt;-axo&lt;/span&gt; rss,command | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'[t]inbase'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run each from a cold start and time it with &lt;code&gt;time&lt;/code&gt;. If your numbers differ a lot from the table, leave a comment with your OS, chip, and Docker version. I'm collecting them.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>supabase</category>
      <category>docker</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Expo Push Notifications: The Backend Half Nobody Finishes</title>
      <dc:creator>Jules Sarah</dc:creator>
      <pubDate>Wed, 30 Sep 2026 06:32:00 +0000</pubDate>
      <link>https://dev.to/jules_sarah_0718e958f0d24/expo-push-notifications-the-backend-half-nobody-finishes-29n6</link>
      <guid>https://dev.to/jules_sarah_0718e958f0d24/expo-push-notifications-the-backend-half-nobody-finishes-29n6</guid>
      <description>&lt;p&gt;Every Expo push notification tutorial ends the same way: you get a token on the device, paste it into Expo's push tool, a notification appears, done.&lt;/p&gt;

&lt;p&gt;That's the first half. The second half is what runs in production: storing tokens per user, sending from your backend, checking &lt;strong&gt;receipts&lt;/strong&gt;, and deleting dead tokens before Apple and Google start throttling you. Most apps skip it, and it's why "push works in testing but not for real users" is such a common bug report.&lt;/p&gt;

&lt;p&gt;This is the backend half, using Supabase for storage and an Edge Function for sending. Two quick ground rules first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Expo Go can't receive remote push since SDK 53.&lt;/strong&gt; You need a development build (&lt;code&gt;eas build --profile development&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A token identifies a device, not a user.&lt;/strong&gt; One user can have three devices; one device can be handed to a new user after sign-out. Model it that way.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: A token table that survives real life
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;push_tokens&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;token&lt;/span&gt;       &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt;     &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;cascade&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;platform&lt;/span&gt;    &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;platform&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'ios'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'android'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="n"&gt;updated_at&lt;/span&gt;  &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

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

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"own tokens"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;push_tokens&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The token is the primary key on purpose. When a device changes hands, an upsert on the token reassigns it to the new user instead of leaving a stale row that pushes the previous user's notifications to a stranger.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Register the token on the device
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-notifications&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Device&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-device&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Constants&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-constants&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Platform&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/supabase&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;registerPushToken&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;Device&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isDevice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                    &lt;span class="c1"&gt;// simulators don't get tokens&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestPermissionsAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;granted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OS&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;android&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setNotificationChannelAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;default&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Default&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;importance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AndroidImportance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DEFAULT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;projectId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Constants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expoConfig&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;extra&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;eas&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;projectId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getExpoPushTokenAsync&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;projectId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="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;push_tokens&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;platform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;updated_at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&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;Call it after sign-in and on every app launch. On sign-out, delete the row for this device's token so the next user doesn't inherit it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Send from an Edge Function
&lt;/h2&gt;

&lt;p&gt;Never send from the client: the Expo push endpoint doesn't need a secret, but your token table does, and you don't want app code deciding who gets notified.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// supabase/functions/send-push/index.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;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;npm:@supabase/supabase-js@2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Expo&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;npm:expo-server-sdk&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;expo&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;Expo&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;admin&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="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SUPABASE_URL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SUPABASE_SERVICE_ROLE_KEY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;   &lt;span class="c1"&gt;// server-only; never in the app&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&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;push_tokens&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&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;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;Expo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isExpoPushToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sound&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;default&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tickets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;expo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chunkPushNotifications&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;tickets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(...(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;expo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendPushNotificationsAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// Store ticket ids so a later job can fetch receipts&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&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;push_tickets&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;tickets&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ticket_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="p"&gt;}))&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;sent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tickets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Call it from a database trigger, a webhook, or another function with the user's ID. The service-role key stays in the function's environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Check receipts (the step everyone skips)
&lt;/h2&gt;

&lt;p&gt;A "ticket" only means Expo accepted the message. Whether Apple or Google delivered it comes back later as a &lt;strong&gt;receipt&lt;/strong&gt;. If you never read receipts, you never learn that a token is dead, and you keep sending to it. Apple and Google both throttle apps that do that.&lt;/p&gt;

&lt;p&gt;Run this as a scheduled function 15–30 minutes after sends:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// supabase/functions/check-receipts/index.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&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;push_tickets&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ticket_id, token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;checked&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;expo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chunkPushNotificationReceiptIds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ticket_id&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;receipts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;expo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPushNotificationReceiptsAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;receipts&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;receipt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;receipt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;details&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DeviceNotRegistered&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ticket_id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&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;push_tokens&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&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;push_tickets&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;checked&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ticket_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;DeviceNotRegistered&lt;/code&gt; means the user uninstalled, revoked permission, or the token expired. Delete it. That single loop is the difference between a push system that works for six months and one that quietly degrades.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Handle taps
&lt;/h2&gt;

&lt;p&gt;A notification that opens the app to the wrong screen feels broken. Read &lt;code&gt;data&lt;/code&gt; and route:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRouter&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addNotificationResponseReceivedListener&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&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;screen&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;order&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/orders/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also check &lt;code&gt;Notifications.getLastNotificationResponseAsync()&lt;/code&gt; on cold start, since the listener isn't mounted yet when a tap launches the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist before you ship
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Tokens stored per device with user reassignment on upsert&lt;/li&gt;
&lt;li&gt;[ ] Token deleted on sign-out&lt;/li&gt;
&lt;li&gt;[ ] Sends happen server-side with the service-role key in the function's env only&lt;/li&gt;
&lt;li&gt;[ ] Tickets stored; receipts checked on a schedule; &lt;code&gt;DeviceNotRegistered&lt;/code&gt; tokens deleted&lt;/li&gt;
&lt;li&gt;[ ] Android notification channel created; Android 13+ runtime permission requested&lt;/li&gt;
&lt;li&gt;[ ] APNs key uploaded and FCM v1 credentials configured in EAS&lt;/li&gt;
&lt;li&gt;[ ] Tap handling covers both the running app and cold start&lt;/li&gt;
&lt;li&gt;[ ] Tested on a physical device with a development build, not Expo Go&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where the scaffold comes from
&lt;/h2&gt;

&lt;p&gt;The device-side code above, the token table, and the Supabase wiring are the same in every app. That's the part I let &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=expo_push_backend_2026&amp;amp;utm_content=footer" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; generate: it produces the Expo app with &lt;code&gt;expo-notifications&lt;/code&gt;, Supabase auth, and the data layer already connected, so the time goes into the sending logic and the receipt job, which are the parts that are actually yours.&lt;/p&gt;

&lt;p&gt;What does your receipt-checking setup look like? I've seen everything from a cron job to "we didn't know receipts existed," and I'm curious where most teams land.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>supabase</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Why We Built a Single-Process Supabase Instead of a docker-compose Stack (and What We Learned)</title>
      <dc:creator>Jules Sarah</dc:creator>
      <pubDate>Fri, 25 Sep 2026 09:20:19 +0000</pubDate>
      <link>https://dev.to/jules_sarah_0718e958f0d24/why-we-built-a-single-process-supabase-instead-of-a-docker-compose-stack-and-what-we-learned-45m6</link>
      <guid>https://dev.to/jules_sarah_0718e958f0d24/why-we-built-a-single-process-supabase-instead-of-a-docker-compose-stack-and-what-we-learned-45m6</guid>
      <description>&lt;h2&gt;
  
  
  The moment docker-compose became the problem
&lt;/h2&gt;

&lt;p&gt;Local Supabase is about a dozen containers. On a laptop that's roughly 1.4 GB of RAM at idle and about a minute to boot cold. For a backend engineer with a 32 GB machine, that's fine. It stops being fine the moment anyone else on the team needs the app running locally: a designer iterating on screens, a PM checking a flow, a CI runner with a 7 GB memory cap.&lt;/p&gt;

&lt;p&gt;We hit that wall building &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=single_binary_story_2026" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt;, where thousands of generated apps each need a working backend for preview. A container stack per preview was never going to work. That constraint is where &lt;a href="https://tinbase.dev/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=single_binary_story_2026&amp;amp;utm_content=intro" rel="noopener noreferrer"&gt;tinbase&lt;/a&gt; came from.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "single process" actually means
&lt;/h2&gt;

&lt;p&gt;We didn't want a slimmer container stack. We wanted the backend to be a library.&lt;/p&gt;

&lt;p&gt;In tinbase, every Supabase service is a plain &lt;code&gt;(Request) =&amp;gt; Response&lt;/code&gt; fetch handler: REST (PostgREST grammar), Auth (GoTrue-shaped), Storage, Realtime (Phoenix protocol over WebSocket), Edge Functions, and a Studio-style dashboard at &lt;code&gt;/_/&lt;/code&gt;. Underneath sits a swappable database engine. On a server, one HTTP + WebSocket server wraps those handlers. In a browser, &lt;code&gt;supabase-js&lt;/code&gt; calls them directly through a custom &lt;code&gt;fetch&lt;/code&gt;, and no server exists at all.&lt;/p&gt;

&lt;p&gt;The official &lt;code&gt;@supabase/supabase-js&lt;/code&gt; works unchanged. That was a hard requirement: the moment you ship your own client, every app has to be written twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers, from the README
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;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;tr&gt;
&lt;td&gt;Install&lt;/td&gt;
&lt;td&gt;GB of images&lt;/td&gt;
&lt;td&gt;tens of MB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same SDK, same APIs, same migration files.&lt;/p&gt;

&lt;h2&gt;
  
  
  What took longer than expected
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Three database engines instead of one.&lt;/strong&gt; We started with PGlite (Postgres compiled to WASM) because it runs in a browser. Then we learned its WASM heap sits at roughly 575–650 MB and doesn't shrink. So we added embedded native Postgres 17 as the default on macOS and Linux, and a pure-JS &lt;code&gt;pgmem&lt;/code&gt; engine (about 3.6 MB, no WASM) for the lightest browser previews. Each engine has a different trade-off, and pretending one fits every case would have been dishonest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Wire-protocol fidelity.&lt;/strong&gt; Keeping the real SDK working meant implementing the actual PostgREST query grammar, GoTrue-shaped responses, and the Phoenix realtime protocol. More work than a "compatible" API, but it's why apps move to hosted Supabase without edits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Single-connection realities.&lt;/strong&gt; PGlite is single-user mode, so writes serialize through one connection. Fine for one developer or one preview. Not a production database, and we say so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas nobody warns you about
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;pgmem&lt;/code&gt; doesn't enforce RLS.&lt;/strong&gt; It runs with superuser rights, so policies are created but not applied per request. Use it for previews, never for testing security.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Realtime DELETE events aren't filtered per row.&lt;/strong&gt; INSERT and UPDATE are; DELETE isn't yet. It's in the coverage table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge Functions with &lt;code&gt;npm:&lt;/code&gt;, &lt;code&gt;jsr:&lt;/code&gt;, or URL imports still need bundling.&lt;/strong&gt; Plain functions run in-process.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safari and OPFS.&lt;/strong&gt; PGlite's OPFS filesystem needs a Web Worker, and Safari's open-file-handle limit blocks it. IndexedDB is the safe browser default.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Would we do it again? Yes, but…
&lt;/h2&gt;

&lt;p&gt;Yes: &lt;code&gt;npx tinbase start&lt;/code&gt; boots real Postgres with RLS in about two seconds, a designer can run the whole stack without knowing what Docker is, and CI jobs run RLS tests with no service containers.&lt;/p&gt;

&lt;p&gt;But: tinbase is &lt;strong&gt;alpha&lt;/strong&gt;, built for local dev, CI, prototypes, and embedded use. It covers roughly 80% of the supabase-js surface, and the gaps (MFA, SSO, phone auth, pgvector, among others) are listed openly. The right setup for most teams is tinbase on every laptop and every PR, and hosted Supabase for staging and production, with the same migration files moving between them.&lt;/p&gt;

&lt;p&gt;The source is MIT on &lt;a href="https://github.com/tinbase/tinbase?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=single_binary_story_2026&amp;amp;utm_content=cta" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. If you hit an API edge we haven't covered, open an issue. Those reports are what move the coverage table.&lt;/p&gt;

&lt;p&gt;What's your experience with the docker-compose vs single-process trade-off? Drop your setup in the comments. I'm curious how teams at different sizes land on this.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>supabase</category>
      <category>docker</category>
      <category>backend</category>
    </item>
    <item>
      <title>Build vs Buy a React Native Template: What Founders Miss</title>
      <dc:creator>Jules Sarah</dc:creator>
      <pubDate>Wed, 23 Sep 2026 05:44:50 +0000</pubDate>
      <link>https://dev.to/jules_sarah_0718e958f0d24/build-vs-buy-a-react-native-template-what-founders-miss-pee</link>
      <guid>https://dev.to/jules_sarah_0718e958f0d24/build-vs-buy-a-react-native-template-what-founders-miss-pee</guid>
      <description>&lt;p&gt;Every week a founder pings me: &lt;em&gt;"Should we build our React Native app from scratch, or buy a template?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Wrong question.&lt;/p&gt;

&lt;p&gt;The right question is: &lt;strong&gt;where is the differentiator in my product actually located?&lt;/strong&gt; Answer that, and the build-vs-buy question answers itself.&lt;/p&gt;

&lt;p&gt;Let me show you why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 80/20 nobody prices out
&lt;/h2&gt;

&lt;p&gt;Every React Native app has two layers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 1, the 80% that's the same in every app:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- Email + OAuth + Apple Sign-In flows
- Password reset with deep-linked tokens
- Row-Level Security on every table
- Push notifications (iOS + Android + delivery receipts)
- Stripe or IAP + license grants
- Offline-safe state, cache invalidation
- Error boundaries, loading skeletons, empty states
- Accessibility labels + dynamic type
- EAS Build + code signing + TestFlight + Play Console
- OTA updates
- Legal screens (privacy, terms, refund)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Layer 2, the 20% that's actually your product:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;- The AI pipeline
- The recipe database
- The marketplace matcher
- The workout planner
- The unique thing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you "build from scratch," you're paying to build both layers. When you "buy a template," you're paying to skip Layer 1 so your whole team can chase Layer 2.&lt;/p&gt;

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

&lt;p&gt;Rough numbers at $80/hr for a mid-market React Native engineer, using realistic (not optimistic) hours:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Hours&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Auth (email + OAuth + Apple + reset)&lt;/td&gt;
&lt;td&gt;90&lt;/td&gt;
&lt;td&gt;$7,200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Supabase schema + RLS + migrations&lt;/td&gt;
&lt;td&gt;70&lt;/td&gt;
&lt;td&gt;$5,600&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Push notifications (iOS + Android)&lt;/td&gt;
&lt;td&gt;60&lt;/td&gt;
&lt;td&gt;$4,800&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stripe + license grants&lt;/td&gt;
&lt;td&gt;80&lt;/td&gt;
&lt;td&gt;$6,400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nav + deep links + tab bar&lt;/td&gt;
&lt;td&gt;50&lt;/td&gt;
&lt;td&gt;$4,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;EAS Build + submission&lt;/td&gt;
&lt;td&gt;40&lt;/td&gt;
&lt;td&gt;$3,200&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Design system + dark mode + a11y&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;$8,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total for Layer 1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;490&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$39,200&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's what you pay before writing one line of Layer 2.&lt;/p&gt;

&lt;p&gt;Meanwhile, a template that ships Layer 1 as a working repo, with backend, auth, Stripe, and pipelines, costs $79 to $299 one time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why so many "buy" attempts fail
&lt;/h2&gt;

&lt;p&gt;Founders who tried a template and hated it usually got burned by one of these:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;UI-only "full-stack" templates.&lt;/strong&gt; You paid for the mockups. Backend is your problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ambiguous licensing.&lt;/strong&gt; You can't legally ship in a paid app without a bigger tier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Copy-paste soup.&lt;/strong&gt; Three state-management libraries in one repo. Cursor and Claude Code refuse to extend it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dead vendor.&lt;/strong&gt; Last commit 14 months ago. React Native version is two majors behind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vendor lock-in.&lt;/strong&gt; Can't swap the AI provider without rewriting half the app.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fix isn't "give up on templates." The fix is "don't buy those templates."&lt;/p&gt;

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

&lt;p&gt;Ask one question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Where is my differentiator actually located?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Three cases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Case A: product-shaped differentiator (~90% of apps).&lt;/strong&gt;&lt;br&gt;
AI pipeline, marketplace mechanic, workflow, recipe DB, planner logic.&lt;br&gt;
→ &lt;strong&gt;Buy a template.&lt;/strong&gt; Spend every hour of engineering budget on Layer 2.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Case B: infrastructure-shaped differentiator (rare).&lt;/strong&gt;&lt;br&gt;
Custom native module, novel audio pipeline, on-device ML.&lt;br&gt;
→ &lt;strong&gt;Build from scratch,&lt;/strong&gt; or fork a template and rewrite what matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Case C: mixed.&lt;/strong&gt;&lt;br&gt;
Product-shaped mostly, but one or two infra pieces are genuinely custom.&lt;br&gt;
→ &lt;strong&gt;Buy and fork.&lt;/strong&gt; Keep the Layer 1 you get. Rewrite the parts that don't fit.&lt;/p&gt;

&lt;p&gt;Case A is where most founders actually are, but Case B is where they think they are. Be honest.&lt;/p&gt;
&lt;h2&gt;
  
  
  What "a good buy" looks like
&lt;/h2&gt;

&lt;p&gt;The template needs to check these boxes. If it can't, keep looking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[x] Full source, TypeScript everywhere
[x] Real backend (Postgres + auth + storage + edge functions)
[x] RLS policies on every table
[x] Consistent state management (one library, not three)
[x] Consistent file structure across screens
[x] Modular AI/backend layer (swap providers without rewriting)
[x] Commercial license, one-time or clear upgrade path
[x] Refund window that isn't hostile
[x] Vendor still active (commits in last 90 days)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last four boxes matter more than any screen-count number in the marketing copy.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Build from scratch&lt;/th&gt;
&lt;th&gt;Cheap UI template&lt;/th&gt;
&lt;th&gt;Marketplace template&lt;/th&gt;
&lt;th&gt;Applighter&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Time to MVP&lt;/td&gt;
&lt;td&gt;8–16 weeks&lt;/td&gt;
&lt;td&gt;6–10 weeks&lt;/td&gt;
&lt;td&gt;5–9 weeks&lt;/td&gt;
&lt;td&gt;1–3 weeks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend included&lt;/td&gt;
&lt;td&gt;❌ (you build)&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;Sometimes&lt;/td&gt;
&lt;td&gt;✅ Supabase&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth flows&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;Screens only&lt;/td&gt;
&lt;td&gt;Screens only&lt;/td&gt;
&lt;td&gt;✅ Real&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI wired up&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅ Where relevant&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Agent-friendly&lt;/td&gt;
&lt;td&gt;Depends&lt;/td&gt;
&lt;td&gt;Rarely&lt;/td&gt;
&lt;td&gt;Rarely&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;License&lt;/td&gt;
&lt;td&gt;You own it&lt;/td&gt;
&lt;td&gt;Ambiguous&lt;/td&gt;
&lt;td&gt;Per-app fees&lt;/td&gt;
&lt;td&gt;✅ Clear&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lifetime updates&lt;/td&gt;
&lt;td&gt;You maintain&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Concrete example
&lt;/h2&gt;

&lt;p&gt;Applighter's &lt;a href="https://www.applighter.com/apps/ai-voice-notes?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=build-vs-buy" rel="noopener noreferrer"&gt;AI Voice Notes template&lt;/a&gt;, $79 one time, ships with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;16 React Native + Expo screens, TypeScript&lt;/li&gt;
&lt;li&gt;Working Supabase backend with schema + RLS + migrations&lt;/li&gt;
&lt;li&gt;Recording, transcription, summarization pipelines wired up&lt;/li&gt;
&lt;li&gt;EAS Build pre-configured for TestFlight + Play Console&lt;/li&gt;
&lt;li&gt;Single commercial license, upgradeable to multi-app&lt;/li&gt;
&lt;li&gt;Lifetime updates, no subscription&lt;/li&gt;
&lt;li&gt;7-day no-questions refund window&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That template gets a founder to TestFlight in about 2 weeks. Building the equivalent Layer 1 from scratch takes about 490 hours. The delta is what ships your product.&lt;/p&gt;

&lt;p&gt;More options and stack comparisons in the &lt;a href="https://www.applighter.com/apps?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=build-vs-buy" rel="noopener noreferrer"&gt;Applighter apps catalog&lt;/a&gt; and the &lt;a href="https://www.applighter.com/blog/best-react-native-boilerplates-in-2026-honest-comparison?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=build-vs-buy" rel="noopener noreferrer"&gt;honest boilerplate comparison post&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reference docs worth reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.expo.dev/build/introduction/" rel="noopener noreferrer"&gt;Expo EAS Build&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://supabase.com/docs/guides/auth/row-level-security" rel="noopener noreferrer"&gt;Supabase RLS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://reactnative.dev/" rel="noopener noreferrer"&gt;React Native&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Build vs buy is a false binary. The right question is where your differentiator lives. If it's product-shaped, and it is for roughly 90% of apps, buy a template that gives you a real Layer 1: backend, auth, payments, EAS. Spend your engineering budget on Layer 2. If your first template was bad, that's a lesson about &lt;em&gt;which&lt;/em&gt; template, not about templates in general.&lt;/p&gt;

&lt;p&gt;If you're currently in week one of a Notion doc about this, close it and ship.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>startup</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Claude Code Is Only as Good as the React Native Repo You Point It At</title>
      <dc:creator>Jules Sarah</dc:creator>
      <pubDate>Wed, 23 Sep 2026 05:43:18 +0000</pubDate>
      <link>https://dev.to/jules_sarah_0718e958f0d24/claude-code-is-only-as-good-as-the-react-native-repo-you-point-it-at-2m95</link>
      <guid>https://dev.to/jules_sarah_0718e958f0d24/claude-code-is-only-as-good-as-the-react-native-repo-you-point-it-at-2m95</guid>
      <description>&lt;p&gt;There's a pattern in every "AI coding is overrated" thread. Someone points Claude Code at their React Native project, asks for a feature, gets a mess, and concludes the tool is hype. Someone else points it at a different repo, asks for the same thing, and ships the feature before lunch.&lt;/p&gt;

&lt;p&gt;The difference usually isn't the prompt. It's the repo.&lt;/p&gt;

&lt;p&gt;Coding agents are imitation machines. They read the code around the task and extend the patterns they find. Give them a codebase with one clear way of doing things and they multiply it. Give them a codebase with three navigation setups, two fetch patterns, and a 900-line screen, and they'll faithfully produce a fourth navigation setup. The repo is the prompt.&lt;/p&gt;

&lt;p&gt;Here's what actually moves the needle for React Native projects.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Write a CLAUDE.md before you write another prompt
&lt;/h2&gt;

&lt;p&gt;Claude Code reads a &lt;code&gt;CLAUDE.md&lt;/code&gt; at the repo root automatically. Most projects don't have one, which means the agent re-derives your stack from scratch every session. Mine looks roughly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Project&lt;/span&gt;
Expo SDK 53, expo-router (typed routes), TypeScript strict.

&lt;span class="gh"&gt;# Conventions&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; State: Zustand for client state, TanStack Query for server state
&lt;span class="p"&gt;-&lt;/span&gt; All data access goes through src/api/. Never call supabase directly from screens.
&lt;span class="p"&gt;-&lt;/span&gt; Styling: unistyles tokens from src/theme. No inline hex colors.

&lt;span class="gh"&gt;# Commands&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; npm run typecheck
&lt;span class="p"&gt;-&lt;/span&gt; npm run lint
&lt;span class="p"&gt;-&lt;/span&gt; npm test
&lt;span class="p"&gt;-&lt;/span&gt; npx expo start

&lt;span class="gh"&gt;# Don't&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Don't add new dependencies without asking
&lt;span class="p"&gt;-&lt;/span&gt; Don't edit files in src/generated/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thirty lines. It removes the entire category of "technically correct code in the wrong style" and stops the agent from installing a second date library.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. One way to do each thing
&lt;/h2&gt;

&lt;p&gt;This is the highest-leverage change and it has nothing to do with AI, which is exactly why it works. Agents pick the nearest example and copy it. If your repo fetches data three ways, the agent picks whichever file it read last.&lt;/p&gt;

&lt;p&gt;Audit for one pattern each: one navigation approach, one data-fetching layer, one form setup, one styling system. Delete the strays. Every duplicate pattern in your repo is a coin flip you're handing the agent.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Make the repo self-verifying
&lt;/h2&gt;

&lt;p&gt;Claude Code is dramatically better when it can check its own work. That means fast, runnable feedback loops:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Strict TypeScript.&lt;/strong&gt; Every hallucinated prop and misnamed hook becomes a compile error the agent sees and fixes itself, instead of a runtime crash you find on device.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Typed routes in expo-router.&lt;/strong&gt; A wrong &lt;code&gt;href&lt;/code&gt; fails typecheck. Without it, it fails in the user's hand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scripts that run headless.&lt;/strong&gt; &lt;code&gt;typecheck&lt;/code&gt;, &lt;code&gt;lint&lt;/code&gt;, &lt;code&gt;test&lt;/code&gt; as npm scripts the agent can execute after every change. If your test suite needs a simulator and three manual steps, the agent skips verification, and so does the code quality.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The loop you want: agent writes, agent runs typecheck, agent fixes, you review a diff that already compiles.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Keep files small enough to read
&lt;/h2&gt;

&lt;p&gt;Agents work inside a context window, and a 900-line screen component eats it. Files under ~200 lines with clear names get read whole; God-components get read in fragments, and fragments produce fragment-quality edits. Colocate the query, the types, and the component for a feature so one directory tells the whole story.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Know what to delegate and what to keep
&lt;/h2&gt;

&lt;p&gt;After months of running Claude Code on RN projects, the split is pretty consistent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Delegate freely:&lt;/strong&gt; wiring new screens from existing patterns, refactors across many files, writing tests for existing logic, dependency upgrades with their config changes, form plumbing, and the long tail of "make this screen match that screen".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep your hands on:&lt;/strong&gt; native module and config-plugin work (the agent can't run the iOS build), anything visual (it can't see the screen), platform-specific quirks like keyboard behavior, and architecture decisions. The agent extends architecture well and invents it badly.&lt;/p&gt;

&lt;p&gt;That last sentence is the whole article, honestly.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Never start it on a blank folder
&lt;/h2&gt;

&lt;p&gt;A blank folder is the worst possible context: no conventions to imitate, so the agent invents an architecture token by token, and you inherit whatever it improvised. Starting from a well-structured starter flips the dynamic. The conventions exist, the CLAUDE.md exists, the verification loops exist, and every feature you ask for is an extension instead of an invention.&lt;/p&gt;

&lt;p&gt;That's the real reason starter kits aged well in the agent era instead of dying. A reviewed template is context engineering you didn't have to do; it's what we curate for on &lt;a href="https://applighter.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=claude-code-rn" rel="noopener noreferrer"&gt;AppLighter&lt;/a&gt;, where React Native templates are vetted for exactly the things above: one pattern per concern, strict types, and a structure an agent can extend without supervision.&lt;/p&gt;

&lt;p&gt;Point Claude Code at a repo like that and the "AI coding is overrated" thread starts to look like a repo problem wearing a tooling costume.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>ai</category>
      <category>expo</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Where AI-Generated Full-Stack Code Silently Rots (and How Templates Cap the Damage)</title>
      <dc:creator>Jules Sarah</dc:creator>
      <pubDate>Wed, 16 Sep 2026 05:11:50 +0000</pubDate>
      <link>https://dev.to/jules_sarah_0718e958f0d24/where-ai-generated-full-stack-code-silently-rots-and-how-templates-cap-the-damage-5fcf</link>
      <guid>https://dev.to/jules_sarah_0718e958f0d24/where-ai-generated-full-stack-code-silently-rots-and-how-templates-cap-the-damage-5fcf</guid>
      <description>&lt;ul&gt;
&lt;li&gt;AI-generated code doesn't fail loudly. It fails in the three places nothing exercises: auth boundaries, RLS policies, and webhook event coverage&lt;/li&gt;
&lt;li&gt;A template's value isn't the code it saves you writing. It's the code that already survived production&lt;/li&gt;
&lt;li&gt;Prompt for features on top of a debugged foundation, never for the plumbing itself&lt;/li&gt;
&lt;li&gt;Any diff touching &lt;code&gt;policies/&lt;/code&gt;, &lt;code&gt;webhooks/&lt;/code&gt;, or &lt;code&gt;auth/&lt;/code&gt; gets a human read. Everything else, trust the agent&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I've shipped a handful of React Native + Supabase apps this year where an agent wrote most of the code. None of them broke on day one. Two of them broke on day forty, and both times the failure had been sitting in the repo since the first week, green in CI, invisible in the demo.&lt;/p&gt;

&lt;p&gt;That's the thing about agent-written full-stack code. It doesn't rot where you're looking. It rots where the happy path never goes.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Auth boundary tests that only test the happy path
&lt;/h2&gt;

&lt;p&gt;Ask an agent to "add tests for the profile endpoint" and you get something like this:&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;returns the profile for the current user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/profile&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;authHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userA&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userA&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Correct, and useless. The test that matters is the one the agent almost never writes unprompted:&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;does not return another user&lt;/span&gt;&lt;span class="se"&gt;\'&lt;/span&gt;&lt;span class="s1"&gt;s profile&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/profile/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;authHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userA&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;403&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rejects an expired token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/profile&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;authHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userA&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;expired&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model optimizes for "the feature works." Nobody in the prompt said "the feature refuses." So the boundary never gets a test, and three refactors later a &lt;code&gt;where user_id = ?&lt;/code&gt; clause quietly becomes a &lt;code&gt;where id = ?&lt;/code&gt; and nothing catches it.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. RLS policies that look right and aren't
&lt;/h2&gt;

&lt;p&gt;This is the one that bit me on day forty. The agent added a &lt;code&gt;workout_logs&lt;/code&gt; table for a new feature. It wrote the migration, the types, the screen. It also wrote this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;workout_logs&lt;/span&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Users can read their own logs"&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;workout_logs&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks fine. Reads fine in review. Three things are wrong with it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;There's no &lt;code&gt;insert&lt;/code&gt; policy, so the app "works" only because the agent also wrote the insert through a service-role edge function to get around the error it hit. That function had no auth check of its own.&lt;/li&gt;
&lt;li&gt;There's no &lt;code&gt;update&lt;/code&gt; or &lt;code&gt;delete&lt;/code&gt; policy, which is safe by accident today and a landmine the day someone adds "edit log."&lt;/li&gt;
&lt;li&gt;There's no &lt;code&gt;with check&lt;/code&gt; clause anywhere, so the moment an insert policy does get added, a user can insert rows with someone else's &lt;code&gt;user_id&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;RLS is where AI-generated code is at its most dangerous, because the failure mode is silent, the code is syntactically perfect, and the agent will route around any error it hits rather than ask why the error exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Stripe webhooks that handle one event
&lt;/h2&gt;

&lt;p&gt;Every agent-written Stripe integration I've reviewed handles &lt;code&gt;checkout.session.completed&lt;/code&gt;. Almost none of them handle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;customer.subscription.updated&lt;/code&gt; (plan changes, trial ending)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;customer.subscription.deleted&lt;/code&gt; (the user cancelled, and your app still thinks they're Pro)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;invoice.payment_failed&lt;/code&gt; (card declined on renewal; you now have a paying user with no payment)&lt;/li&gt;
&lt;li&gt;Duplicate deliveries. Stripe retries. Without idempotency on &lt;code&gt;event.id&lt;/code&gt;, a retry double-grants credits or double-sends the welcome email&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;checkout.session.completed&lt;/code&gt; path is the one the agent can verify in a test run. The other four only show up with real customers over real months, which is exactly when you've stopped looking.&lt;/p&gt;

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

&lt;p&gt;The usual pitch for a starter template is "skip two weeks of setup." That undersells it. Two weeks of typing is not the expensive part. The expensive part is the six months of production that turned the first version of that auth boundary, that RLS policy set, and that webhook handler into the version that actually holds.&lt;/p&gt;

&lt;p&gt;A template's value is debugged code, not written code. Specifically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The boundary tests already exist, so an agent refactoring auth has something to break.&lt;/li&gt;
&lt;li&gt;The RLS policies come as a complete set (select, insert, update, delete, with &lt;code&gt;with check&lt;/code&gt;), so the agent's job is to add a table that matches the pattern, not invent the pattern.&lt;/li&gt;
&lt;li&gt;The webhook handler already switches on the whole subscription lifecycle and dedupes on &lt;code&gt;event.id&lt;/code&gt;, so the agent adds a &lt;code&gt;case&lt;/code&gt;, not a file.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The agent is good at extending a pattern that's in front of it. It's bad at inventing the pattern from a one-line prompt. Templates put the pattern in front of it. Something like &lt;a href="https://applighter.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=where-ai-generated-full-stack-code-silently-rots" rel="noopener noreferrer"&gt;AppLighter&lt;/a&gt; ships exactly these three pieces already wired for Supabase, with the &lt;code&gt;CLAUDE.md&lt;/code&gt; and slash commands so the agent reads the conventions before it writes anything. That's the whole reason the sister product, RapidNative, can generate full-stack apps at all: the generated code lands inside a foundation that already knows where the boundaries are.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 4-step workflow
&lt;/h2&gt;

&lt;p&gt;This is what I do now on every feature that touches auth, data access, or money.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Read the template's boundary tests before you prompt.&lt;/strong&gt; Five minutes. You need to know what "refuses correctly" looks like in this codebase so you can tell when the agent has broken it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Prompt for the feature, not the plumbing.&lt;/strong&gt; "Add a workout log screen that saves to Supabase" is fine. "Set up RLS for workout logs" is not. The RLS pattern already exists; tell the agent to follow it. If the template has a &lt;code&gt;/add-table&lt;/code&gt; command or equivalent, use that instead of describing the migration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Ask for the negative test first.&lt;/strong&gt; Before the happy path. "Write a test proving user A cannot read user B's workout logs, then make it pass." Agents are perfectly capable of writing boundary tests; they just don't volunteer them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Human-read any diff that touches three directories.&lt;/strong&gt; &lt;code&gt;policies/&lt;/code&gt;, &lt;code&gt;webhooks/&lt;/code&gt;, &lt;code&gt;auth/&lt;/code&gt; (or whatever your template calls them). Everything else, review at whatever depth you review agent output today. These three get a line-by-line read, every time, no matter how small the change. That's the entire discipline. It takes about ten minutes per PR.&lt;/p&gt;

&lt;h2&gt;
  
  
  It's a workflow problem
&lt;/h2&gt;

&lt;p&gt;None of the three rot sites are hard to fix once you know they exist. That's what makes them frustrating: the fix isn't a better model or a better prompt, it's starting from a foundation where the boundaries are already tested and pointing the agent at the boundaries before it writes anything.&lt;/p&gt;

&lt;p&gt;The workflow fix is trivially available. Most teams just haven't been burned yet.&lt;/p&gt;

&lt;p&gt;If you've found a fourth place agent code rots quietly, drop it in the comments. I've got a suspicion about push token cleanup on logout but haven't caught it in the wild yet.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>ai</category>
      <category>supabase</category>
    </item>
    <item>
      <title>How we cut support tickets 70% with docs</title>
      <dc:creator>Jules Sarah</dc:creator>
      <pubDate>Fri, 04 Sep 2026 13:15:43 +0000</pubDate>
      <link>https://dev.to/jules_sarah_0718e958f0d24/how-we-cut-support-tickets-70-with-docs-i58</link>
      <guid>https://dev.to/jules_sarah_0718e958f0d24/how-we-cut-support-tickets-70-with-docs-i58</guid>
      <description>&lt;p&gt;We sell React Native + Expo app templates. Six months ago, ~25% of buyers opened a support ticket within their first week. Today it's ~7%. Same product, same buyers. Only the docs changed.&lt;/p&gt;

&lt;p&gt;This is the tactical writeup: what we audited, what we changed, and the seven doc pages that eat most of our former support volume.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Audit 90 days of tickets. Tag each with root cause + which doc would have prevented it.&lt;/li&gt;
&lt;li&gt;Two-thirds of our tickets were &lt;strong&gt;setup friction&lt;/strong&gt;, not bugs.&lt;/li&gt;
&lt;li&gt;Consolidate environment variable docs into ONE page with a complete table.&lt;/li&gt;
&lt;li&gt;Ship a "first 15 minutes" quick start that skips every optional feature.&lt;/li&gt;
&lt;li&gt;Put error strings &lt;strong&gt;verbatim&lt;/strong&gt; in your docs so Google indexes them.&lt;/li&gt;
&lt;li&gt;Make your support surface &lt;strong&gt;more&lt;/strong&gt; prominent, but route through a dialog that suggests docs first.&lt;/li&gt;
&lt;li&gt;Result: ~70% fewer tickets, better bug reports, more time for real work.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The audit
&lt;/h2&gt;

&lt;p&gt;We store support requests in a Supabase table (&lt;code&gt;support_requests&lt;/code&gt;) surfaced through a small admin dashboard. Pulled 90 days, ~312 tickets, tagged each:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;category&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;env-misconfig | supabase-setup | eas-build | stripe | bug | customization&lt;/span&gt;
&lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;weather-app | fitness-app | ai-voice-notes | chat-with-pdf | ...&lt;/span&gt;
&lt;span class="na"&gt;doc_that_would_have_helped&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;path or "none"&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Breakdown:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Category&lt;/th&gt;
&lt;th&gt;% of tickets&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.env&lt;/code&gt; misconfig&lt;/td&gt;
&lt;td&gt;34%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Supabase project setup&lt;/td&gt;
&lt;td&gt;22%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expo / EAS build errors&lt;/td&gt;
&lt;td&gt;15%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Genuine bugs&lt;/td&gt;
&lt;td&gt;11%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stripe / license activation&lt;/td&gt;
&lt;td&gt;9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"How do I customize X"&lt;/td&gt;
&lt;td&gt;9%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;66% of tickets were pre-first-run setup friction.&lt;/strong&gt; Not novel bugs. Not missing features. The twenty-minute cliff between &lt;code&gt;git clone&lt;/code&gt; and a working simulator build.&lt;/p&gt;

&lt;h2&gt;
  
  
  Change #1: Index error strings, not concepts
&lt;/h2&gt;

&lt;p&gt;The single highest-ROI change. For every ticket that mentioned an error message, the literal string now appears verbatim in a doc page with the fix beneath it.&lt;/p&gt;

&lt;p&gt;Bad:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;### AsyncStorage resolution issues&lt;/span&gt;

If your bundler can't resolve async storage, ensure the dependency is installed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Good:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;### Error: `Unable to resolve module "@react-native-async-storage/async-storage"`&lt;/span&gt;

&lt;span class="p"&gt;```&lt;/span&gt;&lt;span class="nl"&gt;bash
&lt;/span&gt;npx expo &lt;span class="nb"&gt;install&lt;/span&gt; @react-native-async-storage/async-storage
npx expo start &lt;span class="nt"&gt;--clear&lt;/span&gt;
&lt;span class="p"&gt;```&lt;/span&gt;

This happens when the module was added transitively (usually via
&lt;span class="sb"&gt;`@supabase/supabase-js`&lt;/span&gt; session persistence) but not explicitly installed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Devs paste error strings into Google. Google indexes the string. Google sends them to the fix. You never see the ticket.&lt;/p&gt;

&lt;h2&gt;
  
  
  Change #2: One &lt;code&gt;environment.mdx&lt;/code&gt;, not seven scattered mentions
&lt;/h2&gt;

&lt;p&gt;Old state: &lt;code&gt;.env&lt;/code&gt; variables were mentioned in the Supabase page, the Stripe page, the push-notifications page, the OneSignal page, the EAS page. Each mention was correct in isolation. Collectively, nobody could find anything.&lt;/p&gt;

&lt;p&gt;New state: one &lt;code&gt;environment.mdx&lt;/code&gt; page. One table. Columns: variable name, source, client-or-server, what fails if missing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;| Variable                          | Source                | Scope   | If missing                                |
|-----------------------------------|-----------------------|---------|-------------------------------------------|
| EXPO_PUBLIC_SUPABASE_URL          | Supabase dashboard    | Client  | App loads, all queries return 401         |
| EXPO_PUBLIC_SUPABASE_ANON_KEY     | Supabase dashboard    | Client  | Auth silently fails                       |
| SUPABASE_SERVICE_ROLE_KEY         | Supabase dashboard    | Server  | Server routes return 500                  |
| STRIPE_SECRET_KEY                 | Stripe dashboard      | Server  | Purchase webhooks 500, no license grants  |
| ...                                                                                                     |
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Boring. Effective. This one page killed a plurality of setup tickets alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Change #3: Six-command quick start
&lt;/h2&gt;

&lt;p&gt;Old &lt;code&gt;quick-start.mdx&lt;/code&gt; was maximalist: clone, install, env, Supabase, Stripe, EAS, push notifications, deep linking. New one:&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 &amp;lt;your-license-url&amp;gt;
&lt;span class="nb"&gt;cd &lt;/span&gt;applighter-template
npm &lt;span class="nb"&gt;install
cp&lt;/span&gt; .env.example .env
&lt;span class="c"&gt;# fill in EXPO_PUBLIC_SUPABASE_URL + ANON_KEY&lt;/span&gt;
npx expo start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Six commands. A running app on your simulator. Everything else is &lt;em&gt;optional&lt;/em&gt; and lives on its own page.&lt;/p&gt;

&lt;p&gt;Nobody wants to configure OneSignal in their first hour. Stop making them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Change #4: Prominent support surface, routed through docs
&lt;/h2&gt;

&lt;p&gt;Counter-intuitive result: making our support link &lt;em&gt;more&lt;/em&gt; visible reduced tickets, not increased them.&lt;/p&gt;

&lt;p&gt;The floating support button (a &lt;code&gt;contact-author-fab&lt;/code&gt; component) opens a dialog. The dialog:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Asks for a one-line problem summary&lt;/li&gt;
&lt;li&gt;Searches our docs for keyword matches&lt;/li&gt;
&lt;li&gt;Surfaces top 3 doc results &lt;strong&gt;before&lt;/strong&gt; offering the submit button&lt;/li&gt;
&lt;li&gt;Only then lets you file a ticket&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Roughly 40% of dialog opens never become tickets. People click a doc result, read it, close the dialog, and move on. Zero human interaction.&lt;/p&gt;

&lt;h2&gt;
  
  
  What didn't work
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Videos.&lt;/strong&gt; Three 8-minute setup walkthroughs. Two weeks of production. ~40 views/month combined. No measurable ticket delta. Devs skim; video prevents skimming.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Chatbot on our docs.&lt;/strong&gt; Hallucinated flags, invented API surface, confidently misrepresented patterns we don't use. Customers pasted its answers back to us as bug reports. Killed after one week. The space is improving fast, but static docs that say the true thing still beat generative docs that say a plausible false thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The seven-page scaffold
&lt;/h2&gt;

&lt;p&gt;If you're starting fresh (this is the structure every &lt;a href="https://www.applighter.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=how-better-docs-cut-our-support-load-by-70-percent" rel="noopener noreferrer"&gt;Applighter&lt;/a&gt; template's docs now follow):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;quick-start.mdx&lt;/code&gt;: six commands, no optional features&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;installation.mdx&lt;/code&gt;: prerequisites, tool versions, common install errors verbatim&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;environment.mdx&lt;/code&gt;: one table, every variable, every failure mode&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;folder-structure.mdx&lt;/code&gt;: where things live, in one screen&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;supabase.mdx&lt;/code&gt;: project provisioning, RLS, storage buckets&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;expo-integration.mdx&lt;/code&gt;: EAS Build, provisioning, error strings verbatim&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ui-components.mdx&lt;/code&gt;: customization points, how to not break updates&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Everything beyond these seven pages is a nice-to-have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Metrics worth tracking
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Tickets per 100 active customers/week (target: &amp;lt;8)&lt;/li&gt;
&lt;li&gt;% of tickets citing a doc page (target: &amp;gt;60%, which means people are reading first)&lt;/li&gt;
&lt;li&gt;% of support-dialog opens that don't become tickets (target: &amp;gt;30%)&lt;/li&gt;
&lt;li&gt;Median first-response resolution rate (target: &amp;gt;50%)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Metric to stop tracking: total weekly support hours. Goes to zero if you ignore your inbox. Useless.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Expo EAS docs: &lt;a href="https://docs.expo.dev/build/introduction/" rel="noopener noreferrer"&gt;https://docs.expo.dev/build/introduction/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Supabase RLS docs: &lt;a href="https://supabase.com/docs/guides/auth/row-level-security" rel="noopener noreferrer"&gt;https://supabase.com/docs/guides/auth/row-level-security&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fewer tickets, better product, same 4,000 words of docs, just pointed at the right problems.&lt;/p&gt;

&lt;p&gt;If you sell templates or dev tools: what's your top ticket category? Curious whether the two-thirds-setup-friction split holds outside our niche. Drop it in the comments.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>supabase</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The auth-screen UX playbook for Expo apps</title>
      <dc:creator>Jules Sarah</dc:creator>
      <pubDate>Wed, 02 Sep 2026 10:58:13 +0000</pubDate>
      <link>https://dev.to/jules_sarah_0718e958f0d24/the-auth-screen-ux-playbook-for-expo-apps-egn</link>
      <guid>https://dev.to/jules_sarah_0718e958f0d24/the-auth-screen-ux-playbook-for-expo-apps-egn</guid>
      <description>&lt;p&gt;Auth is the most scrutinised surface in a mobile app. People decide whether it looks legitimate almost immediately, and whether they're willing to sign up shortly after. Everything downstream is gated behind that.&lt;/p&gt;

&lt;p&gt;Mobile OAuth in particular is where otherwise good apps leak users quietly, because the failure isn't an error. It's a browser tab the user has to close themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  The legitimacy scan
&lt;/h2&gt;

&lt;p&gt;On first open, people scan for a small set of signals:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A logo that isn't pixelated&lt;/li&gt;
&lt;li&gt;Provider buttons using the official brand assets, not lookalikes&lt;/li&gt;
&lt;li&gt;Copy that doesn't read as machine-translated&lt;/li&gt;
&lt;li&gt;No layout shift as the screen mounts&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All four have to land. Any one being off loses users who will never tell you why.&lt;/p&gt;

&lt;p&gt;Use the platform packages rather than rolling your own buttons. &lt;code&gt;expo-apple-authentication&lt;/code&gt; renders Apple's official button, and &lt;code&gt;@react-native-google-signin/google-signin&lt;/code&gt; gives you Google's. Both handle the brand requirements you'd otherwise have to read the guidelines for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the OAuth callback in-app
&lt;/h2&gt;

&lt;p&gt;This is the difference between OAuth working and OAuth converting.&lt;/p&gt;

&lt;p&gt;The naive flow opens a browser, the user signs in, the browser shows a success page, and then the user has to work out that they should return to the app themselves. Some don't.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signInWithOAuth&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;google&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;redirectTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Linking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;skipBrowserRedirect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;WebBrowser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;openAuthSessionAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;Linking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;success&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// parse access_token and refresh_token from the returned URL fragment&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;setSession&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;access_token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;refresh_token&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;openAuthSessionAsync&lt;/code&gt; uses &lt;code&gt;ASWebAuthenticationSession&lt;/code&gt; on iOS and Custom Tabs on Android. Both close themselves when they see the return scheme, so the user lands back in the app with a session already established and no visible handoff.&lt;/p&gt;

&lt;p&gt;The token parsing is the fiddly part and the snippet leaves it as a comment deliberately: the tokens arrive in the URL fragment rather than the query string, so a naive &lt;code&gt;URLSearchParams&lt;/code&gt; on the whole URL returns nothing and you'll spend twenty minutes wondering why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Password reset is a funnel nobody revisits
&lt;/h2&gt;

&lt;p&gt;It gets designed once and never looked at again, which means when it breaks, it breaks silently. Nobody files a bug about a password reset that didn't arrive. They just leave.&lt;/p&gt;

&lt;p&gt;The flow that works:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Email input screen&lt;/li&gt;
&lt;li&gt;&lt;code&gt;resetPasswordForEmail(email, { redirectTo: 'myapp://reset' })&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;The emailed link deep-links into the app with a session already established&lt;/li&gt;
&lt;li&gt;New password via &lt;code&gt;updateUser({ password })&lt;/code&gt;, then a success screen with a sign-in action&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The breakage to know about:&lt;/strong&gt; the emailed link won't open the app unless the scheme is registered under Additional Redirect URLs in your Supabase auth settings. Add it once. This accounts for most "reset doesn't work" reports, and it produces no error anywhere in your code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Error copy that doesn't alarm people
&lt;/h2&gt;

&lt;p&gt;Never surface raw auth errors. &lt;code&gt;AuthApiError: Invalid login credentials&lt;/code&gt; reads as something being broken. "Email or password doesn't match" reads as a typo.&lt;/p&gt;

&lt;p&gt;Map every one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AUTH_ERRORS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid login credentials&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Email or password doesn't match. Try again.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;User already registered&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;You already have an account. Try signing in.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Password should be at least 6 characters&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Password needs to be 6+ characters.&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;Plus a network fallback, because "can't reach the server, check your connection" is a completely different message from "your details are wrong" and users act on them differently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And put errors inline, under the field that failed.&lt;/strong&gt; A red banner at the top of the screen is a web pattern that mobile inherited, and on a phone it means the user is looking at the wrong part of the screen to find out what went wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general shape
&lt;/h2&gt;

&lt;p&gt;Every item here is the same class of problem: nothing throws, nothing appears in your error tracking, and the user simply doesn't complete. A browser tab left open. A reset email that goes nowhere. An error message that reads as a system failure rather than a typo.&lt;/p&gt;

&lt;p&gt;Auth either works or it doesn't, technically. The gap between working and converting is entirely in these details.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://www.applighter.com/apps?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=expo-auth-ux-playbook" rel="noopener noreferrer"&gt;Applighter&lt;/a&gt; Expo templates ship auth wired this way, though everything above is a few hours of work in any project and worth doing before your first round of user testing.&lt;/p&gt;




&lt;p&gt;What's the auth bug that took you longest to find? Mine was the redirect URL, and it took two days because nothing anywhere reported an error.&lt;/p&gt;

</description>
      <category>expo</category>
      <category>ux</category>
      <category>mobile</category>
      <category>supabase</category>
    </item>
    <item>
      <title>7 AI App Templates in 12 Months: What We Learned</title>
      <dc:creator>Jules Sarah</dc:creator>
      <pubDate>Fri, 28 Aug 2026 11:30:28 +0000</pubDate>
      <link>https://dev.to/jules_sarah_0718e958f0d24/7-ai-app-templates-in-12-months-what-we-learned-4cbl</link>
      <guid>https://dev.to/jules_sarah_0718e958f0d24/7-ai-app-templates-in-12-months-what-we-learned-4cbl</guid>
      <description>&lt;p&gt;Seven React Native templates in twelve months. Two AI-first, five conventional apps with AI stitched into the seams.&lt;/p&gt;

&lt;p&gt;This is the concrete retrospective: the stack, the schema, the payment webhooks, the SDK bumps, the pricing. If you're building a template business or considering buying one, it's the post I wanted a year ago.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One AI job per template beats a chat tab bolted onto a generic app&lt;/li&gt;
&lt;li&gt;The schema with correct RLS is the hard part; screens are replaceable&lt;/li&gt;
&lt;li&gt;SDK upgrades are the largest recurring cost and we didn't price them in&lt;/li&gt;
&lt;li&gt;Payments took four weeks, not four days&lt;/li&gt;
&lt;li&gt;Cheap templates sold fewer copies, not more&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;After two false starts we locked it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Runtime      : Expo SDK 54, new architecture
Language     : TypeScript, strict
UI           : NativeWind
Motion       : Reanimated + Gesture Handler
State        : Zustand + TanStack Query
Backend      : Supabase (Postgres, Auth, Storage, Edge Functions)
Payments     : Stripe, webhooks handled server-side
LLM          : Provider abstraction so the model is swappable
Tooling      : EAS build, update, submit
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each piece was picked for one property: it survives contact with a customer who doesn't know the library. NativeWind classes are searchable. Worklets don't stall when the JS thread does. Supabase migrations are versioned SQL you can read.&lt;/p&gt;

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

&lt;p&gt;Every template is a config file registered in one map:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/apps/config/index.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;appConfigs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ProductData&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;weather-app&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="nx"&gt;weatherAppConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;fitness-app&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="nx"&gt;fitnessAppConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;e-learning-app&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="nx"&gt;eLearningAppConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;taxi-booking-app&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="nx"&gt;taxiBookingAppConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ai-voice-notes&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="nx"&gt;aiVoiceNotesConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;chat-with-pdf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;     &lt;span class="nx"&gt;chatWithPdfConfig&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;Adding a template is one import and one map entry. A database-backed catalog was tempting for about a week and turned out to be unnecessary. Seven templates fit in a &lt;code&gt;Record&lt;/code&gt; comfortably.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. AI feature, not AI wrapper
&lt;/h2&gt;

&lt;p&gt;Both of our best-selling AI templates do exactly one AI thing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// AI Voice Notes: one job, audio to structured summary&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;summarize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;audioUri&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;transcript&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;transcribe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;audioUri&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;extractStructure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// { summary, actions, decisions }&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No chat bubble. No "ask this app anything" tab.&lt;/p&gt;

&lt;p&gt;The templates where we bolted a chat tab onto a generic screen underperformed the ones where the AI was the feature. A chat tab is a way of not deciding what the AI is for, and buyers can tell.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. RLS is the moat
&lt;/h2&gt;

&lt;p&gt;Screens are easy to replace. A schema with correct row-level security takes weeks.&lt;/p&gt;

&lt;p&gt;Every template ships migrations under &lt;code&gt;supabase/migrations/&lt;/code&gt;, and every read and write is gated by a policy assuming &lt;code&gt;auth.uid()&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"read own notes"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"write own notes"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"update own notes"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bug we caught most in review was a missing sibling policy. Select correct, update absent. It fails silently, because the read works and the write just quietly does nothing to rows it shouldn't touch.&lt;/p&gt;

&lt;p&gt;We test every template with two dummy accounts on a fresh Supabase project. Not optional. Reference: &lt;a href="https://supabase.com/docs/guides/database/postgres/row-level-security" rel="noopener noreferrer"&gt;Supabase RLS docs&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. SDK bumps eat weeks
&lt;/h2&gt;

&lt;p&gt;Three SDK versions across the year. Each bump cost roughly a full engineering week per template:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;New architecture opt-in&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;expo-audio&lt;/code&gt; replacing &lt;code&gt;expo-av&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Native modules re-verified on both platforms&lt;/li&gt;
&lt;li&gt;Animation API drift&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Seven templates, three bumps, a week each. That arithmetic is unpleasant and we didn't price it into the first three templates. We paid for it in December.&lt;/p&gt;

&lt;p&gt;If you sell templates, maintenance is not overhead. It's the product.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Payments is a project
&lt;/h2&gt;

&lt;p&gt;The webhook granting a customer access to what they bought is larger than any screen in any template.&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;type&lt;/span&gt; &lt;span class="nx"&gt;Grant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;userId&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="nl"&gt;productId&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="nl"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;single&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bundle&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;reconcile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;switch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;checkout.session.completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="cm"&gt;/* create grant(s) */&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;charge.refunded&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;            &lt;span class="cm"&gt;/* revoke grant   */&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;customer.updated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;           &lt;span class="cm"&gt;/* rekey email    */&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="c1"&gt;// multi-line-item bundles, dedupe against prior grants,&lt;/span&gt;
    &lt;span class="c1"&gt;// same customer arriving with two different emails&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;Plan four weeks. Not four days.&lt;/p&gt;

&lt;p&gt;The cases that broke us were never the happy path. They were bundles containing a template the customer already owned, and refunds arriving after an email change.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. NativeWind and Reanimated
&lt;/h2&gt;

&lt;p&gt;A boring combination that kept winning:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Card&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;title&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;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Animated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;View&lt;/span&gt; &lt;span class="na"&gt;entering&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;FadeIn&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"p-4 rounded-2xl bg-white dark:bg-slate-900 shadow"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-lg font-semibold text-slate-900 dark:text-white"&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;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Animated&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;View&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Searchable classes, worklets on the UI thread, and an unexpected third benefit: AI assistants get both libraries right on the first attempt.&lt;/p&gt;

&lt;p&gt;That last one matters more than we expected, because most of our customers open the template in an AI editor rather than reading it top to bottom. A stack that models well is a stack that gets extended correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Demo video beats README
&lt;/h2&gt;

&lt;p&gt;Every template config carries a video. A short edited walkthrough moved conversion more than any pricing test we ran.&lt;/p&gt;

&lt;p&gt;Not a screen recording. An edit.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Pricing
&lt;/h2&gt;

&lt;p&gt;Single-app licence at $79, with a bundle at a materially better per-app rate. Two things surprised us.&lt;/p&gt;

&lt;p&gt;Bundle attach rate is high. Customers who buy one template come back for the bundle rather than buying a second single.&lt;/p&gt;

&lt;p&gt;And cheaper templates sold fewer copies, not more. Below a certain price, buyers appear to read the price as a signal about quality, and the ones who don't are not the customers you want anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scratch versus template
&lt;/h2&gt;

&lt;p&gt;For a buyer starting a new app:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;From scratch&lt;/th&gt;
&lt;th&gt;With a template&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Auth (email, OAuth, Apple)&lt;/td&gt;
&lt;td&gt;About a week&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Supabase schema and RLS&lt;/td&gt;
&lt;td&gt;About a week&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Push notifications&lt;/td&gt;
&lt;td&gt;Several days&lt;/td&gt;
&lt;td&gt;Included, minus certificates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI feature with streaming and storage&lt;/td&gt;
&lt;td&gt;About a week&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Design tokens&lt;/td&gt;
&lt;td&gt;A few days&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Icon, splash, EAS config&lt;/td&gt;
&lt;td&gt;A couple of days&lt;/td&gt;
&lt;td&gt;Included&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Your own payments, if you sell something&lt;/td&gt;
&lt;td&gt;About a week&lt;/td&gt;
&lt;td&gt;Still yours&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last row is the honest one. A template gets you to a working app quickly. It does not remove the work of charging your own customers, and any comparison table that says otherwise is selling you something.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we'd do differently
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Pick the styling stack on day one&lt;/li&gt;
&lt;li&gt;Write the schema before the screens&lt;/li&gt;
&lt;li&gt;Record the demo video before launch, not after&lt;/li&gt;
&lt;li&gt;One AI job per template, never a chatbot&lt;/li&gt;
&lt;li&gt;Test on a clean machine before every release&lt;/li&gt;
&lt;li&gt;Price maintenance from the start&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrap
&lt;/h2&gt;

&lt;p&gt;A year, seven templates, more webhook edge cases than anyone deserves.&lt;/p&gt;

&lt;p&gt;The ones that survived contact with customers had real schemas, one focused AI job, and payments that didn't fall over on the first refund. Everything else was decoration, and we learned which was which by shipping the decoration first.&lt;/p&gt;

&lt;p&gt;Full write-up with the internal architecture detail is on the &lt;a href="https://www.applighter.com/blog/seven-ai-app-templates-in-12-months-what-we-learned?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=seven-templates-12-months" rel="noopener noreferrer"&gt;Applighter blog&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;If you sell templates or plugins, what did maintenance actually cost you in year one? I suspect most people underprice it the same way we did, and nobody publishes the number.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>supabase</category>
      <category>indiehackers</category>
    </item>
  </channel>
</rss>
