<?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: Chloe</title>
    <description>The latest articles on DEV Community by Chloe (@chloe_smith0104).</description>
    <link>https://dev.to/chloe_smith0104</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%2F4131404%2F4c43c853-f6e5-49c4-ade9-b037aedb2cfb.jpg</url>
      <title>DEV Community: Chloe</title>
      <link>https://dev.to/chloe_smith0104</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/chloe_smith0104"/>
    <language>en</language>
    <item>
      <title>Docker Alternatives in 2026: What to Use Instead (and When You Actually Need To)</title>
      <dc:creator>Chloe</dc:creator>
      <pubDate>Fri, 09 Oct 2026 09:34:35 +0000</pubDate>
      <link>https://dev.to/chloe_smith0104/docker-alternatives-in-2026-what-to-use-instead-and-when-you-actually-need-to-1km5</link>
      <guid>https://dev.to/chloe_smith0104/docker-alternatives-in-2026-what-to-use-instead-and-when-you-actually-need-to-1km5</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Docker" is three layers: the runtime, the engine/CLI, and Docker Desktop. Most people only need to replace Docker Desktop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On a Mac?&lt;/strong&gt; OrbStack if $8/user/month is fine, or Colima if you want free and terminal-only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Want free with a GUI, at any company size?&lt;/strong&gt; Podman Desktop or Rancher Desktop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Running Linux servers?&lt;/strong&gt; Podman (daemonless, rootless). For minimal CI runners, nerdctl.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your Dockerfiles, Compose files, and images keep working.&lt;/strong&gt; OCI standards make switching low-risk.&lt;/li&gt;
&lt;li&gt;Sometimes you don't need containers at all. There's a section on that below.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;Docker didn't invent containers, but for a decade it basically &lt;em&gt;was&lt;/em&gt; containers.&lt;/p&gt;

&lt;p&gt;That started to change in 2021. Docker Inc. announced that Docker Desktop would require a paid subscription for commercial use at companies with more than 250 employees or $10M in revenue. Overnight, a lot of teams learned the difference between "Docker the format" and "Docker the product."&lt;/p&gt;

&lt;p&gt;Five years later, the ecosystem has sorted itself out. You can keep your Dockerfiles, your &lt;code&gt;docker compose&lt;/code&gt; habits, and your registry workflow, and swap out almost everything underneath. Here's what to swap in, grouped by what each tool actually replaces.&lt;/p&gt;

&lt;h2&gt;
  
  
  First: which Docker are you replacing?
&lt;/h2&gt;

&lt;p&gt;"Docker" is three layers stacked on top of each other:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The runtime&lt;/strong&gt; (containerd + runc) creates and runs the containers. It's open source and shared by almost everyone, Kubernetes included.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The engine and CLI&lt;/strong&gt; is the &lt;code&gt;docker&lt;/code&gt; command plus the daemon it talks to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker Desktop&lt;/strong&gt; is the macOS/Windows app that bundles a Linux VM, a GUI, and the licensing that started all of this.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most "I want a Docker alternative" requests mean layer 3. Some mean layer 2. Almost nobody needs to replace layer 1.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Figure out which layer you mean, and the choice gets much easier.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Engine and CLI alternatives
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Podman
&lt;/h3&gt;

&lt;p&gt;Podman is the most complete drop-in replacement for the Docker engine.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's daemonless,&lt;/strong&gt; so there's no long-running root process.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's rootless by default.&lt;/strong&gt; That's why security-focused teams and most Linux distros have moved to it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The CLI mirrors Docker closely enough that a single alias covers most day-to-day work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# macOS: Podman runs inside a small Linux VM&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;podman
podman machine init
podman machine start

&lt;span class="c"&gt;# Linux: install from your distro's package manager, no VM needed&lt;/span&gt;

&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;docker&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;podman
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:80 nginx   &lt;span class="c"&gt;# actually Podman&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Podman also has native &lt;strong&gt;pods&lt;/strong&gt;: groups of containers that share a network namespace. It can generate Kubernetes YAML from running containers, which is handy if you're heading toward k8s anyway:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;podman pod create &lt;span class="nt"&gt;--name&lt;/span&gt; web &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:80
podman run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--pod&lt;/span&gt; web nginx
podman kube generate web &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; web-pod.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two sibling tools round it out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Buildah&lt;/strong&gt; builds images without a daemon.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skopeo&lt;/strong&gt; inspects and copies images between registries without pulling them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Trade-offs:&lt;/strong&gt; &lt;code&gt;podman compose&lt;/code&gt; has improved a lot, but it still differs from Docker Compose on some edge cases. Some third-party tools also still expect a Docker socket at a fixed path.&lt;/p&gt;

&lt;h3&gt;
  
  
  nerdctl (containerd directly)
&lt;/h3&gt;

&lt;p&gt;If you're happy to go one layer lower, &lt;code&gt;nerdctl&lt;/code&gt; gives you a Docker-compatible CLI that talks straight to containerd. Many of the desktop tools below use it internally.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nerdctl run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:80 &lt;span class="nt"&gt;--name&lt;/span&gt; web nginx
nerdctl compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You also get containerd features that Docker doesn't expose, such as lazy image pulling and image encryption. It's a great fit for Linux servers and CI runners where you want as few moving parts as possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Docker Desktop alternatives (macOS and Windows)
&lt;/h2&gt;

&lt;p&gt;Most of the real choice happens here. On a Mac or a Windows machine you always need a Linux VM somewhere, and how that VM is managed makes a big difference to speed and battery life.&lt;/p&gt;

&lt;h3&gt;
  
  
  OrbStack (macOS)
&lt;/h3&gt;

&lt;p&gt;OrbStack has effectively become the default on Mac. It's a commercial tool, but the performance gap is real:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It uses roughly a third of Docker Desktop's memory.&lt;/li&gt;
&lt;li&gt;It starts in under two seconds.&lt;/li&gt;
&lt;li&gt;File sharing and port forwarding work without configuration.&lt;/li&gt;
&lt;li&gt;It can run full Linux VMs alongside containers, and it includes Kubernetes (via K3s).
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;orbstack
&lt;span class="c"&gt;# your existing docker / docker compose commands just work&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's free for personal use and $8/user/month for commercial use. That's cheaper than Docker Desktop, but not free for teams.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Trade-offs:&lt;/strong&gt; macOS only, and closed source.&lt;/p&gt;

&lt;h3&gt;
  
  
  Colima (macOS and Linux)
&lt;/h3&gt;

&lt;p&gt;Colima is the free, no-frills option. It's a thin wrapper around Lima that starts a VM running either the Docker engine or containerd, and you keep using your normal &lt;code&gt;docker&lt;/code&gt; CLI.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;colima docker
colima start &lt;span class="nt"&gt;--cpu&lt;/span&gt; 4 &lt;span class="nt"&gt;--memory&lt;/span&gt; 8 &lt;span class="nt"&gt;--vm-type&lt;/span&gt; vz &lt;span class="nt"&gt;--mount-type&lt;/span&gt; virtiofs
docker ps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's terminal-only, and you'll tune CPU, memory, and mount type yourself. In return it's stable and fully open source.&lt;/p&gt;

&lt;h3&gt;
  
  
  Lima
&lt;/h3&gt;

&lt;p&gt;Lima is the layer underneath Colima. It runs Linux VMs on macOS with automatic file sharing and port forwarding.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;lima
limactl start
lima &lt;span class="nb"&gt;uname&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use it directly when you want full control over the VM, or several VMs running different runtimes. It isn't a container tool on its own, but many of the tools in this list are built on it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Finch (AWS)
&lt;/h3&gt;

&lt;p&gt;Finch is AWS's open-source bundle of Lima, containerd, nerdctl, and BuildKit. Think of it as a more opinionated Colima: one install, no daemon, and a &lt;code&gt;finch&lt;/code&gt; command that works like &lt;code&gt;docker&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--cask&lt;/span&gt; finch
finch vm init
finch run &lt;span class="nt"&gt;--rm&lt;/span&gt; public.ecr.aws/finch/hello-finch
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's a reasonable choice if you're an AWS shop and want a big vendor behind your tooling.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rancher Desktop
&lt;/h3&gt;

&lt;p&gt;Rancher Desktop is the pick for Kubernetes-first developers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It's free and open source.&lt;/li&gt;
&lt;li&gt;It has a GUI.&lt;/li&gt;
&lt;li&gt;It runs a local k8s cluster that you can pin to the same version as production.&lt;/li&gt;
&lt;li&gt;You can switch the runtime between containerd and the Docker engine.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--cask&lt;/span&gt; rancher
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's heavier than the CLI tools because Kubernetes always comes along. If you live in &lt;code&gt;kubectl&lt;/code&gt; anyway, that's a feature rather than a cost.&lt;/p&gt;

&lt;h3&gt;
  
  
  Podman Desktop
&lt;/h3&gt;

&lt;p&gt;Podman Desktop is the GUI companion to Podman, now well into its 1.x releases with strong Red Hat backing. It manages containers, pods, images, and Kubernetes connections.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--cask&lt;/span&gt; podman-desktop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's the natural choice if you already use Podman on Linux and want the same model on your laptop. Like Rancher Desktop, it's free at any company size &lt;em&gt;and&lt;/em&gt; has a GUI.&lt;/p&gt;

&lt;h3&gt;
  
  
  Apple's container tooling (macOS)
&lt;/h3&gt;

&lt;p&gt;This one is worth watching. At WWDC 2025, Apple open-sourced a native &lt;code&gt;container&lt;/code&gt; CLI and a Containerization framework. Unlike everything else in this list, they run each container in its own lightweight VM, which isolates containers from each other more strongly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;container system start
container run &lt;span class="nt"&gt;--rm&lt;/span&gt; docker.io/library/alpine:latest &lt;span class="nb"&gt;echo &lt;/span&gt;hi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's still early, and the tooling around it is thin. Expect other tools to start building on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sometimes you don't need a container at all
&lt;/h2&gt;

&lt;p&gt;A lot of local Docker usage isn't really about containers. It's "I need Postgres plus some backend services running so I can develop." The Supabase local stack is a good example: about 12 containers and roughly 2.3 GB of Docker images, just to run your app's backend on a laptop.&lt;/p&gt;

&lt;p&gt;If that's your situation, you can skip the container layer entirely. &lt;a href="https://www.tinbase.dev/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=docker-alternatives-in-2026-what-to-use-instead-and-when-you-actually-need-to" rel="noopener noreferrer"&gt;tinbase&lt;/a&gt; is an open-source, Supabase-compatible backend that runs as a single process:&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;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@supabase/supabase-js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="c1"&gt;// same SDK, pointed at the local process — no Docker&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://127.0.0.1:54321&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ANON_KEY&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;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;todos&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;done&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What you get:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Real Postgres,&lt;/strong&gt; with Row Level Security, Auth, Storage, and Realtime.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your existing setup:&lt;/strong&gt; it reads your &lt;code&gt;supabase/migrations&lt;/code&gt; folder, and the official &lt;code&gt;supabase-js&lt;/code&gt; SDK works unchanged.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Trade-off:&lt;/strong&gt; it's alpha. That's fine for local dev and prototypes, but not production-ready yet.&lt;/p&gt;

&lt;p&gt;The broader lesson goes beyond Supabase. Before you choose a Docker replacement, check whether the thing you're running actually needs to be a container.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick decision guide
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You want...&lt;/th&gt;
&lt;th&gt;Use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Fastest, smoothest Mac experience, and $8/user/month is fine&lt;/td&gt;
&lt;td&gt;OrbStack&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Free at any company size, with a GUI&lt;/td&gt;
&lt;td&gt;Podman Desktop or Rancher Desktop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Free, lightweight, terminal-only on Mac&lt;/td&gt;
&lt;td&gt;Colima&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local Kubernetes that matches production&lt;/td&gt;
&lt;td&gt;Rancher Desktop&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Daemonless, rootless engine on Linux servers&lt;/td&gt;
&lt;td&gt;Podman&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Minimal CI runners&lt;/td&gt;
&lt;td&gt;nerdctl + containerd&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full control over the VM&lt;/td&gt;
&lt;td&gt;Lima&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local Postgres backend, no containers&lt;/td&gt;
&lt;td&gt;A single-process alternative (see above)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  One piece of advice
&lt;/h2&gt;

&lt;p&gt;Whatever you pick, &lt;strong&gt;pick it as a team.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Five developers on five different local setups is a support burden that will quickly cost more than you saved on licensing. Agree on one tool, script the setup, and move on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# scripts/setup-containers.sh&lt;/span&gt;
&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
brew &lt;span class="nb"&gt;install &lt;/span&gt;colima docker docker-compose
colima start &lt;span class="nt"&gt;--cpu&lt;/span&gt; 4 &lt;span class="nt"&gt;--memory&lt;/span&gt; 8 &lt;span class="nt"&gt;--vm-type&lt;/span&gt; vz &lt;span class="nt"&gt;--mount-type&lt;/span&gt; virtiofs
docker context use colima
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"✅ containers ready"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And remember that none of this touches your Dockerfiles, your Compose files, or your images. OCI standards made this whole ecosystem possible, and they're why switching is much less scary than it sounds.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;What's your team running locally in 2026?&lt;/strong&gt; Did you leave Docker Desktop, or decide it was worth paying for? Drop it in the comments. I'm especially curious whether anyone has moved real workloads onto Apple's &lt;code&gt;container&lt;/code&gt; yet. 👇&lt;/p&gt;

