<?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>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>
    <item>
      <title>Postgres RLS in Local Dev: The Three Silent-Fail Modes That Ship to Prod</title>
      <dc:creator>Russel Dsouza</dc:creator>
      <pubDate>Wed, 16 Sep 2026 05:28:31 +0000</pubDate>
      <link>https://dev.to/russel_dsouza_bd584a3cb2a/postgres-rls-in-local-dev-the-three-silent-fail-modes-that-ship-to-prod-46el</link>
      <guid>https://dev.to/russel_dsouza_bd584a3cb2a/postgres-rls-in-local-dev-the-three-silent-fail-modes-that-ship-to-prod-46el</guid>
      <description>&lt;ul&gt;
&lt;li&gt;RLS doesn't fail loudly. A table with no policies and RLS off returns every row, to everyone, and looks like a working app&lt;/li&gt;
&lt;li&gt;Three ways local dev hides that: migrations that never run &lt;code&gt;ENABLE ROW LEVEL SECURITY&lt;/code&gt;, a dev connection that bypasses RLS, and views that evaluate as their owner&lt;/li&gt;
&lt;li&gt;All three pass every test you'd write in the happy path, because the happy path is "the data shows up"&lt;/li&gt;
&lt;li&gt;A 30-line SQL check at boot catches every one of them, in about 50ms&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Row Level Security has one property that makes it different from almost every other security control: when it's not working, the app works better. More rows come back. Nothing errors. Your local dev environment looks great right up until a customer in production sees another customer's invoices.&lt;/p&gt;

&lt;p&gt;I've now seen this ship three different ways, and the fix for all three is the same boring boot-time check. Here's each failure and then the check.&lt;/p&gt;

&lt;h2&gt;
  
  
  Silent-fail 1: your migrations never enabled it
&lt;/h2&gt;

&lt;p&gt;RLS is off by default on every table in Postgres. &lt;code&gt;CREATE TABLE&lt;/code&gt; gives you a table with no row security, and it stays that way until something explicitly runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;invoices&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Supabase dashboard's table editor does this for you. Nothing else does. If you write migrations in raw SQL, dbmate, Prisma, Drizzle, Knex, or anything that turns a schema file into DDL, you get a table with RLS off, no warning, and no policies. And a table with RLS off and no policies is a table with no access control at all.&lt;/p&gt;

&lt;p&gt;The Prisma case is the sneakiest, because Prisma's schema language has no concept of RLS. You write:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;model Invoice {
  id      String @id @default(uuid())
  userId  String
  amount  Int
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and &lt;code&gt;prisma migrate dev&lt;/code&gt; generates a perfectly correct &lt;code&gt;CREATE TABLE&lt;/code&gt; with no &lt;code&gt;ENABLE ROW LEVEL SECURITY&lt;/code&gt;. You have to add it in a hand-written migration, and remember to do it for every table, forever.&lt;/p&gt;

&lt;p&gt;There's a second half most people miss even after they've enabled it. The table &lt;em&gt;owner&lt;/em&gt; bypasses RLS unless you also run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="k"&gt;force&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without &lt;code&gt;force&lt;/code&gt;, the role that created the table (often the same role your migrations run as, often the same role your app connects as in dev) walks straight through every policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Silent-fail 2: your dev connection can't see RLS at all
&lt;/h2&gt;

&lt;p&gt;Superusers bypass RLS. Roles with the &lt;code&gt;BYPASSRLS&lt;/code&gt; attribute bypass RLS. Table owners bypass RLS unless &lt;code&gt;force&lt;/code&gt; is set. In a lot of local setups, the app connects as one of those.&lt;/p&gt;

&lt;p&gt;The common version: local Postgres, connection string is &lt;code&gt;postgres://postgres:postgres@localhost&lt;/code&gt;, the &lt;code&gt;postgres&lt;/code&gt; role is a superuser. Every query the app makes runs with RLS silently disabled. Your seed script inserted rows for five fake users, your UI shows all five users' data when logged in as one of them, and it looks like a demo, not a leak.&lt;/p&gt;

&lt;p&gt;In production the app doesn't connect as a superuser. It connects as &lt;code&gt;authenticated&lt;/code&gt; through PostgREST, or as a scoped application role, and now the policies apply. If they're wrong, or missing, that's the first time anyone finds out.&lt;/p&gt;

&lt;p&gt;You can check what you're running as right now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;rolname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rolsuper&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rolbypassrls&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_roles&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;rolname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If either boolean is &lt;code&gt;true&lt;/code&gt;, RLS is decorative in this session.&lt;/p&gt;

