<?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: Russel Dsouza</title>
    <description>The latest articles on DEV Community by Russel Dsouza (@russel_dsouza_bd584a3cb2a).</description>
    <link>https://dev.to/russel_dsouza_bd584a3cb2a</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%2F3939420%2F1d36f555-5b2b-48e8-a97c-2acbb7603dbd.png</url>
      <title>DEV Community: Russel Dsouza</title>
      <link>https://dev.to/russel_dsouza_bd584a3cb2a</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/russel_dsouza_bd584a3cb2a"/>
    <language>en</language>
    <item>
      <title>Expo Router File-Based Routing: The Patterns That Matter in a Real App</title>
      <dc:creator>Russel Dsouza</dc:creator>
      <pubDate>Thu, 12 Nov 2026 12:11:20 +0000</pubDate>
      <link>https://dev.to/russel_dsouza_bd584a3cb2a/expo-router-file-based-routing-the-patterns-that-matter-in-a-real-app-565d</link>
      <guid>https://dev.to/russel_dsouza_bd584a3cb2a/expo-router-file-based-routing-the-patterns-that-matter-in-a-real-app-565d</guid>
      <description>&lt;p&gt;in Expo Router, your folder structure is your navigation. &lt;code&gt;_layout.tsx&lt;/code&gt; files decide &lt;em&gt;how&lt;/em&gt; screens are shown (stack, tabs, modal), route groups like &lt;code&gt;(tabs)&lt;/code&gt; organize screens without changing URLs, &lt;code&gt;[id].tsx&lt;/code&gt; handles dynamic routes, and every screen is deep-linkable automatically. The basics take ten minutes to learn. This guide covers the patterns you actually need once the app has auth, tabs, modals and links coming in from outside.&lt;/p&gt;

&lt;p&gt;Most file-based routing tutorials stop at "create &lt;code&gt;app/index.tsx&lt;/code&gt; and &lt;code&gt;app/about.tsx&lt;/code&gt;". Real apps need more: a tab bar that disappears on detail screens, a login flow nobody can skip, modals that work with the back gesture and push notifications that open the right screen. All of that is solvable with folder structure, once you know the patterns.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model: files are routes, layouts are navigators
&lt;/h2&gt;

&lt;p&gt;Two rules explain almost everything:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Every file in &lt;code&gt;app/&lt;/code&gt; is a route.&lt;/strong&gt; &lt;code&gt;app/settings.tsx&lt;/code&gt; is &lt;code&gt;/settings&lt;/code&gt;. &lt;code&gt;app/projects/[id].tsx&lt;/code&gt; is &lt;code&gt;/projects/42&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every &lt;code&gt;_layout.tsx&lt;/code&gt; wraps the routes next to it&lt;/strong&gt; and decides how they're presented: as a stack, as tabs, or as anything custom.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A few special names complete the picture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;index.tsx&lt;/code&gt;: the default route for its folder (&lt;code&gt;/&lt;/code&gt; or &lt;code&gt;/projects&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;[param].tsx&lt;/code&gt;: a dynamic segment, read with &lt;code&gt;useLocalSearchParams()&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;[...rest].tsx&lt;/code&gt;: a catch-all for any depth.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;(group)/&lt;/code&gt;: a route group. It organizes files and can have its own layout, but &lt;strong&gt;doesn't appear in the URL&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;+not-found.tsx&lt;/code&gt;: what to show for unmatched routes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Pattern 1: Tabs inside a stack (the structure most apps need)
&lt;/h2&gt;

&lt;p&gt;The classic mobile layout: a bottom tab bar on the main screens, and detail screens that slide in &lt;strong&gt;over&lt;/strong&gt; the tabs, hiding the tab bar. The trick is that the tabs live inside a stack, not the other way round.&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        # Tabs navigator
│   ├── index.tsx          # /          (Home tab)
│   ├── search.tsx         # /search    (Search tab)
│   └── settings.tsx       # /settings  (Settings tab)
├── projects/
│   └── [id].tsx           # /projects/42  (pushed over the tabs)
└── +not-found.tsx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/_layout.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&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="s1"&gt;'expo-router'&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="nc"&gt;RootLayout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Stack&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="o"&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="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"(tabs)"&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tabsOptions&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="o"&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="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"projects/[id]"&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;detailOptions&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Stack&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="n"&gt;tabsOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&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="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;detailOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Project'&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 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="nc"&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="s1"&gt;'expo-router'&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="nc"&gt;TabsLayout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Tabs&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="o"&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="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"index"&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;homeOptions&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="o"&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="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"search"&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;searchOptions&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="o"&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="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"settings"&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;settingsOptions&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Tabs&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="n"&gt;homeOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Home'&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="n"&gt;searchOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Search'&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="n"&gt;settingsOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'Settings'&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;Navigating to &lt;code&gt;/projects/42&lt;/code&gt; from any tab pushes the detail screen onto the root stack, so it covers the tab bar and gets a native back gesture. If you put &lt;code&gt;projects/[id].tsx&lt;/code&gt; &lt;em&gt;inside&lt;/em&gt; &lt;code&gt;(tabs)&lt;/code&gt;, it would become a tab (or render under the tab bar), which is rarely what you want.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Defining screen options as constants outside the JSX isn't required, but it keeps big layouts readable, and it's a habit that saves you from re-creating option objects on every render.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Pattern 2: Modals
&lt;/h2&gt;

&lt;p&gt;A modal is just a stack screen with a different presentation:&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
├── (tabs)/...
└── new-project.tsx        # /new-project, shown as a modal
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/_layout.tsx (add to the root Stack)&lt;/span&gt;
&lt;span class="o"&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="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"new-project"&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;modalOptions&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;modalOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;presentation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'modal'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'New project'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open it with &lt;code&gt;router.push('/new-project')&lt;/code&gt; and close it with &lt;code&gt;router.back()&lt;/code&gt; (or &lt;code&gt;router.dismiss()&lt;/code&gt;). On iOS you get the native sheet with swipe-to-dismiss for free. Because it lives on the root stack, it opens over the tabs from anywhere in the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 3: An auth gate nobody can skip
&lt;/h2&gt;