</description>
      <category>devops</category>
      <category>docker</category>
      <category>linux</category>
      <category>tools</category>
    </item>
    <item>
      <title>Authentication in React Native, Explained Like You're Building It Tonight</title>
      <dc:creator>Chloe</dc:creator>
      <pubDate>Wed, 07 Oct 2026 10:38:34 +0000</pubDate>
      <link>https://dev.to/chloe_smith0104/authentication-in-react-native-explained-like-youre-building-it-tonight-3cec</link>
      <guid>https://dev.to/chloe_smith0104/authentication-in-react-native-explained-like-youre-building-it-tonight-3cec</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't store tokens in AsyncStorage.&lt;/strong&gt; Use &lt;code&gt;expo-secure-store&lt;/code&gt;, which uses the iOS Keychain and Android Keystore.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep auth state in one place&lt;/strong&gt;, a React context that knows three things: &lt;em&gt;loading&lt;/em&gt;, &lt;em&gt;signed in&lt;/em&gt;, &lt;em&gt;signed out&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let your router do the gatekeeping.&lt;/strong&gt; With Expo Router, &lt;code&gt;Stack.Protected&lt;/code&gt; hides screens the user shouldn't see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wrap &lt;code&gt;fetch&lt;/code&gt; once&lt;/strong&gt; so every request gets the token, and a &lt;code&gt;401&lt;/code&gt; triggers one refresh instead of ten.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Biometrics unlock a session. They don't create one.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Grab a coffee. By the end you'll have an auth flow you actually understand.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why auth feels weird in React Native
&lt;/h2&gt;

&lt;p&gt;If you're coming from the web, you're used to the browser doing a lot of quiet work for you. Cookies get sent automatically. &lt;code&gt;httpOnly&lt;/code&gt; cookies keep JavaScript away from your session. Refresh the page and you're still logged in.&lt;/p&gt;

&lt;p&gt;In React Native, none of that is free. There's no cookie jar you'd want to rely on, and no &lt;code&gt;httpOnly&lt;/code&gt; safety net. &lt;strong&gt;Your app is responsible for holding the token, protecting it, and sending it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That sounds scary, but it boils down to four jobs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Get a token when the user logs in.&lt;/li&gt;
&lt;li&gt;Store it somewhere safe.&lt;/li&gt;
&lt;li&gt;Decide which screens to show based on whether you have it.&lt;/li&gt;
&lt;li&gt;Send it with requests, and get a new one when it expires.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Let's do them one at a time.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 30-second token refresher
&lt;/h2&gt;

&lt;p&gt;Most backends give you two tokens:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Token&lt;/th&gt;
&lt;th&gt;Lives for&lt;/th&gt;
&lt;th&gt;Used for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Access token&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Minutes (e.g. 15 min)&lt;/td&gt;
&lt;td&gt;Sent on every API request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Refresh token&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Days or weeks&lt;/td&gt;
&lt;td&gt;Trading in for a new access token&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Short-lived access tokens limit the damage if one leaks. The refresh token keeps users from logging in every 15 minutes. Your app's job is to juggle both without the user ever noticing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Store tokens safely
&lt;/h2&gt;

&lt;p&gt;First, install the storage library:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Now a tiny wrapper so the rest of your app never talks to storage directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// auth/tokenStorage.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;SecureStore&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-secure-store&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ACCESS_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;auth.accessToken&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;REFRESH_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;auth.refreshToken&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;refreshToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="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;saveTokens&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;refreshToken&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;Tokens&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;SecureStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItemAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ACCESS_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;SecureStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItemAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;REFRESH_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;refreshToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getTokens&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Tokens&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;accessToken&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;SecureStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItemAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ACCESS_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;refreshToken&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;SecureStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItemAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;REFRESH_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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;accessToken&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;refreshToken&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;refreshToken&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;clearTokens&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;SecureStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deleteItemAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ACCESS_KEY&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;SecureStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deleteItemAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;REFRESH_KEY&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;Why not AsyncStorage? Because AsyncStorage is plain, unencrypted storage. It's perfect for "dark mode: on" and terrible for "here are the keys to this user's account."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 SecureStore is meant for small values like tokens, not big JSON blobs. If you need to cache a user profile, put the profile in AsyncStorage and keep only the tokens in SecureStore.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 2: One context to rule your auth state
&lt;/h2&gt;

&lt;p&gt;Every screen needs to answer the same question: &lt;em&gt;is this person logged in?&lt;/em&gt; Let's give them one place to ask.&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;// auth/AuthContext.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useState&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&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;saveTokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;getTokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clearTokens&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;./tokenStorage&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;AuthState&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;isLoading&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;isSignedIn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;signIn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;signOut&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AuthContext&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;createContext&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;AuthState&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;API_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;AuthProvider&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;children&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;React&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ReactNode&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;isLoading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setIsLoading&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;true&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;isSignedIn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setIsSignedIn&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="c1"&gt;// On app start: do we already have tokens?&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="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tokens&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;getTokens&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="nf"&gt;setIsSignedIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nf"&gt;setIsLoading&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;})();&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;signIn&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;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;/auth/login`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;password&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="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="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;Invalid email or password&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;refreshToken&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveTokens&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;refreshToken&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="nf"&gt;setIsSignedIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;signOut&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="nf"&gt;clearTokens&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="nf"&gt;setIsSignedIn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;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;AuthContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Provider&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="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;isSignedIn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signIn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signOut&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="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&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;AuthContext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Provider&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useAuth&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;ctx&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;AuthContext&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;ctx&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;useAuth must be used inside &amp;lt;AuthProvider&amp;gt;&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;ctx&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the &lt;code&gt;isLoading&lt;/code&gt; flag. Without it, your app briefly thinks the user is logged out while it reads storage, and they see the login screen flash for a split second. Small thing, very noticeable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Let the router guard the doors
&lt;/h2&gt;

&lt;p&gt;If you're on Expo Router, you can describe which screens are allowed instead of manually redirecting all over the place.&lt;/p&gt;

&lt;p&gt;A simple folder setup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app/
  _layout.tsx
  sign-in.tsx
  (app)/
    _layout.tsx
    index.tsx
    profile.tsx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the root layout:&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;// 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;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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;AuthProvider&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useAuth&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;../auth/AuthContext&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;RootNavigator&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;isLoading&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;isSignedIn&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useAuth&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;isLoading&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// or keep your splash screen up&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;Stack&lt;/span&gt; &lt;span class="na"&gt;screenOptions&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;headerShown&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="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;.&lt;/span&gt;&lt;span class="nc"&gt;Protected&lt;/span&gt; &lt;span class="na"&gt;guard&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isSignedIn&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;.&lt;/span&gt;&lt;span class="nc"&gt;Screen&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"(app)"&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;.&lt;/span&gt;&lt;span class="nc"&gt;Protected&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;.&lt;/span&gt;&lt;span class="nc"&gt;Protected&lt;/span&gt; &lt;span class="na"&gt;guard&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isSignedIn&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;.&lt;/span&gt;&lt;span class="nc"&gt;Screen&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"sign-in"&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;.&lt;/span&gt;&lt;span class="nc"&gt;Protected&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;);&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;AuthProvider&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;RootNavigator&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;AuthProvider&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;That's it. When &lt;code&gt;isSignedIn&lt;/code&gt; flips to &lt;code&gt;true&lt;/code&gt;, the sign-in screen becomes unreachable and the user lands in &lt;code&gt;(app)&lt;/code&gt;. Sign out, and the reverse happens. No &lt;code&gt;router.replace()&lt;/code&gt; sprinkled across ten files.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Using React Navigation directly instead? Same idea: render a different set of &lt;code&gt;&amp;lt;Stack.Screen&amp;gt;&lt;/code&gt;s based on &lt;code&gt;isSignedIn&lt;/code&gt;. Don't navigate &lt;em&gt;to&lt;/em&gt; the login screen; just stop rendering the protected ones.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 4: The login screen
&lt;/h2&gt;

&lt;p&gt;Nothing fancy, just honest UX: a loading state, an error message, and a disabled button while we wait.&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;// app/sign-in.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;useState&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&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;View&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;TextInput&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ActivityIndicator&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useAuth&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;../auth/AuthContext&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;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;SignIn&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;signIn&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useAuth&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setEmail&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setPassword&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setError&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;submitting&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setSubmitting&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handleSubmit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setError&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;setSubmitting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;signIn&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="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Something went wrong&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;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nf"&gt;setSubmitting&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;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="na"&gt;justifyContent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;center&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;padding&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;gap&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="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;placeholder&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Email"&lt;/span&gt;
        &lt;span class="na"&gt;autoCapitalize&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"none"&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="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="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;email&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;onChangeText&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;setEmail&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;TextInput&lt;/span&gt;
        &lt;span class="na"&gt;placeholder&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="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;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;password&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="na"&gt;onChangeText&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;setPassword&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;crimson&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="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;error&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="si"&gt;}&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;submitting&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ActivityIndicator&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"Sign in"&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;handleSubmit&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Little details that matter: &lt;code&gt;autoCapitalize="none"&lt;/code&gt; (nobody wants &lt;code&gt;John@gmail.com&lt;/code&gt;), and &lt;code&gt;textContentType&lt;/code&gt; so iOS can offer saved passwords.&lt;/p&gt;

&lt;p&gt;Honestly, the login, signup, and forgot-password screens are the most repetitive part of this whole flow. If you'd rather spend your time on the auth logic than on pixel-pushing form layouts, you can describe the screens to &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=react-native-authentication-guide" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; and get React Native UI code to drop the &lt;code&gt;useAuth()&lt;/code&gt; hook into.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: Attach the token, and refresh it without chaos
&lt;/h2&gt;

&lt;p&gt;Here's where many apps get messy. The access token expires, five requests fail with &lt;code&gt;401&lt;/code&gt; at the same time, and all five try to refresh. Now you've got a race condition and possibly a logged-out user.&lt;/p&gt;

&lt;p&gt;The fix: &lt;strong&gt;one shared refresh promise.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// api/client.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getTokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;saveTokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;clearTokens&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;../auth/tokenStorage&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;refreshPromise&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;refreshAccessToken&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tokens&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;getTokens&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;tokens&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;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;/auth/refresh`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;refreshToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refreshToken&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="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;clearTokens&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;apiFetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RequestInit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tokens&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;getTokens&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;doRequest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&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;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="nx"&gt;path&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="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;options&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="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{}),&lt;/span&gt;
        &lt;span class="p"&gt;...(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{}),&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;let&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;doRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &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="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Everyone waits on the same refresh&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;refreshPromise&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;refreshPromise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;refreshAccessToken&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;finally&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;refreshPromise&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;newToken&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;refreshPromise&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;newToken&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;res&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// refresh failed, caller handles sign-out&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;doRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newToken&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 anywhere in your app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;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;apiFetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/me&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;user&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One small homework item: when the refresh fails, your &lt;code&gt;AuthContext&lt;/code&gt; should find out and flip &lt;code&gt;isSignedIn&lt;/code&gt; to &lt;code&gt;false&lt;/code&gt;. A simple event emitter or an &lt;code&gt;onAuthFailure&lt;/code&gt; callback you register from the provider works great.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: "Sign in with Google/Apple" in one breath
&lt;/h2&gt;

&lt;p&gt;Social login deserves its own post, but here are the rules that keep you out of trouble:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Use the provider's SDK or &lt;code&gt;expo-auth-session&lt;/code&gt;&lt;/strong&gt; with the PKCE flow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never ship a client secret in your app.&lt;/strong&gt; Anything in your bundle can be extracted. Secrets live on your server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Exchange the provider's token with &lt;em&gt;your&lt;/em&gt; backend&lt;/strong&gt; and get &lt;em&gt;your&lt;/em&gt; access/refresh tokens back. From there, everything above stays the same.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you offer other social logins on iOS, check Apple's rules&lt;/strong&gt; on also offering Sign in with Apple.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The nice part: once you have your own tokens, Steps 1–5 don't care how the user logged in.&lt;/p&gt;




&lt;h2&gt;
  
  
  Bonus: Face ID / fingerprint unlock
&lt;/h2&gt;

&lt;p&gt;Biometrics are a great "welcome back" experience, but they are &lt;strong&gt;not&lt;/strong&gt; a login method on their own. They prove a finger or face is present, not who the user is to your server.&lt;/p&gt;

&lt;p&gt;The right pattern: user logs in normally once, then biometrics gate access to the tokens you already stored.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;LocalAuthentication&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-local-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;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;unlockWithBiometrics&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;hasHardware&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;LocalAuthentication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasHardwareAsync&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;isEnrolled&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;LocalAuthentication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isEnrolledAsync&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;hasHardware&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;isEnrolled&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// fall back gracefully&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;LocalAuthentication&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;authenticateAsync&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;promptMessage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unlock your account&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;return&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Call it on app launch before reading tokens, and you've got a smooth, secure "open app, glance at phone, you're in" flow.&lt;/p&gt;