&lt;p&gt;The fix isn't "don't use the postgres role locally," because you need it for migrations. The fix is to run the &lt;em&gt;app&lt;/em&gt; as the same role it runs as in prod, and only the migration step as the owner. Supabase-style stacks do this for you because the API layer always connects as &lt;code&gt;anon&lt;/code&gt; or &lt;code&gt;authenticated&lt;/code&gt;. Anything that hands your app a raw connection string doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Silent-fail 3: views evaluate as their owner
&lt;/h2&gt;

&lt;p&gt;This one bit me after I'd fixed the first two.&lt;/p&gt;

&lt;p&gt;Before Postgres 15, a view runs its underlying queries with the &lt;em&gt;view owner's&lt;/em&gt; privileges, not the caller's. That's security-definer semantics, and it means RLS on the base tables is evaluated as the owner. If the owner is &lt;code&gt;postgres&lt;/code&gt;, or the table owner, or anything with &lt;code&gt;BYPASSRLS&lt;/code&gt;, the view returns every row regardless of who's querying it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- invoices has RLS enabled, forced, with a correct policy.&lt;/span&gt;
&lt;span class="c1"&gt;-- this view still leaks every row, because it runs as its owner.&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;view&lt;/span&gt; &lt;span class="n"&gt;invoice_summary&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt;
  &lt;span class="k"&gt;select&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;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt;
  &lt;span class="k"&gt;group&lt;/span&gt; &lt;span class="k"&gt;by&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;Postgres 15 added a way out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;view&lt;/span&gt; &lt;span class="n"&gt;invoice_summary&lt;/span&gt;
  &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;security_invoker&lt;/span&gt; &lt;span class="o"&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;as&lt;/span&gt;
  &lt;span class="k"&gt;select&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;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt;
  &lt;span class="k"&gt;group&lt;/span&gt; &lt;span class="k"&gt;by&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;With &lt;code&gt;security_invoker&lt;/code&gt;, the view runs as whoever called it and the base-table policies apply normally. On Postgres 14 or earlier there is no equivalent; you have to either not expose the view through your API layer, or replicate the policy logic inside the view's &lt;code&gt;WHERE&lt;/code&gt; clause and hope nobody edits one without the other.&lt;/p&gt;

&lt;p&gt;The reason this ships to prod is that the view works identically in dev and prod, and in both places it returns all the rows. The difference is only that in dev, all the rows is what you expected to see.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 30-line boot check
&lt;/h2&gt;

&lt;p&gt;None of the three failures produce an error. So produce one yourself. This runs at app boot in development and in CI, against whatever Postgres you're using locally, and refuses to start if any of the three conditions are true:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;declare&lt;/span&gt;
  &lt;span class="n"&gt;bad&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;begin&lt;/span&gt;
  &lt;span class="c1"&gt;-- 1. every table in public must have RLS enabled AND forced&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;string_agg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;', '&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="n"&gt;bad&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
  &lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relnamespace&lt;/span&gt;
  &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
    &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relkind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'r'&lt;/span&gt;
    &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relrowsecurity&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relforcerowsecurity&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="n"&gt;bad&lt;/span&gt; &lt;span class="k"&gt;is&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;then&lt;/span&gt;
    &lt;span class="n"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;exception&lt;/span&gt; &lt;span class="s1"&gt;'RLS not enabled+forced on: %'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;-- 2. the current role must not bypass RLS&lt;/span&gt;
  &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_roles&lt;/span&gt;
    &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;rolname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;current_user&lt;/span&gt;
      &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rolsuper&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;rolbypassrls&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="n"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;exception&lt;/span&gt; &lt;span class="s1"&gt;'connected as % which bypasses RLS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;current_user&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;-- 3. every view in public must be security_invoker (PG15+)&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;string_agg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;', '&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="n"&gt;bad&lt;/span&gt;
  &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
  &lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relnamespace&lt;/span&gt;
  &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;
    &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relkind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'v'&lt;/span&gt;
    &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;option_value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'true'&lt;/span&gt;
       &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_options_to_table&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;reloptions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
       &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;option_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'security_invoker'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="k"&gt;false&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="n"&gt;bad&lt;/span&gt; &lt;span class="k"&gt;is&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;then&lt;/span&gt;
    &lt;span class="n"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;exception&lt;/span&gt; &lt;span class="s1"&gt;'views without security_invoker: %'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bad&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three catalog queries, roughly 50ms on a schema with a few hundred objects. Run it right after migrations apply and before the app accepts a request. If you have tables that legitimately shouldn't have RLS (lookup tables, public config), exclude them by name in check 1; the point is that the exclusion is explicit and reviewable rather than the default.&lt;/p&gt;