&lt;p&gt;You want signed-out users to see only the auth screens, and signed-in users to never see them. Put each side in its own group:&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
├── (auth)/
│   ├── _layout.tsx
│   ├── sign-in.tsx
│   └── sign-up.tsx
└── (app)/
    ├── _layout.tsx
    └── (tabs)/...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Recent versions of Expo Router include &lt;strong&gt;protected routes&lt;/strong&gt;: wrap screens in &lt;code&gt;Stack.Protected&lt;/code&gt; with a &lt;code&gt;guard&lt;/code&gt;, and the router won't let anyone navigate to them while the guard is false, including through deep links:&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="nc"&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="s1"&gt;'expo-router'&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="n"&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="s1"&gt;'@/lib/auth'&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="nc"&gt;RootLayout&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="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&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="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="n"&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 the splash screen visible&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Stack&lt;/span&gt; &lt;span class="n"&gt;screenOptions&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hidden&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="o"&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="n"&gt;guard&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;!!&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="o"&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="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"(app)"&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="o"&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="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="o"&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="n"&gt;guard&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="o"&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="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"(auth)"&lt;/span&gt; &lt;span class="o"&gt;/&amp;gt;&lt;/span&gt;
      &lt;span class="o"&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="o"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Stack&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="n"&gt;hidden&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&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="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On older versions without &lt;code&gt;Stack.Protected&lt;/code&gt;, the same idea works with a redirect in the protected group's 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/(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="nc"&gt;Redirect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&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="s1"&gt;'expo-router'&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="n"&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="s1"&gt;'@/lib/auth'&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="nc"&gt;AppLayout&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="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&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="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="n"&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="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="n"&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="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Redirect&lt;/span&gt; &lt;span class="n"&gt;href&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"/sign-in"&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="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Stack&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Either way, the check lives in &lt;strong&gt;one layout&lt;/strong&gt;, not in every screen. That's the main win: you can't forget to protect a new screen, because it inherits its folder's rules.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 4: Dynamic routes and params done right
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/projects/[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="n"&gt;useLocalSearchParams&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s1"&gt;'expo-router'&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="nc"&gt;ProjectScreen&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="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&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="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&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="n"&gt;project&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useProject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// your data hook&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things to know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Params are always strings.&lt;/strong&gt; Parse numbers and booleans yourself, and validate them: a deep link can contain anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use &lt;code&gt;useLocalSearchParams&lt;/code&gt;, not &lt;code&gt;useGlobalSearchParams&lt;/code&gt;,&lt;/strong&gt; inside screens. The local version only updates when &lt;em&gt;this&lt;/em&gt; screen's params change, so screens further down the stack don't re-render every time you navigate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pass IDs, not objects.&lt;/strong&gt; It's tempting to pass a whole project through params, but params are serialized into the URL. Pass the ID and fetch (or read from your cache) on the screen. This is also what makes the screen work when it's opened from a deep link.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To build links with params, prefer the object form, which handles encoding for you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nc"&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="s1"&gt;'expo-router'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="n"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'/projects/[id]'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt; &lt;span class="n"&gt;href&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;href&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nc"&gt;Open&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Pattern 5: Typed routes
&lt;/h2&gt;

&lt;p&gt;Typos in route strings fail at runtime. Expo Router can generate types for every route in &lt;code&gt;app/&lt;/code&gt; so they fail at compile time instead. Enable it in your app config:&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="nl"&gt;"expo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;"experiments"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nl"&gt;"typedRoutes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the dev server once to generate the types. After that, &lt;code&gt;router.push('/projcts/42')&lt;/code&gt; is a TypeScript error, and editor autocomplete lists your real routes. Depending on your SDK version this may already be enabled by default; check your template.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 6: The same screen in more than one tab
&lt;/h2&gt;

&lt;p&gt;Instagram-style apps open a profile from the Home tab &lt;em&gt;and&lt;/em&gt; the Search tab, and in both cases the profile should stay inside that tab's stack. Expo Router handles this with shared routes, using an array-style group name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app/(tabs)/
├── _layout.tsx
├── (home,search)/
│   ├── _layout.tsx        # a Stack, used by both tabs
│   ├── home.tsx
│   ├── search.tsx
│   └── users/[id].tsx     # one file, available in both tabs
└── settings.tsx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;(home,search)&lt;/code&gt; group creates two copies of the stack, one per tab, from the same files. Use &lt;code&gt;useSegments()&lt;/code&gt; if a screen needs to know which tab it's in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 7: Deep links and notification taps for free
&lt;/h2&gt;

&lt;p&gt;Every route is a deep link automatically. With &lt;code&gt;"scheme": "myapp"&lt;/code&gt; in your app config, &lt;code&gt;myapp://projects/42&lt;/code&gt; opens the project screen with the right params, and the protected route rules from Pattern 3 still apply.&lt;/p&gt;

&lt;p&gt;That makes push notifications simple: put the route path in the notification's data and navigate to it when the user taps:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;useEffect&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s1"&gt;'react'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nc"&gt;Notifications&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s1"&gt;'expo-notifications'&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="n"&gt;router&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="s1"&gt;'expo-router'&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;useNotificationRouting&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;useEffect&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="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="n"&gt;sub&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addNotificationResponseReceivedListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&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="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notification&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="s1"&gt;'string'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="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;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;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="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also handle the case where the app was &lt;strong&gt;closed&lt;/strong&gt; when the notification was tapped, using &lt;code&gt;Notifications.getLastNotificationResponseAsync()&lt;/code&gt; on startup. For links from your website (universal links on iOS, app links on Android), you configure your domain once and the same routes just work.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Putting detail screens inside &lt;code&gt;(tabs)&lt;/code&gt;&lt;/strong&gt; when they should cover the tab bar. Detail screens usually belong on the root stack.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checking auth in every screen&lt;/strong&gt; instead of once in a layout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Passing whole objects as params.&lt;/strong&gt; Pass IDs; they survive deep links and app restarts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting that groups don't change URLs.&lt;/strong&gt; &lt;code&gt;app/(auth)/sign-in.tsx&lt;/code&gt; is &lt;code&gt;/sign-in&lt;/code&gt;, not &lt;code&gt;/auth/sign-in&lt;/code&gt;, so two groups can't both contain &lt;code&gt;index.tsx&lt;/code&gt; at the same level without a conflict.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Using &lt;code&gt;router.push&lt;/code&gt; for redirects.&lt;/strong&gt; After signing in, use &lt;code&gt;router.replace&lt;/code&gt; so the back gesture doesn't return to the sign-in screen.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;strong&gt;Is Expo Router only for Expo apps?&lt;/strong&gt; It's built on React Navigation and designed for Expo projects. Most new React Native apps start with Expo, so in practice it's the default router today.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I still use React Navigation APIs?&lt;/strong&gt; Yes. Expo Router uses React Navigation under the hood, so navigator options, themes and many hooks carry over.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does file-based routing work on the web too?&lt;/strong&gt; Yes. The same &lt;code&gt;app/&lt;/code&gt; folder produces real URLs on the web, which is one of its biggest advantages for apps that ship to web and mobile.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I show a screen without a tab bar or header?&lt;/strong&gt; Move it to the root stack (no tab bar) and set &lt;code&gt;headerShown: false&lt;/code&gt; in its options (no header).&lt;/p&gt;

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

&lt;p&gt;File-based routing turns navigation from a config file you maintain into a folder structure you can read at a glance. The patterns that matter in real apps are few: tabs inside a stack, modals on the root stack, an auth gate in one layout, IDs in params, typed routes, shared routes for multi-tab screens, and deep links that come for free.&lt;/p&gt;

&lt;p&gt;If you'd like to start from a project that's already structured this way, &lt;a href="https://www.rapidnative.com?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=file-based-routing&amp;amp;utm_content=conclusion" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; generates React Native and Expo apps with Expo Router, which you can export as a standard project and keep building on.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;What's the navigation pattern that took you longest to get right? Share it in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>typescript</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Native App Conversion: Turning a Lovable (Vite + React + Supabase) App Into React Native and Expo</title>
      <dc:creator>Russel Dsouza</dc:creator>
      <pubDate>Wed, 04 Nov 2026 11:17:53 +0000</pubDate>
      <link>https://dev.to/russel_dsouza_bd584a3cb2a/native-app-conversion-turning-a-lovable-vite-react-supabase-app-into-react-native-and-expo-4788</link>
      <guid>https://dev.to/russel_dsouza_bd584a3cb2a/native-app-conversion-turning-a-lovable-vite-react-supabase-app-into-react-native-and-expo-4788</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://applighter-blogs.hashnode.dev/native-app-conversion-turning-a-lovable-vite-react-supabase-app-into-react-native-and-expo" rel="noopener noreferrer"&gt;Hashnode&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;converting a Lovable app to native isn't a wrapper job and it isn't a rewrite of your backend. You keep Supabase, your database schema, your RLS policies, your types and most of your business logic. You rebuild the UI layer with React Native primitives and NativeWind, replace React Router with Expo Router, re-plumb Supabase auth for a mobile runtime (session storage, token refresh, OAuth redirects through deep links), swap web-only libraries for native ones, and add the pieces the stores require. This guide walks through each layer with the code we actually use.&lt;/p&gt;

&lt;p&gt;Lovable, like Bolt and v0, generates a Vite + React + TypeScript single-page app, usually styled with Tailwind and shadcn/ui and backed by Supabase. It's a great stack for the web. It's also exactly the stack that gets rejected under App Store Guideline 4.2 when it's wrapped in a WebView and submitted as an app.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: this is written by the team at &lt;a href="https://letsdeploy.it/how-it-works?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=native-app-conversion&amp;amp;utm_content=intro" rel="noopener noreferrer"&gt;LetsDeployIt&lt;/a&gt;, where native conversion of AI-built apps is our day job. Everything below is the process we follow, so you can do it yourself.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Why not just wrap it?
&lt;/h2&gt;

&lt;p&gt;A Capacitor or WebView wrapper ships your existing bundle inside a native shell. It's fast, and for some apps it's fine. For AI-built apps it tends to fail in three places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Review.&lt;/strong&gt; Guideline 4.2 rejects apps that are "a repackaged website". A WebView with browser-style scrolling, no native navigation and no native features is the textbook case. &lt;a href="https://letsdeploy.it/guides/lovable-app-rejected-guideline-4-2?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=native-app-conversion&amp;amp;utm_content=guideline-4-2" rel="noopener noreferrer"&gt;More on Guideline 4.2&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feel.&lt;/strong&gt; Text inputs, the keyboard, momentum scrolling, back gestures and transitions all behave like a browser, and users notice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Platform features.&lt;/strong&gt; Push notifications, secure storage, biometrics and background work are possible through plugins, but every one is a bridge you maintain.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Conversion keeps your data layer and rebuilds the presentation layer natively, which removes all three problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Audit the Lovable codebase first
&lt;/h2&gt;

&lt;p&gt;Before writing any React Native, split the existing code into what ports and what doesn't. Clone the GitHub repo Lovable syncs to and sort files into four buckets:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Portable as-is:&lt;/strong&gt; TypeScript types (including generated Supabase types in &lt;code&gt;src/integrations/supabase/types.ts&lt;/code&gt;), zod schemas, pure utility functions, date and currency formatting, and data access functions that only call &lt;code&gt;supabase&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Portable with changes:&lt;/strong&gt; React hooks that use &lt;code&gt;supabase&lt;/code&gt; plus browser APIs (&lt;code&gt;localStorage&lt;/code&gt;, &lt;code&gt;window&lt;/code&gt;, &lt;code&gt;document&lt;/code&gt;), and anything using TanStack Query (which works in React Native unchanged, but focus and online handling need wiring).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rewrite:&lt;/strong&gt; every component that renders DOM elements, everything under &lt;code&gt;src/components/ui&lt;/code&gt; (shadcn), layout and routing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replace or drop:&lt;/strong&gt; web-only libraries (Recharts, rich text editors, drag-and-drop, PDF generation), SEO and marketing pages, and Stripe Checkout for digital goods.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A quick way to size the job:&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;# Components that render DOM and will need rewriting&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rlE&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;(div|span|section|button|img|input|form)[ &amp;gt;]"&lt;/span&gt; src &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;.tsx | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;

&lt;span class="c"&gt;# Browser APIs that need replacing&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rnE&lt;/span&gt; &lt;span class="s2"&gt;"localStorage|sessionStorage|window&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;|document&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; src &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;.ts &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;.tsx

&lt;span class="c"&gt;# Web-only dependencies to swap&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;(recharts|@radix-ui|react-router-dom|react-dropzone|@tiptap|react-dnd)"&lt;/span&gt; package.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Scaffold the Expo app
&lt;/h2&gt;

&lt;p&gt;Start from a fresh Expo project with TypeScript and Expo Router rather than trying to bend the Vite project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx create-expo-app@latest my-app-native
&lt;span class="nb"&gt;cd &lt;/span&gt;my-app-native
npx expo &lt;span class="nb"&gt;install &lt;/span&gt;nativewind react-native-reanimated react-native-safe-area-context
npx expo &lt;span class="nb"&gt;install&lt;/span&gt; @supabase/supabase-js @react-native-async-storage/async-storage react-native-url-polyfill
npx expo &lt;span class="nb"&gt;install &lt;/span&gt;expo-web-browser expo-auth-session expo-image-picker expo-notifications expo-device
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy the portable code (bucket 1) into a &lt;code&gt;src/lib&lt;/code&gt; or &lt;code&gt;src/shared&lt;/code&gt; folder. If you want both apps to keep sharing it, move it into a workspace package in a monorepo; for most AI-built apps, a copy is simpler and fine.&lt;/p&gt;

&lt;p&gt;Configure NativeWind so your Tailwind classes keep working. With NativeWind v4, that means the NativeWind preset in &lt;code&gt;tailwind.config.js&lt;/code&gt;, a &lt;code&gt;global.css&lt;/code&gt; with the Tailwind directives, the Babel preset and the Metro wrapper:&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;// metro.config.js&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;getDefaultConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo/metro-config&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;withNativeWind&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nativewind/metro&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;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getDefaultConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;__dirname&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;withNativeWind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./global.css&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;Copy your &lt;code&gt;tailwind.config&lt;/code&gt; theme (colors, fonts, radius) across so the native app matches your brand.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The component translation map
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Web (Lovable / Vite)&lt;/th&gt;
&lt;th&gt;React Native + Expo&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;section&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;span&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;View&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;Text&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Every string must sit inside &lt;code&gt;&amp;lt;Text&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;onClick&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;onPress&lt;/code&gt; on &lt;code&gt;&amp;lt;Pressable&amp;gt;&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No hover; use &lt;code&gt;active:&lt;/code&gt; styles for press state&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;img src&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;&amp;lt;Image source={image}&amp;gt;&lt;/code&gt; with &lt;code&gt;{ uri }&lt;/code&gt; or &lt;code&gt;expo-image&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Remote images need explicit width and height&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;input onChange&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;TextInput onChangeText&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Wrap forms in &lt;code&gt;KeyboardAvoidingView&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tailwind CSS&lt;/td&gt;
&lt;td&gt;NativeWind&lt;/td&gt;
&lt;td&gt;Default flex direction is column; no CSS grid&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;shadcn/ui, Radix&lt;/td&gt;
&lt;td&gt;RN primitives or react-native-reusables&lt;/td&gt;
&lt;td&gt;Radix depends on the DOM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;React Router&lt;/td&gt;
&lt;td&gt;Expo Router&lt;/td&gt;
&lt;td&gt;File-based routes in &lt;code&gt;app/&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;localStorage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;AsyncStorage / SecureStore&lt;/td&gt;
&lt;td&gt;Async API; SecureStore has value size limits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;import.meta.env.VITE_*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;process.env.EXPO_PUBLIC_*&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Inlined at build time, so public by definition&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lucide-react&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;lucide-react-native&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Needs &lt;code&gt;react-native-svg&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recharts&lt;/td&gt;
&lt;td&gt;victory-native, react-native-gifted-charts&lt;/td&gt;
&lt;td&gt;Recharts renders SVG via the DOM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;.map()&lt;/code&gt; in a scrolling div&lt;/td&gt;
&lt;td&gt;&lt;code&gt;FlatList&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Virtualized; required for long lists&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stripe Checkout for digital goods&lt;/td&gt;
&lt;td&gt;In-app purchase (StoreKit / Play Billing)&lt;/td&gt;
&lt;td&gt;Guideline 3.1.1&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Here's a typical card from a Lovable app and its native version:&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;// Web (Lovable)&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;
  &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex items-center gap-3 p-4 rounded-xl bg-white shadow hover:bg-gray-50"&lt;/span&gt;
  &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;navigate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/projects/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="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;img&lt;/span&gt; &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;avatarUrl&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"w-10 h-10 rounded-full"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"font-medium text-gray-900"&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// React Native + NativeWind&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;Pressable&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Image&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="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;useRouter&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-router&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;router&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useRouter&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;avatar&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;avatarUrl&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;Pressable&lt;/span&gt;
  &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex-row items-center gap-3 p-4 rounded-xl bg-white active:bg-gray-50"&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="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/projects/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Image&lt;/span&gt; &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;avatar&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"w-10 h-10 rounded-full"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"font-medium text-gray-900"&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;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Text&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;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;Three changes do most of the work: &lt;code&gt;flex-row&lt;/code&gt; is now explicit (React Native defaults to column), &lt;code&gt;hover:&lt;/code&gt; becomes &lt;code&gt;active:&lt;/code&gt;, and text moves into &lt;code&gt;&amp;lt;Text&amp;gt;&lt;/code&gt;. Shadows deserve a check on both platforms, because iOS uses shadow properties and Android uses elevation.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Routing: React Router to Expo Router
&lt;/h2&gt;

&lt;p&gt;Lovable apps define routes in &lt;code&gt;App.tsx&lt;/code&gt; with React Router. Expo Router uses the file system instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;React Router                         Expo Router (app/)
/                    -&amp;gt; Home         (tabs)/index.tsx
/settings            -&amp;gt; Settings     (tabs)/settings.tsx
/projects/:id        -&amp;gt; Project      projects/[id].tsx
/login               -&amp;gt; Login        (auth)/login.tsx
layout with navbar                   (tabs)/_layout.tsx  (bottom tab bar)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hooks map almost one to one: &lt;code&gt;useNavigate()&lt;/code&gt; becomes &lt;code&gt;useRouter()&lt;/code&gt; with &lt;code&gt;router.push()&lt;/code&gt; and &lt;code&gt;router.back()&lt;/code&gt;, &lt;code&gt;useParams()&lt;/code&gt; becomes &lt;code&gt;useLocalSearchParams()&lt;/code&gt;, and &lt;code&gt;&amp;lt;Link to&amp;gt;&lt;/code&gt; becomes &lt;code&gt;&amp;lt;Link href&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Treat this as a design step, not just a rename. A top navigation bar on the web usually becomes a bottom tab bar with three to five tabs on mobile, and secondary pages become stack screens with a native back gesture. Getting navigation native is one of the biggest things reviewers notice.&lt;/p&gt;

&lt;p&gt;Protect routes with a redirect in a layout instead of a wrapper component:&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/(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;Redirect&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="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;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="s1"&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;TabsLayout&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;loading&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;loading&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;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;"/(auth)/login"&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;Tabs&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;h2&gt;
  
  
  6. Supabase on mobile: the part most conversions get wrong
&lt;/h2&gt;

&lt;p&gt;The Supabase client Lovable generates assumes a browser: it stores the session in &lt;code&gt;localStorage&lt;/code&gt;, refreshes tokens on a timer that runs while the tab is open, and reads OAuth results from the URL. None of that holds on a phone.&lt;/p&gt;

&lt;h3&gt;
  
  
  The client
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/lib/supabase.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native-url-polyfill/auto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;AsyncStorage&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@react-native-async-storage/async-storage&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@supabase/supabase-js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;AppState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react-native&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Database&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;./types&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Database&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;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;EXPO_PUBLIC_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;EXPO_PUBLIC_SUPABASE_ANON_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AsyncStorage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;autoRefreshToken&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;persistSession&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;detectSessionInUrl&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;flowType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pkce&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Only refresh tokens while the app is in the foreground&lt;/span&gt;
&lt;span class="nx"&gt;AppState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;change&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startAutoRefresh&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stopAutoRefresh&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;Key points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;detectSessionInUrl: false&lt;/code&gt;&lt;/strong&gt;, because there's no browser URL to read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The AppState listener&lt;/strong&gt; pauses and resumes token refresh with the app lifecycle, as Supabase recommends for React Native.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage choice:&lt;/strong&gt; AsyncStorage isn't encrypted. If you want the session encrypted at rest, use &lt;code&gt;expo-secure-store&lt;/code&gt;, but note it has a per-value size limit that a Supabase session can exceed. The common pattern is to encrypt the session with a key kept in SecureStore and store the ciphertext in AsyncStorage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keys:&lt;/strong&gt; only the anon (publishable) key belongs in the app. &lt;code&gt;EXPO_PUBLIC_&lt;/code&gt; variables are inlined into the bundle, so anything there is readable by anyone who downloads the app. The service role key never leaves the server.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  OAuth with deep links
&lt;/h3&gt;

&lt;p&gt;On the web, Supabase redirects back to your site. On mobile, it has to redirect back into your app through a URL scheme. Set &lt;code&gt;"scheme": "myapp"&lt;/code&gt; in &lt;code&gt;app.json&lt;/code&gt;, add the redirect URL (for example &lt;code&gt;myapp://&lt;/code&gt;) to Supabase's allowed redirect URLs, then:&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="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;WebBrowser&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-web-browser&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;makeRedirectUri&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-auth-session&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./supabase&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;WebBrowser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;maybeCompleteAuthSession&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;redirectTo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;makeRedirectUri&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;signInWithGoogle&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;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="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signInWithOAuth&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;google&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;redirectTo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;skipBrowserRedirect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;WebBrowser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;openAuthSessionAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;redirectTo&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;success&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;code&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;No auth code returned&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="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;exchangeError&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exchangeCodeForSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&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;exchangeError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;exchangeError&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;With the PKCE flow, the app receives a short-lived code and exchanges it for a session, so tokens never travel in the redirect URL. If you offer Google login on iPhone, you also need Sign in with Apple under Guideline 4.8; on iOS, the native Apple sheet with &lt;code&gt;signInWithIdToken&lt;/code&gt; is the smoothest way to add it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Email links
&lt;/h3&gt;

&lt;p&gt;Magic links and password reset emails point at your website by default. Either point them at a universal link or app link that opens the app, or keep that flow on the web and have the app hand off to it. Decide this early, because it touches your email templates and Supabase's redirect settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Data fetching, lists and forms
&lt;/h2&gt;

&lt;p&gt;Your data access functions port unchanged, since they're just &lt;code&gt;supabase.from(...)&lt;/code&gt; calls. What changes is how results render and how the app reacts to the network.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lists:&lt;/strong&gt; replace &lt;code&gt;.map()&lt;/code&gt; inside a scrolling container with &lt;code&gt;FlatList&lt;/code&gt;, which only renders what's on screen:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;FlatList&lt;/span&gt;
  &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;projects&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;keyExtractor&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;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;renderItem&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;item&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;ProjectCard&lt;/span&gt; &lt;span class="na"&gt;project&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="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="na"&gt;onEndReached&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;fetchNextPage&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;refreshing&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isRefetching&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
  &lt;span class="na"&gt;onRefresh&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;refetch&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pull-to-refresh comes almost free and is something users expect on mobile.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TanStack Query:&lt;/strong&gt; it works in React Native, but it can't see browser focus and online events. Wire &lt;code&gt;focusManager&lt;/code&gt; to &lt;code&gt;AppState&lt;/code&gt; and &lt;code&gt;onlineManager&lt;/code&gt; to &lt;code&gt;@react-native-community/netinfo&lt;/code&gt; so queries refetch when the app returns to the foreground or the connection comes back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Forms:&lt;/strong&gt; &lt;code&gt;react-hook-form&lt;/code&gt; and zod port well; swap inputs for &lt;code&gt;TextInput&lt;/code&gt; with &lt;code&gt;onChangeText&lt;/code&gt;, wrap screens in &lt;code&gt;KeyboardAvoidingView&lt;/code&gt;, and set &lt;code&gt;keyboardType&lt;/code&gt;, &lt;code&gt;autoCapitalize&lt;/code&gt;, &lt;code&gt;autoComplete&lt;/code&gt; and &lt;code&gt;textContentType&lt;/code&gt; so the keyboard and password managers behave natively.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. File uploads to Supabase Storage
&lt;/h2&gt;

&lt;p&gt;The web version uploads a &lt;code&gt;File&lt;/code&gt; from an &lt;code&gt;&amp;lt;input type="file"&amp;gt;&lt;/code&gt;. On mobile you get a local URI from the image picker, then upload its bytes:&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="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;ImagePicker&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-image-picker&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;pickAndUpload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="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;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;ImagePicker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;launchImageLibraryAsync&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;mediaTypes&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;images&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;canceled&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;asset&lt;/span&gt; &lt;span class="o"&gt;=&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;assets&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bytes&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="nx"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arrayBuffer&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;path&lt;/span&gt; &lt;span class="o"&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;userId&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="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;.jpg`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&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;uploads&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;upload&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="nx"&gt;bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;contentType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;asset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mimeType&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/jpeg&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;path&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;Keep the &lt;code&gt;userId/&lt;/code&gt; prefix: it lets your Storage policies restrict each user to their own folder, and it makes account deletion straightforward later. Add a photo library usage description in your iOS config, or review will flag it under Guideline 5.1.1.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Native features worth adding
&lt;/h2&gt;

&lt;p&gt;These turn a converted app into an app reviewers recognize as native, and users actually feel:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Push notifications&lt;/strong&gt; with &lt;code&gt;expo-notifications&lt;/code&gt;: request permission at a meaningful moment, get an Expo push token, store it against the user, and send from your backend.&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="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;Notifications&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-notifications&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nx"&gt;Device&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-device&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Constants&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expo-constants&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;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;registerForPush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;Device&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isDevice&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;requestPermissionsAsync&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;granted&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;projectId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Constants&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;expoConfig&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;extra&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;eas&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;projectId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Notifications&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getExpoPushTokenAsync&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;projectId&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;push_tokens&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On Android, create a notification channel; on iOS, EAS can generate and manage the APNs key for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Also worth adding:&lt;/strong&gt; haptic feedback on key actions (&lt;code&gt;expo-haptics&lt;/code&gt;), native share sheets, biometric unlock for sensitive screens (&lt;code&gt;expo-local-authentication&lt;/code&gt;), an offline state instead of a blank screen, and safe area handling with &lt;code&gt;react-native-safe-area-context&lt;/code&gt; so nothing hides under the notch or home indicator.&lt;/p&gt;

&lt;h2&gt;
  
  
  10. Store requirements to build in, not bolt on
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Account deletion (Guideline 5.1.1(v) and Google Play).&lt;/strong&gt; Do it server-side in an Edge Function, because deleting a user needs the service role key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// supabase/functions/delete-account/index.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;npm:@supabase/supabase-js@2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;authHeader&lt;/span&gt; &lt;span class="o"&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;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;authHeader&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unauthorized&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Identify the caller from their own JWT&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userClient&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SUPABASE_URL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SUPABASE_ANON_KEY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;global&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;authHeader&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;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;user&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;userClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;user&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unauthorized&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SUPABASE_URL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SUPABASE_SERVICE_ROLE_KEY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Storage isn't removed by ON DELETE CASCADE, so clean it up explicitly&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&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;uploads&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&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;uploads&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;remove&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;deleteError&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deleteUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deleteError&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;deleteError&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app calls it with &lt;code&gt;supabase.functions.invoke('delete-account')&lt;/code&gt;, which sends the user's JWT automatically, then signs out. Make sure your tables reference &lt;code&gt;auth.users&lt;/code&gt; with &lt;code&gt;ON DELETE CASCADE&lt;/code&gt;, or delete those rows in the function too. &lt;a href="https://letsdeploy.it/guides/app-rejected-account-deletion?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=native-app-conversion&amp;amp;utm_content=account-deletion" rel="noopener noreferrer"&gt;Full account deletion guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Also build in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sign in with Apple (Guideline 4.8)&lt;/strong&gt; if you offer Google or other social login.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In-app purchase (Guideline 3.1.1)&lt;/strong&gt; for digital goods; Stripe stays for physical goods and services.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permission purpose strings&lt;/strong&gt; for every sensitive permission.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  11. Build, update and ship with EAS
&lt;/h2&gt;

&lt;p&gt;Configure build profiles in &lt;code&gt;eas.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cli"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;gt;= 12.0.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"appVersionSource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"remote"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"development"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"developmentClient"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"distribution"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"internal"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"preview"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"distribution"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"internal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"channel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"preview"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"autoIncrement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"channel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"production"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"submit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Development builds&lt;/strong&gt; replace Expo Go once you add native modules; they're your day-to-day testing app on a real device.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Over-the-air updates&lt;/strong&gt; with &lt;code&gt;expo-updates&lt;/code&gt; let you ship JavaScript fixes without a store review. Set a &lt;code&gt;runtimeVersion&lt;/code&gt; policy so an update never reaches a binary it isn't compatible with, and keep OTA for fixes and small changes, not changes to what the app does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production builds&lt;/strong&gt; go to App Store Connect and Play Console with &lt;code&gt;eas submit&lt;/code&gt;. Test through TestFlight and Play's internal testing first, and plan for Google's 14-day closed test if your account is personal and new. &lt;a href="https://letsdeploy.it/guides/lovable-app-google-play-closed-test?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=native-app-conversion&amp;amp;utm_content=closed-test" rel="noopener noreferrer"&gt;Closed test guide&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  12. A realistic plan and timeline
&lt;/h2&gt;

&lt;p&gt;For a typical Lovable app with 10 to 20 screens and Supabase:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Audit and scaffold:&lt;/strong&gt; a day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared code, Supabase client and auth (including OAuth):&lt;/strong&gt; one to two days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Navigation and screens:&lt;/strong&gt; the bulk of the work, roughly a screen or two per few hours once the component patterns are settled.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native features and store requirements:&lt;/strong&gt; one to two days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Builds, TestFlight, store listings and submission:&lt;/strong&gt; one to two days, plus review time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The web app keeps running throughout, on the same database, so you can launch native without a migration or a freeze.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Can I keep using Lovable after converting?&lt;/strong&gt; Yes for the web app. The native app lives in its own codebase, so changes made in Lovable won't flow into it automatically. Shared logic and the shared Supabase backend keep the two consistent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need to change my Supabase schema or RLS?&lt;/strong&gt; Usually not. The same policies protect the same data. You'll add redirect URLs for mobile OAuth and possibly a table for push tokens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I convert to Flutter instead?&lt;/strong&gt; You can, but React Native lets you reuse TypeScript types, zod schemas, data functions and much of your component logic, and keeps your team in one language.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will the converted app pass Guideline 4.2?&lt;/strong&gt; A genuinely native app with native navigation and features is far less likely to be rejected than a wrapped web app, but review always looks at the whole app, including whether it offers enough value on its own.&lt;/p&gt;

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

&lt;p&gt;Native conversion is mostly a UI rewrite on top of a backend you already have. The details that decide whether it works are the mobile-specific ones: session storage and token refresh, OAuth through deep links, virtualized lists, uploads from local URIs, and store requirements built into the server. Get those right and you have an app that feels native and passes review.&lt;/p&gt;

&lt;p&gt;If you'd rather hand the conversion off, &lt;a href="https://letsdeploy.it/how-it-works?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=native-app-conversion&amp;amp;utm_content=conclusion" rel="noopener noreferrer"&gt;LetsDeployIt&lt;/a&gt; converts Lovable, Bolt, Base44 and v0 apps to React Native and Expo and takes them through both stores. See &lt;a href="https://letsdeploy.it/pricing?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=native-app-conversion&amp;amp;utm_content=pricing" rel="noopener noreferrer"&gt;plans&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Questions about a specific part of your conversion? Ask in the comments, and we'll answer with code.&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>supabase</category>
      <category>typescript</category>
    </item>
    <item>
      <title>9 AI App Builders You Should Definitely Try in 2026 🔥💯</title>
      <dc:creator>Russel Dsouza</dc:creator>
      <pubDate>Fri, 30 Oct 2026 07:57:32 +0000</pubDate>
      <link>https://dev.to/russel_dsouza_bd584a3cb2a/9-ai-app-builders-you-should-definitely-try-in-2026-5d58</link>
      <guid>https://dev.to/russel_dsouza_bd584a3cb2a/9-ai-app-builders-you-should-definitely-try-in-2026-5d58</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;A few years ago, no-code meant dragging boxes onto a canvas and connecting them with logic blocks. In 2026, you can describe the app you want in a sentence and an AI builds it for you, often with real code you can keep and change.&lt;/p&gt;

&lt;p&gt;That shift has opened app building to founders, designers and product people who never planned to learn a programming language. It has also made experienced developers much faster, because the AI handles the setup and the repetitive parts while they focus on what makes the app different.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why AI app builders took off
&lt;/h2&gt;

&lt;p&gt;AI app builders took the best part of no-code, the speed, and removed its biggest limit: being stuck inside one platform. Many of them now write standard code in frameworks like React, React Native and Next.js, so you can export your project and keep building on it anywhere.&lt;/p&gt;

&lt;p&gt;They're not all aiming at the same thing, though. Some build websites, some build web apps, and only a few build true mobile apps for the App Store and Google Play. Knowing the difference before you start saves a lot of rework later.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Disclosure: this list is from the RapidNative team, so we've put ourselves first. Every other tool here is one we'd happily recommend for the right project.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  AI app builders worth trying
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. RapidNative
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://www.rapidnative.com?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=ai-app-builders-2026&amp;amp;utm_content=rapidnative-home" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; is an AI app builder made for mobile. You describe your app, or start from a sketch or a screenshot, and it generates a React Native app built on Expo. Instead of a website squeezed onto a phone screen, you get a real mobile app for iPhone and Android, written in the same stack many professional mobile teams use.&lt;/p&gt;

&lt;p&gt;Because the output is standard React Native code, you're never locked in. You can export it, open it in your own editor and keep building, or hand it to a developer. When you're ready to launch, RapidNative's &lt;a href="https://www.rapidnative.com/blogs/introducing-appshots-ai-app-store-screenshot-generator?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=ai-app-builders-2026&amp;amp;utm_content=appshots" rel="noopener noreferrer"&gt;AppShots&lt;/a&gt; generates your App Store screenshots with AI, which takes care of one of the most tedious parts of publishing. If mobile is your goal, this is the place to start.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Lovable
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://lovable.dev" rel="noopener noreferrer"&gt;Lovable&lt;/a&gt; builds full-stack web apps from a chat. You describe what you want, it writes a React frontend, and it connects a Supabase backend for user accounts, data and file storage, either through Lovable Cloud or your own Supabase project.&lt;/p&gt;

&lt;p&gt;It's one of the most popular AI builders for SaaS products, dashboards and internal tools, and its visual editing makes small changes easy without prompting. Lovable syncs your code to GitHub, so you can take it anywhere. Keep in mind that it builds web apps, so getting a Lovable app into the App Store means converting it to a native app later.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Bolt.new
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://bolt.new" rel="noopener noreferrer"&gt;Bolt.new&lt;/a&gt;, from the StackBlitz team, writes, runs and deploys full-stack JavaScript apps right inside your browser tab. There's nothing to install: the whole development environment runs in the browser, and you can watch the AI create files and run your app as it goes.&lt;/p&gt;

&lt;p&gt;Bolt supports popular frameworks like React, Next.js and Astro, and you can step in and edit the code directly whenever you like. That makes it a favorite of developers and technical founders who want AI speed without giving up control.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. v0 by Vercel
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://v0.dev" rel="noopener noreferrer"&gt;v0&lt;/a&gt; started as a tool for generating user interfaces and has grown into a full app builder. It produces React and Next.js code styled with Tailwind CSS and shadcn/ui, which is why its interfaces tend to look polished and modern straight away.&lt;/p&gt;

&lt;p&gt;Since v0 comes from Vercel, deploying what you build is a click away, and the code fits naturally into an existing Next.js project. It's an excellent choice for developers who care about design quality and want a strong starting point for the frontend.&lt;/p&gt;

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

&lt;p&gt;&lt;a href="https://base44.com" rel="noopener noreferrer"&gt;Base44&lt;/a&gt; builds complete apps from a prompt with login, database and hosting all built in. There are no outside services to sign up for or connect, which removes the setup step that trips up many first-time builders.&lt;/p&gt;

&lt;p&gt;That all-in-one approach makes Base44 especially good for internal tools, simple customer portals and quick prototypes you want to share with a link. It's a strong pick for non-technical founders and teams who want something working today.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. FlutterFlow
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://flutterflow.io" rel="noopener noreferrer"&gt;FlutterFlow&lt;/a&gt; is a visual builder for mobile and web apps using Google's Flutter framework. You design screens with drag and drop, add logic visually, and use its AI features to generate pages and components from a description.&lt;/p&gt;

&lt;p&gt;FlutterFlow exports Flutter code and connects to Firebase and Supabase for data. It suits teams who like building visually and want native mobile apps, with the option to bring in developers for custom code later.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. Replit
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://replit.com" rel="noopener noreferrer"&gt;Replit&lt;/a&gt; combines an online code editor, an AI agent that can plan and build an app from a description, and hosting in one place. You can watch the agent work, then take over in the editor whenever you want to make changes yourself.&lt;/p&gt;

&lt;p&gt;Unlike many AI builders, Replit isn't limited to JavaScript; it supports many programming languages. It's a great fit for builders who want an AI agent and a complete coding environment together.&lt;/p&gt;

&lt;h3&gt;
  
  
  8. Bubble
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://bubble.io" rel="noopener noreferrer"&gt;Bubble&lt;/a&gt; has been one of the best-known no-code platforms for web apps for years. Its visual editor, built-in database and workflow system let you build surprisingly complex apps without writing code, and it now adds AI to generate a first version of your app from a description.&lt;/p&gt;

&lt;p&gt;Bubble works differently from the code-generating builders above: your app lives on Bubble's platform rather than as code you export. In return, you get fine-grained no-code control over every workflow, plus a large plugin marketplace and community.&lt;/p&gt;

&lt;h3&gt;
  
  
  9. Glide
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://www.glideapps.com" rel="noopener noreferrer"&gt;Glide&lt;/a&gt; turns spreadsheets and databases into polished apps for teams. Connect Google Sheets, Excel or another data source, pick from ready-made components, and you have a working business app in an afternoon.&lt;/p&gt;

&lt;p&gt;Its AI features can summarize, classify and extract information from your data automatically. Glide is ideal for internal apps built on data your team already keeps in a spreadsheet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one should you pick?
&lt;/h2&gt;

&lt;p&gt;The biggest question is whether you need a mobile app or a web app. If you want an app in the App Store and Google Play, start with a mobile builder like &lt;a href="https://www.rapidnative.com?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=ai-app-builders-2026&amp;amp;utm_content=which-one" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; or FlutterFlow. If the browser is enough, Lovable, Bolt.new, v0 and Base44 are all excellent. Replit suits people who want an AI agent plus a full coding environment, Bubble suits complex no-code web apps, and Glide is perfect for apps built on a spreadsheet.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;What is the best AI app builder for mobile apps?&lt;/strong&gt; For native iPhone and Android apps, choose a builder that outputs mobile code, such as RapidNative (React Native and Expo) or FlutterFlow (Flutter). Most other AI builders make web apps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I keep the code an AI app builder makes?&lt;/strong&gt; With many of them, yes. RapidNative, Lovable, Bolt.new, v0 and FlutterFlow let you export or sync your code. Check each tool's plans, since some limit export to paid tiers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Are AI app builders free?&lt;/strong&gt; Most offer a free plan or free credits to try them, with paid plans for more usage and features.&lt;/p&gt;

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

&lt;p&gt;AI app builders have made it possible to go from idea to working app in a day. The trick is picking the right one for where your app needs to live. Web builders shine in the browser, while mobile builders like &lt;a href="https://www.rapidnative.com?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=ai-app-builders-2026&amp;amp;utm_content=conclusion" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; get you a real app for the App Store and Google Play without the rebuild. Try a couple of them on a small idea, and you'll quickly find the one that fits the way you work.&lt;/p&gt;

&lt;p&gt;Which AI app builder are you using right now? Tell us in the comments.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>nocode</category>
      <category>beginners</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Why Was My App Rejected for Account Deletion? (And How to Fix It)</title>
      <dc:creator>Russel Dsouza</dc:creator>
      <pubDate>Thu, 29 Oct 2026 10:44:28 +0000</pubDate>
      <link>https://dev.to/russel_dsouza_bd584a3cb2a/why-was-my-app-rejected-for-account-deletion-and-how-to-fix-it-3gd6</link>
      <guid>https://dev.to/russel_dsouza_bd584a3cb2a/why-was-my-app-rejected-for-account-deletion-and-how-to-fix-it-3gd6</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://letsdeploy.it/guides/app-rejected-account-deletion?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=app-rejected-account-deletion&amp;amp;utm_content=originally-published" rel="noopener noreferrer"&gt;letsdeploy.it&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Short answer:&lt;/strong&gt; your app has a sign-up and no way to delete the account it creates. Apple has enforced this since June 30, 2022, and it is one of the most common rejections for apps built with Lovable, Bolt, Base44 or v0, because the builders add sign-up for you and never add the delete option.&lt;/p&gt;

&lt;p&gt;You submit, wait two days, and the review note says your app "supports account creation but does not include an option to initiate account deletion". Usually nothing in the app is broken. Apple just wants people to be able to delete the account they made.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"If your app supports account creation, you must also offer account deletion within the app."&lt;br&gt;
— Apple App Review Guideline 5.1.1(v)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It counts even if sign-up is only email and password, only Google sign-in, or a guest account the app creates on its own. If people can get an account from your app, they need a way to delete it there.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does Apple accept as account deletion?
&lt;/h2&gt;

&lt;p&gt;Apple accepts a delete option that is easy to find, usually in account settings, and that removes the account and the personal data that goes with it. Its &lt;a href="https://developer.apple.com/support/offering-account-deletion-in-your-app/" rel="noopener noreferrer"&gt;account deletion page&lt;/a&gt; spells out the details:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deactivating or disabling the account isn't enough.&lt;/strong&gt; You can offer it, but not as the only option.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete the whole account record&lt;/strong&gt; and the personal data tied to it, including content the person shared, such as posts, photos and reviews.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't make people call, email or chat with support to delete&lt;/strong&gt;, unless your app is in a highly regulated field such as banking or health.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A link is fine&lt;/strong&gt; if deletion finishes on your website, as long as it goes straight to that page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It doesn't have to be instant.&lt;/strong&gt; Tell people how long it takes and confirm when it's done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One confirmation step is fine.&lt;/strong&gt; Making deletion hard on purpose fails review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subscriptions:&lt;/strong&gt; if the person has an auto-renewing subscription, tell them billing continues through Apple and point them to cancel it first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sign in with Apple:&lt;/strong&gt; if you offer it, revoke the person's Apple tokens when you delete the account.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Does Google Play require account deletion too?
&lt;/h2&gt;

&lt;p&gt;Yes. Google Play has required it since May 31, 2024, and it asks for two ways in, not one:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An in-app path&lt;/strong&gt; to delete the account and its data, easy to find, for example in settings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A working web link&lt;/strong&gt; where people can ask for deletion without the app installed. It must name your app or your developer name.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You give Google that link in the Data safety form in Play Console, under the data deletion questions. If you keep some data after deletion, for fraud prevention or legal reasons, say so in your privacy policy. The &lt;a href="https://letsdeploy.it/guides/lovable-launch-checklist-and-costs?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=app-rejected-account-deletion&amp;amp;utm_content=launch-checklist" rel="noopener noreferrer"&gt;launch checklist&lt;/a&gt; covers the rest of the Data safety form.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to add account deletion to your app
&lt;/h2&gt;

&lt;p&gt;Most AI-built apps keep their users in Supabase, so the fix has two halves: a button in the app and a small server function that does the deleting.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Add a &lt;strong&gt;Delete account&lt;/strong&gt; row at the bottom of your account or settings screen, in red so it reads as final.&lt;/li&gt;
&lt;li&gt;Open a confirmation that says what gets deleted and how long it takes, with Delete and Cancel.&lt;/li&gt;
&lt;li&gt;On Delete, call a server function (a Supabase Edge Function) with the signed-in user's session.&lt;/li&gt;
&lt;li&gt;In that function, delete the user's rows in your tables (profiles, posts, bookings, uploads in Storage), then delete the user with Supabase's &lt;code&gt;auth.admin.deleteUser&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Sign the person out and show a short "Your account has been deleted" screen.&lt;/li&gt;
&lt;li&gt;Publish a deletion page on your website for Google Play, with your app's name and a form or email to request deletion.&lt;/li&gt;
&lt;li&gt;Test it on a fresh account: sign up, add some data, delete, then check the user and their rows are gone in your database.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A passing deletion flow is easy to find (Settings, then Delete account), has one clear confirmation, and leaves the account and its data gone. Google Play also wants a web link to ask for it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why a server function:&lt;/strong&gt; deleting a user in Supabase needs the service role key, and Supabase says never to put that key in the browser or the app. The app asks, the function checks who is asking and deletes only that user.&lt;/p&gt;

&lt;p&gt;If your tables point at the user with a foreign key set to delete on cascade, deleting the user removes their rows too. Check that before you rely on it: files in Storage are not removed by a cascade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Account deletion in Lovable, Bolt, Base44 and v0 apps
&lt;/h2&gt;

&lt;p&gt;The steps are the same whichever builder you used. What changes is where your users are stored:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Builder&lt;/th&gt;
&lt;th&gt;Where users usually live&lt;/th&gt;
&lt;th&gt;What to ask for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lovable&lt;/td&gt;
&lt;td&gt;Supabase, through Lovable Cloud or your own Supabase project&lt;/td&gt;
&lt;td&gt;A Delete account option in settings that calls an Edge Function to delete the user's data and the user&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bolt.new&lt;/td&gt;
&lt;td&gt;Supabase, or Bolt Database on newer Bolt Cloud projects&lt;/td&gt;
&lt;td&gt;The same, then check the user is gone from the database Bolt set up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Base44&lt;/td&gt;
&lt;td&gt;Base44's built-in users&lt;/td&gt;
&lt;td&gt;A delete account action, then check the user and their records disappear from your app's data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;v0&lt;/td&gt;
&lt;td&gt;Usually Supabase, connected through Vercel&lt;/td&gt;
&lt;td&gt;The same Edge Function, or a server action in the Next.js app&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A prompt that works in most builders:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Add a Delete account option at the bottom of the settings screen. Ask for confirmation, then delete all of this user's data and their auth account on the server, sign them out and show a confirmation."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then test it yourself on a fresh account. The builder will say it's done whether or not the user is really deleted.&lt;/p&gt;

&lt;p&gt;Builder-specific steps for the stores: &lt;a href="https://letsdeploy.it/lovable-to-app-store?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=app-rejected-account-deletion&amp;amp;utm_content=lovable" rel="noopener noreferrer"&gt;Lovable&lt;/a&gt;, &lt;a href="https://letsdeploy.it/bolt-new-to-app-store?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=app-rejected-account-deletion&amp;amp;utm_content=bolt-new" rel="noopener noreferrer"&gt;Bolt.new&lt;/a&gt;, &lt;a href="https://letsdeploy.it/base44-to-app-store?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=app-rejected-account-deletion&amp;amp;utm_content=base44" rel="noopener noreferrer"&gt;Base44&lt;/a&gt; and &lt;a href="https://letsdeploy.it/v0-to-app-store?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=app-rejected-account-deletion&amp;amp;utm_content=v0" rel="noopener noreferrer"&gt;v0&lt;/a&gt;.&lt;/p&gt;

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

&lt;p&gt;Resubmit with a short reply in App Store Connect that tells the reviewer exactly where to look. Reviewers test by hand, so a path they can follow saves a round trip.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Where the option is:&lt;/strong&gt; "Account deletion is in Settings, then Delete account, at the bottom of the screen."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What it deletes:&lt;/strong&gt; "It deletes the account and all its personal data."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How long it takes&lt;/strong&gt;, if it isn't instant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A demo account&lt;/strong&gt; in the App Review information, so the reviewer can sign in and try it (Guideline 2.1).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Apps rejected for this are often rejected for something else in the same review. The &lt;a href="https://letsdeploy.it/guides/lovable-app-rejected-guideline-4-2?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=app-rejected-account-deletion&amp;amp;utm_content=guideline-4-2" rel="noopener noreferrer"&gt;Guideline 4.2 guide&lt;/a&gt; covers the most common one for AI-built apps.&lt;/p&gt;

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

&lt;p&gt;Yes. If your builder can add the button and the server function, and you test it on a fresh account, most people get this done in a few hours without help.&lt;/p&gt;

&lt;p&gt;Where it gets harder is when deletion has to reach several places: Stripe customers, files in Storage, Sign in with Apple tokens and a web deletion page for Google Play. That's the kind of store work we do when we convert an app to native, and it's covered in our &lt;a href="https://letsdeploy.it/pricing?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=app-rejected-account-deletion&amp;amp;utm_content=pricing" rel="noopener noreferrer"&gt;plans&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;We convert AI-built web apps into native React Native and Expo apps, add account deletion that really deletes the account and its data, set up the web deletion page and Data safety answers for Google Play, and submit to both stores until approved. See &lt;a href="https://letsdeploy.it/how-it-works?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=app-rejected-account-deletion&amp;amp;utm_content=how-it-works" rel="noopener noreferrer"&gt;how it works&lt;/a&gt; or read the &lt;a href="https://letsdeploy.it/guides/app-rejected-account-deletion?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=app-rejected-account-deletion&amp;amp;utm_content=end-cta" rel="noopener noreferrer"&gt;full guide on letsdeploy.it&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ios</category>
      <category>android</category>
      <category>appstore</category>
      <category>nocode</category>
    </item>
    <item>
      <title>How to Get a Lovable App Through Google Play's 12-Tester Closed Test</title>
      <dc:creator>Russel Dsouza</dc:creator>
      <pubDate>Mon, 26 Oct 2026 13:13:55 +0000</pubDate>
      <link>https://dev.to/russel_dsouza_bd584a3cb2a/how-to-get-a-lovable-app-through-google-plays-12-tester-closed-test-26i2</link>
      <guid>https://dev.to/russel_dsouza_bd584a3cb2a/how-to-get-a-lovable-app-through-google-plays-12-tester-closed-test-26i2</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://letsdeploy.it/guides/lovable-app-google-play-closed-test?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=lovable-closed-test&amp;amp;utm_content=originally-published" rel="noopener noreferrer"&gt;letsdeploy.it&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If your Google Play developer account is a personal one created after November 13, 2023, you need a closed test with at least 12 testers opted in for 14 days in a row before you can apply for production. Google then reviews the application, usually within 7 days. Organization accounts skip the test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do you need the 12-tester closed test?
&lt;/h2&gt;

&lt;p&gt;You need it if your Google Play developer account is a personal account created after November 13, 2023. Until you meet the requirement, Play Console keeps Production and Pre-registration locked for your apps.&lt;/p&gt;

&lt;p&gt;You finish your Lovable app, open Play Console, and find the publish button behind a two-week test. It catches a lot of first-time publishers, so check your account type before you plan a launch date.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Personal accounts created after November 13, 2023:&lt;/strong&gt; closed test required.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Personal accounts created before that date:&lt;/strong&gt; not affected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Organization accounts:&lt;/strong&gt; not affected. They need a registered business and a D-U-N-S number.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What counts as 12 testers for 14 days?
&lt;/h2&gt;

&lt;p&gt;At least 12 testers must be opted in to your closed test when you apply, and they must have stayed opted in for the 14 days before that, without a break.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Opted in&lt;/strong&gt; means the tester accepted your invite through the opt-in link, not only that you added their email.&lt;/li&gt;
&lt;li&gt;A tester who opts in, leaves before day 14 and rejoins starts their 14 days again.&lt;/li&gt;
&lt;li&gt;Google originally asked for 20 testers. Older posts that say 20 are out of date.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to set up a closed test for a Lovable app
&lt;/h2&gt;

&lt;p&gt;Lovable builds web apps, and Google Play needs an Android app, so the first step is a native build. We convert Lovable apps to React Native and Expo, which gives you an Android App Bundle (an &lt;code&gt;.aab&lt;/code&gt; file) for Play and the same app for the App Store.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create the app in Play Console and fill in the store listing basics.&lt;/li&gt;
&lt;li&gt;Open &lt;strong&gt;Testing&lt;/strong&gt;, then &lt;strong&gt;Closed testing&lt;/strong&gt;, and create a track.&lt;/li&gt;
&lt;li&gt;Upload your App Bundle to that track and roll out the release.&lt;/li&gt;
&lt;li&gt;Add testers by email list or Google Group, choose your countries, and save. The opt-in link appears once the closed test release is published, which can take a few hours.&lt;/li&gt;
&lt;li&gt;Send the opt-in link to your testers. They accept it with their Google account, then install the app from Google Play.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Your listing, content rating and Data safety form are needed for production anyway, so fill them in now. The &lt;a href="https://letsdeploy.it/guides/lovable-launch-checklist-and-costs?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=lovable-closed-test&amp;amp;utm_content=launch-checklist" rel="noopener noreferrer"&gt;Lovable launch checklist&lt;/a&gt; lists everything else the stores ask for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to find 12 testers for Google Play
&lt;/h2&gt;

&lt;p&gt;Start with people who already care about your app: they stay opted in and give useful feedback, which you need for the production application.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Friends, family and colleagues with an Android phone.&lt;/li&gt;
&lt;li&gt;Your own users: the people already using the web version of your Lovable app.&lt;/li&gt;
&lt;li&gt;Your waitlist, newsletter or social followers.&lt;/li&gt;
&lt;li&gt;Communities where builders swap testing, such as tester-swap groups and forums. Offer to test their app in return.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Invite 15 to 20 people, not 12.&lt;/strong&gt; Someone always forgets to accept the invite or uninstalls on day 6, and every drop below 12 delays your application.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closed test problems nobody warns you about
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Testers added but not opted in.&lt;/strong&gt; Adding an email does nothing until that person accepts the opt-in link. Check the count in Play Console, not your invite list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google Group members who never joined.&lt;/strong&gt; Testers in a Google Group must join the group before they can opt in. Check the group lets people outside your organization join, then confirm the opted-in count in Play Console.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Low engagement.&lt;/strong&gt; Google can ask you to keep testing if testers didn't use the app. Ask them to open it a few times and send you feedback.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use the 14 days well: fix what testers report and finish your store listing, so the build you send to production is better than the one you started with.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to apply for production access
&lt;/h2&gt;

&lt;p&gt;Once 12 testers have been opted in for 14 days, apply from the Play Console dashboard. The form has three parts: your closed test, your app, and your production readiness. It asks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;How easy it was to recruit testers.&lt;/li&gt;
&lt;li&gt;How testers engaged with the app.&lt;/li&gt;
&lt;li&gt;A summary of their feedback and what you changed because of it.&lt;/li&gt;
&lt;li&gt;Who the app is for and why it is ready for production.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Answer with specifics: the bugs testers found and what you fixed. Review usually takes 7 days or less. If Google asks for more testing, the usual reasons are fewer than 12 opted-in testers or too little engagement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Can you do the closed test yourself?
&lt;/h2&gt;

&lt;p&gt;Yes. The closed test is free and every step above is something you can do on your own. What it takes is 12 to 20 willing testers and two weeks of reminding them.&lt;/p&gt;

&lt;p&gt;If you would rather not, we run it for you with vetted testers as part of &lt;a href="https://letsdeploy.it/google-play-12-tester-requirement?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=lovable-closed-test&amp;amp;utm_content=closed-test-service" rel="noopener noreferrer"&gt;getting your app on Google Play&lt;/a&gt;, alongside the native build and the store work. You can also skip it with an organization account if you have a registered business.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Do testers have to use the app every day?
&lt;/h3&gt;

&lt;p&gt;No, Google doesn't ask for daily use, but testers must stay opted in for all 14 days, and Google can ask for more testing if they barely used the app.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens if a tester leaves before day 14?
&lt;/h3&gt;

&lt;p&gt;They stop counting. You need 12 testers opted in for the full 14 days when you apply, which is why inviting 15 to 20 people is safer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does an organization account skip the closed test?
&lt;/h3&gt;

&lt;p&gt;Yes. The requirement only applies to personal accounts created after November 13, 2023. Organization accounts need a registered business and a D-U-N-S number.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long does Google take to approve production access?
&lt;/h3&gt;

&lt;p&gt;Usually 7 days or less after you apply, sometimes longer. Count on about three weeks from the start of the closed test to production access.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is it 12 or 20 testers?
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Google originally asked for 20, so older guides that say 20 are out of date.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Can I put a Lovable app on Google Play without the closed test?
&lt;/h3&gt;

&lt;p&gt;Only with an organization account or a personal account created before November 13, 2023. Either way, you still need a native Android build of the app.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Found this useful? The &lt;a href="https://letsdeploy.it/guides/lovable-app-google-play-closed-test?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=lovable-closed-test&amp;amp;utm_content=end-cta" rel="noopener noreferrer"&gt;full guide on letsdeploy.it&lt;/a&gt; is kept up to date when Google changes the rules.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>googleplay</category>
      <category>android</category>
      <category>lovable</category>
      <category>nocode</category>
    </item>
    <item>
      <title>Ship a Live Demo of Your Supabase App That Runs Entirely in the Browser</title>
      <dc:creator>Russel Dsouza</dc:creator>
      <pubDate>Fri, 16 Oct 2026 12:40:54 +0000</pubDate>
      <link>https://dev.to/russel_dsouza_bd584a3cb2a/ship-a-live-demo-of-your-supabase-app-that-runs-entirely-in-the-browser-3189</link>
      <guid>https://dev.to/russel_dsouza_bd584a3cb2a/ship-a-live-demo-of-your-supabase-app-that-runs-entirely-in-the-browser-3189</guid>
      <description>&lt;p&gt;Every SaaS landing page wants a "Try the demo" button. And every developer who's built one knows the problems that come with it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;shared demo account&lt;/strong&gt; that strangers fill with junk (or worse) within a day&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;cron job&lt;/strong&gt; to reset the demo database every night&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real backend costs&lt;/strong&gt; for people who will never sign up&lt;/li&gt;
&lt;li&gt;Rate limits, abuse, and the occasional visitor who deletes everything&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's another way. Run the backend &lt;strong&gt;inside the visitor's browser&lt;/strong&gt;. Each visitor gets their own private copy of your app's database, built from your migrations and seed data. Nothing is shared, nothing needs resetting, and the demo is just static files.&lt;/p&gt;

&lt;p&gt;This guide does that for a Supabase app using &lt;a href="https://github.com/tinbase/tinbase?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=browser_demo" rel="noopener noreferrer"&gt;tinbase&lt;/a&gt;, an open-source Supabase-compatible backend that can run in the browser on PGlite (Postgres compiled to WebAssembly).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;tinbase is &lt;strong&gt;alpha&lt;/strong&gt;. It covers a large part of the &lt;code&gt;supabase-js&lt;/code&gt; surface, but not everything; check the known gaps in the README before relying on a specific feature for your demo.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  How does it work?
&lt;/h2&gt;

&lt;p&gt;Your app already talks to Supabase through &lt;code&gt;supabase-js&lt;/code&gt;. That client accepts a custom &lt;code&gt;fetch&lt;/code&gt;. In demo mode, instead of sending requests over the network, you hand them to a backend running in the same page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;your UI → supabase-js → backend.fetch → Postgres (PGlite, in the browser)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your components don't change. Only the client setup does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Make the client switchable
&lt;/h2&gt;

&lt;p&gt;Here's a Vite example. In normal mode, the app uses your real Supabase project. In demo mode, it starts an in-browser backend.&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;// src/lib/supabase.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;SupabaseClient&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;DEMO&lt;/span&gt; &lt;span class="o"&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;meta&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;VITE_DEMO&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;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;getSupabase&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;SupabaseClient&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;DEMO&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;createClient&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;meta&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;VITE_SUPABASE_URL&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;meta&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;VITE_SUPABASE_KEY&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;createDemoBackend&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="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./demo-backend&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;backend&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;createDemoBackend&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;backend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;anonKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;global&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;init&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;backend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;init&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dynamic &lt;code&gt;import()&lt;/code&gt; matters: visitors to your real app never download the demo backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Build the demo backend from your migrations
&lt;/h2&gt;

&lt;p&gt;Vite can import SQL files as strings with &lt;code&gt;?raw&lt;/code&gt;, so the demo uses the exact same migrations as production:&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;// src/lib/demo-backend.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;createBackend&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;tinbase&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;init&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/migrations/20261001000000_init.sql?raw&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;projects&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/migrations/20261005000000_projects.sql?raw&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;demoSeed&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/demo-seed.sql?raw&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;createDemoBackend&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;createBackend&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;dataDir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;idb://my-app-demo-v1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// persist in IndexedDB between visits&lt;/span&gt;
    &lt;span class="na"&gt;migrations&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="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;20261001000000_init&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;init&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;20261005000000_projects&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;projects&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;99990000000000_demo_seed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;demoSeed&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few choices here:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The demo seed runs as the last migration,&lt;/strong&gt; so every new visitor starts with the same realistic sample data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;dataDir: 'idb://...'&lt;/code&gt;&lt;/strong&gt; keeps the visitor's changes in IndexedDB, so the demo survives a page refresh. Bump the name (&lt;code&gt;-v2&lt;/code&gt;) when you change the seed, and returning visitors get a fresh copy.&lt;/li&gt;
&lt;li&gt;If you'd rather every visit start clean, leave persistence out. Check the README for the current in-memory option.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 3: Sign the visitor in automatically
&lt;/h2&gt;

&lt;p&gt;A demo shouldn't ask for an email. Anonymous sign-in gives each visitor a real authenticated user, so your RLS policies work exactly as they do in production:&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;supabase&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;getSupabase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&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;VITE_DEMO&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSession&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;data&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signInAnonymously&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your seed data needs to belong to the visitor, assign it after sign-in, or write your seed to use rows that any authenticated user can read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Build and host it as static files
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;VITE_DEMO&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true &lt;/span&gt;npm run build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy the output folder to any static host (GitHub Pages, Netlify, Vercel, Cloudflare Pages) at something like &lt;code&gt;demo.yourapp.com&lt;/code&gt;. There's no server, no database, and no bill that grows with demo traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Make it obvious it's a demo
&lt;/h2&gt;

&lt;p&gt;Visitors should know their data stays on their device and isn't saved to a real account. Add a small banner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&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;VITE_DEMO&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;true&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"demo-banner"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    Demo mode: everything runs in your browser and stays on this device.
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;a&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;"https://yourapp.com/signup"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Create a real account&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;a&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That banner is also your conversion point: someone who just spent five minutes in a working app is a much warmer lead than someone who watched a video.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are the trade-offs?
&lt;/h2&gt;

&lt;p&gt;Be honest with yourself about these before shipping:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Download size.&lt;/strong&gt; WASM Postgres adds several megabytes to the demo build. Lazy-load it, and show a short loading state ("Setting up your demo…").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Memory.&lt;/strong&gt; WASM Postgres is memory-hungry. Test on an older phone and a low-end laptop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feature coverage.&lt;/strong&gt; Features your app uses that tinbase doesn't support yet (see the README's known gaps, such as MFA, phone auth, or image transforms) need to be hidden or stubbed in demo mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No server-side logic.&lt;/strong&gt; Anything that depends on third-party APIs, emails, or payments should be faked or disabled in the demo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not for production.&lt;/strong&gt; This is a demo and prototyping technique. Your real app keeps using your real Supabase project.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When is this a good fit?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Dashboards, CRUD tools, internal-tool templates, and productivity apps where the value is obvious once someone clicks around&lt;/li&gt;
&lt;li&gt;Template or boilerplate products where buyers want to try before they buy&lt;/li&gt;
&lt;li&gt;Conference demos and workshops where Wi-Fi can't be trusted&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's a weaker fit for apps whose value depends on other users (social, messaging) or heavy server-side processing.&lt;/p&gt;

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

&lt;p&gt;A browser-only demo removes the worst parts of "Try the demo": no shared account to vandalize, no nightly reset job, and no backend cost per visitor. Your migrations build the database, anonymous auth signs the visitor in, and your real RLS policies keep working.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Source, benchmarks, and known gaps: &lt;a href="https://github.com/tinbase/tinbase?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=browser_demo" rel="noopener noreferrer"&gt;tinbase on GitHub&lt;/a&gt;. Have you built a "try it now" demo for your app? How did you handle the shared-data problem? Tell me in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Your EXPO_PUBLIC_ Keys Are Public: A Practical Guide to EAS Environment Variables</title>
      <dc:creator>Russel Dsouza</dc:creator>
      <pubDate>Thu, 15 Oct 2026 11:23:04 +0000</pubDate>
      <link>https://dev.to/russel_dsouza_bd584a3cb2a/your-expopublic-keys-are-public-a-practical-guide-to-eas-environment-variables-3h66</link>
      <guid>https://dev.to/russel_dsouza_bd584a3cb2a/your-expopublic-keys-are-public-a-practical-guide-to-eas-environment-variables-3h66</guid>
      <description>&lt;p&gt;Open almost any Expo repo and you'll find a &lt;code&gt;.env&lt;/code&gt; file with a mix of things: an API URL, a Supabase key, a Sentry DSN, maybe a Stripe secret key someone added "just for testing."&lt;/p&gt;

&lt;p&gt;Some of those are safe to ship inside your app. Some are not. And the &lt;code&gt;EXPO_PUBLIC_&lt;/code&gt; prefix doesn't make anything private. It does the opposite.&lt;/p&gt;

&lt;p&gt;This guide sorts it out: which values can go where, how EAS environments and visibility levels work, and one easy-to-miss mistake with OTA updates.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one rule: anything in the app is public
&lt;/h2&gt;

&lt;p&gt;Every &lt;code&gt;EXPO_PUBLIC_&lt;/code&gt; variable is inlined into your JavaScript bundle at build time. Anyone who downloads your app can extract it. The Expo docs say it plainly: treat client-side code as public, and secret visibility in EAS "does not provide any additional security for values that you end up embedding in your application itself."&lt;/p&gt;

&lt;p&gt;So the first question for every variable isn't "how do I hide it?" It's &lt;strong&gt;"does the app actually need this?"&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Sort every value into one of three buckets
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bucket&lt;/th&gt;
&lt;th&gt;Examples&lt;/th&gt;
&lt;th&gt;Where it lives&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Public, used at runtime&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;API base URL, Supabase URL and publishable key, Sentry DSN, PostHog project key&lt;/td&gt;
&lt;td&gt;&lt;code&gt;EXPO_PUBLIC_*&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Build-time only&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;APP_VARIANT&lt;/code&gt;, bundle ID suffix, &lt;code&gt;GOOGLE_SERVICES_JSON&lt;/code&gt; file, Sentry auth token for source maps&lt;/td&gt;
&lt;td&gt;Non-prefixed EAS variable, read in &lt;code&gt;app.config.js&lt;/code&gt; or by build tools&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Server only&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Stripe secret key, Supabase secret/service key, OpenAI key, webhook secrets&lt;/td&gt;
&lt;td&gt;Your backend or edge functions. &lt;strong&gt;Never the app.&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If a key can spend money, read every user's data, or call a paid API, it belongs in the third bucket. The app calls your server, and your server holds the key.&lt;/p&gt;

&lt;p&gt;A quick test: if this value leaked on Twitter tomorrow, would you need to rotate it? If yes, it can't be &lt;code&gt;EXPO_PUBLIC_&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  How EAS environments work
&lt;/h2&gt;

&lt;p&gt;EAS gives each project three environments by default: &lt;code&gt;development&lt;/code&gt;, &lt;code&gt;preview&lt;/code&gt;, and &lt;code&gt;production&lt;/code&gt;. Each is an independent set of variables, and a variable can belong to one environment or several.&lt;/p&gt;

&lt;p&gt;Create variables with &lt;code&gt;eas env:set&lt;/code&gt; (it creates or updates):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;eas &lt;span class="nb"&gt;env&lt;/span&gt;:set &lt;span class="nt"&gt;--name&lt;/span&gt; EXPO_PUBLIC_API_URL &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--value&lt;/span&gt; https://api.staging.example.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--environment&lt;/span&gt; preview &lt;span class="nt"&gt;--visibility&lt;/span&gt; plaintext

eas &lt;span class="nb"&gt;env&lt;/span&gt;:set &lt;span class="nt"&gt;--name&lt;/span&gt; EXPO_PUBLIC_API_URL &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--value&lt;/span&gt; https://api.example.com &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--environment&lt;/span&gt; production &lt;span class="nt"&gt;--visibility&lt;/span&gt; plaintext
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check what's set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;eas &lt;span class="nb"&gt;env&lt;/span&gt;:list &lt;span class="nt"&gt;--environment&lt;/span&gt; production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Pick the right visibility
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Visibility&lt;/th&gt;
&lt;th&gt;Who can read it&lt;/th&gt;
&lt;th&gt;Use for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Plain text&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Dashboard, CLI, build logs&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;EXPO_PUBLIC_*&lt;/code&gt; values (they're public anyway)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Sensitive&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;CLI and dashboard (toggle); hidden in logs&lt;/td&gt;
&lt;td&gt;Build-time values you need to resolve locally&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Secret&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Only EAS servers; hidden everywhere else&lt;/td&gt;
&lt;td&gt;Tokens only the build needs, like a source-map upload token&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two details catch people out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Secret variables can't be pulled.&lt;/strong&gt; &lt;code&gt;eas env:pull&lt;/code&gt; skips them, and resolving &lt;code&gt;app.config.js&lt;/code&gt; locally needs at least sensitive visibility. If your config reads a variable on your laptop, don't make it secret.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secret doesn't make an &lt;code&gt;EXPO_PUBLIC_&lt;/code&gt; value private.&lt;/strong&gt; It hides it in the dashboard, but the value still ships in the bundle.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Connect build profiles to environments
&lt;/h2&gt;

&lt;p&gt;In &lt;code&gt;eas.json&lt;/code&gt;, each build profile picks its environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"development"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"developmentClient"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"environment"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"development"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"preview"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"distribution"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"internal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"environment"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"preview"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"production"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"environment"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"production"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;eas build --profile preview&lt;/code&gt; gets the staging API URL, and &lt;code&gt;--profile production&lt;/code&gt; gets the real one, with no &lt;code&gt;.env&lt;/code&gt; juggling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use build-time variables in app.config.js
&lt;/h2&gt;

&lt;p&gt;Non-prefixed variables are available in &lt;code&gt;app.config.js&lt;/code&gt;, which is perfect for running preview and production builds side by side on one phone:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app.config.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;variant&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;APP_VARIANT&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;development&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;suffix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;variant&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;production&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="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;variant&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="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&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="nx"&gt;variant&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;production&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;Acme&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Acme (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;variant&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="na"&gt;ios&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;bundleIdentifier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`com.acme.app&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;suffix&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="na"&gt;android&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;package&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`com.acme.app&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;suffix&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="na"&gt;googleServicesFile&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;GOOGLE_SERVICES_JSON&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./google-services.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;p&gt;Set &lt;code&gt;APP_VARIANT&lt;/code&gt; per environment in EAS. Upload &lt;code&gt;google-services.json&lt;/code&gt; as a file variable, and the fallback keeps local builds working.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pull variables for local development
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;eas &lt;span class="nb"&gt;env&lt;/span&gt;:pull &lt;span class="nt"&gt;--environment&lt;/span&gt; development
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This writes the readable variables to a &lt;code&gt;.env&lt;/code&gt; file (like &lt;code&gt;.env.local&lt;/code&gt;). Add it to &lt;code&gt;.gitignore&lt;/code&gt;. The dashboard and EAS become the source of truth, and nobody pastes keys into Slack again.&lt;/p&gt;

&lt;h2&gt;
  
  
  The OTA mistake: updates read the environment too
&lt;/h2&gt;

&lt;p&gt;Here's the one that bites teams in production.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;EXPO_PUBLIC_&lt;/code&gt; values are baked into the JavaScript bundle. An OTA update ships a &lt;strong&gt;new&lt;/strong&gt; bundle, so it gets whatever environment you publish it with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;eas update &lt;span class="nt"&gt;--channel&lt;/span&gt; production &lt;span class="nt"&gt;--environment&lt;/span&gt; production
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Publish a production update with the wrong environment, and every production user's app now points at your staging API. The native build was fine; the update replaced the config.&lt;/p&gt;

&lt;p&gt;Guard against it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Always pass &lt;code&gt;--environment&lt;/code&gt; explicitly, and match it to the channel.&lt;/li&gt;
&lt;li&gt;Put the command in a package script or an EAS Workflow so nobody types it by hand:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"update:preview"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"eas update --channel preview --environment preview"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"update:prod"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"eas update --channel production --environment production"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Log the API base URL on startup in preview builds, so a mismatch shows up in QA, not in support tickets.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A 10-minute audit for your project
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Search the codebase for &lt;code&gt;EXPO_PUBLIC_&lt;/code&gt; and check each one against the three buckets&lt;/li&gt;
&lt;li&gt;[ ] Move any secret key out of the app and behind your backend, then &lt;strong&gt;rotate it&lt;/strong&gt; (it has already shipped)&lt;/li&gt;
&lt;li&gt;[ ] Use &lt;code&gt;plaintext&lt;/code&gt; for &lt;code&gt;EXPO_PUBLIC_*&lt;/code&gt;, &lt;code&gt;sensitive&lt;/code&gt; for build values you resolve locally, and &lt;code&gt;secret&lt;/code&gt; for build-only tokens&lt;/li&gt;
&lt;li&gt;[ ] Set &lt;code&gt;environment&lt;/code&gt; on every build profile in &lt;code&gt;eas.json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Make sure &lt;code&gt;.env*&lt;/code&gt; files are git-ignored&lt;/li&gt;
&lt;li&gt;[ ] Wrap &lt;code&gt;eas update&lt;/code&gt; in scripts that always pass &lt;code&gt;--environment&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Environment variables feel like plumbing until one leaks or an update points production at staging. The fix is mostly sorting: public runtime config in &lt;code&gt;EXPO_PUBLIC_&lt;/code&gt;, build config in EAS variables, and real secrets on your server where users can't reach them.&lt;/p&gt;

&lt;p&gt;If you'd rather start from a project where this is already laid out, &lt;a href="https://www.rapidnative.com/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=eas_env_variables" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; generates real Expo apps you can export, with config you can drop straight into this setup.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;How does your team split config between the app and the server? Share your setup in the comments.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>reactnative</category>
      <category>expo</category>
      <category>security</category>
      <category>mobile</category>
    </item>
    <item>
      <title>6 Best Ways to Get Your Lovable App into the App Store and Google Play (2026)</title>
      <dc:creator>Russel Dsouza</dc:creator>
      <pubDate>Mon, 12 Oct 2026 12:13:29 +0000</pubDate>
      <link>https://dev.to/russel_dsouza_bd584a3cb2a/6-best-ways-to-get-your-lovable-app-into-the-app-store-and-google-play-2026-fj6</link>
      <guid>https://dev.to/russel_dsouza_bd584a3cb2a/6-best-ways-to-get-your-lovable-app-into-the-app-store-and-google-play-2026-fj6</guid>
      <description>&lt;p&gt;&lt;em&gt;Compared on approval risk, how native the result feels, cost, and how much work you'll do yourself&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Disclosure:&lt;/strong&gt; This article is published by LetsDeployIt, which is ranked first below. We've described every other option as fairly as we can, including when it's the better choice.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you're a developer helping a founder (or yourself) ship a Lovable app to mobile, this is the landscape in 2026, with the trade-offs that actually matter for review.&lt;/p&gt;

&lt;p&gt;You built something real with Lovable. It works, people like it, and now they're asking the inevitable question: &lt;em&gt;"Is it in the App Store?"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here's the catch most people hit first: &lt;strong&gt;Lovable builds web apps, not mobile apps.&lt;/strong&gt; Its own FAQ says it doesn't generate React Native projects, and it suggests two routes to phones: turn your app into a Progressive Web App, or wrap your published URL with a tool like Capacitor and submit that to the stores.&lt;/p&gt;

&lt;p&gt;Those aren't your only options, though. Below are six ways to get a Lovable app onto iPhones and Android phones, ranked for the most common goal: &lt;strong&gt;a real app in both stores, that passes review, without rebuilding everything yourself.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The quick comparison
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;What you get&lt;/th&gt;
&lt;th&gt;App Store approval risk&lt;/th&gt;
&lt;th&gt;Your effort&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;LetsDeployIt&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Native React Native rebuild + full submission&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Very low&lt;/td&gt;
&lt;td&gt;Founders who want a real app, done for them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Median.co&lt;/td&gt;
&lt;td&gt;Web app in a native shell, plus native plugins&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Low to medium&lt;/td&gt;
&lt;td&gt;Apps that can stay web-based inside&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;Capacitor (DIY)&lt;/td&gt;
&lt;td&gt;Web app in a native shell you configure&lt;/td&gt;
&lt;td&gt;Medium to high&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Developers who want full control&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Rebuild yourself with an AI coding tool&lt;/td&gt;
&lt;td&gt;Native React Native app you build&lt;/td&gt;
&lt;td&gt;Low (if done well)&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Developers with time and mobile experience&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Hire a freelancer&lt;/td&gt;
&lt;td&gt;Depends on the freelancer&lt;/td&gt;
&lt;td&gt;Varies&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Teams with budget and a trusted hire&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Progressive Web App&lt;/td&gt;
&lt;td&gt;Installable website, not in the stores&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;Very low&lt;/td&gt;
&lt;td&gt;Testing demand before going native&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Why "just wrap it" isn't always enough
&lt;/h2&gt;

&lt;p&gt;Before the list, one rule shapes everything: &lt;strong&gt;Apple's App Store Guideline 4.2 (Minimum Functionality).&lt;/strong&gt; Apple rejects apps that are essentially a website repackaged in an app shell, without native, mobile-specific features.&lt;/p&gt;

&lt;p&gt;That doesn't make every wrapped app a rejection. Wrappers that add real native features (push notifications, biometrics, offline support, device access) can and do get approved. But a wrapper with nothing added is the most common first-submission rejection for web-based apps, and that risk is why the rankings below look the way they do.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. &lt;a href="https://letsdeploy.it/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=lovable_to_app_store_listicle&amp;amp;utm_term=russel&amp;amp;utm_content=rank1_heading" rel="noopener noreferrer"&gt;LetsDeployIt&lt;/a&gt;: native rebuild and submission, done for you
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; a done-for-you service that rebuilds your Lovable app as a real native app (React Native and Expo, not a WebView) and submits it to both stores.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bring your Lovable project.&lt;/strong&gt; Upload the project file or connect GitHub. With GitHub, the mobile app can stay in sync as you keep changing things in Lovable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's rebuilt as a native app.&lt;/strong&gt; Screens, navigation, and gestures are native. Your Supabase backend (database, sign-in, and data) stays exactly as it is, so web and mobile users share the same accounts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Preview it on your phone for free&lt;/strong&gt; before you pay anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store submission is handled:&lt;/strong&gt; certificates and signing, screenshots, privacy labels, Google's Data Safety form, and the review back-and-forth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You own it.&lt;/strong&gt; The app is published under your own Apple and Google developer accounts.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Why it's #1:&lt;/strong&gt; it's the only option on this list that combines a genuinely native app (the lowest Guideline 4.2 risk) with someone else doing the store work. For most founders, the hard part isn't the code; it's the certificates, forms, testers, and rejections. This handles both halves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for:&lt;/strong&gt; you'll still need your own developer accounts ($99 per year for Apple, $25 once for Google), and a rebuild takes longer than wrapping a URL. If you just need &lt;em&gt;something&lt;/em&gt; in the store this week and approval risk doesn't worry you, a wrapper is faster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; non-technical founders, and anyone who wants real users finding them in the App Store without spending weeks on mobile tooling.&lt;/p&gt;

&lt;p&gt;→ &lt;a href="https://letsdeploy.it/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=lovable_to_app_store_listicle&amp;amp;utm_term=russel&amp;amp;utm_content=rank1_cta" rel="noopener noreferrer"&gt;Preview your Lovable app as a native app, free&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  2. &lt;a href="https://median.co" rel="noopener noreferrer"&gt;Median.co&lt;/a&gt;: a native shell with plugins
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; Median wraps your published Lovable URL inside native iOS and Android shells, then adds device features through a plugin library.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No code changes.&lt;/strong&gt; You paste your URL and configure the app.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Native features through plugins:&lt;/strong&gt; push notifications, haptics, biometric login, deep links, and in-app purchases through a RevenueCat plugin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automated store setup&lt;/strong&gt; after you connect your Apple developer account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pricing:&lt;/strong&gt; self-serve plans start at $179 per year, with managed tiers above that.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Watch out for:&lt;/strong&gt; your app's screens are still your website, so scrolling, buttons, and transitions feel web-like. Median's own guidance is that you need to add enough native functionality to make the case under Guideline 4.2, so plan to use the plugins rather than shipping the URL as-is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; apps where the web experience is good enough and you mainly need store presence plus a few native features.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. &lt;a href="https://capacitorjs.com" rel="noopener noreferrer"&gt;Capacitor&lt;/a&gt;: wrap it yourself
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; an open-source tool that packages a web app into native iOS and Android projects. It's the option Lovable's own FAQ names.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Free and open source.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full control&lt;/strong&gt; over the native projects, with plugins for device features.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Keeps your existing code.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Watch out for:&lt;/strong&gt; you do everything yourself: Xcode and Android Studio setup, signing, store listings, privacy forms, and rejections. And the same Guideline 4.2 risk as any wrapper applies, so budget time for adding genuinely native features.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; developers who are comfortable with native tooling and want to own every detail.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Rebuild it yourself with an AI coding tool
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; recreate your app's screens in React Native and Expo using a coding agent like Claude Code or Cursor, keeping your Supabase backend.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Truly native,&lt;/strong&gt; with the lowest approval risk when done well.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your Lovable app is the spec.&lt;/strong&gt; Every screen and flow is already defined, which speeds up the rebuild.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No service fees.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Watch out for:&lt;/strong&gt; it's real engineering work. Agents are fast at screens but often miss what reviewers check: permission messages, empty and error states, account deletion, privacy manifests, and in-app purchases. And you still own the whole store submission.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; developers with mobile experience and time to spare.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Hire a freelancer
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; pay a developer (on Upwork, Fiverr, or similar) to convert and publish your app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; a human handles it, and a good freelancer brings experience with review.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for:&lt;/strong&gt; quality varies enormously. Many "Lovable to App Store" gigs are wrappers, which brings back the 4.2 risk. Before hiring, ask:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Will it be a native rebuild or a WebView wrapper?&lt;/li&gt;
&lt;li&gt;Will the app be published under &lt;em&gt;my&lt;/em&gt; developer accounts?&lt;/li&gt;
&lt;li&gt;Do I get the full source code?&lt;/li&gt;
&lt;li&gt;What happens if Apple rejects it?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; teams with budget and a freelancer they already trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Turn it into a Progressive Web App
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What it is:&lt;/strong&gt; make your Lovable app installable from the browser. Users tap "Add to Home Screen," and it opens full screen with an icon. Lovable's FAQ lists this as an option.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strengths:&lt;/strong&gt; free, immediate, no review, no developer accounts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watch out for:&lt;/strong&gt; it isn't in the App Store or Google Play, so nobody finds you there. Many users never discover "Add to Home Screen," and you can't sell in-app purchases.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; validating demand before you invest in a store app.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to choose
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"I want a real app in both stores, and I don't want to do the mobile work."&lt;/strong&gt; → &lt;strong&gt;LetsDeployIt&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"My web app is good enough on phones; I just need store presence."&lt;/strong&gt; → Median&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"I'm a developer and want full control."&lt;/strong&gt; → Capacitor, or a rebuild with an AI coding tool&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"I'm not sure anyone will use it yet."&lt;/strong&gt; → Start with a PWA, then move to a native app&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What will it cost?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Upfront cost&lt;/th&gt;
&lt;th&gt;Ongoing cost&lt;/th&gt;
&lt;th&gt;Your time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LetsDeployIt&lt;/td&gt;
&lt;td&gt;Free preview, then a one-time service fee&lt;/td&gt;
&lt;td&gt;Store fees only&lt;/td&gt;
&lt;td&gt;A few hours of review and approvals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Median.co&lt;/td&gt;
&lt;td&gt;From $179/year (self-serve)&lt;/td&gt;
&lt;td&gt;Yearly plan + store fees&lt;/td&gt;
&lt;td&gt;A few days&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Capacitor (DIY)&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;Store fees&lt;/td&gt;
&lt;td&gt;Days to weeks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rebuild with an AI tool&lt;/td&gt;
&lt;td&gt;Free (tool subscriptions aside)&lt;/td&gt;
&lt;td&gt;Store fees&lt;/td&gt;
&lt;td&gt;Weeks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Freelancer&lt;/td&gt;
&lt;td&gt;Varies widely&lt;/td&gt;
&lt;td&gt;Varies&lt;/td&gt;
&lt;td&gt;A few hours of managing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PWA&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Hours&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Store fees for every store option: $99 per year for Apple, $25 once for Google.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you start: a 5-minute checklist
&lt;/h2&gt;

&lt;p&gt;Whatever you choose, do these today. They take longer than you'd expect, and none of them depend on which option you pick.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Create your &lt;strong&gt;Apple Developer&lt;/strong&gt; and &lt;strong&gt;Google Play&lt;/strong&gt; accounts (identity checks can take days)&lt;/li&gt;
&lt;li&gt;[ ] Write down every service your app uses (Supabase, analytics, payments) and what data each collects; you'll need this for the privacy forms&lt;/li&gt;
&lt;li&gt;[ ] Make sure users can &lt;strong&gt;delete their account&lt;/strong&gt; inside the app&lt;/li&gt;
&lt;li&gt;[ ] Create a &lt;strong&gt;demo account&lt;/strong&gt; with sample data for reviewers&lt;/li&gt;
&lt;li&gt;[ ] If you have a new personal Google Play account, line up &lt;strong&gt;15 to 20 people&lt;/strong&gt; willing to test for two weeks (Google requires at least 12)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What every option still requires
&lt;/h2&gt;

&lt;p&gt;Whichever route you pick, the stores ask for the same things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An &lt;strong&gt;Apple Developer account&lt;/strong&gt; ($99 per year) and a &lt;strong&gt;Google Play developer account&lt;/strong&gt; ($25 one time)&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;privacy policy&lt;/strong&gt; at a public URL that matches what your app collects&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apple's privacy details&lt;/strong&gt; and &lt;strong&gt;Google's Data Safety form&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Account deletion inside the app&lt;/strong&gt;, if users can sign up&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Screenshots&lt;/strong&gt; that show the real app&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;demo account&lt;/strong&gt; so reviewers can get past your sign-in screen&lt;/li&gt;
&lt;li&gt;For &lt;strong&gt;new personal Google Play accounts:&lt;/strong&gt; a closed test with at least 12 testers for 14 days before the app can go public&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can Lovable publish to the App Store directly?&lt;/strong&gt;&lt;br&gt;
No. Lovable builds web apps and doesn't generate native mobile projects. You need one of the routes above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will Apple reject a wrapped Lovable app?&lt;/strong&gt;&lt;br&gt;
Not always, but it's a real risk. Apple's Guideline 4.2 targets apps that are just a website in a shell. Wrappers that add meaningful native features have a better chance.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I lose my Supabase backend if I rebuild natively?&lt;/strong&gt;&lt;br&gt;
No. A native rebuild replaces the screens, not the backend. Your database, sign-in, and data can stay exactly as they are.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How long does it take to get into the stores?&lt;/strong&gt;&lt;br&gt;
For brand-new accounts, plan on two to four weeks from start to live. Google's 14-day closed test for new personal accounts is often the longest single step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Who owns the app?&lt;/strong&gt;&lt;br&gt;
Make sure it's published under &lt;em&gt;your&lt;/em&gt; developer accounts, whichever option you choose. If someone else's account owns the listing, you don't fully own your app.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Ready to turn your Lovable app into a real iOS and Android app? &lt;a href="https://letsdeploy.it/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=lovable_to_app_store_listicle&amp;amp;utm_term=russel&amp;amp;utm_content=footer_cta" rel="noopener noreferrer"&gt;Preview yours free with LetsDeployIt&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;What route did you take with your Lovable app? Drop it in the comments, especially if you've been through Guideline 4.2.&lt;/p&gt;

</description>
      <category>lovable</category>
      <category>reactnative</category>
      <category>ios</category>
      <category>android</category>
    </item>
    <item>
      <title>The Supabase Edge Functions Testing Playbook: Unit, Auth, Webhooks, and Local Integration Tests</title>
      <dc:creator>Russel Dsouza</dc:creator>
      <pubDate>Thu, 08 Oct 2026 15:19:11 +0000</pubDate>
      <link>https://dev.to/russel_dsouza_bd584a3cb2a/the-supabase-edge-functions-testing-playbook-unit-auth-webhooks-and-local-integration-tests-5bo2</link>
      <guid>https://dev.to/russel_dsouza_bd584a3cb2a/the-supabase-edge-functions-testing-playbook-unit-auth-webhooks-and-local-integration-tests-5bo2</guid>
      <description>&lt;p&gt;Most Supabase Edge Functions get tested exactly once: by deploying them and hitting them from the app. That works until a function sends a duplicate charge, emails the wrong user, or quietly returns &lt;code&gt;500&lt;/code&gt; for anyone who isn't signed in.&lt;/p&gt;

&lt;p&gt;Edge Functions are where your app's most sensitive logic tends to live: payments, AI calls with secret keys, webhooks, anything that needs the service role. They deserve better tests than "it worked when I clicked it."&lt;/p&gt;

&lt;p&gt;This is a practical playbook: how to structure functions so they're testable, and the five kinds of tests that catch the bugs that matter. Everything here works with the standard Supabase CLI. At the end there's a lighter local option if Docker is slowing your loop down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 0: Split the handler from the server
&lt;/h2&gt;

&lt;p&gt;Almost every Edge Function example puts everything inside &lt;code&gt;Deno.serve&lt;/code&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="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 80 lines of logic&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 hard to test, because the only way to run the logic is to start a server. Split it into two files instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// supabase/functions/summarize/handler.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;SupabaseClient&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;jsr:@supabase/supabase-js@2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;Deps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Everything that talks to the outside world is injectable&lt;/span&gt;
  &lt;span class="na"&gt;makeUserClient&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;authHeader&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="nx"&gt;SupabaseClient&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;summarize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="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="kr"&gt;string&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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;makeHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Deps&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="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="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="o"&gt;&amp;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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&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="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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Method not allowed&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;405&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;auth&lt;/span&gt; &lt;span class="o"&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;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;auth&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unauthorized&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;noteId&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;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;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 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="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&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;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;noteId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;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;noteId is required&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// A client acting as the caller, so RLS decides what they can read&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="nx"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;makeUserClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;note&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;notes&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;body&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;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;noteId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;single&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;note&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;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;Not found&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;404&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;summary&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;deps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;summarize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;note&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;summary&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 typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// supabase/functions/summarize/index.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;jsr:@supabase/supabase-js@2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;makeHandler&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;./handler.ts&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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SUPABASE_URL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SUPABASE_PUBLISHABLE_KEY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SUPABASE_ANON_KEY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nf"&gt;makeHandler&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;makeUserClient&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;authHeader&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;createClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;global&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;authHeader&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="na"&gt;summarize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.example-ai.com/v1/summarize&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;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="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;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;AI_API_KEY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="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="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;text&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`AI provider returned &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return &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;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="nx"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the logic is a plain function from &lt;code&gt;Request&lt;/code&gt; to &lt;code&gt;Response&lt;/code&gt;, with its dependencies passed in. You can call it directly in a test, with fakes, in milliseconds.&lt;/p&gt;

&lt;p&gt;Two details in the handler are worth copying into every function:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The user client forwards the caller's &lt;code&gt;Authorization&lt;/code&gt; header.&lt;/strong&gt; Queries then run as that user, and your RLS policies apply. Reach for the service role only when the function genuinely needs to bypass RLS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every bad input returns a specific status code.&lt;/strong&gt; &lt;code&gt;400&lt;/code&gt; for a bad body, &lt;code&gt;401&lt;/code&gt; for no auth, &lt;code&gt;404&lt;/code&gt; when RLS hides the row. Tests can assert on these, and your app can handle them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Test 1: Unit tests (no server, no database)
&lt;/h2&gt;

&lt;p&gt;Supabase recommends putting function tests in &lt;code&gt;supabase/functions/tests/&lt;/code&gt;, named after the function with a &lt;code&gt;-test.ts&lt;/code&gt; suffix. Unit tests call the handler 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;// supabase/functions/tests/summarize-test.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;assertEquals&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;jsr:@std/assert&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;makeHandler&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;../summarize/handler.ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// A fake Supabase client that returns whatever row you give it&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fakeClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;body&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;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;from&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="na"&gt;select&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="na"&gt;eq&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="na"&gt;single&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&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="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;not found&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
        &lt;span class="p"&gt;}),&lt;/span&gt;
      &lt;span class="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;as&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="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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Bearer test-token&lt;/span&gt;&lt;span class="dl"&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost/summarize&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;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="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="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;body&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;returns a summary for a readable note&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;makeHandler&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;makeUserClient&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="nf"&gt;fakeClient&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;long text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="na"&gt;summarize&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;short text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;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;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;noteId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;n1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
  &lt;span class="nf"&gt;assertEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;assertEquals&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;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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;short text&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;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rejects requests without 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;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;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;makeHandler&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;makeUserClient&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="nf"&gt;fakeClient&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="na"&gt;summarize&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="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost/summarize&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;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;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="nf"&gt;assertEquals&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;handler&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;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;returns 404 when RLS hides the note&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;makeHandler&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;makeUserClient&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="nf"&gt;fakeClient&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="na"&gt;summarize&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;should not be called&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;assertEquals&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;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;noteId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;someone-elses&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}))).&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;validates the body&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;makeHandler&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;makeUserClient&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="nf"&gt;fakeClient&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="na"&gt;summarize&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="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;assertEquals&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;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;({}))).&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;400&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;Run them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;deno &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;--allow-all&lt;/span&gt; supabase/functions/tests/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These run in well under a second and cover the logic you're most likely to break: validation, auth, not-found handling, and the happy path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to unit test in every function:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Wrong HTTP method → &lt;code&gt;405&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Missing auth → &lt;code&gt;401&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Invalid or missing body fields → &lt;code&gt;400&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Row hidden by RLS → &lt;code&gt;404&lt;/code&gt;, and the expensive call (AI, payment) is &lt;strong&gt;not&lt;/strong&gt; made&lt;/li&gt;