&lt;h2&gt;
  
  
  Common mistakes (I've made most of these)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Storing tokens in AsyncStorage&lt;/strong&gt; or Redux Persist. Use SecureStore.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Logging tokens&lt;/strong&gt; with &lt;code&gt;console.log&lt;/code&gt;. They end up in crash reports and screen recordings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Navigating to login on logout&lt;/strong&gt; instead of letting the auth state decide what renders. You get weird back-button bugs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No &lt;code&gt;isLoading&lt;/code&gt; state&lt;/strong&gt;, which causes the login-screen flash on startup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refreshing tokens in parallel.&lt;/strong&gt; Use one shared promise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trusting the client.&lt;/strong&gt; Your app hiding a screen is UX, not security. The server must check every request.&lt;/li&gt;
&lt;/ul&gt;




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

&lt;p&gt;React Native auth isn't magic. It's four small jobs: get the token, store it safely, guard the screens, and keep the token fresh. Get those right and everything else, from social login to biometrics, plugs in on top.&lt;/p&gt;

&lt;p&gt;What are you using for auth right now: your own backend, Firebase, Supabase, Clerk, something else? Drop a comment with what you're building. I'm curious what's working (and what's hurting) for you. 👇&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>mobile</category>
      <category>security</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>React Native Accessibility: The 2026 WCAG 2.2 Guide (Every Criterion Every Prop)</title>
      <dc:creator>Chloe</dc:creator>
      <pubDate>Mon, 05 Oct 2026 08:40:27 +0000</pubDate>
      <link>https://dev.to/chloe_smith0104/react-native-accessibility-the-2026-wcag-22-guide-every-criterion-every-prop-3jf3</link>
      <guid>https://dev.to/chloe_smith0104/react-native-accessibility-the-2026-wcag-22-guide-every-criterion-every-prop-3jf3</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The legal pressure is real.&lt;/strong&gt; The DOJ's ADA Title II rule names mobile apps and WCAG 2.1 AA explicitly (deadlines now April 2027 / April 2028). The EU's EAA has been live since June 2025 and points to WCAG 2.2 AA via EN 301 549.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;React Native maps almost 1:1 to native a11y APIs.&lt;/strong&gt; &lt;code&gt;accessibilityLabel&lt;/code&gt; becomes the iOS accessibility label and the Android content description. You already have the tools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WCAG 2.2 adds four mobile-relevant criteria:&lt;/strong&gt; Focus Not Obscured, Dragging Movements, Target Size (24×24 min), and Accessible Authentication.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Five bugs cause most failures:&lt;/strong&gt; unlabeled &lt;code&gt;Pressable&lt;/code&gt;s, modals that don't trap focus, visual-only form errors, custom switches with no state, and low-contrast text on gradients.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test in four layers:&lt;/strong&gt; ESLint on every commit, a VoiceOver/TalkBack walkthrough on every UI PR, and Accessibility Inspector/Scanner plus axe before every store submission.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Why this suddenly matters
&lt;/h2&gt;

&lt;p&gt;Accessibility used to be the "we'll get to it" ticket. Three things changed that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The DOJ Title II rule.&lt;/strong&gt; Published in April 2024, it's the first U.S. federal regulation to name mobile apps alongside websites and tie them to a specific standard: WCAG 2.1 AA. In April 2026 the DOJ pushed the deadlines back a year. Public entities with 50,000+ population now have until &lt;strong&gt;April 26, 2027&lt;/strong&gt;, and smaller entities until &lt;strong&gt;April 26, 2028&lt;/strong&gt;. The requirements themselves didn't change. State procurement contracts already cite WCAG 2.1 AA as a pass/fail checkbox, so if you build B2G apps you're in scope today.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The European Accessibility Act.&lt;/strong&gt; In force since June 2025, it covers private-sector products like banking, e-commerce and transit apps. It references EN 301 549, which is harmonized with &lt;a href="https://www.w3.org/TR/WCAG22/" rel="noopener noreferrer"&gt;WCAG 2.2 AA&lt;/a&gt;. If you ship to EU users above the small-business threshold, it applies to you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Litigation.&lt;/strong&gt; Mobile app accessibility lawsuits more than doubled between 2022 and 2025. Courts keep treating apps as places of public accommodation under ADA Title III. Insurers have started asking about accessibility testing before writing cyber-liability policies.&lt;/p&gt;

&lt;p&gt;So: you need an a11y audit in your release checklist, run on every store build, with a report you can point to.&lt;/p&gt;




&lt;h2&gt;
  
  
  The entire React Native a11y toolbox
&lt;/h2&gt;

&lt;p&gt;WCAG 2.2 has 86 success criteria across three conformance levels (A, AA, AAA). For Level AA you need to pass the 55 A and AA criteria, minus a few web-only ones like skip links. Everything else maps to this prop surface:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="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="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="kc"&gt;true&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;"Save changes"&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;"Saves your draft"&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;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;disabled&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="na"&gt;busy&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;isSaving&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&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="mi"&gt;42&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;importantForAccessibility&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"yes"&lt;/span&gt;        &lt;span class="c1"&gt;// Android&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="c1"&gt;// Android&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;Save&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;That's it. The hard part isn't the API. It's knowing which prop satisfies which criterion, and making those props the default so nobody ships a screen without them.&lt;/p&gt;

&lt;p&gt;The four criteria &lt;strong&gt;new in WCAG 2.2&lt;/strong&gt; that hit native UIs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;What it means in an app&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2.4.11 Focus Not Obscured (Min)&lt;/td&gt;
&lt;td&gt;AA&lt;/td&gt;
&lt;td&gt;Focused element can't be fully hidden by sticky headers, bottom sheets or the keyboard&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.5.7 Dragging Movements&lt;/td&gt;
&lt;td&gt;AA&lt;/td&gt;
&lt;td&gt;Every drag gesture needs a non-drag alternative&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2.5.8 Target Size (Min)&lt;/td&gt;
&lt;td&gt;AA&lt;/td&gt;
&lt;td&gt;Targets at least 24×24 (aim for 44×44 iOS / 48×48 Android)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3.3.8 Accessible Authentication (Min)&lt;/td&gt;
&lt;td&gt;AA&lt;/td&gt;
&lt;td&gt;No cognitive tests to log in. Allow paste and password managers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Perceivable + Operable: criterion → code
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1.1.1 Non-text Content (A)
&lt;/h3&gt;

&lt;p&gt;Label meaningful images and hide decorative ones.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Meaningful&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="nx"&gt;avatar&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;"Profile photo of Priya"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c1"&gt;// Decorative: hide on both platforms&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="nx"&gt;divider&lt;/span&gt;&lt;span class="si"&gt;}&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="kc"&gt;true&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;               &lt;span class="c1"&gt;// iOS&lt;/span&gt;
  &lt;span class="na"&gt;importantForAccessibility&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"no-hide-descendants"&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;h3&gt;
  
  
  1.3.1 Info and Relationships (A)
&lt;/h3&gt;

&lt;p&gt;A styled &lt;code&gt;View&lt;/code&gt; with an &lt;code&gt;onPress&lt;/code&gt; is invisible to screen readers as a control. Declare the role.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Text&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;"header"&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;styles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;h1&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Settings&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="na"&gt;accessibilityRole&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"link"&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;openTerms&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;Terms of service&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;h3&gt;
  
  
  1.4.3 Contrast (Minimum) (AA)
&lt;/h3&gt;

&lt;p&gt;Body text needs &lt;strong&gt;4.5:1&lt;/strong&gt;. Large text (18pt+, or 14pt+ bold) needs &lt;strong&gt;3:1&lt;/strong&gt;. The classic React Native failure is text on a gradient hero that passes at the top and fails at the bottom. Use semantic tokens so a palette change doesn't mean re-auditing every screen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// theme.js: pairs pre-checked for AA contrast&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;textPrimary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#1A1A1A&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;#FFFFFF&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;textOnBrand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#FFFFFF&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;brand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;#0B5FFF&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;h3&gt;
  
  
  1.4.4 Resize Text + 1.4.10 Reflow (AA)
&lt;/h3&gt;

&lt;p&gt;Text must scale to 200% without clipping. &lt;code&gt;allowFontScaling&lt;/code&gt; is on by default, so leave it alone. Fixed heights are what break:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ❌ clips at large Dynamic Type sizes&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;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;48&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;&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;label&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;&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;// ✅ grows with the text&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;minHeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;48&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;&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;label&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;&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;h3&gt;
  
  
  2.1.1 Keyboard (A)
&lt;/h3&gt;

&lt;p&gt;Hardware keyboards are a real case on iPads, foldables and Android devices with Bluetooth keyboards. &lt;code&gt;TextInput&lt;/code&gt;'s &lt;code&gt;onKeyPress&lt;/code&gt; covers most of it. Any custom gesture needs an alternative, which also covers 2.5.7 below.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.4.3 Focus Order (A) + 2.4.11 Focus Not Obscured (AA)
&lt;/h3&gt;

&lt;p&gt;Focus order follows the view hierarchy, which usually matches the visual order. It breaks when you use absolute positioning or &lt;code&gt;zIndex&lt;/code&gt;. Modals need to trap focus:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="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;modalOpen&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="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;modalOpen&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* background screen */&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;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;Modal&lt;/span&gt; &lt;span class="na"&gt;visible&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;modalOpen&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="na"&gt;accessibilityViewIsModal&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* modal content */&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;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;Modal&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;For 2.4.11, keep focused inputs above the keyboard:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;KeyboardAvoidingView&lt;/span&gt;
  &lt;span class="na"&gt;behavior&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;Platform&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OS&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ios&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;padding&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;height&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;flex&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ScrollView&lt;/span&gt; &lt;span class="na"&gt;keyboardShouldPersistTaps&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"handled"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* form */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;ScrollView&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;KeyboardAvoidingView&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2.5.8 Target Size (AA)
&lt;/h3&gt;

&lt;p&gt;The usual offender is a 16pt icon button with no padding. &lt;code&gt;hitSlop&lt;/code&gt; enlarges the touch area without changing the visuals:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="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;close&lt;/span&gt;&lt;span class="si"&gt;}&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;14&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;14&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;14&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;14&lt;/span&gt; &lt;span class="p"&gt;}&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="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Icon&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"x"&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;16&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;h3&gt;
  
  
  2.5.7 Dragging Movements (AA)
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;react-native-draggable-flatlist&lt;/code&gt; won't give you an alternative for free. The cleanest RN-native fix is custom accessibility actions, which screen reader users reach through the VoiceOver rotor or the TalkBack actions menu:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, position &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;index&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="s2"&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="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;nativeEvent&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;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;moveUp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&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;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;index&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;if &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;moveDown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&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;index&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;index&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="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="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* draggable row */&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;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;Add a visible long-press menu with the same options for sighted users who can't drag.&lt;/p&gt;




&lt;h2&gt;
  
  
  Understandable + Robust: forms, roles, state
&lt;/h2&gt;

&lt;p&gt;These are the failures that get apps sued, because they stop people from completing a transaction.&lt;/p&gt;

&lt;h3&gt;
  
  
  3.3.1 Error Identification + 3.3.3 Error Suggestion
&lt;/h3&gt;

&lt;p&gt;A red border means nothing to a screen reader. Announce the error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="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;function&lt;/span&gt; &lt;span class="nf"&gt;onSubmit&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;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Email is required&lt;/span&gt;&lt;span class="dl"&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;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;Email is required&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TextInput&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;error&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`Email, error: &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="s2"&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;Email&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&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;email&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;onChangeText&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;setEmail&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;h3&gt;
  
  
  3.3.2 Labels or Instructions (A)
&lt;/h3&gt;

&lt;p&gt;Placeholder-only inputs fail because the placeholder disappears when you type. Use a persistent label. On Android you can link the two with &lt;code&gt;accessibilityLabelledBy&lt;/code&gt; + &lt;code&gt;nativeID&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt; &lt;span class="na"&gt;nativeID&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"emailLabel"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Email&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;TextInput&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;"Email"&lt;/span&gt;            &lt;span class="c1"&gt;// iOS&lt;/span&gt;
  &lt;span class="na"&gt;accessibilityLabelledBy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"emailLabel"&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;h3&gt;
  
  
  3.3.8 Accessible Authentication (AA, new in 2.2)
&lt;/h3&gt;

&lt;p&gt;Let password managers do their job, and never block paste:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TextInput&lt;/span&gt;
  &lt;span class="na"&gt;secureTextEntry&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="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;"password"&lt;/span&gt;      &lt;span class="c1"&gt;// Android + iOS&lt;/span&gt;
  &lt;span class="na"&gt;contextMenuHidden&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;    &lt;span class="c1"&gt;// keep paste available&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4.1.2 Name, Role, Value (A)
&lt;/h3&gt;

&lt;p&gt;Custom controls need all three. Miss one and the control fails:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="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="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setEnabled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;)&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;"switch"&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;"Dark mode"&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;enabled&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="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* visual toggle */&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;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;VoiceOver reads it as "Dark mode, switch, on." 👌&lt;/p&gt;

&lt;h3&gt;
  
  
  4.1.3 Status Messages (AA)
&lt;/h3&gt;

&lt;p&gt;Toasts and "Saved" confirmations should be announced without moving focus:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="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;statusMessage&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="c1"&gt;// Android&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;statusMessage&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="nx"&gt;statusMessage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// iOS + Android&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;statusMessage&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The 4-layer testing workflow
&lt;/h2&gt;

&lt;p&gt;You can't claim compliance you can't demonstrate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 1: Lint on every commit.&lt;/strong&gt;&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;"plugins"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"react-native-a11y"&lt;/span&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;This catches missing labels, invalid roles and unlabeled inputs. It won't catch contrast or focus order, and no linter can.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 2: Screen reader walkthrough on every UI PR.&lt;/strong&gt; Turn on VoiceOver (Settings → Accessibility → VoiceOver) or TalkBack, close your eyes, and complete each primary flow. It takes about 20 minutes per flow and catches more real bugs than anything else on this list.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 3: Platform auditors before release.&lt;/strong&gt; On iOS, use Xcode → Open Developer Tool → Accessibility Inspector. On Android, use Google's Accessibility Scanner. Both flag contrast, missing labels and small targets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 4: Automated regression.&lt;/strong&gt; &lt;a href="https://www.deque.com/axe/devtools/mobile/" rel="noopener noreferrer"&gt;axe DevTools Mobile&lt;/a&gt; plugs into Appium and runs WCAG 2.2 rulesets against built apps. It's pricey, but it's how you prove compliance at CI speed on a big app.&lt;/p&gt;