&lt;p&gt;Check 2 is the one people push back on, because it means the app can't connect as &lt;code&gt;postgres&lt;/code&gt; locally. That's the point. If your local app connects as a role that ignores RLS, you have never once tested your policies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this actually runs
&lt;/h2&gt;

&lt;p&gt;The check only means something if local dev is real Postgres with RLS semantics. Mocks, SQLite adapters, and in-memory Postgres emulators either don't implement RLS or implement it loosely enough that the check passes on schemas that would fail in prod. I ended up running it against &lt;a href="https://tinbase.dev/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=postgres-rls-local-dev-silent-fail-modes" rel="noopener noreferrer"&gt;tinbase&lt;/a&gt;, which is a single-process Supabase-compatible backend on real Postgres 17: the API layer connects as &lt;code&gt;anon&lt;/code&gt;/&lt;code&gt;authenticated&lt;/code&gt; the way hosted Supabase does, so check 2 passes naturally, and the catalog queries behave exactly as they will in prod. Full Docker Supabase works too; it's just heavier for a check that takes 50ms.&lt;/p&gt;

&lt;p&gt;Whichever you use, the requirement is the same: the Postgres your app boots against locally has to be the Postgres that enforces RLS, or the boot check is testing nothing.&lt;/p&gt;

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

&lt;p&gt;Three silent failures, one shape: RLS not applying looks like RLS working, only with more data. Nothing in the normal test loop distinguishes "policy correctly returned my rows" from "no policy, returned all rows, and mine were among them."&lt;/p&gt;

&lt;p&gt;The boot check turns the silent failure into a loud one at the earliest possible moment, which is the only place it's cheap. If you've got a fourth mode, I'd like to hear it. My current suspicion is &lt;code&gt;SECURITY DEFINER&lt;/code&gt; functions called from policies, but I haven't seen that one ship yet.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>supabase</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your App Store reviewer is a user. Your listing is a product page.</title>
      <dc:creator>Russel Dsouza</dc:creator>
      <pubDate>Wed, 02 Sep 2026 11:06:01 +0000</pubDate>
      <link>https://dev.to/russel_dsouza_bd584a3cb2a/your-app-store-reviewer-is-a-user-your-listing-is-a-product-page-39mk</link>
      <guid>https://dev.to/russel_dsouza_bd584a3cb2a/your-app-store-reviewer-is-a-user-your-listing-is-a-product-page-39mk</guid>
      <description>&lt;p&gt;Most submission guides are engineering documents. They explain how to make the technical parts work.&lt;/p&gt;

&lt;p&gt;Almost none treat submission as a UX problem, which it is. The reviewer is a user with a time budget. Your listing is a product page competing in a grid. The same first-impression rules that apply to onboarding apply here, and almost nobody applies them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reviewer is a user
&lt;/h2&gt;

&lt;p&gt;A first-pass reviewer spends a short, bounded time in your app. Roughly what they encounter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The launch screen. Is it branded, or is it a white rectangle?&lt;/li&gt;
&lt;li&gt;A sign-in screen, if you have one. Do they even need to sign in?&lt;/li&gt;
&lt;li&gt;The first functional screen. Does it work with the account you provided?&lt;/li&gt;
&lt;li&gt;One or two feature interactions. Do these match what your description claims?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If any of that breaks, you're rejected. If it merely takes too long to work out — a demo account that doesn't support the sign-in method they tried first, for instance — you can also be rejected for missing information, which is the same outcome with more steps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The dry run:&lt;/strong&gt; hand your app and your demo account to someone who has never seen it, give them a minute, and watch. Don't help. Wherever they hesitate, the reviewer will hesitate too, except the reviewer has other apps to get to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Review notes: write a route, not credentials
&lt;/h2&gt;

&lt;p&gt;This is the highest-value item here and it costs ten minutes.&lt;/p&gt;

&lt;p&gt;Most teams fill the App Review Notes field with the minimum:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight email"&gt;&lt;code&gt;&lt;span class="nt"&gt;Email&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="na"&gt; reviewer@yourapp.com&lt;/span&gt;
&lt;span class="nt"&gt;Password&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="na"&gt; TestPass123!&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gets the reviewer into the app and no further. They then explore on their own, in whatever order occurs to them, and whatever they don't find, they conclude isn't there.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Email: reviewer@yourapp.com
Password: TestPass123!

Once signed in:
1. Tap + on the home screen to create a Budget.
2. Add two or three expenses.
3. Tap Reports in the tab bar for the visualisation.
4. Tap Share, top right, for the export flow.

All features are unlocked on this account. No paywall.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same amount of reviewer time, far more of your app actually seen. It also pre-empts the most common completeness rejection, which is a reviewer concluding a feature doesn't exist because they didn't reach it.&lt;/p&gt;