&lt;li&gt;[ ] External API failure → a clear error, not an unhandled exception&lt;/li&gt;
&lt;li&gt;[ ] Happy path → correct response shape&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That fifth one deserves its own test:&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="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;surfaces AI provider failures cleanly&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;makeHandler&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;makeUserClient&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="nf"&gt;fakeClient&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
    &lt;span class="na"&gt;summarize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;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;AI provider returned 503&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="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;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;noteId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;n1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
  &lt;span class="c1"&gt;// Decide what you want here and pin it down: 502 is a reasonable choice&lt;/span&gt;
  &lt;span class="nf"&gt;assertEquals&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;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&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;Writing this test will probably make you add a &lt;code&gt;try/catch&lt;/code&gt; around the external call, which is the point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test 2: Auth and JWT behavior
&lt;/h2&gt;

&lt;p&gt;By default, Supabase verifies the JWT on every function request before your code runs. You can turn that off per function in &lt;code&gt;supabase/config.toml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[functions.stripe-webhook]&lt;/span&gt;
&lt;span class="py"&gt;verify_jwt&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Turn it off only for functions called by third parties (webhooks) or genuinely public endpoints, and then &lt;strong&gt;verify the caller yourself&lt;/strong&gt;: a webhook signature, a shared secret header, or similar.&lt;/p&gt;

