<?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: Hugo Rus</title>
    <description>The latest articles on DEV Community by Hugo Rus (@hugo_rus_630dd942fcf7cc62).</description>
    <link>https://dev.to/hugo_rus_630dd942fcf7cc62</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%2F4004016%2F5d302419-e375-4c77-b600-eeb065755471.png</url>
      <title>DEV Community: Hugo Rus</title>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hugo_rus_630dd942fcf7cc62"/>
    <language>en</language>
    <item>
      <title>Mobile App Development Platform (MADP): The 2026 Guide</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Mon, 02 Nov 2026 05:56:32 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/mobile-app-development-platform-madp-the-2026-guide-24km</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/mobile-app-development-platform-madp-the-2026-guide-24km</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://www.rapidnative.com/blogs/mobile-app-development-platform?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=madp-guide&amp;amp;utm_content=originally-published" rel="noopener noreferrer"&gt;rapidnative.com&lt;/a&gt; by Sanket Sahu.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What a mobile app development platform is, how the category went from MEAP to MADP to low-code to AI, and the eight questions that separate a good platform from an expensive detour.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you have ever sat through an enterprise software review, you have heard someone ask which mobile app development platform the company should standardize on. The question sounds simple. The answers rarely are, because the category has been renamed four times in fifteen years, and the tools sold under the label range from visual workflow builders to raw frameworks to, now, AI that writes the whole app from a paragraph of text.&lt;/p&gt;

&lt;p&gt;This guide untangles it. It covers what a mobile app development platform (MADP) actually is, where the term came from, the five kinds of platforms you will meet in 2026, and the questions that tell a good one from an expensive detour. We build &lt;a href="https://www.rapidnative.com?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=madp-guide&amp;amp;utm_content=intro" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt;, an AI mobile app builder, so we have a point of view, and we will be clear about where it fits and where it doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is a mobile app development platform?
&lt;/h2&gt;

&lt;p&gt;A mobile app development platform (MADP) is a set of tools and services for designing, building, testing, publishing and maintaining mobile apps for iOS and Android from one place. A typical MADP combines a front-end builder or framework, backend services, integrations with existing systems, security controls, and a way to distribute and update apps once they are in people's hands.&lt;/p&gt;