&lt;p&gt;The last line matters more than it looks. A reviewer hitting an unexpected paywall has to decide whether that's the intended experience, and that decision doesn't go your way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Screenshots that survive being small
&lt;/h2&gt;

&lt;p&gt;Search results render your screenshots at thumbnail size. That's where the install decision gets made, and text sized for a full-screen preview is unreadable there.&lt;/p&gt;

&lt;p&gt;A hierarchy that works:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Screenshot 1.&lt;/strong&gt; One enormous headline stating the single most valuable outcome. Nothing else. It must be readable at thumbnail size, which means far larger type than feels reasonable in the design file.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Screenshots 2 and 3.&lt;/strong&gt; One feature each, device UI, short caption.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Screenshots 4 onward.&lt;/strong&gt; Supporting features and social proof, for the minority who swipe.&lt;/p&gt;

&lt;p&gt;The test costs nothing: scale your first screenshot down to roughly the width of a thumbnail and look at it. If you can't read the headline, neither can anyone else, and that's the only one most people ever see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Changelogs people actually read
&lt;/h2&gt;

&lt;p&gt;"Bug fixes and improvements" tells a user nothing, which means it gives them no reason to update, which means they stay on an older build and eventually conclude the app is abandoned.&lt;/p&gt;

&lt;p&gt;One line per user-visible change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;• Faster launch on iOS 18
• Fixed: widget showing stale data after a sync
• Fixed: dark mode contrast on Settings
• Added: CSV export for Reports
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what that does beyond informing. "Fixed: widget showing stale data" tells someone who hit that bug that you saw it. That's worth more than the fix, because a user who reported something and never heard back assumes nobody is home.&lt;/p&gt;

&lt;h2&gt;
  
  
  The connecting idea
&lt;/h2&gt;

&lt;p&gt;Every item here is the same move: treating someone you can't talk to as a user with limited time and no context.&lt;/p&gt;

&lt;p&gt;The reviewer can't ask you where the feature is. The person scrolling search results can't zoom in. The user on an old build can't tell whether the update is worth the download. In each case they make a decision with whatever you gave them, and in each case most teams gave them very little.&lt;/p&gt;

&lt;p&gt;Automating the mechanical parts — generating changelog stubs from the release diff, checking listing assets before submission, keeping the workflow in one place — is what &lt;a href="https://www.letsdeploy.it/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=app-store-submission-ux" rel="noopener noreferrer"&gt;letsdeploy.it&lt;/a&gt; does. The judgement parts stay yours, and the review notes especially are worth writing by hand.&lt;/p&gt;




&lt;p&gt;What's in your App Review Notes field right now? My guess is an email and a password, and that ten minutes spent there has a better return than anything else on this list.&lt;/p&gt;

</description>
      <category>ios</category>
      <category>appstore</category>
      <category>ux</category>
      <category>mobile</category>
    </item>
    <item>
      <title>Self-Hosted Supabase Doesn't Break Because Postgres Is Hard</title>
      <dc:creator>Russel Dsouza</dc:creator>
      <pubDate>Fri, 28 Aug 2026 12:21:49 +0000</pubDate>
      <link>https://dev.to/russel_dsouza_bd584a3cb2a/self-hosted-supabase-doesnt-break-because-postgres-is-hard-cf0</link>
      <guid>https://dev.to/russel_dsouza_bd584a3cb2a/self-hosted-supabase-doesnt-break-because-postgres-is-hard-cf0</guid>
      <description>&lt;p&gt;I spoke to teams who had run self-hosted Supabase in production. Some still do. Some moved back to hosted. A couple ended up assembling their own stack from primitives.&lt;/p&gt;

&lt;p&gt;The question I wanted answered: is the pain "Postgres is hard", or something more specific?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disclosure before anything else:&lt;/strong&gt; I work on a Supabase-compatible runtime, so I had a stake in one of the possible answers. The findings below happen to point at a product shape close to what my team builds, and you should weigh them accordingly. I've tried to report what people said rather than what would be convenient.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Nobody complained about Postgres. Every team described it as boring and working.&lt;/li&gt;
&lt;li&gt;The pain is the surrounding services: auth, realtime, storage, proxy, meta, and the rest&lt;/li&gt;
&lt;li&gt;Version drift between local and production was named unprompted by every team&lt;/li&gt;
&lt;li&gt;Realtime dropping events during deploy restarts was the second most common&lt;/li&gt;
&lt;li&gt;Both are distribution problems, not database problems&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The three failure modes
&lt;/h2&gt;