&lt;p&gt;If you ship weekly, that's roughly a 2-hour a11y pass per release.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 5 bugs I see in almost every RN audit
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Bug&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Icon-only &lt;code&gt;Pressable&lt;/code&gt; with no label&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;accessibilityLabel&lt;/code&gt; + &lt;code&gt;accessibilityRole="button"&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Modal doesn't trap focus&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;accessibilityViewIsModal&lt;/code&gt; (iOS) + hide background (Android)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Form errors are visual only&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AccessibilityInfo.announceForAccessibility()&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Custom switch reads as "button"&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;accessibilityRole="switch"&lt;/code&gt; + &lt;code&gt;accessibilityState={{ checked }}&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Low contrast on gradients&lt;/td&gt;
&lt;td&gt;Semi-opaque overlay behind text, or move the text&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Bugs 1–4 are all "forgot a prop" bugs, which makes them a defaults problem more than a skills problem. Your component library and codegen should require these props on interactive elements so the accessible path is the easy path. Some generators already work this way. &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=react-native-wcag-compliance-guide" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt;, for example, outputs screens with &lt;code&gt;accessibilityRole&lt;/code&gt;, &lt;code&gt;accessibilityLabel&lt;/code&gt;, &lt;code&gt;accessibilityState&lt;/code&gt; and &lt;code&gt;hitSlop&lt;/code&gt; already filled in. Contrast (bug 5) is harder to automate because it depends on your palette, but AA-checked semantic tokens get you most of the way.&lt;/p&gt;




&lt;h2&gt;
  
  
  Build it in, don't bolt it on
&lt;/h2&gt;

&lt;p&gt;Retrofitting a 40-screen app that shipped without a11y props takes 2–4 weeks of focused work. Building those props in from day one costs close to nothing. The cheapest fix is upstream:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Make &lt;code&gt;accessibilityLabel&lt;/code&gt; and &lt;code&gt;accessibilityRole&lt;/code&gt; &lt;strong&gt;required&lt;/strong&gt; in your design-system components (TypeScript helps here).&lt;/li&gt;
&lt;li&gt;Turn on &lt;code&gt;eslint-plugin-react-native-a11y&lt;/code&gt; with errors, not warnings.&lt;/li&gt;
&lt;li&gt;Ship AA-safe color tokens and &lt;code&gt;hitSlop&lt;/code&gt; defaults in your base &lt;code&gt;Button&lt;/code&gt; and &lt;code&gt;IconButton&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then do the one thing that beats every tool: spend 20 minutes with VoiceOver on your current build.&lt;/p&gt;

&lt;p&gt;What's the worst a11y bug you've found in your own app? Drop it in the comments 👇&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>a11y</category>
      <category>mobile</category>
      <category>javascript</category>
    </item>
    <item>
      <title>File-Based Routing Explained (Next.js, Expo Router &amp; Why Your Folder Is Now Your Router)</title>
      <dc:creator>Chloe</dc:creator>
      <pubDate>Wed, 30 Sep 2026 07:21:48 +0000</pubDate>
      <link>https://dev.to/chloe_smith0104/file-based-routing-explained-nextjs-expo-router-why-your-folder-is-now-your-router-3905</link>
      <guid>https://dev.to/chloe_smith0104/file-based-routing-explained-nextjs-expo-router-why-your-folder-is-now-your-router-3905</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;File-based routing = your folder structure &lt;em&gt;is&lt;/em&gt; your route config. No &lt;code&gt;routes.js&lt;/code&gt;, no giant switch statement.&lt;/li&gt;
&lt;li&gt;The core conventions are nearly universal: &lt;code&gt;index&lt;/code&gt; → &lt;code&gt;/&lt;/code&gt;, &lt;code&gt;[param]&lt;/code&gt; → dynamic segment, &lt;code&gt;[...rest]&lt;/code&gt; → catch-all, &lt;code&gt;(group)&lt;/code&gt; → organize without changing the URL, &lt;code&gt;_layout&lt;/code&gt; / &lt;code&gt;layout&lt;/code&gt; → shared UI.&lt;/li&gt;
&lt;li&gt;Next.js (web) and Expo Router (mobile) use almost the same mental model, so you can learn it once and use it everywhere.&lt;/li&gt;
&lt;li&gt;The gotchas: route groups, layout nesting, and where auth guards live. We'll cover all three with code.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The old way: routes as config
&lt;/h2&gt;

&lt;p&gt;If you've been writing React for a while, you've written something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// App.jsx — the "before" picture&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;BrowserRouter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Routes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Route&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react-router-dom&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;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;App&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;BrowserRouter&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;Routes&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;Route&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/"&lt;/span&gt; &lt;span class="na"&gt;element&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;lt;&lt;/span&gt;&lt;span class="nc"&gt;Home&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Route&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/blog"&lt;/span&gt; &lt;span class="na"&gt;element&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;lt;&lt;/span&gt;&lt;span class="nc"&gt;BlogIndex&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Route&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/blog/:slug"&lt;/span&gt; &lt;span class="na"&gt;element&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;lt;&lt;/span&gt;&lt;span class="nc"&gt;BlogPost&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Route&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/settings"&lt;/span&gt; &lt;span class="na"&gt;element&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;lt;&lt;/span&gt;&lt;span class="nc"&gt;SettingsLayout&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Route&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"profile"&lt;/span&gt; &lt;span class="na"&gt;element&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;lt;&lt;/span&gt;&lt;span class="nc"&gt;Profile&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Route&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"billing"&lt;/span&gt; &lt;span class="na"&gt;element&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;lt;&lt;/span&gt;&lt;span class="nc"&gt;Billing&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Route&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;Route&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"*"&lt;/span&gt; &lt;span class="na"&gt;element&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;lt;&lt;/span&gt;&lt;span class="nc"&gt;NotFound&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Routes&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;BrowserRouter&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;It works. But now you have &lt;strong&gt;two sources of truth&lt;/strong&gt;: the file where the component lives, and the config that says where it's mounted. Rename a file, forget the config, and you get a broken route that nobody notices until prod.&lt;/p&gt;

&lt;p&gt;File-based routing deletes the second source of truth.&lt;/p&gt;




&lt;h2&gt;
  
  
  The new way: the folder &lt;em&gt;is&lt;/em&gt; the router
&lt;/h2&gt;

&lt;p&gt;Here's the same app with file-based routing (Next.js App Router):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app/
├── layout.tsx            → wraps everything
├── page.tsx              → /
├── blog/
│   ├── page.tsx          → /blog
│   └── [slug]/
│       └── page.tsx      → /blog/:slug
├── settings/
│   ├── layout.tsx        → shared settings shell
│   ├── profile/page.tsx  → /settings/profile
│   └── billing/page.tsx  → /settings/billing
└── not-found.tsx         → 404
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No config file. You look at the tree, you know the URLs. New dev joins the team? They &lt;code&gt;ls app/&lt;/code&gt; and they understand the whole app's navigation in ten seconds.&lt;/p&gt;




&lt;h2&gt;
  
  
  The conventions (they're basically universal)
&lt;/h2&gt;

&lt;p&gt;Once you learn these five patterns, you can read almost any file-based router.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;th&gt;Example URL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;index&lt;/code&gt; / &lt;code&gt;page&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The root of a folder&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/blog&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;[id]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Dynamic segment&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/blog/hello-world&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;[...slug]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Catch-all (1+ segments)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/docs/a/b/c&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;(group)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Folder for organization, &lt;strong&gt;not in URL&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;(marketing)/about&lt;/code&gt; → &lt;code&gt;/about&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;layout&lt;/code&gt; / &lt;code&gt;_layout&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Shared UI wrapping children&lt;/td&gt;
&lt;td&gt;nav bars, tabs, auth shells&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Dynamic segments
&lt;/h3&gt;

&lt;p&gt;The bracket name becomes a param you can read:&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;// app/blog/[slug]/page.tsx  (Next.js 15+)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&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;BlogPost&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;params&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="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;slug&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;params&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;post&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;getPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&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="nt"&gt;article&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;h1&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;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;h1&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;dangerouslySetInnerHTML&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;__html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;html&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="nt"&gt;article&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;blockquote&gt;
&lt;p&gt;Heads up: in Next.js 15, &lt;code&gt;params&lt;/code&gt; became a Promise in server components. If you're upgrading from 14 and suddenly getting &lt;code&gt;undefined&lt;/code&gt;, that's why.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Catch-all routes
&lt;/h3&gt;

&lt;p&gt;Great for docs sites where depth is unknown:&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;// app/docs/[...slug]/page.tsx&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&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;Docs&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;params&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="nl"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="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;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;slug&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;params&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// /docs/getting-started/install → ["getting-started", "install"]&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;doc&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;getDocByPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;DocRenderer&lt;/span&gt; &lt;span class="na"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Want it to &lt;em&gt;also&lt;/em&gt; match &lt;code&gt;/docs&lt;/code&gt; with no segments? Use double brackets: &lt;code&gt;[[...slug]]&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Route groups (the one everyone gets confused by)
&lt;/h3&gt;

&lt;p&gt;Parentheses mean "this folder exists for me, not for the URL":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app/
├── (marketing)/
│   ├── layout.tsx    → marketing header + footer
│   ├── page.tsx      → /
│   └── pricing/page.tsx  → /pricing
└── (app)/
    ├── layout.tsx    → sidebar + auth check
    └── dashboard/page.tsx → /dashboard
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/pricing&lt;/code&gt; and &lt;code&gt;/dashboard&lt;/code&gt; both live at the top level of the URL, but they get &lt;strong&gt;completely different layouts&lt;/strong&gt;. That's the real superpower of groups: different shells without ugly URL prefixes like &lt;code&gt;/app/dashboard&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Same idea, now on mobile: Expo Router
&lt;/h2&gt;

&lt;p&gt;Here's where it gets fun. React Native used to be the land of &lt;code&gt;createStackNavigator&lt;/code&gt; and deeply nested navigator config. Expo Router brought file-based routing to mobile, and the conventions are almost identical:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app/
├── _layout.tsx           → root Stack
├── (tabs)/
│   ├── _layout.tsx       → bottom tab bar
│   ├── index.tsx         → Home tab  (/)
│   └── profile.tsx       → Profile tab (/profile)
├── post/
│   └── [id].tsx          → /post/123
└── +not-found.tsx        → 404
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Differences worth noting: layouts are &lt;code&gt;_layout.tsx&lt;/code&gt; (underscore), screens are named files instead of &lt;code&gt;page.tsx&lt;/code&gt;, and special files use a &lt;code&gt;+&lt;/code&gt; prefix (&lt;code&gt;+not-found.tsx&lt;/code&gt;, &lt;code&gt;+api.ts&lt;/code&gt; for API routes).&lt;/p&gt;

&lt;h3&gt;
  
  
  The root layout
&lt;/h3&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;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="s2"&gt;expo-router&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;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;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;Stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Screen&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"(tabs)"&lt;/span&gt; &lt;span class="na"&gt;options&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;headerShown&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="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;.&lt;/span&gt;&lt;span class="nc"&gt;Screen&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"post/[id]"&lt;/span&gt; &lt;span class="na"&gt;options&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;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Post&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="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;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Tabs via a route group
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/(tabs)/_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;Tabs&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;expo-router&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Ionicons&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@expo/vector-icons&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;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;TabLayout&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;Tabs&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;Tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Screen&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"index"&lt;/span&gt;
        &lt;span class="na"&gt;options&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;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Home&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;tabBarIcon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;color&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Ionicons&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"home"&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;color&lt;/span&gt;&lt;span class="si"&gt;}&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="nx"&gt;size&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;&lt;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;Tabs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Screen&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"profile"&lt;/span&gt;
        &lt;span class="na"&gt;options&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;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Profile&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;tabBarIcon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;color&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;size&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Ionicons&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"person"&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;color&lt;/span&gt;&lt;span class="si"&gt;}&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="nx"&gt;size&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;&lt;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;Tabs&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice &lt;code&gt;(tabs)&lt;/code&gt; doesn't show up in the URL. Same exact rule as Next.js route groups.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reading params and navigating
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/post/[id].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;useLocalSearchParams&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Link&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;expo-router&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;View&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Text&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;react-native&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;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;PostScreen&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;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useLocalSearchParams&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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="kr"&gt;string&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;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;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;padding&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="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;Post #&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;id&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;Link&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`/post/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Next post →&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Link&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the bonus you get for free: &lt;strong&gt;every screen is deep-linkable&lt;/strong&gt;. &lt;code&gt;myapp://post/42&lt;/code&gt; just works, because the URL was the route all along. With config-based navigation, you'd be hand-writing a linking config for that.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where do auth guards go?
&lt;/h2&gt;