&lt;p&gt;For functions that keep JWT verification on, test both sides against a running local backend:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An anonymous request returns &lt;code&gt;401&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A signed-in user can reach their own data.&lt;/li&gt;
&lt;li&gt;User A can't reach user B's data through the function. This is where forwarding the &lt;code&gt;Authorization&lt;/code&gt; header pays off: RLS does the work, and your test proves it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Test 3: Webhook-triggered functions
&lt;/h2&gt;

&lt;p&gt;Database webhooks call your function with a fixed payload shape: &lt;code&gt;type&lt;/code&gt;, &lt;code&gt;table&lt;/code&gt;, &lt;code&gt;schema&lt;/code&gt;, &lt;code&gt;record&lt;/code&gt;, and &lt;code&gt;old_record&lt;/code&gt;. Save real payloads as fixtures and unit-test against them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// supabase/functions/tests/fixtures/order-insert.json&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;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="s2"&gt;INSERT&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;table&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;orders&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;schema&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;public&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;record&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;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="s2"&gt;o1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user_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="s2"&gt;u1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;total_cents&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;status&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;paid&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;old_record&lt;/span&gt;&lt;span class="dl"&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;/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="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sends a receipt for paid orders only&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;sent&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;=&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;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;makeOrderHandler&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;sendReceipt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&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;sent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;readTextFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./fixtures/order-insert.json&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;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://localhost&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;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;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;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}));&lt;/span&gt;
  &lt;span class="nf"&gt;assertEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sent&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;o1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add fixtures for the cases that cause real incidents: an &lt;code&gt;UPDATE&lt;/code&gt; where the status didn't change (no duplicate receipt), a &lt;code&gt;DELETE&lt;/code&gt; (where &lt;code&gt;record&lt;/code&gt; is null), and the same payload delivered twice. &lt;strong&gt;Webhooks can retry&lt;/strong&gt;, so make handlers idempotent and test it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test 4: Secrets and environment