&lt;p&gt;Ranked by how many teams raised each without prompting:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Auth service version drift between local and production.&lt;/strong&gt; Every team.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Realtime falling over during deploy churn.&lt;/strong&gt; Most of them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage service behaving differently from S3 behind a reverse proxy.&lt;/strong&gt; Several.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Postgres was described as boring and working, by everyone. Not one complaint.&lt;/p&gt;

&lt;p&gt;The pain lives in the distribution: the wrapping services that turn a database into a platform. Auth, realtime, storage, functions, image proxy, gateway, metadata. Roughly ten moving parts, each with its own version, its own restart behaviour, and its own proxy quirks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Version drift, in detail
&lt;/h2&gt;

&lt;p&gt;This came up in every conversation.&lt;/p&gt;

&lt;p&gt;The shape: you develop locally against current service versions. Production runs older ones, because upgrading is a maintenance window and there is always a reason to defer it. Then code that works locally fails in production, because something changed between those versions.&lt;/p&gt;

&lt;p&gt;Error taxonomies are the usual culprit. An auth error name that exists in the version on your laptop does not exist in the version on your server, so the client falls through to a generic unknown-error path and tells you nothing useful.&lt;/p&gt;

&lt;p&gt;One team described losing a full day to exactly this. The conclusion they reached, which several others reached independently: nothing about that day was Postgres.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The workaround everyone lands on&lt;/strong&gt; is pinning local development to the same service versions as production. Which means every developer runs deliberately outdated services, nobody benefits from upstream fixes, and onboarding a new engineer means reproducing a bespoke pinned setup that exists in one person's head.&lt;/p&gt;

&lt;p&gt;That is a distribution problem. Not a code problem, and not a database one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Realtime during deploys
&lt;/h2&gt;

&lt;p&gt;The second most common, and the more insidious of the two, because it produces no errors.&lt;/p&gt;

&lt;p&gt;Rolling deploy. The realtime service restarts. WebSocket clients disconnect, then reconnect, which works correctly. But during that reconnect window, subscriptions can miss changes.&lt;/p&gt;

&lt;p&gt;Nobody sees an error. Users just don't get updates. The team finds out days later, when a customer mentions that a change on one device never showed up on another.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The workaround:&lt;/strong&gt; deploy realtime only in low-traffic windows and manually verify subscription counts either side. That is toil, and it scales with team size in the wrong direction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Storage behind a proxy
&lt;/h2&gt;

&lt;p&gt;Less universal, sharper when it lands. Several teams lost an afternoon to 403s on presigned uploads that worked locally and failed behind nginx or HAProxy.&lt;/p&gt;

&lt;p&gt;The cause was some combination of header case sensitivity and a Host header mismatch. Straightforward once you know where to look, and genuinely baffling if you assumed "S3-compatible" means "behaves identically to S3 in every deployment topology."&lt;/p&gt;

&lt;p&gt;It doesn't. Compatibility claims describe an API surface, not a network path.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this actually implies
&lt;/h2&gt;

&lt;p&gt;The useful finding isn't that self-hosting is hard. It's that the difficulty is misattributed.&lt;/p&gt;

&lt;p&gt;Self-hosted Supabase doesn't fail because a database is hard to run. It fails because a platform assembled from around ten independently versioned services has a coordination problem, and coordination problems don't announce themselves. They present as a weird error in production, an event that didn't arrive, a 403 that only happens behind the proxy.&lt;/p&gt;

&lt;p&gt;Two things would remove most of what these teams described:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ship every service at one version, by construction.&lt;/strong&gt; Not tooling that makes alignment easier. A shape where misalignment isn't expressible, because the whole platform is one artifact with one version.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Make realtime restarts transparent.&lt;/strong&gt; Resumption plus event replay across the disconnect window, so a deploy stops being a silent data-loss event.&lt;/p&gt;

&lt;p&gt;Proxy compatibility is a third, and it becomes essential rather than nice-to-have the moment anyone puts this behind enterprise networking.&lt;/p&gt;

&lt;p&gt;I'll be straightforward about the conflict here: the first of those describes &lt;a href="https://tinbase.dev/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=self-hosted-supabase-version-drift" rel="noopener noreferrer"&gt;tinbase&lt;/a&gt;, which is what I work on. It is a single binary, so services can't drift apart. It does not solve the second, and I'm not aware of anything that does yet.&lt;/p&gt;

&lt;p&gt;It's also a local development runtime rather than a production self-hosting replacement, which is worth stating plainly given that everything above is about production.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you're running it now
&lt;/h2&gt;

&lt;p&gt;If you're hitting these, you aren't doing anything wrong. The shape of the distribution is doing it, and every workaround people described was a reasonable response to an unreasonable coordination problem.&lt;/p&gt;