&lt;p&gt;The key word is &lt;em&gt;platform&lt;/em&gt;. A framework like React Native or Flutter is one ingredient. A platform wraps the ingredients into a lifecycle: you start a project, connect data, give it to testers, ship it to the App Store and Google Play (or to employees' managed devices), and keep it updated, without assembling a dozen separate products yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  From MEAP to MADP to MXDP to LCAP: a short history
&lt;/h2&gt;

&lt;p&gt;Most of the vocabulary comes from analyst firms, chiefly Gartner, whose Magic Quadrant reports shaped how enterprises shopped for mobile tools. The names changed as the market did.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Era&lt;/th&gt;
&lt;th&gt;Term&lt;/th&gt;
&lt;th&gt;What it described&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2008 to early 2010s&lt;/td&gt;
&lt;td&gt;MEAP, mobile enterprise application platform&lt;/td&gt;
&lt;td&gt;Middleware and tools to build and run internal apps for employees, often across BlackBerry, Windows Mobile and early iPhone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Around 2010&lt;/td&gt;
&lt;td&gt;MCAP, mobile consumer application platform&lt;/td&gt;
&lt;td&gt;The customer-facing sibling of MEAP, a short-lived separate category&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2012 to 2018&lt;/td&gt;
&lt;td&gt;MADP, mobile app development platform&lt;/td&gt;
&lt;td&gt;One category for employee and customer apps, as smartphones went mainstream&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2019 to early 2020s&lt;/td&gt;
&lt;td&gt;MXDP, multiexperience development platform&lt;/td&gt;
&lt;td&gt;Apps across mobile, web, wearables, chat and voice from one toolset&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2019 onward&lt;/td&gt;
&lt;td&gt;LCAP, enterprise low-code application platform&lt;/td&gt;
&lt;td&gt;Visual, model-driven platforms where mobile is one output among many&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2023 onward&lt;/td&gt;
&lt;td&gt;AI app builders&lt;/td&gt;
&lt;td&gt;Platforms that generate the app from natural language&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few details are worth knowing. Gartner coined MEAP in a 2008 Magic Quadrant, along with a "rule of three": a MEAP made sense once a company had three or more mobile apps, three or more mobile operating systems, or three or more backend systems to connect. The category was later renamed MADP to reflect that mobile had gone mainstream. In July 2019 Gartner published its first Magic Quadrant for Multiexperience Development Platforms, which superseded the MADP report. Its leaders were mostly low-code vendors: OutSystems, Mendix, Salesforce, Oracle, Progress and Kony.&lt;/p&gt;

&lt;p&gt;The vendors themselves moved too. Kony was acquired by Temenos in 2019, and its non-banking platform was licensed to HCL and relaunched as HCL Volt MX. OutSystems bought Ionic, the team behind Capacitor. Today most of the old MADP leaders are evaluated as enterprise low-code platforms, and "MADP" survives mainly as a search term and an RFP line item. That is useful to know: when someone asks you for a MADP, they usually mean "one supported way to build our mobile apps", not a specific product category.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a mobile app development platform includes
&lt;/h2&gt;

&lt;p&gt;Whatever the label, buyers expect a MADP to cover the full lifecycle of an app. In practice that breaks into six layers.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Front-end development.&lt;/strong&gt; How screens get built: visual drag and drop, a code framework, generated code, or a mix. This decides how the app looks and feels and how fast your team can change it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backend services.&lt;/strong&gt; Data storage, sign-in, file storage, push notifications and realtime updates, often called mobile backend as a service (MBaaS). Some platforms bundle their own; others connect to Firebase, Supabase or your existing APIs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integration.&lt;/strong&gt; Connectors to the systems an enterprise already runs: identity providers, ERP and CRM, internal REST or GraphQL APIs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security and management.&lt;/strong&gt; Single sign-on, role-based access, data encryption, and support for mobile device management (MDM) or mobile application management (MAM) so IT can control company data on phones.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testing and distribution.&lt;/strong&gt; Previewing on real devices, beta testing, and publishing to the App Store and Google Play, or privately to employees through Apple Business Manager and managed Google Play.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lifecycle and updates.&lt;/strong&gt; Over-the-air updates, crash reporting, analytics, and a way to keep the app current as iOS and Android change every year.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No platform is best at all six. The useful question is which layers you want the platform to own, and which you want to keep in your own hands.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five types of mobile app development platforms in 2026
&lt;/h2&gt;

&lt;p&gt;The market has split into five approaches. They differ less in features than in two things: what they output, and what you are left holding if you leave.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;Examples&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;th&gt;Who it suits&lt;/th&gt;
&lt;th&gt;The catch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Native SDKs&lt;/td&gt;
&lt;td&gt;Xcode with Swift, Android Studio with Kotlin&lt;/td&gt;
&lt;td&gt;Two separate native codebases&lt;/td&gt;
&lt;td&gt;Teams with dedicated iOS and Android engineers&lt;/td&gt;
&lt;td&gt;Two of everything: code, bugs, hiring&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cross-platform frameworks&lt;/td&gt;
&lt;td&gt;React Native, Flutter, Kotlin Multiplatform&lt;/td&gt;
&lt;td&gt;One codebase, native apps&lt;/td&gt;
&lt;td&gt;Product teams with mobile developers&lt;/td&gt;
&lt;td&gt;You still assemble backend, CI, distribution yourself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enterprise low-code (LCAP)&lt;/td&gt;
&lt;td&gt;OutSystems, Mendix, Microsoft Power Apps, HCL Volt MX&lt;/td&gt;
&lt;td&gt;Visual apps on the vendor's runtime&lt;/td&gt;
&lt;td&gt;Large IT teams building many internal apps&lt;/td&gt;
&lt;td&gt;Per-user pricing and runtime lock-in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No-code builders&lt;/td&gt;
&lt;td&gt;FlutterFlow, Adalo, Glide&lt;/td&gt;
&lt;td&gt;Apps assembled visually, some with code export&lt;/td&gt;
&lt;td&gt;Small teams and simple apps&lt;/td&gt;
&lt;td&gt;Hand-building every screen; limits on custom logic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AI app builders&lt;/td&gt;
&lt;td&gt;RapidNative for native mobile; Lovable, Bolt and v0 for web&lt;/td&gt;
&lt;td&gt;An app generated from a description&lt;/td&gt;
&lt;td&gt;Teams that want working software in days&lt;/td&gt;
&lt;td&gt;Quality depends on the output being real, ownable code&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two observations from building in this space.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, the framework layer has been commoditized.&lt;/strong&gt; In 2012 a MADP's main job was solving cross-platform: write once, run on iOS, Android and BlackBerry. React Native and Flutter solved that for free. What a platform sells today is everything around the framework: speed, backend, governance, and the last mile into the stores.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, runtime lock-in is the real dividing line.&lt;/strong&gt; Many low-code and no-code platforms run your app inside their own engine. That is fine until pricing changes, a feature you need sits on their roadmap, or the company is acquired. Platforms that output standard source code give you an exit. We wrote more about this in our guide to avoiding vendor lock-in on the RapidNative blog.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to evaluate a mobile app development platform: 8 questions
&lt;/h2&gt;

&lt;p&gt;Feature matrices all look the same. These questions don't.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Is the output a native app or a website in a frame?
&lt;/h3&gt;

&lt;p&gt;Many tools that call themselves mobile platforms produce a web app wrapped in a web view. Users notice the scrolling, the keyboard, the lack of native gestures. Apple notices too: App Store Review Guideline 4.2 rejects apps that are "a repackaged website". Ask for a build on a real phone, not a desktop browser preview. For the longer version of this argument, see our post on native vs hybrid app development.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Can you leave with working source code?
&lt;/h3&gt;

&lt;p&gt;"Export" can mean a zip that still calls the vendor's runtime. Ask for the exported project, hand it to a developer who has never used the platform, and see whether they can build and run it with standard tools. If they can't, you are renting, not owning.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Where does the data live, and can you point it elsewhere?
&lt;/h3&gt;

&lt;p&gt;A bundled backend is great for speed. Check you can also connect your own database, identity provider and APIs, and that data can be exported in a standard format.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. How does it handle identity and device management?
&lt;/h3&gt;

&lt;p&gt;For internal apps: SSO through your identity provider, role-based access, and compatibility with your MDM or MAM tooling. For customer apps: sign-in methods, account deletion (which both stores now require), and privacy labels.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. How do apps reach users?
&lt;/h3&gt;

&lt;p&gt;Publishing is where many projects stall. Certificates, provisioning profiles, store listings, screenshots, privacy policies and review rejections take weeks if nobody on the team has done it. Ask the vendor to walk you through the path from "done" to "live in both stores", and to private distribution if you need it.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. How are updates shipped?
&lt;/h3&gt;

&lt;p&gt;Can you push a fix without a full store review? Frameworks like Expo support over-the-air updates for JavaScript changes within the store rules. Native changes still go through review.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Who on your team can use it?
&lt;/h3&gt;

&lt;p&gt;If only certified specialists can work in the tool, it becomes a bottleneck. The best platforms let a product manager or designer make real progress while engineers stay in control of the codebase.&lt;/p&gt;

&lt;h3&gt;
  
  
  8. What does it cost at 10x the users?
&lt;/h3&gt;

&lt;p&gt;Per-user licensing that looks cheap for a 50-person pilot can be the biggest line item at 5,000 employees, or impossible for a consumer app. Model the price at the scale you are hoping for, not the scale you start at.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where AI fits in mobile app development platforms
&lt;/h2&gt;

&lt;p&gt;The newest entrant changes the economics of question 7 and question 2 at the same time.&lt;/p&gt;

&lt;p&gt;Low-code made building faster by replacing code with visual blocks, at the cost of ownership. AI app builders make building faster by writing the code for you, which means the speed and the ownership no longer have to trade off. The catch is that most AI app builders generate web apps. For mobile, the output has to be a real native project, or you are back to the web-view problem in question 1.&lt;/p&gt;

&lt;p&gt;This is the gap we built &lt;a href="https://www.rapidnative.com?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=madp-guide&amp;amp;utm_content=ai-section" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; to fill. You describe the app, or upload a sketch, a screenshot or a product requirements document, and it generates a full-stack React Native and Expo app: screens, navigation, a database, sign-in, file storage and realtime updates. You scan a QR code to run it on your own phone while you build, and change it by chatting or by clicking an element and describing the change. On paid plans you download the complete Expo project, written in TypeScript with Expo Router and NativeWind, with no proprietary runtime. When the app is ready, &lt;a href="https://letsdeploy.it?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=madp-guide&amp;amp;utm_content=rapidnative-madp-guide" rel="noopener noreferrer"&gt;LetsDeploy.It&lt;/a&gt;, our sister service, handles the App Store and Google Play submission.&lt;/p&gt;

&lt;p&gt;Mapped against the six layers above, that covers front-end, backend, testing and publishing, with your own Supabase or APIs plugged in where you need them. Teams also use it alongside an existing stack: generate the first version in days, then move the exported project into their own repository and CI. Larger teams can bring their design system so generated screens match their brand.&lt;/p&gt;

&lt;p&gt;It is not the right tool for everything. If you need to model hundreds of approval workflows across an ERP, an enterprise low-code platform with built-in process automation will serve you better. If you have a mature native team, a cross-platform framework and your existing CI may be all you need. AI-generated code is a starting point you own, not a replacement for engineering judgment on security and scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is MADP still the right term?
&lt;/h2&gt;

&lt;p&gt;Strictly, no. The analyst category moved on in 2019. But the need it named hasn't changed: one supported, governed way to get good mobile apps built and shipped. If an RFP asks for a MADP, answer the underlying questions above rather than the acronym. For the full list of acronyms you will run into (MEAP, MBaaS, LCAP, MXDP, MDM, MAM and the rest), see our enterprise mobile glossary and our enterprise mobile app development guide on the RapidNative blog.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;A mobile app development platform is less a product category than a promise: build once, ship to both stores, keep it running. Over fifteen years that promise has been sold as middleware, as cross-platform tooling, as low-code and now as AI. The best choice in 2026 is the one that gets a real native app on a real phone fastest, while leaving you holding source code you can take anywhere.&lt;/p&gt;

&lt;p&gt;The quickest way to judge any platform is to build one real screen of your own app with it. If you want to try that with RapidNative, &lt;a href="https://www.rapidnative.com?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=madp-guide&amp;amp;utm_content=free-plan" rel="noopener noreferrer"&gt;start from a prompt on the free plan&lt;/a&gt;. Read the &lt;a href="https://www.rapidnative.com/blogs/mobile-app-development-platform?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=madp-guide&amp;amp;utm_content=end-cta" rel="noopener noreferrer"&gt;full guide on RapidNative&lt;/a&gt; for links to everything mentioned here.&lt;/p&gt;

</description>
      <category>mobile</category>
      <category>reactnative</category>
      <category>ai</category>
      <category>lowcode</category>
    </item>
    <item>
      <title>How to Turn Your App Idea Into a Real Mobile App With AI (Step-by-Step Guide for 2026) 📱✨</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Fri, 30 Oct 2026 13:01:27 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/how-to-turn-your-app-idea-into-a-real-mobile-app-with-ai-step-by-step-guide-for-2026-2k3d</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/how-to-turn-your-app-idea-into-a-real-mobile-app-with-ai-step-by-step-guide-for-2026-2k3d</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;You have an app idea. Maybe it's a habit tracker, a booking app for your business, or a tool your friends keep asking for. A few years ago, the next step was hiring a developer or spending months learning to code.&lt;/p&gt;

&lt;p&gt;In 2026, you can describe your app to an AI and have a working mobile app on your phone the same day. But "describe your app" hides a lot of small decisions, and those decisions are what separate an app that looks like a demo from one people actually use.&lt;/p&gt;

&lt;p&gt;This guide walks through the whole path, from a rough idea to an app in the App Store and Google Play, using &lt;a href="https://www.rapidnative.com?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=idea-to-mobile-app-ai&amp;amp;utm_content=intro" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt;, an AI app builder that creates real React Native mobile apps.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: this guide is from the RapidNative team. The steps apply whichever tool you use.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Mobile app or web app? Decide first
&lt;/h2&gt;

&lt;p&gt;Before anything else, decide whether you need a mobile app or a website. Many popular AI builders, like Lovable, Bolt and v0, create web apps. They're great in a browser, but putting them in the App Store means converting them into native apps later.&lt;/p&gt;

&lt;p&gt;If you want people to download your app, get notifications and open it from their home screen, start with a builder that makes mobile apps from day one. RapidNative creates React Native apps built on Expo, the same technology many professional mobile teams use, so there's nothing to rebuild when it's time to launch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Shrink your idea to one main job
&lt;/h2&gt;

&lt;p&gt;The most common reason AI-built apps feel messy is that the idea is too big. Before you write a single prompt, finish this sentence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"My app helps &lt;strong&gt;[who]&lt;/strong&gt; to &lt;strong&gt;[do one thing]&lt;/strong&gt;."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For example: "My app helps dog walkers keep track of which dogs they walked today and send owners a quick update."&lt;/p&gt;

&lt;p&gt;Everything else, like chat, payments or social features, can come later. A focused first version is faster to build, easier for the AI to get right and easier for your first users to understand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: List your screens
&lt;/h2&gt;

&lt;p&gt;Next, write down the 3 to 6 screens your app needs for that one job. For the dog walker app, that might be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Today:&lt;/strong&gt; the list of dogs to walk today.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Walk:&lt;/strong&gt; a timer and a button to mark the walk done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Update:&lt;/strong&gt; a quick note and photo to send to the owner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dogs:&lt;/strong&gt; all dogs with owner contact details.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Settings:&lt;/strong&gt; profile and notifications.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You don't need to design them. A rough sketch on paper, or even a screenshot of an app you like, gives the AI a much clearer picture. RapidNative can start from a sketch or screenshot as well as from words.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Write a prompt that gives the AI what it needs
&lt;/h2&gt;

&lt;p&gt;A good first prompt has four parts: who the app is for, the main job, the screens, and the look and feel. Here's an example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Build a mobile app for professional dog walkers.

Main job: keep track of today's walks and send owners a quick update.

Screens:
1. Today – list of dogs scheduled today, with photo, name and walk time
2. Walk – a big start/stop timer and a "Mark as done" button
3. Update – add a short note and a photo, then send to the owner
4. Dogs – all dogs with breed, owner name and phone number
5. Settings – profile and notification settings

Use a bottom tab bar for Today, Dogs and Settings.
Style: friendly and clean, white background, green accents, rounded cards, large buttons that are easy to tap while walking.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice what the prompt includes: real content, clear navigation and a style. The more specific you are, the less you'll need to fix later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Generate the first version
&lt;/h2&gt;

&lt;p&gt;Paste your prompt into &lt;a href="https://www.rapidnative.com?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=idea-to-mobile-app-ai&amp;amp;utm_content=step-4" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; and let it build. In a few minutes you'll have your screens, navigation and styling as a working React Native app.&lt;/p&gt;

&lt;p&gt;Don't expect perfection on the first try. Think of it as a first draft from a fast designer and developer working together. Your job now is to review it like a user would.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Improve it one screen at a time
&lt;/h2&gt;

&lt;p&gt;The best way to refine an AI-built app is with small, specific requests, one change at a time. Compare these two:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Too vague:&lt;/strong&gt; "Make it better."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Specific:&lt;/strong&gt; "On the Today screen, show the walk time on the right side of each card and make the dog's name bold."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A few more tips that save a lot of back and forth:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fix navigation first, then layout, then colors.&lt;/strong&gt; Moving screens around later can undo styling work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use real example content.&lt;/strong&gt; Real dog names and times reveal layout problems that "Lorem ipsum" hides.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check every screen on a small phone.&lt;/strong&gt; Text that fits on a large screen often wraps badly on smaller ones.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 6: Connect real data
&lt;/h2&gt;

&lt;p&gt;So far your app shows example content. To make it work for real users, it needs somewhere to store data and a way for people to sign in. Many builders use a backend service like Supabase or Firebase for accounts, data and photo storage.&lt;/p&gt;

&lt;p&gt;Because RapidNative gives you standard React Native code, you can connect the backend you prefer, and export the code to keep working in your own editor or with a developer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Test it on your own phone
&lt;/h2&gt;

&lt;p&gt;Nothing replaces holding your app in your hand. Because RapidNative apps are built on Expo, you can run them on a real phone during development. Use the app the way your users would: outside, one-handed, in a hurry.&lt;/p&gt;

&lt;p&gt;Then hand it to three or four people who match your "who" from Step 1. Watch them use it without explaining anything. Wherever they hesitate, there's something to fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 8: Prepare for the App Store and Google Play
&lt;/h2&gt;

&lt;p&gt;Launching takes a little paperwork, and planning for it early avoids surprises:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Developer accounts:&lt;/strong&gt; Apple's costs $99 a year and Google Play's costs $25 once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store screenshots:&lt;/strong&gt; these sell your app before anyone opens it. &lt;a href="https://www.rapidnative.com/blogs/introducing-appshots-ai-app-store-screenshot-generator?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=idea-to-mobile-app-ai&amp;amp;utm_content=appshots" rel="noopener noreferrer"&gt;AppShots&lt;/a&gt; generates App Store screenshots with AI from your app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A privacy policy&lt;/strong&gt; describing what data you collect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Account deletion&lt;/strong&gt; inside the app, if people can sign up. Both Apple and Google require it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sign in with Apple&lt;/strong&gt;, if you offer Google or other social login on iPhone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A closed test on Google Play:&lt;/strong&gt; new personal developer accounts need 12 testers for 14 days before they can publish.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 9: Launch, listen and update
&lt;/h2&gt;

&lt;p&gt;Your first version is a starting point. Once real people are using it, their feedback will tell you what to build next far better than any plan. Add one improvement at a time, using the same small, specific prompts from Step 5, and ship updates regularly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes to avoid
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Building every feature at once.&lt;/strong&gt; Start with one main job and add the rest after launch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prompting without screens in mind.&lt;/strong&gt; A list of screens turns a vague idea into a clear plan for the AI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Choosing a web builder when you need a mobile app.&lt;/strong&gt; Converting later costs time; starting mobile saves it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skipping real-phone testing.&lt;/strong&gt; Apps feel very different in your hand than in a preview.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leaving store requirements until the end.&lt;/strong&gt; Account deletion and Sign in with Apple are much easier to add early.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;Can I build a mobile app with AI without knowing how to code?&lt;/strong&gt; Yes. AI app builders like RapidNative let you create the screens, navigation and design by describing them. Some steps, like connecting a backend or publishing, are easier with a little technical help, but you can get a working app on your phone without writing code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How long does it take to build an app with AI?&lt;/strong&gt; A first working version can take an afternoon. A polished app ready for the stores usually takes a few days to a few weeks, depending on features and testing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How much does it cost to build an app with AI?&lt;/strong&gt; Most AI builders have free plans or credits to start, plus paid plans for more usage. To publish, you'll also pay Apple $99 a year and Google $25 once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need a Mac to publish to the App Store?&lt;/strong&gt; Not necessarily. Expo's cloud build service can build and submit iPhone apps without a Mac.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I keep the code?&lt;/strong&gt; With RapidNative, yes. You get standard React Native and Expo code you can export and keep building on.&lt;/p&gt;

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

&lt;p&gt;Turning an idea into a real mobile app has never been more achievable. Keep the first version focused, plan your screens, write specific prompts, and test on a real phone before you launch. If your goal is an app people download from the App Store and Google Play, &lt;a href="https://www.rapidnative.com?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=idea-to-mobile-app-ai&amp;amp;utm_content=conclusion" rel="noopener noreferrer"&gt;start building with RapidNative&lt;/a&gt; and go from idea to app on your phone today.&lt;/p&gt;

&lt;p&gt;What app are you planning to build? Share your idea in the comments, and we'll suggest a first prompt for it.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>mobile</category>
      <category>beginners</category>
      <category>reactnative</category>
    </item>
    <item>
      <title>App Rejected Under Guideline 4.8? How to Fix Login for AI-Built Apps</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Thu, 29 Oct 2026 12:57:39 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/app-rejected-under-guideline-48-how-to-fix-login-for-ai-built-apps-3hnp</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/app-rejected-under-guideline-48-how-to-fix-login-for-ai-built-apps-3hnp</guid>
      <description>&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; your app lets people sign in with Google (or Facebook, GitHub, X) but doesn't offer a login option that keeps their data private. Apple's Guideline 4.8 says that if you offer a third-party login, you must also offer one that limits what you collect. Sign in with Apple is the quickest way to meet it, and for apps built with Lovable, Bolt, Base44 or v0 it usually takes an afternoon.&lt;/p&gt;

&lt;p&gt;AI app builders make it easy to add "Continue with Google". It works on the web, so nobody thinks about it again until the App Store review note arrives:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Guideline 4.8 - Design - Login Services&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Nothing in your app is broken. Apple just wants iPhone users to have a login option that doesn't hand their data to another company.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does Guideline 4.8 ask for?
&lt;/h2&gt;

&lt;p&gt;If your app uses a third-party or social login service to set up or sign in to the main account, you must also offer another login option with all three of these features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;It only collects the person's name and email address.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It lets people keep their email address private&lt;/strong&gt; from everyone, including you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It doesn't track people for advertising&lt;/strong&gt; without their consent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Sign in with Apple has all three built in. Since early 2024, Apple accepts any login service that meets them, but Sign in with Apple is still the one reviewers expect, and the only one that comes ready-made on every iPhone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do I have to use Sign in with Apple?
&lt;/h2&gt;

&lt;p&gt;Not strictly. Apple's wording asks for "another login service" with the three features above, so another provider that meets them counts. In practice, Sign in with Apple is the safest choice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reviewers recognize it immediately, so there's nothing to argue about.&lt;/li&gt;
&lt;li&gt;It uses Face ID or Touch ID, so it's the fastest login on an iPhone.&lt;/li&gt;
&lt;li&gt;Supabase, which most AI-built apps use for login, supports it out of the box.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Does email and password login count?
&lt;/h2&gt;

&lt;p&gt;Usually not as the fix. Your own email and password sign-up is your account system, not a login service with private email built in. The exemption for your own accounts only applies when your app &lt;strong&gt;only&lt;/strong&gt; uses them. Once you add Google login, Apple expects a private third-party option next to it. If you want to avoid Guideline 4.8 entirely, the other route is removing social login and keeping email and password only.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which apps don't need it?
&lt;/h2&gt;

&lt;p&gt;Guideline 4.8 doesn't apply if your app:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Only uses your own email and password accounts, with no social login.&lt;/li&gt;
&lt;li&gt;Is an education or enterprise app where people sign in with an existing school or work account.&lt;/li&gt;
&lt;li&gt;Uses a government or industry-backed identity system, such as a national e-ID.&lt;/li&gt;
&lt;li&gt;Is a client for a specific third-party service, where people sign in to that service directly (for example, an app that reads their Google Drive files).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If none of those fit and you have Google login, plan on adding Sign in with Apple.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to add Sign in with Apple to a Supabase app
&lt;/h2&gt;

&lt;p&gt;Most apps built with Lovable, Bolt, Base44 or v0 store their users in Supabase, so here's the path for a native React Native and Expo app with Supabase.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Turn on the capability.&lt;/strong&gt; In your Apple Developer account, open Identifiers, select your app's App ID and enable &lt;strong&gt;Sign in with Apple&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turn on the Apple provider in Supabase.&lt;/strong&gt; In Authentication, then Providers, enable Apple and add your app's bundle ID (for example &lt;code&gt;it.letsdeploy.myapp&lt;/code&gt;) to the Client IDs. If you also want Apple login on your website, create a Services ID and a signing key in Apple Developer and add those too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add the Expo package.&lt;/strong&gt; Install &lt;code&gt;expo-apple-authentication&lt;/code&gt; and set &lt;code&gt;"usesAppleSignIn": true&lt;/code&gt; under &lt;code&gt;ios&lt;/code&gt; in your &lt;code&gt;app.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add the button and sign in.&lt;/strong&gt; Show Apple's own button, then pass the token Apple gives you to Supabase:
&lt;/li&gt;
&lt;/ol&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="o"&gt;*&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;AppleAuthentication&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-apple-authentication&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;./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;signInWithApple&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;credential&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;AppleAuthentication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signInAsync&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;requestedScopes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="nx"&gt;AppleAuthentication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AppleAuthenticationScope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;FULL_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;AppleAuthentication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AppleAuthenticationScope&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;EMAIL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="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;credential&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;identityToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;No identity token from Apple&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;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;signInWithIdToken&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;apple&lt;/span&gt;&lt;span class="dl"&gt;'&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;credential&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;identityToken&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;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Apple only sends the name the first time, so save it now&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;credential&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fullName&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;givenName&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;updateUser&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;full_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fullName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;givenName&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;credential&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fullName&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;familyName&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&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="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Test on a real iPhone&lt;/strong&gt; with a fresh Apple ID: sign in, choose "Hide My Email", and check the new user appears in Supabase.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;On the web, Supabase handles Apple login with a redirect, the same way it does Google, so your Lovable or Bolt web version can keep working as it does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mistakes that get apps rejected again
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Apple button is hidden or smaller.&lt;/strong&gt; Put Sign in with Apple at least as high and as large as Google on the login screen. Use Apple's official button style, not a custom one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The name is lost.&lt;/strong&gt; Apple sends the person's name only on the first sign-in. If you don't save it then, you'll never get it again, and your profile screens show blanks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Emails to "Hide My Email" users bounce.&lt;/strong&gt; People who hide their email get an address ending in &lt;code&gt;privaterelay.appleid.com&lt;/code&gt;. To send them emails (password resets, receipts), register your sending domain in Apple Developer under the private email relay settings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Account deletion doesn't revoke Apple tokens.&lt;/strong&gt; If you offer Sign in with Apple, Apple expects you to revoke the person's tokens when they delete their account. Our &lt;a href="https://letsdeploy.it/guides/app-rejected-account-deletion?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=guideline-4-8-sign-in-with-apple&amp;amp;utm_content=account-deletion-guide" rel="noopener noreferrer"&gt;account deletion guide&lt;/a&gt; covers the full deletion flow.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to write back to App Review
&lt;/h2&gt;

&lt;p&gt;When you resubmit, add a short note in App Store Connect so the reviewer doesn't have to hunt:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Where it is:&lt;/strong&gt; "Sign in with Apple is now on the login screen, above Continue with Google."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What it offers:&lt;/strong&gt; "It collects only name and email and supports Hide My Email."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A demo account&lt;/strong&gt; in the App Review information, if parts of the app need a login to review.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Apps rejected under 4.8 are often rejected for something else in the same review. The &lt;a href="https://letsdeploy.it/guides/lovable-app-rejected-guideline-4-2?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=guideline-4-8-sign-in-with-apple&amp;amp;utm_content=guideline-4-2-guide" rel="noopener noreferrer"&gt;Guideline 4.2 guide&lt;/a&gt; covers the most common one for AI-built apps, and the &lt;a href="https://letsdeploy.it/guides/lovable-launch-checklist-and-costs?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=guideline-4-8-sign-in-with-apple&amp;amp;utm_content=launch-checklist" rel="noopener noreferrer"&gt;launch checklist&lt;/a&gt; lists everything else Apple and Google ask for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can you fix it yourself?
&lt;/h2&gt;

&lt;p&gt;Yes. If you can edit your app's login screen and your Apple Developer settings, most people fix this in an afternoon.&lt;/p&gt;

&lt;p&gt;It gets harder when login touches several places: the native app, the web version, Supabase, the private email relay and account deletion. That's part of the store work we do when we convert an app to native, with steps for &lt;a href="https://letsdeploy.it/lovable-to-app-store?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=guideline-4-8-sign-in-with-apple&amp;amp;utm_content=lovable" rel="noopener noreferrer"&gt;Lovable&lt;/a&gt;, &lt;a href="https://letsdeploy.it/bolt-new-to-app-store?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=guideline-4-8-sign-in-with-apple&amp;amp;utm_content=bolt-new" rel="noopener noreferrer"&gt;Bolt.new&lt;/a&gt;, &lt;a href="https://letsdeploy.it/base44-to-app-store?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=guideline-4-8-sign-in-with-apple&amp;amp;utm_content=base44" rel="noopener noreferrer"&gt;Base44&lt;/a&gt; and &lt;a href="https://letsdeploy.it/v0-to-app-store?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=guideline-4-8-sign-in-with-apple&amp;amp;utm_content=v0" rel="noopener noreferrer"&gt;v0&lt;/a&gt;. See &lt;a href="https://letsdeploy.it/how-it-works?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=guideline-4-8-sign-in-with-apple&amp;amp;utm_content=how-it-works" rel="noopener noreferrer"&gt;how it works&lt;/a&gt; or our &lt;a href="https://letsdeploy.it/pricing?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=guideline-4-8-sign-in-with-apple&amp;amp;utm_content=pricing" rel="noopener noreferrer"&gt;plans&lt;/a&gt;.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Is Sign in with Apple required if I only have email and password login?
&lt;/h3&gt;

&lt;p&gt;No. Guideline 4.8 only applies when you offer a third-party or social login, such as Google, Facebook or GitHub.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need Sign in with Apple on Android too?
&lt;/h3&gt;

&lt;p&gt;No. Guideline 4.8 is an App Store rule. You can show the Apple button only on iOS and keep Google login on Android.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use Google login and nothing else?
&lt;/h3&gt;

&lt;p&gt;Not on the App Store, unless your app fits one of the exemptions. You need at least one login option with the three privacy features next to it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Will Apple reject a custom-styled Apple button?
&lt;/h3&gt;

&lt;p&gt;It can. Apple's design guidelines ask you to use their official button styles, and &lt;code&gt;expo-apple-authentication&lt;/code&gt; gives you those buttons ready-made.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Lovable add Sign in with Apple automatically?
&lt;/h3&gt;

&lt;p&gt;No. Lovable, Bolt, Base44 and v0 build web apps and usually add email and Google login. Sign in with Apple needs Apple Developer setup and, in a native app, the native sign-in sheet.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I fix this without a new build?
&lt;/h3&gt;

&lt;p&gt;No. Adding a login option changes the app, so you need a new build and a new submission. Expo's EAS Build makes that a single command once the code is in.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>appstore</category>
      <category>supabase</category>
      <category>reactnative</category>
    </item>
    <item>
      <title>The Complete Guide to Offline Storage in React Native (2026)</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Fri, 16 Oct 2026 11:57:33 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/the-complete-guide-to-offline-storage-in-react-native-2026-2bb8</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/the-complete-guide-to-offline-storage-in-react-native-2026-2bb8</guid>
      <description>&lt;p&gt;Pick the wrong storage library in React Native and your app stalls on launch. Pick the right one and it feels instant on a subway with no signal. Here's the 2026 landscape: what each option is good at, when to combine them, and the architecture that makes writes survive airplane mode.&lt;/p&gt;

&lt;h2&gt;
  
  
  The six options, by job
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;AsyncStorage&lt;/strong&gt;: the classic default. A simple async key-value store. Fine for a handful of small preferences; slow for anything large, because every call is asynchronous and values are stored as serialized strings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MMKV&lt;/strong&gt;: Tencent's key-value engine, exposed to JavaScript through JSI with synchronous reads. Its README claims it's about 30x faster than AsyncStorage, and it supports optional encryption. For a new project in 2026, it's the go-to key-value store.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createMMKV&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-mmkv&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// v4 API&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;storage&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createMMKV&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;prefs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;onboarded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;onboarded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getBoolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;onboarded&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// synchronous&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;On MMKV v3, the equivalent is &lt;code&gt;new MMKV({ id: 'prefs' })&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Expo SecureStore&lt;/strong&gt;: a thin wrapper over the iOS Keychain and Android Keystore, encrypted by the OS. Slower than the others and meant for small values. Use it for refresh tokens and secrets only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;expo-sqlite&lt;/strong&gt;: full relational SQLite with a synchronous API. Indexes, joins, transactions. Comfortably handles large datasets on a modern phone.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;SQLite&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-sqlite&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="nx"&gt;SQLite&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;openDatabaseSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app.db&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&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;execSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`
  CREATE TABLE IF NOT EXISTS notes (
    id TEXT PRIMARY KEY,
    body TEXT,
    updated_at INTEGER
  )
`&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;since&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&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;getAllSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT * FROM notes WHERE updated_at &amp;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="nx"&gt;since&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;WatermelonDB&lt;/strong&gt;: an ORM on top of SQLite with reactive queries and a sync protocol. Reach for it when you have tens of thousands of records and two-way sync.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Realm&lt;/strong&gt;: a reactive object database with its own engine. It still works well locally, but MongoDB ended Atlas Device Sync in September 2025, so don't plan on hosted sync.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Need&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Secrets, tokens&lt;/td&gt;
&lt;td&gt;Expo SecureStore&lt;/td&gt;
&lt;td&gt;OS-level encryption&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Preferences, flags, hot reads&lt;/td&gt;
&lt;td&gt;MMKV&lt;/td&gt;
&lt;td&gt;Fast synchronous reads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Structured, queryable records&lt;/td&gt;
&lt;td&gt;expo-sqlite&lt;/td&gt;
&lt;td&gt;SQL, indexes, joins&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large offline-first data with sync&lt;/td&gt;
&lt;td&gt;WatermelonDB&lt;/td&gt;
&lt;td&gt;Reactive queries + sync&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reactive object DB, local only&lt;/td&gt;
&lt;td&gt;Realm&lt;/td&gt;
&lt;td&gt;Clean object API&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most production apps use three at once: &lt;strong&gt;SecureStore + MMKV + SQLite&lt;/strong&gt;. They don't conflict; each owns a different concern.&lt;/p&gt;

&lt;h2&gt;
  
  
  The offline-first architecture (the part everyone skips)
&lt;/h2&gt;

&lt;p&gt;Picking a library is the easy part. The architecture around it is what makes an app work offline.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Hydrate from local storage on launch, refresh in the background
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;home_feed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;hydrateFeed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;refreshFeedInBackground&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Users should never see a blank screen because of a network request.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Queue writes while offline
&lt;/h3&gt;

&lt;p&gt;On every change, store two things: the local row (so the UI updates instantly) and an entry in a sync queue.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;mutation_queue&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&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="k"&gt;type&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;attempts&lt;/span&gt; &lt;span class="nb"&gt;INTEGER&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Drain the queue when the network is really back
&lt;/h3&gt;

&lt;p&gt;Check &lt;code&gt;isInternetReachable&lt;/code&gt;, not just &lt;code&gt;isConnected&lt;/code&gt;: a captive portal Wi-Fi reports "connected" while nothing actually loads.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;NetInfo&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-community/netinfo&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;API_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;EXPO_PUBLIC_API_URL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;draining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;NetInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isInternetReachable&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;draining&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="nx"&gt;draining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt; &lt;span class="o"&gt;=&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;getAllSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT * FROM mutation_queue ORDER BY created_at ASC&lt;/span&gt;&lt;span class="dl"&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;m&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;API_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&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;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Idempotency-Key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="p"&gt;},&lt;/span&gt;
          &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`HTTP &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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&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;runSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DELETE FROM mutation_queue WHERE id = ?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&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;runSync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;UPDATE mutation_queue SET attempts = attempts + 1 WHERE id = ?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// keep order; retry on the next reconnect&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;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;draining&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details matter here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Idempotency-Key&lt;/code&gt;&lt;/strong&gt;: without it, a flaky network creates duplicates whenever a request succeeds on the server but the response never arrives. Your server must store and honor the key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;draining&lt;/code&gt; flag&lt;/strong&gt;: NetInfo can fire several events in a row. Without a guard, two loops send the same mutations at once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checking &lt;code&gt;res.ok&lt;/code&gt;&lt;/strong&gt;: &lt;code&gt;fetch&lt;/code&gt; only throws on network failure, not on a 500. Without the check, failed writes get deleted from the queue.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Resolve conflicts explicitly
&lt;/h3&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Last write wins&lt;/strong&gt;: compare timestamps. Simple, but can lose data. Fine for most single-user apps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server-authoritative merge&lt;/strong&gt;: the server applies changes in order and returns the canonical state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CRDTs (Yjs, Automerge)&lt;/strong&gt;: needed for real-time collaborative editing. Heavier, but correct.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Serializing giant arrays into AsyncStorage.&lt;/strong&gt; A classic cause of slow launches. Move large data to SQLite.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard-coding an MMKV &lt;code&gt;encryptionKey&lt;/code&gt;.&lt;/strong&gt; Encryption is opt-in, and a key in your source code is readable by anyone. Generate a random key on first launch and keep it in SecureStore.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trusting &lt;code&gt;isConnected&lt;/code&gt;.&lt;/strong&gt; Use &lt;code&gt;isInternetReachable&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retrying without idempotency keys.&lt;/strong&gt; You'll create duplicate records.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loading huge tables with &lt;code&gt;getAllSync&lt;/code&gt;.&lt;/strong&gt; Synchronous reads block the JS thread. Paginate, add indexes, or use the async API for large queries.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Want this scaffolded for you?
&lt;/h2&gt;

&lt;p&gt;If you're starting a new React Native app and want SecureStore + MMKV + SQLite pre-wired with hydration on launch and a sync queue skeleton, &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=offline-storage-react-native" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; generates it from a plain-English prompt and lets you export the full Expo project.&lt;/p&gt;

&lt;p&gt;Everything in this guide is a pattern you can copy by hand too; the whole stack is open source and well documented. Pick the engines that match your access patterns, wrap them in an offline-first architecture, and your app will feel instant whether the network is there or not.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>mobile</category>
      <category>javascript</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Stop Your AI Agent Writing 2023 React Native: A CLAUDE.md for Expo + Supabase Apps</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Thu, 15 Oct 2026 11:37:49 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/stop-your-ai-agent-writing-2023-react-native-a-claudemd-for-expo-supabase-apps-3fia</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/stop-your-ai-agent-writing-2023-react-native-a-claudemd-for-expo-supabase-apps-3fia</guid>
      <description>&lt;p&gt;Ask an AI coding agent to "add a video player" to your Expo app, and there's a good chance it reaches for &lt;code&gt;expo-av&lt;/code&gt;. Ask for a login screen, and it may store the session token in plain AsyncStorage, wrap the screen in &lt;code&gt;SafeAreaView&lt;/code&gt; from &lt;code&gt;react-native&lt;/code&gt;, and install packages with &lt;code&gt;npm install&lt;/code&gt; instead of &lt;code&gt;npx expo install&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;None of that is the agent being careless. It's the agent averaging over years of tutorials, most of which were written for older versions of React Native and Expo.&lt;/p&gt;

&lt;p&gt;The fix is cheap: a project rules file. Claude Code reads &lt;code&gt;CLAUDE.md&lt;/code&gt; at the start of every session, and other agents read similar files (&lt;code&gt;AGENTS.md&lt;/code&gt;, Cursor rules). A rules file tells the agent how &lt;em&gt;this&lt;/em&gt; project works, so it stops guessing.&lt;/p&gt;

&lt;p&gt;Here's one built for Expo + Supabase apps, section by section, then the checks that catch what still slips through.&lt;/p&gt;

&lt;h2&gt;
  
  
  What makes a good rules file?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Specific over general.&lt;/strong&gt; "Write clean code" changes nothing. "Use &lt;code&gt;expo-image&lt;/code&gt;, not &lt;code&gt;Image&lt;/code&gt; from &lt;code&gt;react-native&lt;/code&gt;" changes the output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Short.&lt;/strong&gt; It's loaded into every session. Keep it to things the agent actually gets wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Includes the commands.&lt;/strong&gt; Tell the agent how to check its own work, and it will.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lives in the repo.&lt;/strong&gt; Commit it, so every teammate and every agent gets the same rules.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The CLAUDE.md
&lt;/h2&gt;

&lt;p&gt;Copy this into the root of your project and edit it to match your stack.&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 rules&lt;/span&gt;

&lt;span class="gu"&gt;## Stack&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Expo (managed workflow, development builds) + TypeScript (strict)
&lt;span class="p"&gt;-&lt;/span&gt; Expo Router for navigation (file-based, in /app). Do NOT add React Navigation directly.
&lt;span class="p"&gt;-&lt;/span&gt; Supabase for auth, database, and storage. Client in /lib/supabase.ts.

&lt;span class="gu"&gt;## Packages&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Install with &lt;span class="sb"&gt;`npx expo install &amp;lt;pkg&amp;gt;`&lt;/span&gt;, never &lt;span class="sb"&gt;`npm install`&lt;/span&gt;, so versions match the SDK.
&lt;span class="p"&gt;-&lt;/span&gt; Before adding a dependency, check if Expo already provides one.
&lt;span class="p"&gt;-&lt;/span&gt; Use: expo-image (images), expo-video / expo-audio (media; NOT expo-av),
  react-native-safe-area-context (NOT SafeAreaView from react-native),
  react-native-reanimated (animations), expo-secure-store (tokens/secrets).

&lt;span class="gu"&gt;## Navigation&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Screens live in /app. Layouts in _layout.tsx.
&lt;span class="p"&gt;-&lt;/span&gt; Auth gating uses Stack.Protected guards in app/_layout.tsx. No useEffect redirects.
&lt;span class="p"&gt;-&lt;/span&gt; Navigate with &lt;span class="nt"&gt;&amp;lt;Link&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt; or router.push. Use typed routes.

&lt;span class="gu"&gt;## Supabase&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Never use the secret/service key in the app. Only the publishable key.
&lt;span class="p"&gt;-&lt;/span&gt; Every new table: enable RLS in the same migration, with policies.
&lt;span class="p"&gt;-&lt;/span&gt; Schema changes go in supabase/migrations/, never only in the dashboard.
&lt;span class="p"&gt;-&lt;/span&gt; After a schema change, regenerate types: &lt;span class="sb"&gt;`npm run db:types`&lt;/span&gt;.
&lt;span class="p"&gt;-&lt;/span&gt; Pass the generated Database type to createClient. No &lt;span class="sb"&gt;`any`&lt;/span&gt; for query results.

&lt;span class="gu"&gt;## Code style&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Function components and hooks only.
&lt;span class="p"&gt;-&lt;/span&gt; Handle loading, empty, and error states for every screen that fetches data.
&lt;span class="p"&gt;-&lt;/span&gt; Every user-facing string that requests a permission needs a clear reason.
&lt;span class="p"&gt;-&lt;/span&gt; No inline secrets. Public config uses EXPO_PUBLIC_&lt;span class="err"&gt;*&lt;/span&gt;; everything else stays on the server.

&lt;span class="gu"&gt;## Before you say you're done&lt;/span&gt;
Run these and fix any failures:
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`npx tsc --noEmit`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`npx expo lint`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="sb"&gt;`npx expo-doctor`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why each section is there
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Packages
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;npx expo install&lt;/code&gt; picks versions compatible with your Expo SDK. Plain &lt;code&gt;npm install&lt;/code&gt; grabs the latest, which is how you end up with a native module that doesn't match your SDK and a build that fails in EAS.&lt;/p&gt;

&lt;p&gt;The "use X, not Y" list is the highest-value part of the whole file. Each line replaces a deprecated or weaker default the agent would otherwise pick:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Agents often write&lt;/th&gt;
&lt;th&gt;Current choice&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;expo-av&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;expo-video&lt;/code&gt; and &lt;code&gt;expo-audio&lt;/code&gt; (&lt;code&gt;expo-av&lt;/code&gt; is deprecated)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;SafeAreaView&lt;/code&gt; from &lt;code&gt;react-native&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;react-native-safe-area-context&lt;/code&gt; (the core one is deprecated)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Image&lt;/code&gt; from &lt;code&gt;react-native&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;expo-image&lt;/code&gt; (caching, placeholders, better formats)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Animated&lt;/code&gt; API&lt;/td&gt;
&lt;td&gt;&lt;code&gt;react-native-reanimated&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tokens in AsyncStorage&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;expo-secure-store&lt;/code&gt; for anything sensitive&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Update this table when you upgrade SDKs. It's the part that ages fastest.&lt;/p&gt;

&lt;h3&gt;
  
  
  Navigation
&lt;/h3&gt;

&lt;p&gt;Agents trained on years of React Navigation code will happily add a &lt;code&gt;NavigationContainer&lt;/code&gt; to an Expo Router app. One line prevents it. The auth rule matters too: protected routes handle redirects and history correctly, while scattered &lt;code&gt;useEffect&lt;/code&gt; redirects cause flashes and back-button bugs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Supabase
&lt;/h3&gt;

&lt;p&gt;These four rules prevent the mistakes that actually hurt:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Secret key in the app:&lt;/strong&gt; anyone can extract it from the bundle and bypass every security rule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A table without RLS:&lt;/strong&gt; readable by anyone with your public key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dashboard-only schema changes:&lt;/strong&gt; they vanish on the next reset, and your teammates never get them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stale types:&lt;/strong&gt; the compiler can't catch a renamed column if the types are out of date.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  "Before you say you're done"
&lt;/h3&gt;

&lt;p&gt;This is the section that changes agent behavior the most. Claude Code runs commands, so when the rules say "run these and fix failures," it type-checks and lints its own work before handing it back. &lt;code&gt;expo-doctor&lt;/code&gt; also catches dependency versions that don't match your SDK.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should you still review by hand?
&lt;/h2&gt;

&lt;p&gt;A rules file reduces mistakes; it doesn't remove them. When reviewing agent-written Expo code, check these first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Permissions:&lt;/strong&gt; is every permission request explained, and only asked for when needed?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Empty and error states:&lt;/strong&gt; what does the screen show with no data, or no network?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets:&lt;/strong&gt; search the diff for &lt;code&gt;key&lt;/code&gt;, &lt;code&gt;secret&lt;/code&gt;, and &lt;code&gt;token&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New tables:&lt;/strong&gt; does the migration enable RLS?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New dependencies:&lt;/strong&gt; were they needed, and were they installed with &lt;code&gt;npx expo install&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Platform differences:&lt;/strong&gt; did it test only iOS? Keyboard handling and back buttons often break on Android.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How do you keep the rules useful?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Add a rule every time you correct the agent twice.&lt;/strong&gt; If you fix the same mistake again, it belongs in the file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete rules the agent never breaks.&lt;/strong&gt; Shorter files get followed better.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review it on every SDK upgrade.&lt;/strong&gt; Deprecated packages are the fastest-changing part.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Share it across tools.&lt;/strong&gt; If your team uses several agents, keep one source of truth and point the other files at it.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;AI agents write React Native the way the internet wrote it, which is often a few versions behind. A one-page &lt;code&gt;CLAUDE.md&lt;/code&gt; tells them how your project works today: which packages, which navigation, which security rules, and how to check their own work.&lt;/p&gt;

&lt;p&gt;If you'd rather start from a project that already follows these conventions, &lt;a href="https://applighter.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=claude_md_expo" rel="noopener noreferrer"&gt;AppLighter's Expo + Supabase templates&lt;/a&gt; come with Expo Router, Supabase auth, and RLS set up, so your agent builds on a clean base.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What's the one rule your agent keeps breaking? Drop it in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>ai</category>
      <category>supabase</category>
    </item>
    <item>
      <title>Supabase Background Jobs, Tested Locally: Cron, Queues, HTTP from SQL, and Webhooks</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Mon, 12 Oct 2026 09:58:40 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/supabase-background-jobs-tested-locally-cron-queues-http-from-sql-and-webhooks-3jm5</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/supabase-background-jobs-tested-locally-cron-queues-http-from-sql-and-webhooks-3jm5</guid>
      <description>&lt;p&gt;Every Supabase app eventually grows a background layer. A nightly cleanup job. A digest email every morning. A queue that processes uploads one at a time. A webhook that fires when an order is paid.&lt;/p&gt;

&lt;p&gt;In Supabase, that layer is mostly SQL: &lt;code&gt;pg_cron&lt;/code&gt; schedules jobs, &lt;code&gt;pgmq&lt;/code&gt; queues work, &lt;code&gt;pg_net&lt;/code&gt; makes HTTP calls from the database (usually to an Edge Function), and database webhooks fire on inserts and updates.&lt;/p&gt;

&lt;p&gt;It's also the part of the app most teams never run locally. It's easy to push a cron job straight to the hosted project and find out the next morning that the schedule was in the wrong timezone, or that the Edge Function it calls rejected the request.&lt;/p&gt;

&lt;p&gt;This post walks through the four building blocks with working SQL, the patterns that hold up in production, and how to exercise all of it on your laptop before it ships.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four building blocks
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;th&gt;Typical use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pg_cron&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Run SQL on a schedule&lt;/td&gt;
&lt;td&gt;Nightly cleanup, scheduled digests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pg_net&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Make HTTP requests from SQL&lt;/td&gt;
&lt;td&gt;Call an Edge Function from a cron job or trigger&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pgmq&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Durable message queue in Postgres&lt;/td&gt;
&lt;td&gt;Process work one item at a time, with retries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Database webhooks&lt;/td&gt;
&lt;td&gt;HTTP call on insert, update, or delete&lt;/td&gt;
&lt;td&gt;React to a row changing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most real setups combine them: &lt;strong&gt;a cron job uses &lt;code&gt;pg_net&lt;/code&gt; to call an Edge Function, which reads work from a &lt;code&gt;pgmq&lt;/code&gt; queue.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Scheduled jobs with &lt;code&gt;pg_cron&lt;/code&gt;
&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;select&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s1"&gt;'purge-old-sessions'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;-- job name&lt;/span&gt;
  &lt;span class="s1"&gt;'0 3 * * *'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                   &lt;span class="c1"&gt;-- every day at 03:00&lt;/span&gt;
  &lt;span class="err"&gt;$$&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sessions&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'30 days'&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inspect jobs and their runs:&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;select&lt;/span&gt; &lt;span class="n"&gt;jobid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;jobname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;job&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;jobid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;return_message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;start_time&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;job_run_details&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;start_time&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;
&lt;span class="k"&gt;limit&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remove a job by name:&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;select&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;unschedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'purge-old-sessions'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The timezone trap.&lt;/strong&gt; Hosted Supabase runs cron schedules in &lt;strong&gt;UTC&lt;/strong&gt;. &lt;code&gt;0 3 * * *&lt;/code&gt; is 3am UTC, which might be the middle of your users' afternoon. Write the timezone in the job name or a comment, and check it before you ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Calling an Edge Function from SQL with &lt;code&gt;pg_net&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Most cron jobs shouldn't do the work in SQL. They should kick off an Edge Function that can call APIs, send email, and use secrets.&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;select&lt;/span&gt; &lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;http_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'https://&amp;lt;project-ref&amp;gt;.supabase.co/functions/v1/send-digest'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jsonb_build_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s1"&gt;'Content-Type'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'application/json'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s1"&gt;'Authorization'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Bearer '&lt;/span&gt; &lt;span class="o"&gt;||&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;decrypted_secret&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;vault&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;decrypted_secrets&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'digest_function_key'&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jsonb_build_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'kind'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'daily'&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;Combine it with cron:&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;select&lt;/span&gt; &lt;span class="n"&gt;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s1"&gt;'daily-digest'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s1"&gt;'0 8 * * *'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;-- 08:00 UTC&lt;/span&gt;
  &lt;span class="err"&gt;$$&lt;/span&gt;
    &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;http_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'https://&amp;lt;project-ref&amp;gt;.supabase.co/functions/v1/send-digest'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jsonb_build_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="s1"&gt;'Content-Type'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'application/json'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="s1"&gt;'Authorization'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'Bearer '&lt;/span&gt; &lt;span class="o"&gt;||&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;decrypted_secret&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;vault&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;decrypted_secrets&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'digest_function_key'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'{"kind":"daily"}'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;jsonb&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pg_net&lt;/code&gt; is asynchronous: &lt;code&gt;net.http_post&lt;/code&gt; queues the request and returns a request ID immediately. Check what came back in the response table:&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;select&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status_code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_http_response&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;created&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;
&lt;span class="k"&gt;limit&lt;/span&gt; &lt;span class="mi"&gt;10&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;Two rules:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep keys in Vault&lt;/strong&gt;, not in the job's SQL. Anything written inline into &lt;code&gt;cron.schedule&lt;/code&gt; is stored in plain text in &lt;code&gt;cron.job&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check &lt;code&gt;net._http_response&lt;/code&gt; for failures.&lt;/strong&gt; A &lt;code&gt;401&lt;/code&gt; or &lt;code&gt;500&lt;/code&gt; from your function won't surface anywhere else.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Queues with &lt;code&gt;pgmq&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;When work needs to happen reliably, one item at a time, with retries if something fails, use a queue instead of a bare cron job.&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;select&lt;/span&gt; &lt;span class="n"&gt;pgmq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'image_jobs'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Producer: enqueue work (from a trigger, an RPC, or app code)&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;pgmq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'image_jobs'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'{"image_id": "abc123"}'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A worker (usually an Edge Function called on a schedule) reads a batch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Read up to 10 messages, hiding them from other readers for 60 seconds&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;msg_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;read_ct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pgmq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'image_jobs'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a message is processed, delete or archive it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;pgmq&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="s1"&gt;'image_jobs'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;     &lt;span class="c1"&gt;-- done, discard&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;pgmq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;archive&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'image_jobs'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;43&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;    &lt;span class="c1"&gt;-- done, keep a record&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the worker crashes before deleting a message, the &lt;strong&gt;visibility timeout&lt;/strong&gt; (60 seconds above) expires and the message becomes readable again. That's your retry. Use &lt;code&gt;read_ct&lt;/code&gt; to spot messages that keep failing and move them aside after a few attempts, so one bad message doesn't block the queue forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Database webhooks
&lt;/h2&gt;

&lt;p&gt;Webhooks call a URL when rows change. The payload has a fixed shape your Edge Function can rely on:&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;"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;"INSERT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"table"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"public"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"record"&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;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"o1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"paid"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"user_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"u1"&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;"old_record"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&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;For &lt;code&gt;UPDATE&lt;/code&gt;, both &lt;code&gt;record&lt;/code&gt; and &lt;code&gt;old_record&lt;/code&gt; are set. For &lt;code&gt;DELETE&lt;/code&gt;, &lt;code&gt;record&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make handlers idempotent.&lt;/strong&gt; Deliveries can be retried, and an &lt;code&gt;UPDATE&lt;/code&gt; can fire even when the column you care about didn't change. Compare &lt;code&gt;record&lt;/code&gt; and &lt;code&gt;old_record&lt;/code&gt; before sending a receipt, and record what you've already processed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing all of it locally
&lt;/h2&gt;

&lt;p&gt;The usual options for running this before deploying:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The Supabase CLI&lt;/strong&gt; (&lt;code&gt;supabase start&lt;/code&gt;) runs the full Docker stack, including these extensions. It's the highest-fidelity local option, at about a dozen containers and roughly 1.4 GB of RAM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://tinbase.dev/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=supabase_background_jobs_2026" rel="noopener noreferrer"&gt;tinbase&lt;/a&gt;&lt;/strong&gt;, an open-source, Supabase-compatible backend that runs as one process with no Docker. It emulates &lt;code&gt;pg_cron&lt;/code&gt;, &lt;code&gt;pgmq&lt;/code&gt;, &lt;code&gt;pg_net&lt;/code&gt;, Vault, and database webhooks, so the SQL above runs as written.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A local test loop with tinbase:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Put your jobs in a migration so every environment gets the same schedule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- supabase/migrations/20261012000000_background_jobs.sql&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;pgmq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'image_jobs'&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;cron&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'process-images'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'* * * * *'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;net&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;http_post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'http://127.0.0.1:54321/functions/v1/process-images'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;jsonb_build_object&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Content-Type'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'application/json'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;jsonb&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, in the Studio-style dashboard's SQL editor or from a test:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Enqueue a few messages with &lt;code&gt;pgmq.send&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Wait a minute for the cron job to fire.&lt;/li&gt;
&lt;li&gt;Check &lt;code&gt;cron.job_run_details&lt;/code&gt; for the run.&lt;/li&gt;
&lt;li&gt;Check &lt;code&gt;net._http_response&lt;/code&gt; for the function's status code.&lt;/li&gt;
&lt;li&gt;Check that the queue is empty (or the bad message is still there with a higher &lt;code&gt;read_ct&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Edge Functions written with &lt;code&gt;Deno.serve&lt;/code&gt; run in-process from &lt;code&gt;supabase/functions/&lt;/code&gt;, so the cron → &lt;code&gt;pg_net&lt;/code&gt; → function → queue loop happens entirely on your laptop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to know about the emulation:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It implements the surface real projects call, not every function each extension offers. If you use something exotic, check tinbase's compatibility table.&lt;/li&gt;
&lt;li&gt;It's alpha, and built for local development and CI, not production.&lt;/li&gt;
&lt;li&gt;Edge Functions with &lt;code&gt;npm:&lt;/code&gt; or &lt;code&gt;jsr:&lt;/code&gt; imports need bundling first.&lt;/li&gt;
&lt;li&gt;Run your jobs once against the real Supabase CLI or a staging project before deploying, especially anything time-sensitive.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Checklist before shipping background jobs
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Jobs are defined in migrations, not typed into the dashboard&lt;/li&gt;
&lt;li&gt;[ ] Every schedule's timezone is written down (hosted cron runs in UTC)&lt;/li&gt;
&lt;li&gt;[ ] Function keys live in Vault, never inline in &lt;code&gt;cron.schedule&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Someone (or something) checks &lt;code&gt;net._http_response&lt;/code&gt; for errors&lt;/li&gt;
&lt;li&gt;[ ] Queue workers delete or archive every processed message&lt;/li&gt;
&lt;li&gt;[ ] Messages that fail repeatedly are moved aside using &lt;code&gt;read_ct&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Webhook handlers are idempotent and compare &lt;code&gt;record&lt;/code&gt; with &lt;code&gt;old_record&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] The whole loop has run at least once before it reaches production&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Background jobs fail quietly, at 3am, in UTC. Running them on your laptop first is the cheapest insurance you can buy.&lt;/p&gt;

&lt;p&gt;What's the background job that bit you hardest? I'd like to collect the war stories.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Your Own Expo Boilerplate in 60 Minutes: The Day-One Setup I Wish I'd Done Every Time</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Thu, 08 Oct 2026 14:45:25 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/your-own-expo-boilerplate-in-60-minutes-the-day-one-setup-i-wish-id-done-every-time-3de8</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/your-own-expo-boilerplate-in-60-minutes-the-day-one-setup-i-wish-id-done-every-time-3de8</guid>
      <description>&lt;p&gt;Every Expo project I've regretted had the same origin story: &lt;code&gt;npx create-expo-app&lt;/code&gt;, straight into building screens, and "I'll set up the boring stuff later." Later arrived as a broken production build, secrets in the bundle, a crash nobody could reproduce, and a weekend of retrofitting.&lt;/p&gt;

&lt;p&gt;The boring stuff takes about an hour on day one. On day ninety it takes a week. This is the checklist I now run before writing a single feature, in order, with the commands. At the end you have your own boilerplate: a repo you can copy for every new app.&lt;/p&gt;

&lt;h2&gt;
  
  
  0. Start from the current template (2 minutes)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx create-expo-app@latest my-app
&lt;span class="nb"&gt;cd &lt;/span&gt;my-app
npx expo-doctor
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default template already gives you TypeScript, Expo Router, and an &lt;code&gt;@/*&lt;/code&gt; path alias. Run &lt;code&gt;expo-doctor&lt;/code&gt; immediately and fix anything it flags while the project is still empty.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Make TypeScript strict (5 minutes)
&lt;/h2&gt;

&lt;p&gt;In &lt;code&gt;tsconfig.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"extends"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"expo/tsconfig.base"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"compilerOptions"&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;"strict"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"noUncheckedIndexedAccess"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"paths"&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;"@/*"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"./*"&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;Add a script so CI and you run the same check:&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;"scripts"&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;"typecheck"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Turning strict mode on later means fixing hundreds of errors at once. Turning it on now means fixing zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Linting and formatting (5 minutes)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx expo lint          &lt;span class="c"&gt;# sets up ESLint with Expo's config on first run&lt;/span&gt;
npm i &lt;span class="nt"&gt;-D&lt;/span&gt; prettier eslint-config-prettier
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the rules that catch real bugs, not style debates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;react-hooks/exhaustive-deps&lt;/code&gt; as an error&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@typescript-eslint/no-explicit-any&lt;/code&gt; as an error&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;no-restricted-imports&lt;/code&gt; for &lt;code&gt;@react-navigation/*&lt;/code&gt;, so navigation always comes from &lt;code&gt;expo-router&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Decide the folder structure before it decides itself (5 minutes)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app/                  # routes only (Expo Router)
components/           # reusable UI
features/&amp;lt;name&amp;gt;/      # screens' logic, hooks, and components per feature
lib/                  # clients (supabase, api), utilities
queries/              # data-fetching hooks
theme/                # colors, spacing, typography tokens
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule that matters most: &lt;strong&gt;&lt;code&gt;app/&lt;/code&gt; contains routes, not logic.&lt;/strong&gt; A route file imports a screen from &lt;code&gt;features/&lt;/code&gt; and renders it. Routes stay thin, and features stay testable.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Theme tokens before the first screen (5 minutes)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// theme/tokens.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;colors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;bg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#0B0B0F&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;surface&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#16161D&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#F5F5F7&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;muted&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#9A9AA3&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#3B82F6&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;danger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#EF4444&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;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;space&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;xs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;md&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;xl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;radius&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;sm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;md&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whether you use NativeWind, StyleSheet, or a UI kit, the tokens live in one place. When the designer changes the primary color, it's one line, not a search across forty files.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Data fetching that handles loading and errors (5 minutes)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i @tanstack/react-query
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/_layout.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;QueryClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;QueryClientProvider&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;@tanstack/react-query&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;Stack&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-router&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;queryClient&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;QueryClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;RootLayout&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;QueryClientProvider&lt;/span&gt; &lt;span class="na"&gt;client&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;queryClient&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Stack&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;QueryClientProvider&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;Then make it a rule: no &lt;code&gt;fetch&lt;/code&gt; or database calls inside &lt;code&gt;useEffect&lt;/code&gt; in screens. Every request goes through a hook in &lt;code&gt;queries/&lt;/code&gt;, which gives you caching, retries, and loading and error states for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Secure storage for anything sensitive (3 minutes)
&lt;/h2&gt;



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

&lt;/div&gt;



&lt;p&gt;Session tokens and refresh tokens go in the Keychain (iOS) or Keystore (Android) via &lt;code&gt;expo-secure-store&lt;/code&gt;, not in AsyncStorage. Set the pattern now and every auth library you add later plugs into it.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Environment variables, the safe way (5 minutes)
&lt;/h2&gt;

&lt;p&gt;Two kinds of values, two places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Public config&lt;/strong&gt; (API URL, publishable keys): &lt;code&gt;EXPO_PUBLIC_&lt;/code&gt; variables, which are inlined into the JavaScript bundle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets&lt;/strong&gt; (third-party API keys, service keys): never in the app. They live on your server or in a serverless function the app calls.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Store build-time values in EAS so every machine and CI run uses the same ones:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i &lt;span class="nt"&gt;-g&lt;/span&gt; eas-cli
eas init
eas &lt;span class="nb"&gt;env&lt;/span&gt;:create &lt;span class="nt"&gt;--environment&lt;/span&gt; production &lt;span class="nt"&gt;--name&lt;/span&gt; EXPO_PUBLIC_API_URL &lt;span class="nt"&gt;--value&lt;/span&gt; https://api.example.com
eas &lt;span class="nb"&gt;env&lt;/span&gt;:pull &lt;span class="nt"&gt;--environment&lt;/span&gt; development
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a CI check that fails if anything like &lt;code&gt;EXPO_PUBLIC_.*SECRET&lt;/code&gt; appears in the repo.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Build profiles and a development build (10 minutes)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx expo &lt;span class="nb"&gt;install &lt;/span&gt;expo-dev-client
eas build:configure
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set up three profiles in &lt;code&gt;eas.json&lt;/code&gt;, each linked to its environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cli"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"appVersionSource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"remote"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"development"&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;"developmentClient"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"distribution"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"internal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"environment"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"development"&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;"preview"&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;"distribution"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"internal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"environment"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"preview"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"channel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"preview"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"environment"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"channel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"autoIncrement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then make your first development build:&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:dev &lt;span class="nt"&gt;--platform&lt;/span&gt; ios
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From here on, you develop in your own dev client instead of Expo Go. Push notifications, payments, and most native modules don't work in Expo Go, so finding out on day one beats finding out the week before launch.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Over-the-air updates, configured safely (5 minutes)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx expo &lt;span class="nb"&gt;install &lt;/span&gt;expo-updates
eas update:configure
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In &lt;code&gt;app.json&lt;/code&gt;, use the fingerprint runtime policy so an update only reaches binaries that can run it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"runtimeVersion"&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;"policy"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fingerprint"&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;Now a JavaScript-only bug fix is a five-minute publish instead of a store review.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Crash reporting before the first user (5 minutes)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @sentry/wizard@latest &lt;span class="nt"&gt;-i&lt;/span&gt; reactNative
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The wizard adds the SDK, the config plugin, and source map uploads during EAS builds. A crash report with a readable stack trace is the difference between "fixed in an hour" and "can't reproduce."&lt;/p&gt;

&lt;h2&gt;
  
  
  11. CI that runs on every pull request (5 minutes)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/ci.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CI&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;check&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;22&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;cache&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;npm&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm ci&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npm run typecheck&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx expo lint&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;npx expo install --check&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three checks: types, lint, and dependency versions that match your Expo SDK. That last one catches the "works locally, fails on EAS" class of bugs before they cost a build.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. The two screens every app needs (optional, but cheap now)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Settings → Account → Delete account.&lt;/strong&gt; Both app stores require in-app account deletion if users can sign up. Add the route now, even if the button is a stub.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error and empty states.&lt;/strong&gt; A shared &lt;code&gt;&amp;lt;ErrorState /&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;EmptyState /&amp;gt;&lt;/code&gt; component means every screen handles failure the same way.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Save it as your boilerplate
&lt;/h2&gt;

&lt;p&gt;Once all twelve are done, commit the repo, tag it, and mark it as a GitHub template repository. Every new app starts with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh repo create my-next-app &lt;span class="nt"&gt;--template&lt;/span&gt; you/expo-boilerplate &lt;span class="nt"&gt;--private&lt;/span&gt; &lt;span class="nt"&gt;--clone&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Update the template whenever you upgrade the Expo SDK, and every future project starts current.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;[ ] Fresh template, &lt;code&gt;expo-doctor&lt;/code&gt; clean&lt;/li&gt;
&lt;li&gt;[ ] Strict TypeScript and a &lt;code&gt;typecheck&lt;/code&gt; script&lt;/li&gt;
&lt;li&gt;[ ] ESLint and Prettier with bug-catching rules&lt;/li&gt;
&lt;li&gt;[ ] Folder structure: routes in &lt;code&gt;app/&lt;/code&gt;, logic in &lt;code&gt;features/&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Theme tokens in one file&lt;/li&gt;
&lt;li&gt;[ ] TanStack Query for all data fetching&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;expo-secure-store&lt;/code&gt; for tokens&lt;/li&gt;
&lt;li&gt;[ ] Public vs secret env vars separated; values on EAS&lt;/li&gt;
&lt;li&gt;[ ] Three build profiles and a development build&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;expo-updates&lt;/code&gt; with fingerprint runtime version&lt;/li&gt;
&lt;li&gt;[ ] Sentry with source maps&lt;/li&gt;
&lt;li&gt;[ ] CI: typecheck, lint, dependency check&lt;/li&gt;
&lt;li&gt;[ ] Account deletion route and shared error/empty states&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;About an hour of work, and every app you build afterward starts from a foundation instead of a to-do list.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you'd rather skip straight to screens, &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=expo_boilerplate_day_one_2026" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; generates Expo apps as real React Native code you own, so you can drop them into a setup like this one and keep going.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What's on your day-one list that isn't on mine?&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>typescript</category>
      <category>mobile</category>
    </item>
    <item>
      <title>React Native Accessibility: The 2026 WCAG 2.2 Compliance Guide</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Mon, 05 Oct 2026 11:00:29 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/react-native-accessibility-the-2026-wcag-22-compliance-guide-56ja</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/react-native-accessibility-the-2026-wcag-22-compliance-guide-56ja</guid>
      <description>&lt;p&gt;Most React Native apps fail accessibility audits for the same dozen reasons, and almost none of them are hard to fix. An unlabeled icon button. A tap target that's 28 points wide. A modal that VoiceOver can read straight through. Text that clips at 200% font size.&lt;/p&gt;

&lt;p&gt;This guide maps WCAG 2.2 Level AA to the React Native APIs that satisfy it, with code for each. If you build to WCAG 2.2 AA, you also meet WCAG 2.1 AA, because 2.2 is backwards compatible and only adds criteria (plus removes one obsolete one, 4.1.1 Parsing).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Not legal advice.&lt;/strong&gt; If you're building for a government agency, a public university, or a healthcare provider, confirm your specific obligations with counsel.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why this matters in 2026
&lt;/h2&gt;

&lt;p&gt;The U.S. Department of Justice's ADA Title II rule makes &lt;strong&gt;WCAG 2.1 Level AA&lt;/strong&gt; the technical standard for web content and mobile apps provided by state and local governments, including public schools, universities, courts, transit authorities, and public hospitals. That includes apps built for them by contractors.&lt;/p&gt;

&lt;p&gt;The deadlines moved. On April 20, 2026, four days before the original deadline, DOJ published an interim final rule extending them by a year:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Public entity&lt;/th&gt;
&lt;th&gt;Original deadline&lt;/th&gt;
&lt;th&gt;Current deadline&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Population 50,000 or more&lt;/td&gt;
&lt;td&gt;April 24, 2026&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;April 26, 2027&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Population under 50,000, and special districts&lt;/td&gt;
&lt;td&gt;April 26, 2027&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;April 26, 2028&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things the extension didn't change. First, it only delays the WCAG compliance dates; Title II's underlying nondiscrimination obligations still apply today, and those have supported accessibility lawsuits for years. Second, the separate HHS Section 504 rule for recipients of HHS funding kept its May 11, 2026 first deadline.&lt;/p&gt;

&lt;p&gt;And outside the U.S., the European Accessibility Act has applied to many consumer-facing apps (banking, e-commerce, transport, e-books) since June 28, 2025.&lt;/p&gt;

&lt;p&gt;Even without a legal requirement, roughly one in six people worldwide lives with a significant disability. Accessibility is a big chunk of your addressable market.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Label everything a screen reader can focus
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WCAG 1.1.1 Non-text Content (A), 4.1.2 Name, Role, Value (A)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The single most common failure: icon-only buttons with no label. VoiceOver reads "button." TalkBack reads "unlabeled."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ Screen reader says: "button"&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt; &lt;span class="na"&gt;onPress&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;onDelete&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TrashIcon&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// ✅ Screen reader says: "Delete note, button. Removes this note permanently."&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt;
  &lt;span class="na"&gt;onPress&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;onDelete&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityRole&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityLabel&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Delete note"&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityHint&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Removes this note permanently"&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;TrashIcon&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rules of thumb:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Labels describe what it is&lt;/strong&gt;, not what it looks like. "Delete note," not "trash can icon."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't put the role in the label.&lt;/strong&gt; "Delete button" becomes "Delete button, button."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hints are optional&lt;/strong&gt; and describe the result. Use them only when the label isn't enough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decorative images&lt;/strong&gt; should be hidden: &lt;code&gt;accessible={false}&lt;/code&gt; on the &lt;code&gt;Image&lt;/code&gt;, or wrap them in a parent that sets &lt;code&gt;importantForAccessibility="no"&lt;/code&gt; on Android.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Since React Native 0.71 you can also use the web-style &lt;code&gt;aria-*&lt;/code&gt; props and &lt;code&gt;role&lt;/code&gt;, which map to the same native properties:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt; &lt;span class="na"&gt;aria-label&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Delete note"&lt;/span&gt; &lt;span class="na"&gt;onPress&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;onDelete&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TrashIcon&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pick one style per codebase and lint for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Expose state, not just labels
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WCAG 4.1.2 Name, Role, Value (A)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A checkbox that looks checked but announces nothing about its state fails, even with a perfect label.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityRole&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"checkbox"&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityState&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;checked&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;isDone&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityLabel&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Buy groceries"&lt;/span&gt;
  &lt;span class="na"&gt;onPress&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;toggle&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Checkbox&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isDone&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Buy groceries&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Other states worth wiring up: &lt;code&gt;selected&lt;/code&gt; for tabs and segmented controls, &lt;code&gt;expanded&lt;/code&gt; for accordions, &lt;code&gt;disabled&lt;/code&gt; for inactive buttons, and &lt;code&gt;busy&lt;/code&gt; while something loads.&lt;/p&gt;

&lt;p&gt;For sliders and progress bars, use &lt;code&gt;accessibilityValue&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;View&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityRole&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"adjustable"&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityLabel&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Volume"&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityValue&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;min&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;max&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;volume&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityActions&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;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;increment&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;decrement&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;onAccessibilityAction&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nf"&gt;setVolume&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nativeEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actionName&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;increment&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;accessibilityActions&lt;/code&gt;, screen reader users can't change the value at all; swipe up and down on an adjustable element does nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Group related content
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WCAG 1.3.1 Info and Relationships (A)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A product card with an image, title, price, and rating becomes five separate swipe stops by default. Group it so it reads as one sentence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt;
  &lt;span class="na"&gt;accessible&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityRole&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityLabel&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, rated &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;rating&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; out of 5`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;onPress&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;openProduct&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Image&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;photo&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Text&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;name&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;Text&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;price&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;StarRating&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;rating&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Setting &lt;code&gt;accessible&lt;/code&gt; on the parent makes it a single focusable element. The label you write replaces the children's text, so make sure it includes everything that matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Make tap targets big enough
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WCAG 2.5.8 Target Size (Minimum), new in 2.2 (AA)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;WCAG 2.2 requires targets of at least &lt;strong&gt;24×24 CSS pixels&lt;/strong&gt;, with spacing exceptions. That's the legal floor. Apple's Human Interface Guidelines recommend 44×44 points and Material Design recommends 48×48 dp; aim for those.&lt;/p&gt;

&lt;p&gt;You don't have to make the icon bigger. Use &lt;code&gt;hitSlop&lt;/code&gt; to expand the touchable area:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt;
  &lt;span class="na"&gt;onPress&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;onClose&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityRole&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityLabel&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Close"&lt;/span&gt;
  &lt;span class="na"&gt;hitSlop&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;top&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;bottom&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;right&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;// 20pt icon → 44pt target&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;CloseIcon&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Pressable&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watch for adjacent targets: two 24-point icons touching each other technically pass the size rule but are still hard to hit. Leave space between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Provide alternatives to dragging
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WCAG 2.5.7 Dragging Movements, new in 2.2 (AA)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If a feature only works by dragging (reordering a list, a swipe-to-delete row, a range slider), there must be a single-pointer alternative. Typical fixes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Swipe-to-delete → also show a delete action in a long-press menu or an edit mode.&lt;/li&gt;
&lt;li&gt;Drag-to-reorder → add "Move up" and "Move down" buttons, or expose them as accessibility actions:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;View&lt;/span&gt;
  &lt;span class="na"&gt;accessible&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityLabel&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityActions&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="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;moveUp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Move up&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;moveDown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Move down&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="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;onAccessibilityAction&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;move&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nativeEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;actionName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Screen reader users reach these through the actions rotor on iOS and the actions menu on Android.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Keep focus visible and unobscured
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WCAG 2.4.7 Focus Visible (AA), 2.4.11 Focus Not Obscured (Minimum), new in 2.2 (AA)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Mobile users navigate with keyboards, switch control, and external devices more than most developers assume. The rule in 2.2: when an element receives focus, it can't be completely hidden behind something else, like a sticky footer, a floating action button, or a cookie banner.&lt;/p&gt;

&lt;p&gt;In practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Don't remove focus indicators on &lt;code&gt;TextInput&lt;/code&gt; and custom buttons.&lt;/li&gt;
&lt;li&gt;Pad scroll views so the last focusable item can scroll clear of a sticky bottom bar (&lt;code&gt;contentContainerStyle={{ paddingBottom: footerHeight }}&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Test with a hardware keyboard on iPad and with Switch Access on Android.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  7. Trap focus in modals
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WCAG 2.4.3 Focus Order (A)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;When a modal opens, screen reader focus should move into it and stay there. React Native's built-in &lt;code&gt;Modal&lt;/code&gt; handles this on both platforms. Custom overlays (a bottom sheet built from an absolutely positioned &lt;code&gt;View&lt;/code&gt;) usually don't.&lt;/p&gt;

&lt;p&gt;For custom overlays, hide everything behind them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;View&lt;/span&gt;
  &lt;span class="na"&gt;importantForAccessibility&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;sheetOpen&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;no-hide-descendants&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;auto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;// Android&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityElementsHidden&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;sheetOpen&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;                                  &lt;span class="c1"&gt;// iOS&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;MainContent&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;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="nx"&gt;sheetOpen&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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;View&lt;/span&gt; &lt;span class="na"&gt;accessibilityViewIsModal&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;   &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* iOS: ignore siblings */&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;BottomSheetContent&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;View&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then move focus to the sheet's heading when it opens:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headingRef&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRef&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nf"&gt;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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sheetOpen&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;headingRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&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;tag&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;findNodeHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;headingRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&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;tag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;AccessibilityInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAccessibilityFocus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tag&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;sheetOpen&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  8. Announce changes nobody can see
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WCAG 4.1.3 Status Messages (AA)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;"Item added to cart," "Saved," "3 results found." Sighted users see a toast; screen reader users hear nothing unless you announce it.&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;addToCart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;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;addToCart&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;AccessibilityInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;announceForAccessibility&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Added to cart&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;On Android you can also mark a region as live so updates are read automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt; &lt;span class="na"&gt;accessibilityLiveRegion&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"polite"&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;resultCount&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; results&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  9. Support text scaling to 200%
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WCAG 1.4.4 Resize Text (AA), 1.4.10 Reflow (AA)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;React Native scales &lt;code&gt;Text&lt;/code&gt; with the system font size by default. The failures come from layouts that can't handle it: fixed heights, single-line rows, and text that clips.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't disable scaling&lt;/strong&gt; with &lt;code&gt;allowFontScaling={false}&lt;/code&gt;. If a specific element genuinely can't grow (a tab bar label), cap it with &lt;code&gt;maxFontSizeMultiplier={1.5}&lt;/code&gt; instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Avoid fixed heights&lt;/strong&gt; on containers that hold text. Use &lt;code&gt;minHeight&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let rows wrap.&lt;/strong&gt; &lt;code&gt;flexWrap: 'wrap'&lt;/code&gt; or a column layout at large sizes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test at the largest accessibility size&lt;/strong&gt;, not just the largest standard size. On iOS that's Settings → Accessibility → Display &amp;amp; Text Size → Larger Text.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  10. Meet contrast minimums
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WCAG 1.4.3 Contrast Minimum (AA), 1.4.11 Non-text Contrast (AA)&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;4.5:1&lt;/strong&gt; for body text&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;3:1&lt;/strong&gt; for large text (18pt, or 14pt bold) and for UI components like input borders, icons, and focus indicators&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The usual offenders are light grey placeholder text, disabled-looking secondary buttons that aren't actually disabled, and thin icon strokes on colored backgrounds. Check both light and dark mode; a palette that passes in one often fails in the other.&lt;/p&gt;

&lt;p&gt;Also: &lt;strong&gt;don't use color alone&lt;/strong&gt; to convey meaning (1.4.1). An error field needs an icon or text, not just a red border.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. Respect reduced motion
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WCAG 2.3.3 Animation from Interactions (AAA, but widely expected)&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;reduceMotion&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setReduceMotion&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="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="nx"&gt;AccessibilityInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isReduceMotionEnabled&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;setReduceMotion&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;AccessibilityInfo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reduceMotionChanged&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setReduceMotion&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;If you use Reanimated, &lt;code&gt;useReducedMotion()&lt;/code&gt; does the same in one line. When reduce motion is on, replace slides and parallax with fades or instant transitions.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. Don't make people re-type or memorize
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WCAG 3.3.7 Redundant Entry (A), 3.3.8 Accessible Authentication (Minimum) (AA), both new in 2.2&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Redundant Entry:&lt;/strong&gt; if a user already entered their address in step 1 of checkout, prefill it in step 3 or offer "same as shipping."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accessible Authentication:&lt;/strong&gt; don't block password managers or paste. Set the right content types so autofill works:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TextInput&lt;/span&gt;
  &lt;span class="na"&gt;textContentType&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"emailAddress"&lt;/span&gt;      &lt;span class="c1"&gt;// iOS&lt;/span&gt;
  &lt;span class="na"&gt;autoComplete&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"email"&lt;/span&gt;                &lt;span class="c1"&gt;// Android&lt;/span&gt;
  &lt;span class="na"&gt;keyboardType&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"email-address"&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;TextInput&lt;/span&gt;
  &lt;span class="na"&gt;textContentType&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"password"&lt;/span&gt;
  &lt;span class="na"&gt;autoComplete&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"password"&lt;/span&gt;
  &lt;span class="na"&gt;secureTextEntry&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;TextInput&lt;/span&gt;
  &lt;span class="na"&gt;textContentType&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"oneTimeCode"&lt;/span&gt;       &lt;span class="c1"&gt;// iOS SMS code autofill&lt;/span&gt;
  &lt;span class="na"&gt;autoComplete&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"sms-otp"&lt;/span&gt;              &lt;span class="c1"&gt;// Android&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Avoid CAPTCHAs that require transcribing distorted text. Passkeys, magic links, and biometrics all satisfy this criterion.&lt;/p&gt;

&lt;h2&gt;
  
  
  13. Keep help in the same place
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;WCAG 3.2.6 Consistent Help, new in 2.2 (A)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If your app offers help (a chat button, a support link, a phone number), put it in the same relative place on every screen that has it. A help icon in the header on one screen and in a settings submenu on another fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing: what to actually do
&lt;/h2&gt;

&lt;p&gt;Automated tools catch maybe a third of issues. The rest needs a human with a screen reader.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lint in CI.&lt;/strong&gt; &lt;code&gt;eslint-plugin-react-native-a11y&lt;/code&gt; flags missing roles, labels, and invalid states:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i &lt;span class="nt"&gt;-D&lt;/span&gt; eslint-plugin-react-native-a11y
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"extends"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"plugin:react-native-a11y/all"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Test by role in unit tests.&lt;/strong&gt; React Native Testing Library queries by role and label, so a test that can't find the button is also a screen reader that can't:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;deleteBtn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;screen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getByRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;button&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;Delete note&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;fireEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;press&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deleteBtn&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;Run the platform tools.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;iOS: Accessibility Inspector in Xcode (audit any simulator screen)&lt;/li&gt;
&lt;li&gt;Android: Accessibility Scanner app from Google Play&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Test manually, once per release, on real devices:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Navigate every core flow with VoiceOver (iOS) and TalkBack (Android), eyes closed&lt;/li&gt;
&lt;li&gt;[ ] Every interactive element has a sensible label and role&lt;/li&gt;
&lt;li&gt;[ ] Modals and sheets trap focus; closing returns focus to the trigger&lt;/li&gt;
&lt;li&gt;[ ] Status changes are announced&lt;/li&gt;
&lt;li&gt;[ ] Largest text size: nothing clips, overlaps, or disappears&lt;/li&gt;
&lt;li&gt;[ ] Contrast passes in both light and dark mode&lt;/li&gt;
&lt;li&gt;[ ] Every drag interaction has a tap alternative&lt;/li&gt;
&lt;li&gt;[ ] Reduce motion is respected&lt;/li&gt;
&lt;li&gt;[ ] Password managers and SMS code autofill work&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Start with the 20% that fixes 80%
&lt;/h2&gt;

&lt;p&gt;If you only have a day, do these in order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Label every icon-only button.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;hitSlop&lt;/code&gt; to every small target.&lt;/li&gt;
&lt;li&gt;Fix text clipping at the largest font size.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;accessibilityState&lt;/code&gt; to every checkbox, switch, and tab.&lt;/li&gt;
&lt;li&gt;Announce toasts with &lt;code&gt;announceForAccessibility&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That clears the majority of failures in a typical audit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building it in from the start
&lt;/h2&gt;

&lt;p&gt;Retrofitting accessibility is far more expensive than starting with it. If you're generating new screens, &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=rn_accessibility_wcag_2026" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; produces React Native and Expo code you own, so you can add the patterns above, lint for them, and keep them consistent as the app grows.&lt;/p&gt;

&lt;p&gt;What's the accessibility bug you see most often in React Native apps? Mine is still the unlabeled icon button, every single time.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>a11y</category>
      <category>mobile</category>
      <category>webdev</category>
    </item>
    <item>
      <title>The Code Review Checklist for AI-Generated React Native (10 Things Agents Get Wrong)</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Wed, 30 Sep 2026 06:44:46 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/the-code-review-checklist-for-ai-generated-react-native-10-things-agents-get-wrong-1lkf</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/the-code-review-checklist-for-ai-generated-react-native-10-things-agents-get-wrong-1lkf</guid>
      <description>&lt;p&gt;AI-generated React Native code has a specific failure profile. It compiles. It renders. The screenshot looks right. And then a reviewer who's shipped a few apps opens the PR and finds the same ten problems they found in the last one.&lt;/p&gt;

&lt;p&gt;That predictability is useful. If agents make the same mistakes, you can review for them mechanically, and you can stop most of them before they're written. This is the checklist I use when reviewing Claude Code, Cursor, or Codex output in Expo projects, with the fix for each and, where possible, a lint rule that catches it automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Data fetching inside &lt;code&gt;useEffect&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The most common pattern in generated code:&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="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="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;notes&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;*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&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;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setNotes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&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="p"&gt;[]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No loading state, no error state, no refetch, no cache, and a race condition if the component remounts. The fix is a query library:&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="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;notes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isLoading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useQuery&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;queryKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notes&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;queryFn&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="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;notes&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;*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Catch it:&lt;/strong&gt; an ESLint &lt;code&gt;no-restricted-syntax&lt;/code&gt; rule that flags &lt;code&gt;supabase.from&lt;/code&gt; inside &lt;code&gt;useEffect&lt;/code&gt;, or a &lt;code&gt;CLAUDE.md&lt;/code&gt; line: "All data fetching goes through TanStack Query hooks in &lt;code&gt;src/queries/&lt;/code&gt;."&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Secrets in &lt;code&gt;EXPO_PUBLIC_&lt;/code&gt; variables
&lt;/h2&gt;

&lt;p&gt;Agents fix a failing API call by moving the key to where it "works":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;EXPO_PUBLIC_OPENAI_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;sk-...   &lt;span class="c"&gt;# shipped to every user's device&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything prefixed &lt;code&gt;EXPO_PUBLIC_&lt;/code&gt; is inlined into the JS bundle. Third-party keys belong in an Edge Function; the app calls the function with the user's session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Catch it:&lt;/strong&gt; a CI grep for &lt;code&gt;EXPO_PUBLIC_.*(KEY|SECRET|TOKEN)&lt;/code&gt; that fails on matches, plus a &lt;code&gt;deny&lt;/code&gt; rule in &lt;code&gt;.claude/settings.json&lt;/code&gt; for reading &lt;code&gt;.env&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Tables created without RLS
&lt;/h2&gt;

&lt;p&gt;Generated migrations create the table and stop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="p"&gt;(...);&lt;/span&gt;
&lt;span class="c1"&gt;-- no "alter table ... enable row level security"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On mobile, RLS is the only security boundary, because the client key ships inside the binary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Catch it:&lt;/strong&gt; a CI query that fails if any public table has &lt;code&gt;rowsecurity = false&lt;/code&gt;, and a deny-case test per table.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. &lt;code&gt;any&lt;/code&gt; everywhere, or no types at all
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handleSubmit&lt;/span&gt; &lt;span class="o"&gt;=&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="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generated code reaches for &lt;code&gt;any&lt;/code&gt; the moment a type gets inconvenient, which hides exactly the bugs TypeScript exists to catch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Catch it:&lt;/strong&gt; &lt;code&gt;@typescript-eslint/no-explicit-any&lt;/code&gt; as an error, and &lt;code&gt;npx tsc --noEmit&lt;/code&gt; in the agent's definition of done.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Inline styles mixed with the styling system
&lt;/h2&gt;

&lt;p&gt;Half the components use NativeWind classes, half use &lt;code&gt;style={{ marginTop: 12 }}&lt;/code&gt;, and a few use &lt;code&gt;StyleSheet.create&lt;/code&gt;. Each one was locally reasonable; together they're unmaintainable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Catch it:&lt;/strong&gt; &lt;code&gt;react-native/no-inline-styles&lt;/code&gt; from &lt;code&gt;eslint-plugin-react-native&lt;/code&gt;, and a single line in the agent instructions naming the one system you use.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Lists without stable keys and without &lt;code&gt;FlashList&lt;/code&gt;
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Card&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="err"&gt;...&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Index keys break reordering and deletion. A &lt;code&gt;.map&lt;/code&gt; inside a &lt;code&gt;ScrollView&lt;/code&gt; renders every item at once. Long lists need &lt;code&gt;FlatList&lt;/code&gt; at minimum, and &lt;code&gt;FlashList&lt;/code&gt; for anything that scrolls fast.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Catch it:&lt;/strong&gt; &lt;code&gt;react/no-array-index-key&lt;/code&gt;, and a review rule: any list that can exceed ~20 items uses a virtualized list.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. No empty, loading, or error states
&lt;/h2&gt;

&lt;p&gt;Generated screens show the happy path. First launch with no data renders a blank screen; a failed request renders nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Catch it:&lt;/strong&gt; a review checklist item, not a lint rule. Every screen that fetches needs three extra states. Ask the agent for them explicitly: "include loading, empty, and error states."&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Keyboard covers the input
&lt;/h2&gt;

&lt;p&gt;Forms work in the simulator and fail on a real device: the keyboard hides the submit button, and nothing scrolls.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; &lt;code&gt;KeyboardAvoidingView&lt;/code&gt; with &lt;code&gt;behavior="padding"&lt;/code&gt; on iOS, or &lt;code&gt;react-native-keyboard-controller&lt;/code&gt; for anything more than a login form.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Catch it:&lt;/strong&gt; test every form on a physical device with the keyboard open. No lint rule saves you here.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Navigation imported from the wrong package
&lt;/h2&gt;

&lt;p&gt;In Expo Router projects (SDK 56+), agents still import &lt;code&gt;useNavigation&lt;/code&gt; from &lt;code&gt;@react-navigation/native&lt;/code&gt;, which now creates a second navigation context and breaks routing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Catch it:&lt;/strong&gt; &lt;code&gt;no-restricted-imports&lt;/code&gt; for &lt;code&gt;@react-navigation/*&lt;/code&gt;, and the instruction "navigation comes from &lt;code&gt;expo-router&lt;/code&gt; only."&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Hardcoded strings and magic numbers
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;'Something went wrong'&lt;/code&gt; in twelve places, &lt;code&gt;#3B82F6&lt;/code&gt; in thirty, &lt;code&gt;1000&lt;/code&gt; as a timeout with no name.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Catch it:&lt;/strong&gt; a &lt;code&gt;theme.ts&lt;/code&gt; and a &lt;code&gt;strings.ts&lt;/code&gt; (or i18n setup) that exist before the agent starts, so it has somewhere to put them. Agents copy the structure they see.&lt;/p&gt;

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

&lt;p&gt;Look at the fixes: most of them are &lt;strong&gt;structure that existed before the agent wrote a line&lt;/strong&gt;. A queries folder. A theme file. RLS in the migration template. Lint rules that fail the build. An instruction file that names the one way to do each thing.&lt;/p&gt;

&lt;p&gt;Agents don't write bad code because they're bad at code. They write the code the codebase implies. Give them a codebase that implies good code, and the checklist mostly stops firing.&lt;/p&gt;

&lt;p&gt;That's the thesis behind &lt;a href="https://www.applighter.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=ai_code_review_checklist_2026&amp;amp;utm_content=footer" rel="noopener noreferrer"&gt;AppLighter&lt;/a&gt;: Expo + Supabase templates where the structure above (queries, theme, RLS migrations, lint config, agent instructions) is already in place, so Claude Code or Cursor extends a good codebase instead of inventing a mediocre one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The checklist, copy-pasteable
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] No data fetching in &lt;code&gt;useEffect&lt;/code&gt;; queries live in hooks&lt;/li&gt;
&lt;li&gt;[ ] No secrets in &lt;code&gt;EXPO_PUBLIC_&lt;/code&gt;; third-party keys in Edge Functions&lt;/li&gt;
&lt;li&gt;[ ] Every new table enables RLS in the same migration&lt;/li&gt;
&lt;li&gt;[ ] No &lt;code&gt;any&lt;/code&gt;; &lt;code&gt;tsc --noEmit&lt;/code&gt; passes&lt;/li&gt;
&lt;li&gt;[ ] One styling system, no inline styles&lt;/li&gt;
&lt;li&gt;[ ] Stable keys; virtualized lists for anything long&lt;/li&gt;
&lt;li&gt;[ ] Loading, empty, and error states on every fetching screen&lt;/li&gt;
&lt;li&gt;[ ] Forms tested with the keyboard open on a device&lt;/li&gt;
&lt;li&gt;[ ] Navigation from &lt;code&gt;expo-router&lt;/code&gt; only&lt;/li&gt;
&lt;li&gt;[ ] Strings and tokens in &lt;code&gt;strings.ts&lt;/code&gt; / &lt;code&gt;theme.ts&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What's on your list that isn't on mine? I'd bet the eleventh item is different for every team.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>ai</category>
      <category>codereview</category>
    </item>
    <item>
      <title>React Native Maps in Production: 7 Gotchas Nobody Warns You About</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Wed, 23 Sep 2026 05:37:36 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/react-native-maps-in-production-7-gotchas-nobody-warns-you-about-2pd8</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/react-native-maps-in-production-7-gotchas-nobody-warns-you-about-2pd8</guid>
      <description>&lt;p&gt;Every tutorial makes &lt;code&gt;react-native-maps&lt;/code&gt; look easy. &lt;code&gt;npx expo install&lt;/code&gt;, drop a &lt;code&gt;&amp;lt;MapView&amp;gt;&lt;/code&gt;, done. Then you ship, and you meet the real map. The one with API keys that fail only in TestFlight, permission prompts that Apple rejects, and marker lists that turn your framerate into a slideshow.&lt;/p&gt;

&lt;p&gt;Here are the seven things that would have saved me a week of debugging on my first production map screen.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The blank map is almost always a height problem
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Won't render. Parent has no explicit height.&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;MapView&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;View&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// Works.&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;View&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;MapView&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;StyleSheet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;absoluteFill&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;View&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;MapView&lt;/code&gt; needs an ancestor with a resolved height. &lt;code&gt;flex: 1&lt;/code&gt; inside a zero-height parent silently fails. &lt;code&gt;StyleSheet.absoluteFill&lt;/code&gt; is the safest pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. &lt;code&gt;initialRegion&lt;/code&gt; vs &lt;code&gt;region&lt;/code&gt;: pick one and don't switch
&lt;/h2&gt;

&lt;p&gt;Use &lt;code&gt;initialRegion&lt;/code&gt; for uncontrolled maps where the user pans freely. Use &lt;code&gt;region&lt;/code&gt; only if you have a genuine reason to re-center on every render. Otherwise you're fighting user gestures and the map feels broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. API keys must be restricted per platform
&lt;/h2&gt;

&lt;p&gt;An unrestricted "any bundle ID" key works in dev and gets rotated by Google abuse detection within a week of launch. Restrict:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;iOS&lt;/strong&gt;: by bundle identifier&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Android&lt;/strong&gt;: by package name + SHA-1 fingerprint&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do this on day 1. Rotating a key that's already in the wild is painful.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Location permission ordering matters
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fg&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;Location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestForegroundPermissionsAsync&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;fg&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bg&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;Location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestBackgroundPermissionsAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You cannot get background permission on iOS without foreground first. And &lt;strong&gt;do not request permission on app launch&lt;/strong&gt;. Ask when the user taps a "show my location" button. Apple reviewers will reject an unexplained cold prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. &lt;code&gt;Accuracy.BestForNavigation&lt;/code&gt; will kill your battery
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;Location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getCurrentPositionAsync&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;accuracy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Accuracy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Balanced&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;// 👈 default for 99% of apps&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;BestForNavigation&lt;/code&gt; is for driving and running apps that literally need meter-level precision. It's roughly 10x the battery drain of &lt;code&gt;Balanced&lt;/code&gt;. Apple reviewers do notice.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. &lt;code&gt;watchPositionAsync&lt;/code&gt; leaks if you don't clean it up
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LocationSubscription&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="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="nx"&gt;sub&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;Location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;watchPositionAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;accuracy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Accuracy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Balanced&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;distanceInterval&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setPosition&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;coords&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;})();&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="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="c1"&gt;// 👈 THIS line&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;Forget the &lt;code&gt;sub?.remove()&lt;/code&gt; and every screen visit adds a permanent GPS listener. Users notice the battery drain within a day.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Every &lt;code&gt;&amp;lt;Marker&amp;gt;&lt;/code&gt; is a native view
&lt;/h2&gt;

&lt;p&gt;Above roughly 200 to 300 markers on Android, frames drop. Above 2,000, iOS runs out of memory. Two fixes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cluster&lt;/strong&gt; with &lt;code&gt;react-native-map-clustering&lt;/code&gt; (a drop-in &lt;code&gt;MapView&lt;/code&gt; replacement built on supercluster).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bitmap markers.&lt;/strong&gt; Set &lt;code&gt;image={require('./pin.png')}&lt;/code&gt; instead of rendering React children. Native views for markers are cheap; React trees inside markers are expensive.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you must render custom React markers, wrap them in &lt;code&gt;React.memo&lt;/code&gt; and hoist all callbacks. Otherwise every pan re-renders every marker.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: expo-maps vs react-native-maps in 2026
&lt;/h2&gt;

&lt;p&gt;Short version:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;react-native-maps&lt;/strong&gt;: huge community, works in Expo Go, needs a config plugin. The default choice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;expo-maps&lt;/strong&gt;: SwiftUI and Jetpack Compose under the hood, faster startup, iOS 17+ only, no Expo Go. Good for greenfield apps.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For most teams shipping today, react-native-maps is still the safer bet, because every LLM, every Stack Overflow answer, and every AI code assistant still assumes it.&lt;/p&gt;

&lt;p&gt;Speaking of which: the entire scaffold above (maps, permissions, markers, backend) can also be generated end to end from a prompt on &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=content&amp;amp;utm_campaign=react-native-maps-location-based-features-guide" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt;, if you'd rather skip the 400 lines of boilerplate and go straight to the differentiated part of your app.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>mobile</category>
      <category>javascript</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Boilerplate That Actually Ships: What to Include (and Cut) in a 2026 React Native Starter</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Wed, 23 Sep 2026 05:33:08 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/boilerplate-that-actually-ships-what-to-include-and-cut-in-a-2026-react-native-starter-2o3f</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/boilerplate-that-actually-ships-what-to-include-and-cut-in-a-2026-react-native-starter-2o3f</guid>
      <description>&lt;h2&gt;
  
  
  Why most React Native boilerplates fail past week 2
&lt;/h2&gt;

&lt;p&gt;Boilerplates optimize for the wrong number. The number that matters isn't screens included. It's time-to-first-paid-user. Every extra screen, every extra library, every unused abstraction is a tax you pay on the second refactor. The boilerplates that survive past week 2 keep the surface area small and the defaults opinionated.&lt;/p&gt;

&lt;h2&gt;
  
  
  The five must-includes
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Auth with session restore on cold start.&lt;/strong&gt; Sign in with Apple wired, refresh tokens handled, and a password reset that doesn't 404 in production. The scaffold that skips this rebuilds it under pressure in month one.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Payments end to end.&lt;/strong&gt; Stripe or RevenueCat, with one test charge working in TestFlight. Not a screen with a Buy button. A working flow. Payments is the plumbing every indie dev underestimates.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;EAS Build + Submit wired.&lt;/strong&gt; &lt;code&gt;eas.json&lt;/code&gt; filled with staging and production profiles. GitHub Actions or manual submit, either works, but the pipeline must exist. If your first submission takes longer than an hour, that's a leading indicator of shipping never.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A real error boundary.&lt;/strong&gt; &lt;code&gt;react-native-error-boundary&lt;/code&gt; or a custom top-level catch, plus a crash-log SDK like Sentry or Bugsnag. Silent JS crashes in prod are the number one reason indie apps ship a broken v1.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;OTA update channels.&lt;/strong&gt; &lt;code&gt;expo-updates&lt;/code&gt; with channel-level control. Push a JS bundle to a beta channel, promote to production without touching the app-store binary. This is where "faster to build" becomes "faster to fix".&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The five to cut by default
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The navigation grab-bag.&lt;/strong&gt; Pick one (expo-router with typed hrefs) and delete the others. Multiple navigators in one boilerplate is a smell.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Animation libraries "just in case".&lt;/strong&gt; Don't ship reanimated + moti + lottie + a bespoke animation hook. Include one and document why.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In-house UI kits.&lt;/strong&gt; Every custom Button component is a maintenance burden. Use platform primitives or a maintained design-system library (Tamagui, Restyle), not a hand-rolled kit that will diverge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redux for a v1 app.&lt;/strong&gt; Zustand or Jotai handles it. Redux plus Redux Toolkit plus RTK Query is 300 lines of setup before your first fetch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The "example" screens.&lt;/strong&gt; Delete the calendar demo, the chat demo, the map demo. They confuse new devs and drift into stale APIs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The rename-screen test
&lt;/h2&gt;

&lt;p&gt;Here's a one-minute audit for any starter you're evaluating. Rename &lt;code&gt;screens/Home.tsx&lt;/code&gt; to &lt;code&gt;screens/Dashboard.tsx&lt;/code&gt;. Everything that survives without manual patching (router, deep links, typegen) is a real boilerplate. Anything that needs a find-and-replace is a demo dressed up as one.&lt;/p&gt;

&lt;h2&gt;
  
  
  A pragmatic 2026 stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Framework:&lt;/strong&gt; Expo SDK 53+, expo-router with typed hrefs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State:&lt;/strong&gt; Zustand for client, TanStack Query for server&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auth + DB:&lt;/strong&gt; Supabase (or &lt;a href="https://tinbase.dev/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=rn-boilerplate" rel="noopener noreferrer"&gt;tinbase&lt;/a&gt; for local-first and self-host)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payments:&lt;/strong&gt; Stripe SDK + Payment Sheet&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Style:&lt;/strong&gt; Restyle or unistyles, design tokens on day one&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI:&lt;/strong&gt; GitHub Actions + EAS Build with staging and prod profiles&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The folder-deletion principle
&lt;/h2&gt;

&lt;p&gt;The best RN scaffold is one you can delete a folder from. Every project ships a subset, and your job as a maintainer is to make the deletion trivial. We built &lt;a href="https://rapidnative.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=rn-boilerplate" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; to generate React Native + Expo apps that start from these defaults. Grab it, or clone your own. The principles matter more than the repo.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>mobile</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Delayed Push Permissions in React Native: Why the Third-Session Prompt Wins</title>
      <dc:creator>Hugo Rus</dc:creator>
      <pubDate>Wed, 16 Sep 2026 04:58:11 +0000</pubDate>
      <link>https://dev.to/hugo_rus_630dd942fcf7cc62/delayed-push-permissions-in-react-native-why-the-third-session-prompt-wins-2fc0</link>
      <guid>https://dev.to/hugo_rus_630dd942fcf7cc62/delayed-push-permissions-in-react-native-why-the-third-session-prompt-wins-2fc0</guid>
      <description>&lt;ul&gt;
&lt;li&gt;Prompt-on-install caps push acceptance at ~45%; a third-session gate lifts it to ~78%&lt;/li&gt;
&lt;li&gt;Session count is a proxy: tie the prompt to a habit moment when you have one&lt;/li&gt;
&lt;li&gt;Skip the iOS pre-permission modal (net -6pp in practice)&lt;/li&gt;
&lt;li&gt;Already shipped? &lt;code&gt;Linking.openSettings()&lt;/code&gt; is your only recovery path&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most React Native tutorials on push notifications skip the single decision that matters most: when to ask.&lt;/p&gt;

&lt;p&gt;The default in every boilerplate is to prompt on install day, usually in the onboarding flow. That decision quietly caps your addressable notification audience at whatever fraction of users don't tap "Don't Allow" during the least trusting three seconds of their relationship with your app. Across the RapidNative-built apps we can see, that fraction is about 45%.&lt;/p&gt;

&lt;p&gt;Move the prompt to after the third session — or better, tie it to a habit-forming moment — and acceptance climbs to about 78%. The change is a 3-line delta in your app entry file. This post walks through the pattern.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "the third session" actually means
&lt;/h2&gt;

&lt;p&gt;You don't need a fancy engagement model. Track sessions in AsyncStorage:&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="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="nx"&gt;AsyncStorage&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-async-storage/async-storage&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;SESSION_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;session_count&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;PROMPTED_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;push_prompted&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;maybeAskForPushPermission&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;already&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;AsyncStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PROMPTED_KEY&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;already&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rawCount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;AsyncStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SESSION_KEY&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;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawCount&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;AsyncStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SESSION_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&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;count&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3&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="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;await&lt;/span&gt; &lt;span class="nx"&gt;AsyncStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PROMPTED_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Call &lt;code&gt;maybeAskForPushPermission()&lt;/code&gt; from your root layout's &lt;code&gt;useEffect&lt;/code&gt;, not from onboarding. The user sees the prompt on their third open, in a moment where they've already decided your app is worth returning to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the habit-moment version is better
&lt;/h2&gt;

&lt;p&gt;Session count is a proxy. The signal you actually want is "user just completed a meaningful action." Tie the prompt to the completion event instead:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;onFirstJournalEntrySaved&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;AsyncStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;has_first_entry&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;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;maybeAskForPushPermission&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;Now the permission prompt lands in the exact second the user is thinking "I could see myself using this again." Different question in the user's head. Different answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The iOS-specific trap: pre-permission modal
&lt;/h2&gt;

&lt;p&gt;The pattern iOS teams reach for — showing your own custom "Would you like push?" modal before triggering the real prompt — has a subtle failure mode. If the user taps "Yes" on your modal and "Don't Allow" on the real one, you cannot re-prompt. iOS is one-and-done.&lt;/p&gt;

&lt;p&gt;Skip the pre-modal unless you have data that your specific audience needs it. In our tests it dropped acceptance by 6 percentage points net, because "yes to modal + no to real prompt" was a bigger cohort than "no to modal at all."&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do if you already shipped with prompt-on-install
&lt;/h2&gt;

&lt;p&gt;You cannot re-prompt users who already denied. But you can:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add the delayed prompt for new installs.&lt;/li&gt;
&lt;li&gt;Build a settings page that deep-links to the iOS Settings &amp;gt; Notifications page for your app, so denied users can re-enable manually. &lt;code&gt;Linking.openSettings()&lt;/code&gt; handles this.&lt;/li&gt;
&lt;li&gt;Show the deep-link once, in-app, at a habit moment. Don't nag.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The whole thing in ~20 lines
&lt;/h2&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="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="nx"&gt;AsyncStorage&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-async-storage/async-storage&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;Linking&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SESSION_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;session_count&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;PROMPTED_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;push_prompted&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;maybeAskForPushPermission&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;force&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &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;AsyncStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PROMPTED_KEY&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&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;AsyncStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SESSION_KEY&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;AsyncStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SESSION_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&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;count&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;force&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="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;await&lt;/span&gt; &lt;span class="nx"&gt;AsyncStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;PROMPTED_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Recovery flow for users who already denied&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;openNotificationSettings&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPermissionsAsync&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;denied&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Linking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;openSettings&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;That is the pattern. Session-count gate, tie to a habit moment if you can (pass &lt;code&gt;force = true&lt;/code&gt; from the completion event), use &lt;code&gt;Linking.openSettings()&lt;/code&gt; for the recovery flow.&lt;/p&gt;

&lt;p&gt;Something like &lt;a href="https://rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=delayed-push-permissions-third-session" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; ships this gate in its generated Expo projects by default, so if you're scaffolding a new app the prompt is already off line 3 before you've written anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't ship the 45% cap
&lt;/h2&gt;

&lt;p&gt;The point of writing this up is not that it's complex. It's that this is the highest-leverage thing you can do to your push notification stack this quarter, and most React Native tutorials still show you &lt;code&gt;requestPermissionsAsync()&lt;/code&gt; on line 3 of the app.&lt;/p&gt;

&lt;p&gt;If you've measured acceptance before and after moving the prompt, drop a comment with your numbers. Curious whether the third-session threshold holds across categories or whether it's a journaling/fitness thing.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>notifications</category>
      <category>ux</category>
    </item>
  </channel>
</rss>