&lt;/h2&gt;

&lt;p&gt;Locally, put function secrets in &lt;code&gt;supabase/functions/.env&lt;/code&gt; (and add it to &lt;code&gt;.gitignore&lt;/code&gt;), or pass a file with &lt;code&gt;--env-file&lt;/code&gt; when serving. In production, set them with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;supabase secrets &lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;--env-file&lt;/span&gt; ./supabase/functions/.env.production
supabase secrets list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two tests worth having:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The function fails loudly if a required secret is missing.&lt;/strong&gt; Read secrets at startup and throw a clear error, rather than discovering &lt;code&gt;undefined&lt;/code&gt; in a request to your AI provider.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets never reach the response.&lt;/strong&gt; A test that serializes every error response and asserts it doesn't contain your key catches the classic &lt;code&gt;return Response.json({ error })&lt;/code&gt; leak.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Test 5: Integration tests against a local backend
&lt;/h2&gt;

&lt;p&gt;Unit tests prove the logic. Integration tests prove the wiring: the real &lt;code&gt;supabase-js&lt;/code&gt; client, real JWTs, real RLS, the real function runtime.&lt;/p&gt;

&lt;p&gt;The standard way is the Supabase CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;supabase start
supabase functions serve &lt;span class="nt"&gt;--env-file&lt;/span&gt; ./supabase/functions/.env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then invoke through the client, exactly as your app does:&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;jsr:@supabase/supabase-js@2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;assertEquals&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;jsr:@std/assert&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;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SUPABASE_URL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="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;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SUPABASE_PUBLISHABLE_KEY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SUPABASE_ANON_KEY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;persistSession&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="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;anonymous callers are rejected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;functions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;summarize&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;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;noteId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x&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;assertEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sign a test user in first to test the authenticated paths.&lt;/p&gt;