&lt;p&gt;This is the question I see most. Answer: &lt;strong&gt;in the layout that wraps the protected routes.&lt;/strong&gt; Groups make this clean.&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;// app/(app)/_layout.tsx  — Expo Router&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;Redirect&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="s2"&gt;expo-router&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useSession&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@/lib/auth&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;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;AppLayout&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;session&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="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useSession&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;isLoading&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// or a splash screen&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;session&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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Redirect&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/sign-in"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&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;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything inside &lt;code&gt;(app)/&lt;/code&gt; is now protected. Everything outside it (&lt;code&gt;sign-in.tsx&lt;/code&gt;, marketing screens) isn't. One file, one rule, zero per-screen checks.&lt;/p&gt;

&lt;p&gt;In Next.js you'd typically do the same check in &lt;code&gt;middleware.ts&lt;/code&gt; (runs before render) or in the group's &lt;code&gt;layout.tsx&lt;/code&gt; with a &lt;code&gt;redirect()&lt;/code&gt; from &lt;code&gt;next/navigation&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  If you'd rather skip the scaffolding
&lt;/h2&gt;

&lt;p&gt;Honestly, the most tedious part of file-based routing isn't learning it, it's setting up the initial tree: root layout, tab group, auth group, a couple of dynamic screens, wiring the icons. It's the same 10 files every time.&lt;/p&gt;

&lt;p&gt;I've been using &lt;a href="https://rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=file-based-routing" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; for that part: you describe the app, and it generates a React Native + Expo project with the Expo Router structure already in place, so you start at "customize the screens" instead of "create &lt;code&gt;_layout.tsx&lt;/code&gt; again". Worth a look if you spin up mobile prototypes often.&lt;/p&gt;




&lt;h2&gt;
  
  
  Gotchas I've hit (so you don't have to)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Two files, one route.&lt;/strong&gt; &lt;code&gt;app/about.tsx&lt;/code&gt; and &lt;code&gt;app/about/index.tsx&lt;/code&gt; both resolve to &lt;code&gt;/about&lt;/code&gt;. Pick one convention and stick with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Groups don't isolate URLs.&lt;/strong&gt; &lt;code&gt;(marketing)/pricing&lt;/code&gt; and &lt;code&gt;(app)/pricing&lt;/code&gt; would collide, since both become &lt;code&gt;/pricing&lt;/code&gt;. Groups change layouts, not paths.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Colocating non-route files.&lt;/strong&gt; In Next.js App Router, only &lt;code&gt;page.tsx&lt;/code&gt; becomes a route, so you can safely drop &lt;code&gt;components/&lt;/code&gt; or &lt;code&gt;utils.ts&lt;/code&gt; next to it. In Expo Router, &lt;strong&gt;every file in &lt;code&gt;app/&lt;/code&gt; is a route&lt;/strong&gt;, so keep helpers outside (&lt;code&gt;src/components&lt;/code&gt;, &lt;code&gt;lib/&lt;/code&gt;), or prefix with &lt;code&gt;_&lt;/code&gt; where supported.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Layouts persist.&lt;/strong&gt; A layout doesn't remount when you navigate between its children. Great for keeping tab state, surprising if you expected a &lt;code&gt;useEffect&lt;/code&gt; in the layout to fire on every navigation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Typed routes are worth turning on.&lt;/strong&gt; Both Next.js and Expo Router can generate types from your file tree, so &lt;code&gt;href="/pots/123"&lt;/code&gt; (typo) fails at compile time instead of at runtime:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;app.json&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;(Expo)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"experiments"&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;"typedRoutes"&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;h2&gt;
  
  
  Should you use file-based routing?
&lt;/h2&gt;

&lt;p&gt;My take:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;New project?&lt;/strong&gt; Yes. Default to it. The discoverability alone pays for itself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Big existing app on config routing?&lt;/strong&gt; Migrate only if navigation bugs are an actual pain point. It's a real refactor, especially on mobile.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Need wildly dynamic, runtime-defined routes&lt;/strong&gt; (e.g. a CMS defining the whole URL space)? Use a catch-all and resolve inside, or stay with config. File-based routing assumes your routes are known at build time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The biggest win isn't fewer lines of code. It's that the answer to "where does this URL live?" is always "look at the folder."&lt;/p&gt;




&lt;p&gt;What's your setup: Next.js, Expo Router, SvelteKit, Nuxt, or still hand-rolling routes? And what's the weirdest routing bug you've shipped? Drop a comment, I read all of them. 👇&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>reactnative</category>
      <category>nextjs</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Running Real Postgres Locally Without Docker (with Tinbase)</title>
      <dc:creator>Chloe</dc:creator>
      <pubDate>Mon, 28 Sep 2026 06:35:29 +0000</pubDate>
      <link>https://dev.to/chloe_smith0104/running-real-postgres-locally-without-docker-with-tinbase-cgg</link>
      <guid>https://dev.to/chloe_smith0104/running-real-postgres-locally-without-docker-with-tinbase-cgg</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Docker Supabase on an 8 GB laptop: 12 containers, ~1.6 GB RAM idle, 45 s cold boot&lt;/li&gt;
&lt;li&gt;Tinbase: one ~58 MB binary, real Postgres 17, ~100 MB RAM idle, ~2.5 s cold boot&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;supabase-js&lt;/code&gt; works with zero code changes: swap one env var&lt;/li&gt;
&lt;li&gt;Migrate an existing project with &lt;code&gt;supabase db dump | tinbase import&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;RLS, edge functions, and realtime use the same contract as hosted Supabase&lt;/li&gt;
&lt;li&gt;Not for you if you live in the Supabase dashboard or need Storage image transforms&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Disclosure: I work on Tinbase. It's MIT-licensed and not affiliated with Supabase. It matches the supabase-js client contract, it's not a Supabase replacement.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bother
&lt;/h2&gt;

&lt;p&gt;Most of us run local Postgres via Docker. On an M1 Air with 8 GB RAM, the Docker Supabase stack eats 1.6 GB of RAM at rest and takes 45 seconds to cold boot. That's a lot of headroom to give up for a dev environment, and it's brutal in CI or on a workshop laptop.&lt;/p&gt;

&lt;p&gt;Tinbase is a single binary that runs real Postgres 17 plus Supabase's auth, realtime, and edge-function layers. Here's the whole setup, start to finish.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install
&lt;/h2&gt;

&lt;p&gt;macOS / Linux:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://tinbase.dev/install.sh | sh
tinbase &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;span class="c"&gt;# tinbase 0.9.4&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Windows: grab the &lt;code&gt;.exe&lt;/code&gt; from the releases page. Single binary either way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/tinbase-data
tinbase start &lt;span class="nt"&gt;--data-dir&lt;/span&gt; ~/tinbase-data
&lt;span class="c"&gt;# server listening on http://localhost:8080&lt;/span&gt;
&lt;span class="c"&gt;# postgres available at postgresql://postgres@localhost:5432/postgres&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Boot time: ~2.5 s cold, ~800 ms warm. &lt;code&gt;supabase start&lt;/code&gt; is ~45 s cold on the same machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify it's real Postgres
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;psql postgresql://postgres@localhost:5432/postgres &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'select version();'&lt;/span&gt;
&lt;span class="c"&gt;#              version&lt;/span&gt;
&lt;span class="c"&gt;# --------------------------------------------------&lt;/span&gt;
&lt;span class="c"&gt;#  PostgreSQL 17.1 on x86_64-apple-darwin ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not PGlite (Postgres compiled to WASM with the client-server pieces cut out). Not SQLite with a Postgres wire adapter. Actual Postgres 17.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use it with supabase-js
&lt;/h2&gt;

&lt;p&gt;Any existing Supabase project works by changing one env var:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# .env.local
SUPABASE_URL=http://localhost:8080
SUPABASE_ANON_KEY=eyJ...  # printed by `tinbase start`
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Code is unchanged:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@supabase/supabase-js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SUPABASE_URL&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SUPABASE_ANON_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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="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;projects&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tenant_id&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;a-uuid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same client, same auth flow (&lt;code&gt;supabase.auth.signInWithPassword&lt;/code&gt;), same realtime subscriptions, same RLS. For a typical Next.js + Supabase codebase the migration is one env var plus one dump/import.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrate an existing Supabase project
&lt;/h2&gt;

&lt;p&gt;From your existing Docker Supabase project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Dump schema + data&lt;/span&gt;
supabase db dump &lt;span class="nt"&gt;--file&lt;/span&gt; dump.sql &lt;span class="nt"&gt;--data-only&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt;

&lt;span class="c"&gt;# 2. Load into Tinbase&lt;/span&gt;
tinbase import &lt;span class="nt"&gt;--file&lt;/span&gt; dump.sql

&lt;span class="c"&gt;# 3. Verify&lt;/span&gt;
psql postgresql://postgres@localhost:5432/postgres &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'\dt'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you rely on Supabase-specific extensions or hooks that Tinbase doesn't support yet, &lt;code&gt;tinbase import&lt;/code&gt; prints a warning listing each one. The current compatibility matrix is in the &lt;a href="https://www.tinbase.dev/docs?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=running-real-postgres-locally-without-docker-tinbase" rel="noopener noreferrer"&gt;import docs&lt;/a&gt;, so check that before you commit an afternoon to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Postgres extensions
&lt;/h2&gt;

&lt;p&gt;Enabled by default:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Extension&lt;/th&gt;
&lt;th&gt;What it's for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pgvector&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;embeddings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pg_trgm&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;fuzzy text match&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pg_stat_statements&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;query profiling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;unaccent&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;accent-insensitive search&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pgcrypto&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gen_random_uuid()&lt;/code&gt;, &lt;code&gt;crypt()&lt;/code&gt;, &lt;code&gt;digest()&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;uuid-ossp&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;legacy uuid helpers&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Check what's loaded:&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;extname&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_extension&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;extname&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enable anything else the normal way:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;extension&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="n"&gt;postgis&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  RLS behaves identically
&lt;/h2&gt;

&lt;p&gt;Because it's real Postgres, RLS policies work exactly as they do on hosted Supabase. This is the piece I trust most:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="n"&gt;projects_tenant_scope&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;projects&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&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;tenant_id&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;profiles&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;auth.uid()&lt;/code&gt; is provided by Tinbase's auth layer and matches the Supabase contract. If your policies pass locally against Tinbase, they pass in hosted Supabase.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge functions
&lt;/h2&gt;

&lt;p&gt;Tinbase runs edge functions in an embedded V8 (not a Deno subprocess). Cold start is ~15 ms.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// functions/hello.ts&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&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;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hi&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;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="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tinbase functions serve
&lt;span class="c"&gt;# functions listening on http://localhost:8080/functions/v1&lt;/span&gt;
curl http://localhost:8080/functions/v1/hello
&lt;span class="c"&gt;# {"msg":"hi"}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same HTTP surface as Supabase Edge Functions, same client call (&lt;code&gt;supabase.functions.invoke('hello')&lt;/code&gt;).&lt;/p&gt;

&lt;h2&gt;
  
  
  Realtime subscriptions
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;supabase&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;projects&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;postgres_changes&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;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;INSERT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;public&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;projects&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;payload&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;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;new project:&lt;/span&gt;&lt;span class="dl"&gt;'&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="k"&gt;new&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="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same client, same channel API, same event payloads. The realtime server inside Tinbase is a Go implementation of the Supabase realtime spec and consumes the same logical replication stream from Postgres.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Docker Supabase is still the right call
&lt;/h2&gt;

&lt;p&gt;Being honest about the trade-offs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You use the local Supabase dashboard heavily.&lt;/strong&gt; Tinbase doesn't ship one yet. &lt;code&gt;tinbase studio&lt;/code&gt; is planned, not shipped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You depend on Supabase Storage's image transformation pipeline.&lt;/strong&gt; Tinbase storage is plain file I/O, no on-the-fly transforms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your team is fine with the Docker setup and RAM isn't a constraint.&lt;/strong&gt; Nothing to fix.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For laptop dev, workshops, CI, and demo environments, the single-binary story won on every axis that mattered to me: RAM, boot time, disk, distribution.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://tinbase.dev/install.sh | sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source is on GitHub under &lt;code&gt;tinbase-dev/tinbase&lt;/code&gt; (MIT).&lt;/p&gt;




&lt;p&gt;If you try it against a real project, drop a comment with what broke, what didn't, and what your &lt;code&gt;supabase start&lt;/code&gt; cold boot was before. The import warnings are the part I most want feedback on.&lt;/p&gt;

</description>
      <category>database</category>
      <category>opensource</category>
      <category>performance</category>
      <category>postgres</category>
    </item>
    <item>
      <title>What React Native Devs Actually Struggle With: A Year of Support Tickets</title>
      <dc:creator>Chloe</dc:creator>
      <pubDate>Fri, 25 Sep 2026 08:24:16 +0000</pubDate>
      <link>https://dev.to/chloe_smith0104/what-react-native-devs-actually-struggle-with-a-year-of-support-tickets-43c5</link>
      <guid>https://dev.to/chloe_smith0104/what-react-native-devs-actually-struggle-with-a-year-of-support-tickets-43c5</guid>
      <description>&lt;p&gt;We tagged and categorized every customer support ticket that came into Applighter for twelve months. Not tweets. Not surveys. Real developers, stuck on real code, mid-shipping a real app. The result is a very different picture of react native common problems than what you'll find in the annual community surveys — because people file tickets when they're bleeding, not when they're reflecting.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1517694712202-14dd9538aa97%3Fauto%3Dformat%26fit%3Dcrop%26w%3D1600%26q%3D80" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1517694712202-14dd9538aa97%3Fauto%3Dformat%26fit%3Dcrop%26w%3D1600%26q%3D80" alt="Developer at a laptop debugging" width="1600" height="1067"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The short version:&lt;/strong&gt; React Native developers don't mostly struggle with React Native. They struggle with the ten things around it — EAS credentials, Supabase RLS, Xcode signing, Android package name mismatches, Metro caches, environment variable leaks, App Store rejections for &lt;code&gt;NSPhotoLibraryUsageDescription&lt;/code&gt;, and voice-model latency on Android. The core framework is fine. The perimeter is a minefield.&lt;/p&gt;