&lt;p&gt;Pin your versions deliberately rather than accidentally. Write down which ones, somewhere a new engineer will find. Deploy realtime when it costs least. And treat "S3-compatible" as a claim about an API, not a promise about your network.&lt;/p&gt;




&lt;p&gt;If you run self-hosted Supabase in production, what broke that wasn't the database? I'm collecting these, and my expectation is that almost none of the answers are Postgres.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>devops</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>Should You Self-Host Supabase? It's Three Decisions, Not One</title>
      <dc:creator>Russel Dsouza</dc:creator>
      <pubDate>Thu, 27 Aug 2026 10:53:45 +0000</pubDate>
      <link>https://dev.to/russel_dsouza_bd584a3cb2a/should-you-self-host-supabase-its-three-decisions-not-one-517b</link>
      <guid>https://dev.to/russel_dsouza_bd584a3cb2a/should-you-self-host-supabase-its-three-decisions-not-one-517b</guid>
      <description>&lt;p&gt;The self-hosting argument never resolves because both sides are right about different things.&lt;/p&gt;

&lt;p&gt;Own your data, no lock-in, cheaper at scale. Also true: it's an operational tax, upgrades cascade, and local drifts from prod.&lt;/p&gt;

&lt;p&gt;Both hold. "Should I self-host Supabase" isn't one question, it's three, and most people answer all three with a single decision.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Local dev, staging, and production are separate decisions with separate constraints&lt;/li&gt;
&lt;li&gt;Picking one answer for all three is where the pain comes from&lt;/li&gt;
&lt;li&gt;Local: worst default, cheapest to change&lt;/li&gt;
&lt;li&gt;Production: usually-correct default, most expensive to change&lt;/li&gt;
&lt;li&gt;Most people optimise the expensive one and inherit the cheap one&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The three decisions
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Decision&lt;/th&gt;
&lt;th&gt;Optimises for&lt;/th&gt;
&lt;th&gt;Cost of getting it wrong&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Local dev&lt;/td&gt;
&lt;td&gt;Iteration speed, low friction&lt;/td&gt;
&lt;td&gt;Paid daily, compounds quietly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Staging&lt;/td&gt;
&lt;td&gt;Fidelity to production&lt;/td&gt;
&lt;td&gt;Bugs reach users&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Production&lt;/td&gt;
&lt;td&gt;Reliability, operational burden&lt;/td&gt;
&lt;td&gt;Paid at the worst possible moment&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;They pull in opposite directions. Local wants to be fast and disposable. Staging wants to be identical to prod. Production wants to be reliable and ideally someone else's problem.&lt;/p&gt;

&lt;p&gt;No single answer satisfies all three, which is why whichever one you pick feels wrong somewhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  Local: the one worth changing
&lt;/h2&gt;

&lt;p&gt;Worst default, and almost nobody revisits it.&lt;/p&gt;

&lt;p&gt;The standard setup runs the full container stack. Gigabytes of RAM, tens of seconds to boot, a laptop that gets loud. The consequence isn't complaints, it's that people stop running it.&lt;/p&gt;

&lt;p&gt;That's the actual cost and it appears in no comparison table. A local environment nobody runs is one that quietly stops working, and you find out the day prod breaks and three people spend an afternoon building a repro from scratch.&lt;/p&gt;

&lt;p&gt;Switching cost here is also the lowest of the three. Local is disposable. If a lighter setup doesn't work, you lost an afternoon.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to tell:&lt;/strong&gt; is anyone on your team pointing at staging instead of running locally? Then you already have your answer.&lt;/p&gt;

&lt;p&gt;Single-binary runtimes exist for this now. &lt;a href="https://tinbase.dev/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=where-should-supabase-run" rel="noopener noreferrer"&gt;tinbase&lt;/a&gt; is the one my team builds, so discount accordingly. It serves the Supabase-compatible surface from one process. It's a local dev runtime, not a production self-hosting replacement, which is exactly why these decisions need separating.&lt;/p&gt;

&lt;h2&gt;
  
  
  Staging: the one people skip
&lt;/h2&gt;

&lt;p&gt;Staging is where the parity argument belongs, and it's usually where nobody's looking.&lt;/p&gt;

&lt;p&gt;If local runs a different implementation from prod, local passing stops guaranteeing prod passing. Real cost. But the fix isn't making local heavier, it's having something between local and prod that's genuinely faithful.&lt;/p&gt;

&lt;p&gt;Run the full stack in staging. Once before a release, not every morning. One boot a week instead of one a day, and fidelity lands where fidelity matters.&lt;/p&gt;