&lt;h3&gt;
  
  
  A lighter local option
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;supabase start&lt;/code&gt; runs a dozen Docker containers, roughly 1.4 GB of RAM on a Mac, and takes about a minute to boot. If that's what keeps you from running integration tests on every change, &lt;a href="https://tinbase.dev/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=edge_functions_testing_playbook_2026" rel="noopener noreferrer"&gt;tinbase&lt;/a&gt; is an open-source alternative that runs a Supabase-compatible backend in one process, with no Docker, in about two seconds.&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;It loads functions from &lt;code&gt;supabase/functions/&amp;lt;name&amp;gt;/index.ts&lt;/code&gt;, and functions written with &lt;code&gt;Deno.serve(handler)&lt;/code&gt; and &lt;code&gt;Deno.env&lt;/code&gt; run unchanged, so &lt;code&gt;supabase.functions.invoke()&lt;/code&gt; works against it. It also runs database webhooks with the same payload shape and emulates &lt;code&gt;pg_cron&lt;/code&gt; and &lt;code&gt;pg_net&lt;/code&gt;, so a "cron job calls a function" flow can be exercised end to end.&lt;/p&gt;

&lt;p&gt;Be aware of the trade-offs before you switch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Imports:&lt;/strong&gt; functions that use &lt;code&gt;npm:&lt;/code&gt;, &lt;code&gt;jsr:&lt;/code&gt;, or URL imports need to be bundled first. Functions using only web APIs run as-is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Maturity:&lt;/strong&gt; tinbase is alpha, built for local development and CI, not production.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Parity:&lt;/strong&gt; run your integration suite against the real Supabase CLI before deploying.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A common setup is tinbase for the fast inner loop and every pull request, and the Supabase CLI in a nightly or pre-deploy job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it in CI
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/functions.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Edge Functions&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;supabase/functions/**'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;unit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v4&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;denoland/setup-deno@v2&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;deno-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;v2.x&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deno test --allow-all supabase/functions/tests/&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unit tests need nothing but Deno, so they run in seconds on every pull request. Add the integration job (with the Supabase CLI or tinbase) once the unit layer is solid.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Structure&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Logic lives in &lt;code&gt;handler.ts&lt;/code&gt;; &lt;code&gt;index.ts&lt;/code&gt; only wires dependencies and calls &lt;code&gt;Deno.serve&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] External calls (AI, payments, email) are injected, so tests can fake them&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Unit tests&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Method, auth, and body validation return the right status codes&lt;/li&gt;
&lt;li&gt;[ ] RLS-hidden rows return &lt;code&gt;404&lt;/code&gt; and skip expensive calls&lt;/li&gt;
&lt;li&gt;[ ] External failures are handled, not thrown&lt;/li&gt;
&lt;li&gt;[ ] Webhook handlers are idempotent, tested with saved fixtures&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Security&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] User clients forward the &lt;code&gt;Authorization&lt;/code&gt; header; service role only when needed&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;verify_jwt = false&lt;/code&gt; only where you verify the caller another way&lt;/li&gt;
&lt;li&gt;[ ] Missing secrets fail at startup; secrets never appear in responses&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Integration&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Anonymous, own-data, and other-user's-data cases tested through &lt;code&gt;functions.invoke&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Runs on every pull request that touches &lt;code&gt;supabase/functions/&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Edge Functions hold the code you least want to break. An hour spent splitting handlers and writing these tests pays for itself the first time it stops a duplicate charge from reaching production.&lt;/p&gt;

&lt;p&gt;What's the bug your Edge Function tests would have caught? I'd like to collect them.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>typescript</category>
      <category>testing</category>
      <category>deno</category>
    </item>
    <item>
      <title>How to Run a Supabase Workshop for 30 People Without a Single Docker Install</title>
      <dc:creator>Russel Dsouza</dc:creator>
      <pubDate>Wed, 30 Sep 2026 07:02:02 +0000</pubDate>
      <link>https://dev.to/russel_dsouza_bd584a3cb2a/how-to-run-a-supabase-workshop-for-30-people-without-a-single-docker-install-33f</link>
      <guid>https://dev.to/russel_dsouza_bd584a3cb2a/how-to-run-a-supabase-workshop-for-30-people-without-a-single-docker-install-33f</guid>
      <description>&lt;p&gt;If you've ever taught a backend workshop, you know the first 40 minutes. Someone's Docker Desktop won't start. Someone's on an 8 GB laptop and the Supabase stack won't fit. Someone's corporate machine blocks virtualization. Someone's on Windows Home. By the time everyone has &lt;code&gt;supabase start&lt;/code&gt; running, you've lost a third of the session and most of the room's energy.&lt;/p&gt;

&lt;p&gt;I've run enough of these to want a setup where the whole room has real Postgres with Row Level Security in under two minutes, no Docker anywhere. This is that setup, using &lt;a href="https://tinbase.dev/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=supabase_workshop_2026&amp;amp;utm_content=intro" rel="noopener noreferrer"&gt;tinbase&lt;/a&gt;, an open-source, Supabase-compatible backend that runs as one process.&lt;/p&gt;

&lt;h2&gt;
  
  
  What every student needs installed
&lt;/h2&gt;

&lt;p&gt;Node.js. That's the list.&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;That boots embedded native Postgres 17 (macOS and Linux) with REST, Auth, Storage, Realtime, Edge Functions, and a Studio-style dashboard at &lt;code&gt;http://localhost:54321/_/&lt;/code&gt;. About two seconds to boot, about 59 MB of RAM. The official &lt;code&gt;@supabase/supabase-js&lt;/code&gt; works unchanged, and it reads the same &lt;code&gt;supabase/migrations/*.sql&lt;/code&gt; files the Supabase CLI uses.&lt;/p&gt;

&lt;p&gt;On Windows, or if native Postgres can't run for any reason, the same command works with the WASM engine (PGlite):&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;span class="nt"&gt;--engine&lt;/span&gt; wasm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Slower, heavier on memory, but still real Postgres and still no Docker.&lt;/p&gt;

&lt;h2&gt;
  
  
  The workshop repo structure
&lt;/h2&gt;

&lt;p&gt;Give students one repo with the schema and seed data already written, so the session is about &lt;em&gt;using&lt;/em&gt; Supabase, not typing &lt;code&gt;create table&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;workshop/
  supabase/
    migrations/
      20260101000000_init.sql
    seed.sql
  app/                 # the frontend (Expo, Next.js, plain HTML, whatever you teach)
  README.md            # "npm install &amp;amp;&amp;amp; npx tinbase start"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- supabase/migrations/20260101000000_init.sql&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;cascade&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

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

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"read all posts"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;posts&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

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

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

&lt;/div&gt;



&lt;p&gt;Every student gets the same database, the same policies, the same seed rows. When someone's exercise breaks, you know it's their code, not their environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exercise 1: the RLS "aha" moment
&lt;/h2&gt;

&lt;p&gt;This is the exercise that makes RLS click, and it only works if every student has their own database with two users in it.&lt;/p&gt;

&lt;p&gt;Have each student sign up twice in the app (two emails), create a post as each, then run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="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;posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;as user A. They see both posts, because the read policy is &lt;code&gt;using (true)&lt;/code&gt;. Then have them try to update user B's post as user A. Zero rows change. No error. Just nothing.&lt;/p&gt;

&lt;p&gt;Then have them change the read policy to &lt;code&gt;using ((select auth.uid()) = user_id)&lt;/code&gt;, re-run the migration, and watch user B's post disappear from A's query.&lt;/p&gt;

&lt;p&gt;That five-minute loop teaches more about RLS than an hour of slides. It needs a database each student can break and reset without asking you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reset is one command
&lt;/h2&gt;

&lt;p&gt;When someone's database is in a weird state (and someone's always is):&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;span class="nt"&gt;--memory&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In-memory mode boots a fresh database from the migrations and seed every time. Nothing persists, nothing to clean up, and it's the right default for a workshop anyway: students should be able to blow it away and start over in two seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exercise 2: realtime, in a room full of laptops
&lt;/h2&gt;

&lt;p&gt;Realtime demos usually need a shared server so students see each other's changes. Here's a trick that avoids that: pair students up, and have one of them run tinbase on a port the other can reach on the venue Wi-Fi:&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;span class="nt"&gt;--port&lt;/span&gt; 54321
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The partner points their app at &lt;code&gt;http://&amp;lt;partner-ip&amp;gt;:54321&lt;/code&gt;. Now two laptops share one database, and a &lt;code&gt;postgres_changes&lt;/code&gt; subscription on one shows inserts from the other. Real realtime, no cloud project, no shared credentials to leak.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exercise 3: an Edge Function that holds a secret
&lt;/h2&gt;

&lt;p&gt;Give students an API key they aren't allowed to put in the client. Have them write &lt;code&gt;supabase/functions/summarize/index.ts&lt;/code&gt;, call it with &lt;code&gt;supabase.functions.invoke('summarize')&lt;/code&gt;, and confirm the key never appears in the browser's network tab. Plain functions run in-process with no extra setup; functions that use &lt;code&gt;npm:&lt;/code&gt; or &lt;code&gt;jsr:&lt;/code&gt; imports need bundling first, so keep the exercise dependency-free.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to tell students about the limits
&lt;/h2&gt;

&lt;p&gt;Be straight with the room, because they'll ask:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;tinbase is &lt;strong&gt;alpha&lt;/strong&gt;, built for local dev, CI, prototypes, and teaching. Not production.&lt;/li&gt;
&lt;li&gt;It covers roughly 80% of the supabase-js surface. MFA, SSO, phone auth, and pgvector aren't there yet.&lt;/li&gt;
&lt;li&gt;Writes go through a single connection, so it's for one person (or one pair), not a class-wide shared server.&lt;/li&gt;
&lt;li&gt;The migration files are the same ones they'll deploy to hosted Supabase. That's the point: nothing they learn today changes when they get a real project.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The wrap-up slide
&lt;/h2&gt;

&lt;p&gt;Before the session ends, have everyone create a free hosted Supabase project and run:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;against the same &lt;code&gt;supabase/migrations/&lt;/code&gt; folder. The schema and RLS policies they wrote locally are now live in the cloud, unchanged. Students leave with a working backend they built, not a tutorial they followed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it before your next session
&lt;/h2&gt;



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

&lt;/div&gt;



&lt;p&gt;Source and the coverage table are on &lt;a href="https://github.com/tinbase/tinbase?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=supabase_workshop_2026&amp;amp;utm_content=cta" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. If you teach with it, I'd like to hear what broke and what landed; workshop feedback is some of the most useful we get.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>teaching</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Every Way to Run Postgres for Local Dev in 2026, Ranked by Weight</title>
      <dc:creator>Russel Dsouza</dc:creator>
      <pubDate>Wed, 23 Sep 2026 05:54:51 +0000</pubDate>
      <link>https://dev.to/russel_dsouza_bd584a3cb2a/every-way-to-run-postgres-for-local-dev-in-2026-ranked-by-weight-1ol0</link>
      <guid>https://dev.to/russel_dsouza_bd584a3cb2a/every-way-to-run-postgres-for-local-dev-in-2026-ranked-by-weight-1ol0</guid>
      <description>&lt;p&gt;"Just run Postgres locally" hides a real decision. In 2026 there are four genuinely different ways to do it, and they differ by two orders of magnitude in weight. Picking wrong doesn't break anything; it just quietly taxes every &lt;code&gt;git clone&lt;/code&gt;, every CI run, and every laptop on the team for the life of the project.&lt;/p&gt;

&lt;p&gt;Here they are, lightest to heaviest, with the honest trade-offs.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Embedded: PGlite (~3 MB)
&lt;/h2&gt;

&lt;p&gt;The newest option and the lightest by far. PGlite is Postgres compiled to WebAssembly, packaged as a library. You don't start a database, you import one:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;PGlite&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;            &lt;span class="c1"&gt;// in-memory&lt;/span&gt;
&lt;span class="c1"&gt;// const db = new PGlite('./data') // or persisted to disk&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;select 'hello' as message&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Under 3 MB gzipped, starts in milliseconds, persists to the filesystem or IndexedDB, and supports real extensions including pgvector. This stopped being a curiosity a while ago: it crossed 10 million weekly npm downloads this year, and Prisma, Firebase, and Netlify all ship it as the local engine behind their database products.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it wins:&lt;/strong&gt; test suites (a fresh, real Postgres per test, no containers in CI), instant onboarding, browser playgrounds, coding-agent sandboxes, any environment where Docker isn't available.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it doesn't:&lt;/strong&gt; it's single-connection and single-user. No replication, not a production database, and tools that expect a TCP connection string need an adapter. It's a library, and everything that implies cuts both ways.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Native install (~50 to 300 MB)
&lt;/h2&gt;

&lt;p&gt;Homebrew, apt, or Postgres.app. The old way, and still the fastest raw Postgres you can run, since there's no VM and no container layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it wins:&lt;/strong&gt; one long-lived database you use daily, maximum performance, lowest conceptual overhead.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it doesn't:&lt;/strong&gt; one global version for the whole machine. Two projects on different Postgres majors means version-manager gymnastics. It pollutes the host, upgrades are a small ritual, and "install Postgres 16 locally" is the onboarding step that fails differently on every teammate's laptop.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Docker (~400 MB image, plus the runtime)
&lt;/h2&gt;

&lt;p&gt;The default answer of the last decade. &lt;code&gt;docker compose up&lt;/code&gt;, one isolated Postgres per project, any version you like, throw it away when you're done.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it wins:&lt;/strong&gt; version isolation per project, parity with a containerized production setup, and the whole team runs the identical environment from one committed file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it doesn't:&lt;/strong&gt; the real cost isn't the image, it's the runtime. Docker Desktop idles at gigabytes of RAM on Mac and Windows, licensing applies at larger companies, CI needs service containers with health checks and port juggling, and there are environments (locked-down laptops, cloud IDEs, agent sandboxes) where it simply can't run. You're running a VM to get a process.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Cloud dev branches (0 MB local, network required)
&lt;/h2&gt;

&lt;p&gt;Neon-style branching, or your platform's preview databases: every PR gets its own copy-on-write database in the cloud, nothing on your machine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it wins:&lt;/strong&gt; production-identical infrastructure, instant branches with real data shapes, zero local footprint, great for review apps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where it doesn't:&lt;/strong&gt; no network, no database. Every query pays a round trip, tests share remote state unless you're disciplined about branching, and it's the only option on this list with a monthly bill. It complements a local option more than it replaces one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cheat sheet
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Use case&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Unit and integration tests&lt;/td&gt;
&lt;td&gt;PGlite (fresh DB per test)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI pipelines&lt;/td&gt;
&lt;td&gt;PGlite, or Docker service containers if you need TCP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One daily-driver database&lt;/td&gt;
&lt;td&gt;Native install&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multiple projects, multiple versions&lt;/td&gt;
&lt;td&gt;Docker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Matching containerized prod&lt;/td&gt;
&lt;td&gt;Docker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PR preview environments&lt;/td&gt;
&lt;td&gt;Cloud branches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Demos, workshops, browser sandboxes&lt;/td&gt;
&lt;td&gt;PGlite&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most teams end up with two: something heavy for prod parity, something feather-weight for the inner loop. That split is the point. The 200 iterations a day don't need the faithful production copy; the last check before deploy does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Supabase-shaped footnote
&lt;/h2&gt;

&lt;p&gt;One case the table above doesn't cover: your app doesn't talk to bare Postgres. If you build on Supabase, your client calls the platform's REST, Auth, Storage, and Realtime APIs, so a lightweight Postgres alone doesn't give you a lightweight backend. The official local stack solves it with Docker, about a dozen containers of it.&lt;/p&gt;

&lt;p&gt;That's the gap &lt;a href="https://tinbase.dev/?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=postgres-local-dev" rel="noopener noreferrer"&gt;tinbase&lt;/a&gt; fills: an open-source (MIT) Supabase-compatible backend that runs the whole API surface on PGlite, in one process, &lt;code&gt;npx tinbase start&lt;/code&gt;, up in about two seconds, with the official supabase-js SDK working unchanged. Same idea as row 1 of the table, extended to the entire platform. It's alpha and aimed at local dev and tests, not production, which is exactly the inner-loop slot in the two-tier split above.&lt;/p&gt;

&lt;p&gt;Whatever your stack, weigh the setup against how you actually spend your day. The database you iterate against should cost you nothing to start, nothing to reset, and nothing to explain in the onboarding doc.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>docker</category>
      <category>webdev</category>
    </item>
    <item>
      <title>AI App Builders in 2026: RapidNative vs Bolt vs v0 vs Lovable, an Honest Comparison</title>
      <dc:creator>Russel Dsouza</dc:creator>
      <pubDate>Fri, 18 Sep 2026 10:05:38 +0000</pubDate>
      <link>https://dev.to/russel_dsouza_bd584a3cb2a/ai-app-builders-in-2026-rapidnative-vs-bolt-vs-v0-vs-lovable-an-honest-comparison-lei</link>
      <guid>https://dev.to/russel_dsouza_bd584a3cb2a/ai-app-builders-in-2026-rapidnative-vs-bolt-vs-v0-vs-lovable-an-honest-comparison-lei</guid>
      <description>&lt;ul&gt;
&lt;li&gt;These four tools are not four answers to one question; they're answers to different questions&lt;/li&gt;
&lt;li&gt;v0: best-in-class React UI, no backend story&lt;/li&gt;
&lt;li&gt;Lovable: fastest full-stack web MVP, deep Supabase wiring, web only&lt;/li&gt;
&lt;li&gt;Bolt: browser IDE, most framework-flexible (Expo included), deployment is on you&lt;/li&gt;
&lt;li&gt;RapidNative: mobile-first; full React Native + Expo apps with the backend wired&lt;/li&gt;
&lt;li&gt;Disclosure up front: I work on RapidNative. The competitors' wins below are real wins.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The "AI app builder" category has exploded, and most comparisons read like feature-matrix theater. Having used all four of these for real projects (and shipping one of them), here's the honest version: each tool optimizes for a different job, and picking by hype instead of by job is how you lose a weekend.&lt;/p&gt;

&lt;h2&gt;
  
  
  v0 (Vercel)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://v0.dev" rel="noopener noreferrer"&gt;v0&lt;/a&gt; turns prompts into React components with shadcn/ui and Tailwind, and it produces the cleanest, most conventional UI code in the category. If you live in the Next.js/Vercel world, the design fidelity and deployment integration are genuinely excellent.&lt;/p&gt;

&lt;p&gt;The boundary is just as clean: it's a frontend tool. No backend generation; your API routes, database, and auth are your problem. That's not a flaw, it's the product's shape.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick v0 when:&lt;/strong&gt; the job is beautiful React UI inside a stack you already own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lovable
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://lovable.dev" rel="noopener noreferrer"&gt;Lovable&lt;/a&gt; is the most complete prompt-to-web-app tool: frontend, Supabase database, auth, even RLS policies, wired together from a description. It's also the most accessible of the four for people who don't write code, which explains its trajectory.&lt;/p&gt;

&lt;p&gt;The boundary: it generates web code. React for browsers, not React Native for phones. A Lovable app in a mobile webview is still a website.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick Lovable when:&lt;/strong&gt; the job is a full-stack web MVP, fastest, especially if you're non-technical.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bolt (StackBlitz)
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://bolt.new" rel="noopener noreferrer"&gt;Bolt&lt;/a&gt; gives you an AI pair inside a browser IDE, and it's the most framework-flexible of the group: React, Next.js, Svelte, Astro, and yes, Expo. For a technical user who wants direct code control and iteration speed in a tab, it's the strongest fit.&lt;/p&gt;

&lt;p&gt;The mobile boundary is subtler here, and it's where I should be precise since this is my lane: Bolt can generate Expo projects, but mobile is one framework among many rather than the product's center of gravity, and getting from generated code to a store build (EAS setup, certificates, submission) is manual work it doesn't do for you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick Bolt when:&lt;/strong&gt; you're technical, want an in-browser IDE, and value flexibility across stacks.&lt;/p&gt;

&lt;h2&gt;
  
  
  RapidNative
&lt;/h2&gt;

&lt;p&gt;Full disclosure again: this is the one I work on, so weigh accordingly.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.rapidnative.com/ai-app-builder?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=ai-app-builders-comparison" rel="noopener noreferrer"&gt;RapidNative&lt;/a&gt; exists for one job: native mobile apps. Prompts, screenshots, PRDs, or sketches in; complete React Native + Expo + TypeScript projects out, with navigation, theming, and the Supabase backend wired, built as apps headed for the App Store and Play Store rather than as websites that might one day get wrapped. You export and own the code.&lt;/p&gt;

&lt;p&gt;The honest boundaries: it's not the tool for a web SaaS (use Lovable), not a general-purpose IDE (use Bolt), and if your job is pixel-perfect web UI, v0 will out-design it. Mobile-first cuts both ways.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pick RapidNative when:&lt;/strong&gt; the deliverable is an iOS/Android app, not a website.&lt;/p&gt;

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

&lt;p&gt;The category's dirty secret is that the web/mobile split matters more than any feature comparison. Almost every tool in this space builds web apps; if you're shipping to a browser, you have great options and this article barely matters. If you're shipping to the app stores, the field narrows sharply, and "supports Expo" and "built for Expo" are different products.&lt;/p&gt;

&lt;p&gt;These tools also compose. Plenty of teams run Lovable or Bolt for their web app and RapidNative for the mobile app, same Supabase underneath. Treating this as a winner-take-all bracket is the feature-matrix brain talking.&lt;/p&gt;

&lt;p&gt;Pricing, for completeness: all four land in the same rough $20-25/month ballpark for pro tiers as of writing, with credit systems that vary. Don't pick on price; at these numbers, one saved afternoon pays the annual difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-line version
&lt;/h2&gt;

&lt;p&gt;Web UI: v0. Full-stack web, fastest: Lovable. Browser IDE with maximum flexibility: Bolt. Native mobile apps, end to end: RapidNative.&lt;/p&gt;

&lt;p&gt;What are you building, and which of these did you end up on? Genuinely curious how the split looks in the comments, especially from anyone who's shipped with two of them.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>reactnative</category>
      <category>mobile</category>
    </item>
  </channel>
</rss>