&lt;h2&gt;
  
  
  Methodology (in one paragraph so you can skip it)
&lt;/h2&gt;

&lt;p&gt;We track tickets in a lightweight tag taxonomy: &lt;code&gt;platform:ios|android|web&lt;/code&gt;, &lt;code&gt;layer:build|backend|ui|auth|payment|ai&lt;/code&gt;, &lt;code&gt;severity:blocker|degraded|question&lt;/code&gt;, and &lt;code&gt;template:&amp;lt;slug&amp;gt;&lt;/code&gt;. Between the &lt;a href="https://www.applighter.com/apps?utm_source=blog&amp;amp;utm_medium=content&amp;amp;utm_campaign=what-react-native-devs-actually-struggle-with-a-year-of-support-tickets" rel="noopener noreferrer"&gt;seven starter templates&lt;/a&gt; we sell — from the &lt;a href="https://www.applighter.com/apps/weather-app?utm_source=blog&amp;amp;utm_medium=content&amp;amp;utm_campaign=what-react-native-devs-actually-struggle-with-a-year-of-support-tickets" rel="noopener noreferrer"&gt;Weather App&lt;/a&gt; to the &lt;a href="https://www.applighter.com/apps/ai-voice-notes?utm_source=blog&amp;amp;utm_medium=content&amp;amp;utm_campaign=what-react-native-devs-actually-struggle-with-a-year-of-support-tickets" rel="noopener noreferrer"&gt;AI Voice Notes template&lt;/a&gt; — that's a few thousand tickets spread across a wide slice of the ecosystem: Expo SDK 50 through 53, Supabase, NativeWind, Stripe licensing, and a rotating cast of AI providers. We threw out feature-request tickets, refund questions, and anything that turned out to be a typo. What's left is signal: the reproducible, teachable friction that developers hit when they take a working starting point and try to ship it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The ranked list: what React Native devs struggle with
&lt;/h2&gt;

&lt;p&gt;Here's the top ten, sorted by ticket volume. The percentage is share of total in-scope tickets. We'll take each one below and say what it actually is, and what the fix looks like.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rank&lt;/th&gt;
&lt;th&gt;Problem&lt;/th&gt;
&lt;th&gt;Share&lt;/th&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;EAS Build fails on first run&lt;/td&gt;
&lt;td&gt;18.4%&lt;/td&gt;
&lt;td&gt;build&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Supabase RLS returning empty arrays after auth&lt;/td&gt;
&lt;td&gt;12.1%&lt;/td&gt;
&lt;td&gt;backend&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Environment variables not reaching the native binary&lt;/td&gt;
&lt;td&gt;9.7%&lt;/td&gt;
&lt;td&gt;build&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Metro bundler cache poisoning after dependency bump&lt;/td&gt;
&lt;td&gt;7.8%&lt;/td&gt;
&lt;td&gt;build&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;iOS Info.plist permission strings missing → App Store reject&lt;/td&gt;
&lt;td&gt;6.9%&lt;/td&gt;
&lt;td&gt;ios&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Android package name conflicts on rename&lt;/td&gt;
&lt;td&gt;6.2%&lt;/td&gt;
&lt;td&gt;android&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Push notifications silently not delivering on Android&lt;/td&gt;
&lt;td&gt;5.8%&lt;/td&gt;
&lt;td&gt;native&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;AI streaming stalls / dropped tokens&lt;/td&gt;
&lt;td&gt;5.1%&lt;/td&gt;
&lt;td&gt;ai&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;Stripe webhook not firing to Supabase Edge Function&lt;/td&gt;
&lt;td&gt;4.4%&lt;/td&gt;
&lt;td&gt;payment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;Voice transcription latency on mid-range Android&lt;/td&gt;
&lt;td&gt;3.9%&lt;/td&gt;
&lt;td&gt;ai&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The rest of the tail (about a quarter of volume) is spread across dozens of one-off issues: font loading order, NativeWind class caching, Reanimated worklet crashes on old Fabric versions, and the ever-popular "why is my white screen white."&lt;/p&gt;

&lt;h2&gt;
  
  
  1. EAS Build fails on first run (18.4%)
&lt;/h2&gt;

&lt;p&gt;The #1 problem people hit isn't in React Native at all. It's the first time they try to actually build an installable binary.&lt;/p&gt;

&lt;p&gt;The ticket pattern is almost always the same: they clone the template, run &lt;code&gt;bunx expo prebuild&lt;/code&gt;, then &lt;code&gt;eas build --profile development --platform ios&lt;/code&gt; — and the build dies in the "Install pods" step with a version mismatch. Or worse, it dies in a &lt;code&gt;Fastlane&lt;/code&gt; step with a signing error, and Fastlane's error messages are famously not aimed at humans.&lt;/p&gt;

&lt;p&gt;The root causes cluster into three buckets:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Expo SDK vs. &lt;code&gt;newArchEnabled&lt;/code&gt; mismatch.&lt;/strong&gt; The dev cloned an SDK 50 template into an SDK 53 CLI without running &lt;code&gt;expo doctor&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;iOS Simulator vs. device profile confusion.&lt;/strong&gt; People pick &lt;code&gt;--platform ios&lt;/code&gt; and don't realize the default profile targets a physical device with provisioning that hasn't been set up yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing &lt;code&gt;EXPO_TOKEN&lt;/code&gt; in CI.&lt;/strong&gt; If you're building from a CI job, EAS won't authenticate.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What we did on our side: every template config now pins Expo SDK versions inside &lt;code&gt;package.json&lt;/code&gt;, plus a preflight script (&lt;code&gt;scripts/preflight.sh&lt;/code&gt;) that runs &lt;code&gt;expo doctor&lt;/code&gt; and blocks the build if anything is red. Ticket volume in this category dropped 42% quarter-over-quarter after we shipped the preflight.&lt;/p&gt;

&lt;p&gt;Actionable takeaway: before you file a Discord post, run &lt;code&gt;bunx expo doctor&lt;/code&gt; and paste the output. Nine times out of ten, it names the file that's wrong.&lt;/p&gt;

&lt;p&gt;Docs: the official Expo troubleshooting page at &lt;a href="https://docs.expo.dev/build-reference/troubleshooting/" rel="noopener noreferrer"&gt;docs.expo.dev/build-reference/troubleshooting&lt;/a&gt; covers most of the actual error strings.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Supabase RLS returns empty arrays after auth (12.1%)
&lt;/h2&gt;

&lt;p&gt;The second-most-common ticket makes people think Supabase is broken. It isn't. Their Row Level Security policy is silently doing exactly what it was designed to do: filter out every row that doesn't match &lt;code&gt;auth.uid()&lt;/code&gt;, including the ones they think should match.&lt;/p&gt;

&lt;p&gt;The ticket almost always reads: &lt;em&gt;"I'm signed in, I can see my user ID, but &lt;code&gt;select * from projects&lt;/code&gt; returns &lt;code&gt;[]&lt;/code&gt;."&lt;/em&gt; And then a screenshot of the Supabase table, full of rows.&lt;/p&gt;

&lt;p&gt;The fix falls into four checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is your &lt;code&gt;Authorization: Bearer &amp;lt;access_token&amp;gt;&lt;/code&gt; actually being sent? Log the raw fetch headers, not the client abstraction. Half the time the token is &lt;code&gt;undefined&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Does your RLS policy use &lt;code&gt;auth.uid()&lt;/code&gt; or &lt;code&gt;auth.jwt() -&amp;gt;&amp;gt; 'sub'&lt;/code&gt;? The two behave differently when you have anon-key requests mixed in.&lt;/li&gt;
&lt;li&gt;Are you querying with the &lt;code&gt;service_role&lt;/code&gt; key in a client bundle? (You should not be. But we do see it.)&lt;/li&gt;
&lt;li&gt;Is your session refreshing? Sessions expire in an hour by default. If the client isn't refreshing, subsequent requests silently fall to &lt;code&gt;anon&lt;/code&gt; role.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For everyone shipping a Supabase-backed app on iOS or Android, we wrote up the full pattern in &lt;a href="https://www.applighter.com/blog/the-complete-guide-to-supabase-auth-in-react-native-email-o-auth-apple-sign?utm_source=blog&amp;amp;utm_medium=content&amp;amp;utm_campaign=what-react-native-devs-actually-struggle-with-a-year-of-support-tickets" rel="noopener noreferrer"&gt;our guide to Supabase auth in React Native&lt;/a&gt;. Bookmark that if you're touching auth.&lt;/p&gt;

&lt;p&gt;The Supabase docs on RLS at &lt;a href="https://supabase.com/docs/guides/database/postgres/row-level-security" rel="noopener noreferrer"&gt;supabase.com/docs/guides/database/postgres/row-level-security&lt;/a&gt; are the canonical reference — but the mental model shift ("policies are &lt;code&gt;WHERE&lt;/code&gt; clauses that always run") is the hard part.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Environment variables don't reach the native binary (9.7%)
&lt;/h2&gt;

&lt;p&gt;This one is sneaky. Everything works in Expo Go. Everything works in the simulator. Then the developer runs &lt;code&gt;eas build&lt;/code&gt;, installs the standalone &lt;code&gt;.ipa&lt;/code&gt;, launches — and the API base URL is &lt;code&gt;undefined&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1629654297299-c8506221ca97%3Fauto%3Dformat%26fit%3Dcrop%26w%3D1600%26q%3D80" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1629654297299-c8506221ca97%3Fauto%3Dformat%26fit%3Dcrop%26w%3D1600%26q%3D80" alt="Terminal showing environment variable output" width="1600" height="1200"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Root cause: Expo does not automatically pull &lt;code&gt;.env&lt;/code&gt; into a production build. You have to declare secrets in &lt;a href="https://docs.expo.dev/eas/environment-variables/" rel="noopener noreferrer"&gt;EAS Secrets&lt;/a&gt; or push them via &lt;code&gt;eas.json&lt;/code&gt; &lt;code&gt;env&lt;/code&gt; blocks, and you must prefix runtime-safe variables with &lt;code&gt;EXPO_PUBLIC_&lt;/code&gt;. Anything without that prefix is dropped from the client bundle at build time.&lt;/p&gt;

&lt;p&gt;The tell: &lt;code&gt;console.log(process.env.API_URL)&lt;/code&gt; prints in Metro but is &lt;code&gt;undefined&lt;/code&gt; in the standalone app. The pattern almost always resolves by renaming &lt;code&gt;API_URL&lt;/code&gt; → &lt;code&gt;EXPO_PUBLIC_API_URL&lt;/code&gt; and re-building.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Metro cache poisoning after dependency bump (7.8%)
&lt;/h2&gt;

&lt;p&gt;You updated a package. The app crashes with an inscrutable error. Nothing you do fixes it. Then a friend on Discord says "try &lt;code&gt;--reset-cache&lt;/code&gt;" and it works.&lt;/p&gt;

&lt;p&gt;We see this so often that our templates now ship with a &lt;code&gt;bun reset&lt;/code&gt; script that runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;watchman watch-del-all
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; node_modules ios/Pods .expo
bun &lt;span class="nb"&gt;install
&lt;/span&gt;bunx expo prebuild &lt;span class="nt"&gt;--clean&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you don't have this scripted, you'll type it wrong at 2am and blame yourself. Don't. Metro's cache invalidation is genuinely aggressive and genuinely wrong sometimes. React Native itself is famously an "if in doubt, wipe caches" ecosystem — &lt;a href="https://facebook.github.io/metro/docs/troubleshooting" rel="noopener noreferrer"&gt;Metro's own docs&lt;/a&gt; admit as much.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. iOS Info.plist permission strings (6.9%)
&lt;/h2&gt;

&lt;p&gt;The App Store review rejection nobody sees coming. Apple's automated review will reject builds that use the camera, microphone, photo library, or location and don't have a corresponding &lt;code&gt;NSXxxUsageDescription&lt;/code&gt; string in &lt;code&gt;Info.plist&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The ticket flow: dev submits build to TestFlight, it uploads fine, they get an email saying "your app has been rejected," and the reason is something like "your app uses the camera but doesn't say why."&lt;/p&gt;

&lt;p&gt;The fix is easy. Add the strings to your &lt;code&gt;app.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"expo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ios"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"infoPlist"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"NSCameraUsageDescription"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"This app needs camera access to scan documents."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"NSMicrophoneUsageDescription"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"This app records voice notes."&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mistake we most often see is developers writing generic strings ("This app uses the camera"). Apple has started rejecting those too. Say &lt;em&gt;why&lt;/em&gt;, in a sentence a regulator could quote back.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Android package name mismatch on rename (6.2%)
&lt;/h2&gt;

&lt;p&gt;The template ships as &lt;code&gt;com.applighter.starter&lt;/code&gt;. The developer wants &lt;code&gt;com.acmecorp.myapp&lt;/code&gt;. They change it in &lt;code&gt;app.json&lt;/code&gt; and half the Android build system stops working.&lt;/p&gt;