&lt;p&gt;Skip staging and try to get parity out of local, and you end up with the worst combination available: a heavy local setup that still isn't identical to prod.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production: highest stakes, usually-correct default
&lt;/h2&gt;

&lt;p&gt;Self-hosting prod means owning image updates, CVE patching, cert renewal, backup verification, and restore drills.&lt;/p&gt;

&lt;p&gt;With a platform team, that amortises across roles that already exist. Without one, it's evenings and weekends, and it has no deadline pressure right up until it has all of it.&lt;/p&gt;

&lt;p&gt;The honest test isn't cost. It's: &lt;strong&gt;when did you last restore from a backup?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If never, you're not self-hosting. You're holding an unverified assumption about data you can't afford to lose. A backup nobody has restored is a hypothesis.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Self-hosting prod is reasonable when:&lt;/strong&gt; someone owns operations, upgrades have a runbook, restore drills are scheduled, and the hosted bill justifies the work. All four. Not one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mistake
&lt;/h2&gt;

&lt;p&gt;People spend their thinking on the production decision, where the default is usually right and switching is expensive. Then they inherit the local decision, where the default is usually wrong and switching costs an afternoon.&lt;/p&gt;

&lt;p&gt;Flip the allocation. Change local this week. Leave prod alone until all four conditions above are true.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Optimise the decision you pay for daily, not the one you argue about online.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Worked examples
&lt;/h2&gt;

&lt;p&gt;Solo dev with a live product:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Local&lt;/td&gt;
&lt;td&gt;Lightweight, no containers&lt;/td&gt;
&lt;td&gt;Runs constantly, friction compounds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Staging&lt;/td&gt;
&lt;td&gt;Full stack, before releases&lt;/td&gt;
&lt;td&gt;Parity where it counts, weekly not daily&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Production&lt;/td&gt;
&lt;td&gt;Hosted&lt;/td&gt;
&lt;td&gt;No ops capacity, no restore drills happening&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Team of fifteen with a platform engineer:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Choice&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Local&lt;/td&gt;
&lt;td&gt;Developer preference&lt;/td&gt;
&lt;td&gt;Individual cost, individual call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Staging&lt;/td&gt;
&lt;td&gt;Full stack, always running&lt;/td&gt;
&lt;td&gt;Fidelity is cheap when someone owns it&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Production&lt;/td&gt;
&lt;td&gt;Either, run the numbers&lt;/td&gt;
&lt;td&gt;Ops capacity makes it a real option&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Notice what moves. Production changes with team structure. Staging barely moves. Local is personal and should be.&lt;/p&gt;

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

&lt;p&gt;If you have no staging environment, local is doing two jobs and doing at least one badly. Fix that first.&lt;/p&gt;

&lt;p&gt;Then: light local, faithful staging, boring production. Revisit production only when all four conditions hold.&lt;/p&gt;




&lt;p&gt;Which of the three did you actually decide on purpose? My bet is production, because it feels consequential, and that local came from a quickstart guide three years ago and nobody has touched it since.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Migrating a Supabase Local Dev Project to Tinbase in 10 Minutes</title>
      <dc:creator>Russel Dsouza</dc:creator>
      <pubDate>Mon, 24 Aug 2026 12:26:47 +0000</pubDate>
      <link>https://dev.to/russel_dsouza_bd584a3cb2a/migrating-a-supabase-local-dev-project-to-tinbase-in-10-minutes-189g</link>
      <guid>https://dev.to/russel_dsouza_bd584a3cb2a/migrating-a-supabase-local-dev-project-to-tinbase-in-10-minutes-189g</guid>
      <description>&lt;p&gt;If you run Docker-based Supabase for local development and want to try the same stack on a single binary, this is the walkthrough. It takes about ten minutes for a typical project and nothing about it is destructive.&lt;/p&gt;

&lt;p&gt;Tinbase is a Supabase-compatible runtime that ships as one executable. Your app code doesn't change, the SDK is the same, only the connection string and the way you boot the backend differ.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Install the binary, dump your schema, import it, swap the connection string&lt;/li&gt;
&lt;li&gt;Your Docker Supabase state is never touched, so rollback is a &lt;code&gt;git checkout&lt;/code&gt; away&lt;/li&gt;
&lt;li&gt;Auth and realtime work through the same &lt;code&gt;@supabase/supabase-js&lt;/code&gt; client&lt;/li&gt;
&lt;li&gt;Edge functions and non-standard extensions are where the gaps are&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;macOS, Linux, or Windows&lt;/li&gt;
&lt;li&gt;A working Docker-based Supabase local environment you can currently run&lt;/li&gt;
&lt;li&gt;A Node project using &lt;code&gt;@supabase/supabase-js&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This assumes your app connects through the standard SDK. Direct low-level Postgres connections work too, but aren't covered here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: install the binary
&lt;/h2&gt;



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

&lt;/div&gt;



&lt;p&gt;If you'd rather not pipe a script into a shell, download and read it first. Same URL, no pipe.&lt;/p&gt;

&lt;p&gt;Verify the install:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tinbase &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: dump your Supabase schema
&lt;/h2&gt;

&lt;p&gt;With Docker Supabase running:&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 dump &lt;span class="nt"&gt;--file&lt;/span&gt; schema.sql &lt;span class="nt"&gt;--schema&lt;/span&gt; public
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That produces a &lt;code&gt;schema.sql&lt;/code&gt; containing your tables, functions, RLS policies, and triggers.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;auth&lt;/code&gt; schema doesn't need exporting. Both platforms manage it themselves, and importing one over the other creates conflicts rather than continuity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: import into Tinbase
&lt;/h2&gt;

&lt;p&gt;Start in a fresh directory:&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="nb"&gt;mkdir &lt;/span&gt;tinbase-dev &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;tinbase-dev
tinbase up
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a second terminal, import:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tinbase psql &lt;span class="nt"&gt;-f&lt;/span&gt; ../schema.sql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Confirm the tables landed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tinbase psql &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'\dt public.*'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If this errors, read the message rather than re-running. Import failures are almost always a missing extension, and the error names it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: point your app at it
&lt;/h2&gt;

&lt;p&gt;Docker Supabase serves on &lt;code&gt;54321&lt;/code&gt;. Tinbase uses a different default port, configurable through an environment variable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- SUPABASE_URL=http://localhost:54321
- SUPABASE_ANON_KEY=eyJhbGciOiJI...
&lt;/span&gt;&lt;span class="gi"&gt;+ SUPABASE_URL=http://localhost:8080
+ SUPABASE_ANON_KEY=&amp;lt;from `tinbase config show`&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;tinbase config show&lt;/code&gt; to get the anon key for your local instance. It's different from your Docker Supabase key, which is the step people miss and then spend twenty minutes debugging a 401.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: verify
&lt;/h2&gt;

&lt;p&gt;Three checks, in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Sign up a test user&lt;/strong&gt; through your app's normal signup flow, then confirm the row exists:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tinbase psql &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'SELECT id, email FROM auth.users'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Sign in as that user&lt;/strong&gt; and confirm a protected route resolves. This exercises JWT issuing and verification, which is the part most likely to differ.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Trigger a realtime change&lt;/strong&gt; if your app subscribes to anything, and confirm the subscription fires.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If all three pass, you're done. If step two fails, it's the anon key from step four.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rolling back
&lt;/h2&gt;

&lt;p&gt;Your Docker Supabase state was never modified.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git checkout .env
supabase start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the entire rollback. Worth knowing before you start, because it changes how much you need to think about this.&lt;/p&gt;

&lt;h2&gt;
  
  
  What doesn't migrate cleanly
&lt;/h2&gt;

&lt;p&gt;Known gaps, worth checking against your project before you begin:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Edge functions written for Deno.&lt;/strong&gt; Tinbase runs a v8 isolate rather than Deno, so Deno-specific APIs need adjusting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Uncommon Postgres extensions.&lt;/strong&gt; The widely used ones are preloaded; anything niche may not be.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage.&lt;/strong&gt; Tinbase uses the local filesystem rather than S3-compatible object storage, which is usually what you want locally but differs from production.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a standard project using Postgres, auth, realtime, and the JS client, none of these apply.&lt;/p&gt;

&lt;p&gt;That third one is worth a thought regardless of which tool you use. If your local storage layer behaves differently from production storage, you have a category of bug that only appears after deploy, and no local setup will surface it.&lt;/p&gt;

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

&lt;p&gt;The honest version: if Docker Supabase boots fast enough on your machine and you never avoid running it, there's no problem to solve here.&lt;/p&gt;

&lt;p&gt;The case for a single binary is about the days you'd otherwise skip local entirely. Boot time and memory footprint determine whether a local environment gets used, and an environment nobody runs is one that quietly stops working.&lt;/p&gt;

&lt;p&gt;Full docs and source are at &lt;a href="https://tinbase.dev/?utm_source=devto&amp;amp;utm_medium=blog&amp;amp;utm_campaign=supabase-to-tinbase-migration" rel="noopener noreferrer"&gt;tinbase.dev&lt;/a&gt;. MIT licensed.&lt;/p&gt;




&lt;p&gt;If you try this, I'm interested in what broke. Particularly edge functions, since that's the gap where "small syntax adjustments" covers a wide range of actual experiences.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>opensource</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