&lt;p&gt;The problem: on Android, the package name lives in about six places — &lt;code&gt;app.json&lt;/code&gt;, &lt;code&gt;android/app/build.gradle&lt;/code&gt;, &lt;code&gt;AndroidManifest.xml&lt;/code&gt;, the directory structure under &lt;code&gt;android/app/src/main/java/&lt;/code&gt;, &lt;code&gt;google-services.json&lt;/code&gt; (if you use Firebase), and the OAuth redirect URI. Change it in one, forget the others, and the build succeeds but the app crashes on launch with a &lt;code&gt;ClassNotFoundException&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Our &lt;a href="https://www.applighter.com/blog/how-to-change-app-name?utm_source=blog&amp;amp;utm_medium=content&amp;amp;utm_campaign=what-react-native-devs-actually-struggle-with-a-year-of-support-tickets" rel="noopener noreferrer"&gt;renaming guide&lt;/a&gt; covers every location, but honestly the safest move if you're on managed Expo is: delete the &lt;code&gt;android/&lt;/code&gt; folder, change the config, run &lt;code&gt;expo prebuild --clean&lt;/code&gt;. Let the tooling regenerate it.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Android push notifications silently not delivering (5.8%)
&lt;/h2&gt;

&lt;p&gt;The insidious one. Everything looks like it's working: &lt;code&gt;expo-notifications&lt;/code&gt; returns a valid Expo push token, you send a POST to the Expo push API, you get back &lt;code&gt;"status": "ok"&lt;/code&gt; — and the notification never appears on the device.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1512428559087-560fa5ceab42%3Fauto%3Dformat%26fit%3Dcrop%26w%3D1600%26q%3D80" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1512428559087-560fa5ceab42%3Fauto%3Dformat%26fit%3Dcrop%26w%3D1600%26q%3D80" alt="Phone showing a notification" width="1600" height="1067"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Android-specific tickets almost always trace to one of these:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Missing FCM setup. Expo's push service is a proxy; on Android it still needs your FCM credentials configured in the Expo dashboard.&lt;/li&gt;
&lt;li&gt;Battery optimization killing the process. Xiaomi and Samsung devices in particular have aggressive background restrictions that suppress notifications from apps the user hasn't opened in 24 hours.&lt;/li&gt;
&lt;li&gt;Notification channel not created. Android 8+ requires you to create a channel before you can post to it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We covered the full stack in &lt;a href="https://www.applighter.com/blog/the-definitive-guide-to-push-notifications-in-expo-i-os-android-in-production?utm_source=blog&amp;amp;utm_medium=content&amp;amp;utm_campaign=what-react-native-devs-actually-struggle-with-a-year-of-support-tickets" rel="noopener noreferrer"&gt;The Definitive Guide to Push Notifications in Expo&lt;/a&gt;. If you're shipping push, read that end-to-end before you spend a week debugging.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. AI streaming stalls or drops tokens (5.1%)
&lt;/h2&gt;

&lt;p&gt;Specific to the AI templates — the &lt;a href="https://www.applighter.com/apps/chat-with-pdf?utm_source=blog&amp;amp;utm_medium=content&amp;amp;utm_campaign=what-react-native-devs-actually-struggle-with-a-year-of-support-tickets" rel="noopener noreferrer"&gt;Chat with PDF template&lt;/a&gt;, the &lt;a href="https://www.applighter.com/apps/ai-voice-notes?utm_source=blog&amp;amp;utm_medium=content&amp;amp;utm_campaign=what-react-native-devs-actually-struggle-with-a-year-of-support-tickets" rel="noopener noreferrer"&gt;AI Voice Notes template&lt;/a&gt;, and the AI Calorie Tracker.&lt;/p&gt;

&lt;p&gt;The pattern: streaming works locally with &lt;code&gt;fetch&lt;/code&gt; + &lt;code&gt;getReader()&lt;/code&gt;, but on the device it stalls after a few hundred tokens or drops the last chunk. Almost always the culprit is React Native's &lt;code&gt;fetch&lt;/code&gt; polyfill: it doesn't support &lt;code&gt;ReadableStream&lt;/code&gt; the way the browser does. You need to use &lt;code&gt;expo-fetch&lt;/code&gt; or the underlying &lt;code&gt;XMLHttpRequest&lt;/code&gt; progress events, and buffer manually.&lt;/p&gt;

&lt;p&gt;We walked through the full pattern in &lt;a href="https://www.applighter.com/blog/streaming-ai-responses-to-react-native-the-complete-guide?utm_source=blog&amp;amp;utm_medium=content&amp;amp;utm_campaign=what-react-native-devs-actually-struggle-with-a-year-of-support-tickets" rel="noopener noreferrer"&gt;Streaming AI Responses to React Native&lt;/a&gt;. The short answer: &lt;code&gt;expo/fetch&lt;/code&gt; or a Server-Sent Events shim. Don't fight the platform fetch.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Stripe webhook doesn't fire to Supabase (4.4%)
&lt;/h2&gt;

&lt;p&gt;Stripe → Supabase Edge Function webhooks failing is almost always one of three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Signing secret from Stripe dashboard doesn't match the one in the Edge Function env.&lt;/li&gt;
&lt;li&gt;Edge Function is behind Supabase's default auth. You need to explicitly mark it public in &lt;code&gt;supabase/config.toml&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The Stripe webhook endpoint is pointing at the deploy branch preview URL, not production.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These are boring, but they're a nontrivial share of our support load — enough that we now include a &lt;code&gt;test-webhook.sh&lt;/code&gt; script that fires a dummy &lt;code&gt;checkout.session.completed&lt;/code&gt; event so you can verify end-to-end before you go live.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Voice transcription latency on mid-range Android (3.9%)
&lt;/h2&gt;

&lt;p&gt;The last one on the list is a hardware reality more than a bug. Real-time voice transcription (Whisper, Deepgram, whatever) on a $300 Android device is slower than on an iPhone Pro. Users assume the app is broken. It's not — it's just that the STT model is either running on a slower CPU or over a slower network connection to a cloud endpoint.&lt;/p&gt;

&lt;p&gt;The fixes we recommend:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Chunk audio at 4-second intervals instead of 10 (feels more responsive even if wall-clock latency is the same).&lt;/li&gt;
&lt;li&gt;Show a token-level typing indicator so the user knows the app is alive.&lt;/li&gt;
&lt;li&gt;Fall back to a smaller model tier for older devices.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We build this fallback into the AI Voice Notes template by default: &lt;code&gt;deepgram-nova-2&lt;/code&gt; for iOS ≥ iPhone 12 and modern flagship Android, &lt;code&gt;whisper-small&lt;/code&gt; for everything else, config-driven.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this tells us about the ecosystem
&lt;/h2&gt;

&lt;p&gt;The pattern is clear: React Native itself, the framework, is not what most developers struggle with. The framework has stabilized. What developers hit is the seam between the framework and everything around it — build tooling, native permissions, backends, push infrastructure, AI providers, and platform-specific device quirks.&lt;/p&gt;

&lt;p&gt;That's why we started Applighter in the first place. A UI template that gives you screens doesn't solve any of these ten problems. A full-stack template that ships with a working Supabase backend, a working push notification setup, a working Stripe webhook, and a working streaming AI pipeline solves eight of them out of the box.&lt;/p&gt;

&lt;p&gt;Here's how the two approaches compare in terms of what tickets you'd actually file:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Problem&lt;/th&gt;
&lt;th&gt;Blank Expo template&lt;/th&gt;
&lt;th&gt;UI-only kit&lt;/th&gt;
&lt;th&gt;Applighter template&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;EAS build config&lt;/td&gt;
&lt;td&gt;You write it&lt;/td&gt;
&lt;td&gt;You write it&lt;/td&gt;
&lt;td&gt;Preflight-scripted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Supabase RLS policies&lt;/td&gt;
&lt;td&gt;You write them&lt;/td&gt;
&lt;td&gt;Not included&lt;/td&gt;
&lt;td&gt;Included + tested&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Push notifications&lt;/td&gt;
&lt;td&gt;You wire it&lt;/td&gt;
&lt;td&gt;Screens only&lt;/td&gt;
&lt;td&gt;Full FCM + APNs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stripe → Supabase webhook&lt;/td&gt;
&lt;td&gt;You build it&lt;/td&gt;
&lt;td&gt;Not included&lt;/td&gt;
&lt;td&gt;Ships with test script&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iOS Info.plist strings&lt;/td&gt;
&lt;td&gt;You add them&lt;/td&gt;
&lt;td&gt;Missing&lt;/td&gt;
&lt;td&gt;Pre-declared&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Streaming AI&lt;/td&gt;
&lt;td&gt;You debug fetch&lt;/td&gt;
&lt;td&gt;Not applicable&lt;/td&gt;
&lt;td&gt;expo/fetch by default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Env variable propagation&lt;/td&gt;
&lt;td&gt;You learn the hard way&lt;/td&gt;
&lt;td&gt;Same&lt;/td&gt;
&lt;td&gt;Documented in README&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Some folks would rather build these seven layers themselves and learn everything on the way up. That's a completely reasonable choice. Others would rather buy the layers and spend their time on their actual product idea. That's also reasonable. What isn't reasonable is buying a "template" that gives you nothing but screens and thinking you've bought a foundation. (For an honest look at that market, see &lt;a href="https://reactnativetemplates.com/" rel="nofollow noopener noreferrer"&gt;reactnativetemplates.com&lt;/a&gt; and similar aggregators — most of what's listed is a UI kit calling itself a template.)&lt;/p&gt;

&lt;h2&gt;
  
  
  What we changed based on the data
&lt;/h2&gt;

&lt;p&gt;Because we get to see this data and most template vendors don't, we made concrete changes over the year:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Added &lt;code&gt;bunx expo doctor&lt;/code&gt; and a preflight script to every template's &lt;code&gt;postinstall&lt;/code&gt;, killing 42% of build-related tickets.&lt;/li&gt;
&lt;li&gt;Shipped a &lt;code&gt;docs/troubleshooting.md&lt;/code&gt; in every template repo with the exact ten problems above, pre-answered.&lt;/li&gt;
&lt;li&gt;Added a &lt;code&gt;bun reset&lt;/code&gt; script so the cache-poisoning ticket has a one-command fix.&lt;/li&gt;
&lt;li&gt;Pinned Expo SDK versions and included an upgrade path doc for the next minor.&lt;/li&gt;
&lt;li&gt;Baked FCM and APNs setup into every template's setup guide with real screenshots, not "consult the Expo docs."&lt;/li&gt;
&lt;li&gt;Ship RLS policies as part of the initial migration — no dev should have to invent them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is glamorous work. It's the exact same set of concerns &lt;a href="https://www.applighter.com/blog/the-react-native-template-industry-is-mostly-broken-heres-how-to-fix-it?utm_source=blog&amp;amp;utm_medium=content&amp;amp;utm_campaign=what-react-native-devs-actually-struggle-with-a-year-of-support-tickets" rel="noopener noreferrer"&gt;a real production engineering team would prioritize&lt;/a&gt; — because "hard to ship" is a bug, not a marketing feature.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1552664730-d307ca884978%3Fauto%3Dformat%26fit%3Dcrop%26w%3D1600%26q%3D80" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fimages.unsplash.com%2Fphoto-1552664730-d307ca884978%3Fauto%3Dformat%26fit%3Dcrop%26w%3D1600%26q%3D80" alt="Product team reviewing metrics" width="1600" height="1067"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways for developers
&lt;/h2&gt;

&lt;p&gt;If you're building a React Native app right now, the empirical priority order for what you should invest a day learning:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;EAS Build config&lt;/strong&gt; — this is where you'll die first. Learn &lt;code&gt;eas.json&lt;/code&gt; and profiles before you write your second screen.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supabase RLS&lt;/strong&gt; — or whatever backend auth model you're using. Get the mental model right on day one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The env variable propagation model&lt;/strong&gt; — because "works in dev, undefined in prod" is a 3am problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Metro cache reset command&lt;/strong&gt; — put it in a script.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;App Store rejection rules&lt;/strong&gt; — read Apple's list of common rejections before submission.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you're building on top of a starter, pick one whose vendor tracks this data and iterates on it. If your template hasn't had a commit in 4 months, that's not "stable" — that's "abandoned." See our &lt;a href="https://www.applighter.com/blog/best-react-native-boilerplates-in-2026-honest-comparison?utm_source=blog&amp;amp;utm_medium=content&amp;amp;utm_campaign=what-react-native-devs-actually-struggle-with-a-year-of-support-tickets" rel="noopener noreferrer"&gt;honest comparison of the market&lt;/a&gt; for how to evaluate.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;What is the most common problem React Native developers face in 2026?&lt;/strong&gt;&lt;br&gt;
Based on our year of support ticket data, EAS Build failures on first run are the single most common category (18.4% of tickets). The core framework issues are far less common than the tooling around it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Are Expo templates production ready?&lt;/strong&gt;&lt;br&gt;
Expo's own official templates are production ready as starting points but are intentionally minimal. Third-party templates vary enormously — some ship full backends, tests, and push notification setup; others ship only screens and call themselves templates. Read the source before you buy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why do Supabase queries return empty arrays even when the table has rows?&lt;/strong&gt;&lt;br&gt;
Almost always because Row Level Security policies are filtering the rows out. Confirm the &lt;code&gt;Authorization&lt;/code&gt; header is being sent, verify &lt;code&gt;auth.uid()&lt;/code&gt; matches the row's owner column, and log the raw request to check the session hasn't expired.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I stop my Expo app from breaking after upgrading dependencies?&lt;/strong&gt;&lt;br&gt;
Run &lt;code&gt;bunx expo doctor&lt;/code&gt; after every upgrade. Pin your Expo SDK version explicitly in &lt;code&gt;package.json&lt;/code&gt;. Clear Metro cache with a scripted reset command rather than typing it by hand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is React Native still worth using in 2026?&lt;/strong&gt;&lt;br&gt;
Yes — the framework is more stable than ever and the ecosystem has matured on debugging, performance, and toolchain. The remaining friction is in the seam between the framework and native platform/backend infrastructure. That's solvable with better templates and better tooling — which is exactly what we spend our year building.&lt;/p&gt;

&lt;p&gt;Ready to skip the ticket queue and start with a template that already handles the top ten? &lt;a href="https://www.applighter.com/apps?utm_source=blog&amp;amp;utm_medium=content&amp;amp;utm_campaign=what-react-native-devs-actually-struggle-with-a-year-of-support-tickets" rel="noopener noreferrer"&gt;Browse the Applighter catalog&lt;/a&gt; or read our &lt;a href="https://www.applighter.com/legal/refund-policy" rel="noopener noreferrer"&gt;refund policy&lt;/a&gt; if you want to try one risk-free.&lt;/p&gt;

</description>
      <category>debugging</category>
      <category>mobile</category>
      <category>programming</category>
      <category>reactnative</category>
    </item>
    <item>
      <title>OTA Updates That Actually Save You: A 2026 Playbook for Shipping JS Fixes Without the App Store</title>
      <dc:creator>Chloe</dc:creator>
      <pubDate>Wed, 23 Sep 2026 05:55:53 +0000</pubDate>
      <link>https://dev.to/chloe_smith0104/ota-updates-that-actually-save-you-a-2026-playbook-for-shipping-js-fixes-without-the-app-store-4j5o</link>
      <guid>https://dev.to/chloe_smith0104/ota-updates-that-actually-save-you-a-2026-playbook-for-shipping-js-fixes-without-the-app-store-4j5o</guid>
      <description>&lt;h2&gt;
  
  
  Why most OTA setups fail the first real emergency
&lt;/h2&gt;

&lt;p&gt;Teams add expo-updates in week one, publish a couple of bundles, and call it done. Then a checkout bug hits production on a Friday night, and the "fix it in five minutes" promise turns into two hours of wondering which build is listening to which channel, whether the fix is compatible with the installed binary, and why half the users still have the old code. The goal of OTA isn't "can push JS". It's time-to-fixed-user. Everything below optimises for that number.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Separate channels per environment.&lt;/strong&gt; At minimum &lt;code&gt;preview&lt;/code&gt; and &lt;code&gt;production&lt;/code&gt;, mapped in eas.json build profiles. Every binary should know exactly which channel it listens to, and nobody should be able to reach production users by accident from a laptop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A runtime version policy you can trust.&lt;/strong&gt; Use &lt;code&gt;runtimeVersion: { policy: "fingerprint" }&lt;/code&gt; so any native change (a new module, a config plugin, a permission) automatically produces a new runtime version. An OTA bundle then only reaches binaries that can actually run it. Hand-bumped version strings are how you ship JS that calls a native module that isn't there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Staged rollouts.&lt;/strong&gt; Push to 10% of production, watch crash rates and key events for an hour, then widen. A bad bundle to 10% of users is an incident. A bad bundle to 100% is a postmortem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A one-command rollback.&lt;/strong&gt; Know before you need it: republish the previous good update, or roll back to the bundle embedded in the binary. Write the command in your README. At 11 PM nobody wants to read CLI docs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error recovery plus crash reporting tied to the update ID.&lt;/strong&gt; expo-updates can fall back when a new bundle crashes on launch, but you still need Sentry (or similar) tagging every event with the update ID and channel. Otherwise "crash rate went up" can't be traced to "update X caused it."&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The five mistakes to avoid
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One channel for everything.&lt;/strong&gt; If staging and production binaries share a channel, your QA build is your production build. Split them on day one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OTA-ing native changes.&lt;/strong&gt; Upgraded a library with native code? That needs a store build. The fingerprint policy guards this, but only if you don't override it "just this once."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forcing reloads mid-session.&lt;/strong&gt; Calling &lt;code&gt;reloadAsync()&lt;/code&gt; the moment a download finishes wipes forms, carts and scroll position. Apply on next cold start by default, and reserve immediate reloads for critical fixes behind a friendly prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skipping code signing.&lt;/strong&gt; Signed updates mean the app rejects any bundle your key didn't sign. It takes a certificate and two config lines. Do it before you have users, not after an incident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treating OTA as your release strategy.&lt;/strong&gt; OTA is for fixes and small iterations. Shipping major features over the air bypasses review in ways store guidelines frown on, and it trains the team to skip proper releases. Ship features in binaries; use OTA for fixes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The bad-bundle test
&lt;/h2&gt;

&lt;p&gt;Publish an update to your preview channel that throws on launch. Then check four things: the app recovers to the last working bundle, your crash tool shows the error tagged with the update ID, you can roll back with one command, and the next good update reaches the device. If any step needs Slack archaeology or a store resubmission, your OTA setup is a demo, not a safety net.&lt;/p&gt;

&lt;h2&gt;
  
  
  A pragmatic 2026 OTA config
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Updates:&lt;/strong&gt; expo-updates with EAS Update, Expo SDK 53+.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runtime version:&lt;/strong&gt; fingerprint policy, no manual overrides.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Channels:&lt;/strong&gt; &lt;code&gt;preview&lt;/code&gt; and &lt;code&gt;production&lt;/code&gt;, bound per build profile in eas.json.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check strategy:&lt;/strong&gt; check on launch, apply on next cold start; &lt;code&gt;useUpdates()&lt;/code&gt; for an optional "Update ready, restart?" banner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safety:&lt;/strong&gt; code signing enabled, staged rollouts on production, rollback command in the README.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability:&lt;/strong&gt; Sentry with the update ID and channel as tags on every event.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI:&lt;/strong&gt; GitHub Actions publishes to &lt;code&gt;preview&lt;/code&gt; on merge to main; promotion to &lt;code&gt;production&lt;/code&gt; is a manual, reviewed step.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The best OTA setup is the one you've already rehearsed. Every team eventually ships a broken bundle; the ones that recover in minutes are the ones who tested rollback on a quiet Tuesday. &lt;a href="https://rapidnative.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=ota-updates-playbook" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; ships with these OTA defaults wired in: channels, fingerprint runtime versions, and rollback scripts. Grab it or wire your own. The rehearsal matters more than the repo.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>mobiledev</category>
      <category>devops</category>
    </item>
    <item>
      <title>Postgres for local dev: one container, one Makefile</title>
      <dc:creator>Chloe</dc:creator>
      <pubDate>Mon, 21 Sep 2026 05:45:00 +0000</pubDate>
      <link>https://dev.to/chloe_smith0104/postgres-for-local-dev-one-container-one-makefile-58l4</link>
      <guid>https://dev.to/chloe_smith0104/postgres-for-local-dev-one-container-one-makefile-58l4</guid>
      <description>&lt;p&gt;Most local database setups fail in one of two ways. Either everyone shares a staging database and steps on each other's data, or the repo ships a 200-line compose file with six services that nobody fully understands. You need neither. One Postgres container, plain SQL migrations, and a reset command that runs in seconds will carry you a long way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The container
&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;# docker-compose.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;db&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;postgres:17&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5432:5432"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_USER&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_PASSWORD&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
      &lt;span class="na"&gt;POSTGRES_DB&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app_dev&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pgdata:/var/lib/postgresql/data&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="s"&gt;postgres&lt;/span&gt;
      &lt;span class="s"&gt;-c fsync=off&lt;/span&gt;
      &lt;span class="s"&gt;-c synchronous_commit=off&lt;/span&gt;
      &lt;span class="s"&gt;-c full_page_writes=off&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CMD-SHELL"&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pg_isready&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-U&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;-d&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app_dev"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;15&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pgdata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pin the major version to whatever you run in production. &lt;code&gt;postgres:latest&lt;/code&gt; is how you discover a version mismatch at deploy time. (If you pin 18 or later, the image expects the volume mounted at &lt;code&gt;/var/lib/postgresql&lt;/code&gt; instead.)&lt;/p&gt;

&lt;p&gt;The three &lt;code&gt;-c&lt;/code&gt; flags trade durability for speed. Your dev data is disposable, so take the trade, and never copy those lines into a production config.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# .env&lt;/span&gt;
&lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;postgres://app:app@localhost:5432/app_dev
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one variable is the only thing your app knows about the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrations are SQL files
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;migrations/
  001_users.sql
  002_sessions.sql
  003_add_user_timezone.sql
seed.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;They're numbered, append-only, and written in plain SQL. No ORM-generated diffs, no DSL to learn. When you need to know what the schema looks like, you read the files.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reset command
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight make"&gt;&lt;code&gt;&lt;span class="k"&gt;include&lt;/span&gt;&lt;span class="sx"&gt; .env&lt;/span&gt;
&lt;span class="err"&gt;export&lt;/span&gt;

&lt;span class="nv"&gt;ADMIN_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; postgres://app:app@localhost:5432/postgres

&lt;span class="nl"&gt;db-up&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;--wait&lt;/span&gt; db

&lt;span class="nl"&gt;db-reset&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;db-up&lt;/span&gt;
    psql &lt;span class="p"&gt;$(&lt;/span&gt;ADMIN_URL&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"DROP DATABASE IF EXISTS app_dev WITH (FORCE)"&lt;/span&gt;
    psql &lt;span class="p"&gt;$(&lt;/span&gt;ADMIN_URL&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"CREATE DATABASE app_dev"&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;f &lt;span class="k"&gt;in &lt;/span&gt;migrations/&lt;span class="k"&gt;*&lt;/span&gt;.sql&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
      psql &lt;span class="p"&gt;$(&lt;/span&gt;DATABASE_URL&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;ON_ERROR_STOP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;$$&lt;/span&gt;f &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="k"&gt;done&lt;/span&gt;
    psql &lt;span class="p"&gt;$(&lt;/span&gt;DATABASE_URL&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nv"&gt;ON_ERROR_STOP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; seed.sql

&lt;span class="nl"&gt;db-shell&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    psql &lt;span class="p"&gt;$(&lt;/span&gt;DATABASE_URL&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;make db-reset&lt;/code&gt; is the primitive everything else rests on. If it's fast, nobody hoards a precious local database they're afraid to touch. After a bad migration, a branch switch, or a weird state, you reset and move on.&lt;/p&gt;

&lt;p&gt;It also means every migration gets replayed from zero on every reset, so your migration history is continuously tested instead of only tested in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tests: template databases
&lt;/h2&gt;

&lt;p&gt;Don't run tests against &lt;code&gt;app_dev&lt;/code&gt;, and don't truncate tables between tests. Postgres can clone a database at the file level:&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;-- once per test run, after migrating app_template&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;test_a1b2c3&lt;/span&gt; &lt;span class="k"&gt;TEMPLATE&lt;/span&gt; &lt;span class="n"&gt;app_template&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- when the test finishes&lt;/span&gt;
&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="k"&gt;DATABASE&lt;/span&gt; &lt;span class="n"&gt;test_a1b2c3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cloning a small schema takes tens of milliseconds. Each test, or each test worker, gets a real, isolated, fully migrated database, which means no mocks and no shared state. It also removes "passes alone, fails in the suite." The one catch is that nothing can be connected to the template while it's being cloned, so migrate it and then leave it alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this breaks
&lt;/h2&gt;

&lt;p&gt;This setup is right for projects that:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use stock Postgres or extensions with a published image&lt;/li&gt;
&lt;li&gt;Have a seed dataset small enough to rebuild in seconds&lt;/li&gt;
&lt;li&gt;Run on a single database&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It gets less comfortable when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need extensions. Swap the image (&lt;code&gt;pgvector/pgvector&lt;/code&gt;, &lt;code&gt;postgis/postgis&lt;/code&gt;) instead of building your own until you truly have to.&lt;/li&gt;
&lt;li&gt;Performance bugs hide behind tiny data. A query that's instant on 50 seed rows can be a sequential scan on 5 million. Keep a script that generates volume with &lt;code&gt;generate_series&lt;/code&gt;, and check &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; against it before shipping anything query-heavy.&lt;/li&gt;
&lt;li&gt;Your production is managed Postgres. Hosted providers restrict superuser and some extensions. If your migrations assume superuser locally, they'll fail on deploy, so run them as a non-superuser role in dev too.&lt;/li&gt;
&lt;li&gt;You run several projects. They'll all want port 5432, so give each project its own host port in compose and its own &lt;code&gt;DATABASE_URL&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each of these changes a line or two, and the shape of the setup stays the same.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;Local database setups fail when they're slow or fragile, because people stop resetting them, and then every machine drifts into its own private schema. A fast reset and a versioned container prevent that, and template databases keep tests isolated.&lt;/p&gt;

&lt;p&gt;For a batteries-included setup with Postgres, auth, and realtime prebundled, tinbase.dev is worth a look. And if the thing you're building on top of that database is a mobile app, &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=postgres-local-dev&amp;amp;utm_content=wrapup-cta" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; generates a full-stack React Native app from a prompt. Otherwise, the compose file and Makefile above will have you running in ten minutes.&lt;/p&gt;

&lt;p&gt;Make the reset cheap. Everything else follows.&lt;/p&gt;

</description>
      <category>database</category>
      <category>devops</category>
      <category>docker</category>
      <category>postgres</category>
    </item>
  </channel>
</rss>
