<?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: Joan Comadran</title>
    <description>The latest articles on DEV Community by Joan Comadran (@joancomadran).</description>
    <link>https://dev.to/joancomadran</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%2F4184546%2Fa52291c1-199f-41dc-afaf-da5af90cac59.jpg</url>
      <title>DEV Community: Joan Comadran</title>
      <link>https://dev.to/joancomadran</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/joancomadran"/>
    <language>en</language>
    <item>
      <title>Supabase "new row violates row-level security policy": every cause and the fix</title>
      <dc:creator>Joan Comadran</dc:creator>
      <pubDate>Wed, 14 Oct 2026 13:48:04 +0000</pubDate>
      <link>https://dev.to/joancomadran/supabase-new-row-violates-row-level-security-policy-every-cause-and-the-fix-136j</link>
      <guid>https://dev.to/joancomadran/supabase-new-row-violates-row-level-security-policy-every-cause-and-the-fix-136j</guid>
      <description>&lt;p&gt;If Supabase is throwing &lt;code&gt;new row violates row-level security policy for table "your_table"&lt;/code&gt; (code &lt;code&gt;42501&lt;/code&gt;, HTTP 403), &lt;strong&gt;Row Level Security is on and the row you're writing didn't pass any policy's &lt;code&gt;WITH CHECK&lt;/code&gt;.&lt;/strong&gt; Nine times out of ten the fix is one of these two:&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;-- 1. The table has no INSERT policy. Add one:&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;"insert own rows"&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;notes&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;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="c1"&gt;-- 2. The row doesn't carry the user's id. Let the database fill it:&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;notes&lt;/span&gt;
  &lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;column&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that didn't do it, one of the other four causes below is yours. I'll show how to tell which, with the SQL for each. The examples come from the RLS I've written for two production Supabase apps: &lt;a href="https://www.joancomadran.dev/projects/claient" rel="noopener noreferrer"&gt;Claient&lt;/a&gt;, a multi-tenant WhatsApp lead agent where every row belongs to an account, and pollitics.es, a public polling site.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the error actually means
&lt;/h2&gt;

&lt;p&gt;RLS is &lt;strong&gt;default-deny per command&lt;/strong&gt;. Enabling it on a table without policies blocks everything for &lt;code&gt;anon&lt;/code&gt; and &lt;code&gt;authenticated&lt;/code&gt;. A &lt;code&gt;SELECT&lt;/code&gt; policy lets people read, but it says nothing about &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt; or &lt;code&gt;DELETE&lt;/code&gt;. Each of those needs its own policy, or a &lt;code&gt;for all&lt;/code&gt; policy.&lt;/p&gt;

&lt;p&gt;Two clauses matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;USING&lt;/code&gt;: which &lt;strong&gt;existing&lt;/strong&gt; rows you can see, update or delete.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;WITH CHECK&lt;/code&gt;: which &lt;strong&gt;new&lt;/strong&gt; row values you're allowed to write (inserts, and the after-image of updates).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"new row violates…" always means a &lt;code&gt;WITH CHECK&lt;/code&gt; evaluated to false or null for your row. It never means "the table doesn't exist" or "your key is wrong".&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 1: there is no INSERT policy
&lt;/h2&gt;

&lt;p&gt;The classic one. You enabled RLS, added "Enable read access for authenticated users", and reads work, so you assume writes will too. They won't.&lt;/p&gt;

&lt;p&gt;Check what you have:&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;policyname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;with_check&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_policies&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;schemaname&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="n"&gt;tablename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'notes'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If no row has &lt;code&gt;cmd&lt;/code&gt; = &lt;code&gt;INSERT&lt;/code&gt; or &lt;code&gt;ALL&lt;/code&gt;, add the policy from the top of this post. If you want one policy per table, &lt;code&gt;for all&lt;/code&gt; with both clauses is fine. This is how every tenant table in Claient is written:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="n"&gt;leads_all&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;leads&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_account_member&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_account_member&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Cause 2: user_id is null or not the caller
&lt;/h2&gt;

&lt;p&gt;Your policy says &lt;code&gt;with check (auth.uid() = user_id)&lt;/code&gt;, but the client inserted &lt;code&gt;{ title }&lt;/code&gt; without &lt;code&gt;user_id&lt;/code&gt;. Then &lt;code&gt;auth.uid() = null&lt;/code&gt; is null, not true, and the row is rejected.&lt;/p&gt;

&lt;p&gt;Don't make the client responsible for sending its own id. Set a default so it can omit the column:&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="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;column&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the &lt;code&gt;WITH CHECK&lt;/code&gt; anyway. A default only applies when the column is omitted, so the policy is what stops a client sending someone else's id.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 3: the request isn't authenticated (auth.uid() is null)
&lt;/h2&gt;

&lt;p&gt;The policy and the column are right, and it still fails. The usual culprit is that the request reached Postgres as &lt;code&gt;anon&lt;/code&gt;, so &lt;code&gt;auth.uid()&lt;/code&gt; is null and every &lt;code&gt;auth.uid() = …&lt;/code&gt; check fails. That happens when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a Server Action or Route Handler uses a client created with &lt;code&gt;createClient(url, anonKey)&lt;/code&gt; and no session;&lt;/li&gt;
&lt;li&gt;in Next.js with &lt;code&gt;@supabase/ssr&lt;/code&gt;, the server client isn't reading the request cookies, or the session cookie expired and was never refreshed by middleware;&lt;/li&gt;
&lt;li&gt;the insert runs before the session exists, e.g. right after &lt;code&gt;signUp()&lt;/code&gt; with email confirmation still pending.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Quick test: run &lt;code&gt;select auth.uid(), auth.role();&lt;/code&gt; through the same client (as an RPC), or log &lt;code&gt;await supabase.auth.getUser()&lt;/code&gt; right before the insert. If it's null or &lt;code&gt;anon&lt;/code&gt;, fix the client, not the policy. On the server, build the client with &lt;code&gt;createServerClient&lt;/code&gt; from &lt;code&gt;@supabase/ssr&lt;/code&gt; and pass it the request cookies. That's how the dashboard in Claient does it.&lt;/p&gt;

&lt;p&gt;If you're creating a profile row right after sign-up, don't insert from the client at all. Use a trigger on &lt;code&gt;auth.users&lt;/code&gt; with a &lt;code&gt;security definer&lt;/code&gt; function, which runs regardless of the session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 4: insert().select() without a SELECT policy
&lt;/h2&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="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="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;.select()&lt;/code&gt; turns the statement into &lt;code&gt;INSERT … RETURNING *&lt;/code&gt;. Postgres must let you &lt;strong&gt;read&lt;/strong&gt; the row you just wrote, so a table with an INSERT policy but no matching SELECT policy fails with the same "new row violates" message. It's confusing because the insert is the part that's allowed.&lt;/p&gt;

&lt;p&gt;Fix: add a SELECT policy that covers the new row, or drop &lt;code&gt;.select()&lt;/code&gt; if you don't need the row back.&lt;/p&gt;

&lt;p&gt;The same thing explains the famous &lt;strong&gt;Storage&lt;/strong&gt; version of the error. Uploads do an &lt;code&gt;INSERT … RETURNING&lt;/code&gt; on &lt;code&gt;storage.objects&lt;/code&gt;, so you need a SELECT policy on &lt;code&gt;storage.objects&lt;/code&gt; for that bucket and path, not just an INSERT one. &lt;a href="https://supabase.com/docs/guides/troubleshooting/storage-error-403-forbidden-new-row-violates-row-level-security-policy-on-upload-a94384" rel="noopener noreferrer"&gt;Supabase's troubleshooting note&lt;/a&gt; confirms it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 5: an UPDATE whose new row fails WITH CHECK
&lt;/h2&gt;

&lt;p&gt;Updates are checked twice: &lt;code&gt;USING&lt;/code&gt; on the old row, and &lt;code&gt;WITH CHECK&lt;/code&gt; on the new one. If the &lt;code&gt;UPDATE&lt;/code&gt; policy has no &lt;code&gt;WITH CHECK&lt;/code&gt;, Postgres reuses the &lt;code&gt;USING&lt;/code&gt; expression for it. So an update that changes a column your policy depends on, like moving a row to another account or user, is rejected with "new row violates…".&lt;/p&gt;

&lt;p&gt;Usually that's exactly what you want: it stops users from writing a row into someone else's tenant. If it's blocking a legitimate update, look at which column in your &lt;code&gt;SET&lt;/code&gt; the policy reads, and write an explicit &lt;code&gt;WITH CHECK&lt;/code&gt; for the state the row is allowed to end up in. Don't reach for &lt;code&gt;with check (true)&lt;/code&gt; unless you've thought through what it lets through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cause 6: a policy that can't see what it needs
&lt;/h2&gt;

&lt;p&gt;Multi-tenant policies often check membership in another table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&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;account_members&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;
               &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;leads&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;account_members&lt;/code&gt; has RLS too, that subquery runs &lt;strong&gt;under the caller's policies&lt;/strong&gt;. It can come back empty, making the check false, or recurse into itself if &lt;code&gt;account_members&lt;/code&gt;' own policy does the same lookup. The fix I use in Claient is one &lt;code&gt;security definer&lt;/code&gt; function that every policy calls:&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;or&lt;/span&gt; &lt;span class="k"&gt;replace&lt;/span&gt; &lt;span class="k"&gt;function&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;is_account_member&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target_account&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt;
&lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt;
&lt;span class="k"&gt;stable&lt;/span&gt;
&lt;span class="k"&gt;security&lt;/span&gt; &lt;span class="k"&gt;definer&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;search_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;
&lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
  &lt;span class="k"&gt;select&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="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account_members&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;
    &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;target_account&lt;/span&gt;
      &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;security definer&lt;/code&gt; makes the lookup run as the function's owner, so it doesn't depend on &lt;code&gt;account_members&lt;/code&gt;' own policies and can't recurse. &lt;code&gt;set search_path&lt;/code&gt; stops anyone from shadowing the table with their own. I go deeper into this pattern, including wrapping calls as &lt;code&gt;(select fn())&lt;/code&gt; so they run once per query instead of once per row, in the &lt;a href="https://www.joancomadran.dev/blog/supabase-nextjs-pre-launch-security-checklist" rel="noopener noreferrer"&gt;pre-launch security checklist for Next.js + Supabase&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not this error: "permission denied for table"
&lt;/h2&gt;

&lt;p&gt;Same &lt;code&gt;42501&lt;/code&gt; code, different problem. &lt;code&gt;permission denied for table notes&lt;/code&gt; (or &lt;code&gt;for column status&lt;/code&gt;) means the role lacks a &lt;strong&gt;GRANT&lt;/strong&gt;, which Postgres checks &lt;em&gt;before&lt;/em&gt; RLS. No policy will fix it.&lt;/p&gt;

&lt;p&gt;I hit this deliberately in Claient. Users could update their own feature request, and the RLS policy limited that to their own row. But RLS can't limit &lt;strong&gt;which columns&lt;/strong&gt; you change, so someone could call PostgREST directly and mark their own request as "done". The fix was column-level grants:&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;revoke&lt;/span&gt; &lt;span class="k"&gt;update&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;feature_requests&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;description&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;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;feature_requests&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now updating &lt;code&gt;status&lt;/code&gt; as a user returns "permission denied for column", while the admin path still works.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the service_role key is the right answer (and when it isn't)
&lt;/h2&gt;

&lt;p&gt;The fastest way to make the error go away is the &lt;code&gt;service_role&lt;/code&gt; (secret) key, which bypasses RLS completely. That's correct for server code that legitimately acts across users. In Claient, the WhatsApp webhook writes messages and leads for whichever tenant the message belongs to, and the Stripe webhook writes subscriptions. Those tables deliberately have &lt;strong&gt;no&lt;/strong&gt; write policy for users, so only the server can write them. Some internal tables have RLS on with zero policies, which is deny-all for everyone but &lt;code&gt;service_role&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It's the wrong answer in user-facing code. The key must stay server-only (never &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt;), and swapping it in to silence an error means that request no longer has any data isolation.&lt;/p&gt;

&lt;h2&gt;
  
  
  A 60-second checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;select * from pg_policies where tablename = 'x'&lt;/code&gt;: is there an INSERT/UPDATE/ALL policy for the right role?&lt;/li&gt;
&lt;li&gt;Does it have a &lt;code&gt;WITH CHECK&lt;/code&gt;, and does your row satisfy it? Is &lt;code&gt;user_id&lt;/code&gt; actually set?&lt;/li&gt;
&lt;li&gt;Is &lt;code&gt;auth.uid()&lt;/code&gt; non-null for this request? Check the client and session first.&lt;/li&gt;
&lt;li&gt;Are you calling &lt;code&gt;.select()&lt;/code&gt; after the insert, or uploading to Storage? Then you need a SELECT policy too.&lt;/li&gt;
&lt;li&gt;Is it an UPDATE changing a column the policy depends on?&lt;/li&gt;
&lt;li&gt;Does the policy query another RLS table? Move the lookup into a &lt;code&gt;security definer&lt;/code&gt; function.&lt;/li&gt;
&lt;li&gt;Does the message say "permission denied"? That's a GRANT, not RLS.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I build Supabase and AI products end to end, and RLS for multi-tenant apps is a recurring part of that. If you'd rather have someone get it right the first time, &lt;a href="https://www.joancomadran.dev/ai-engineering" rel="noopener noreferrer"&gt;here's how I work&lt;/a&gt;.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  What does "new row violates row-level security policy" mean in Supabase?
&lt;/h3&gt;

&lt;p&gt;It is Postgres error 42501, returned by Supabase as HTTP 403. Row Level Security is enabled on the table and the row you are writing did not pass any policy's WITH CHECK expression for that operation. Because RLS is default-deny per command, the most common reason is simply that the table has no INSERT (or UPDATE) policy for the role making the request.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I fix new row violates row-level security policy on insert?
&lt;/h3&gt;

&lt;p&gt;Add an INSERT policy for the authenticated role with a WITH CHECK clause, for example: create policy "insert own rows" on public.notes for insert to authenticated with check ((select auth.uid()) = user_id). Then make sure the row actually carries the signed-in user's id, ideally with a column default of auth.uid(), and that the request is made with the user's session rather than only the anon key.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why do I get the RLS error when I call insert().select()?
&lt;/h3&gt;

&lt;p&gt;insert().select() runs INSERT ... RETURNING, and returning the row requires that the caller can also SELECT it. If the table has an INSERT policy but no SELECT policy that covers the new row, the whole statement fails with the same RLS error. Add a matching SELECT policy or drop the .select().&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use the service_role key to get around RLS errors?
&lt;/h3&gt;

&lt;p&gt;Only on the server, and only for code that legitimately acts across users, such as webhooks or background workers. The service_role key bypasses RLS entirely, so it must never reach the browser. Using it to silence an RLS error in user-facing code removes your data isolation instead of fixing the policy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is "permission denied for table" the same as the RLS error?
&lt;/h3&gt;

&lt;p&gt;No. Both use SQLSTATE 42501, but "permission denied for table" (or "for column") comes from missing GRANT privileges, which Postgres checks before RLS. "new row violates row-level security policy" means the grant was fine and a policy rejected the row. Fix the first with GRANT, the second with a policy.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.joancomadran.dev/blog/supabase-new-row-violates-row-level-security-policy" rel="noopener noreferrer"&gt;joancomadran.dev&lt;/a&gt;. I'm Joan, an AI engineer in Barcelona — &lt;a href="https://www.joancomadran.dev/ai-engineering" rel="noopener noreferrer"&gt;work with me&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>nextjs</category>
      <category>postgres</category>
      <category>rls</category>
    </item>
    <item>
      <title>Shipping shareable poll results with Next.js ImageResponse</title>
      <dc:creator>Joan Comadran</dc:creator>
      <pubDate>Wed, 14 Oct 2026 13:42:41 +0000</pubDate>
      <link>https://dev.to/joancomadran/shipping-shareable-poll-results-with-nextjs-imageresponse-2kji</link>
      <guid>https://dev.to/joancomadran/shipping-shareable-poll-results-with-nextjs-imageresponse-2kji</guid>
      <description>&lt;p&gt;I've been building &lt;a href="https://pollitics.es" rel="noopener noreferrer"&gt;pollitics.es&lt;/a&gt;: a Spanish citizen thermometer for vote intention, wellbeing, and political mapping. The product lives or dies on whether people actually &lt;em&gt;spread&lt;/em&gt; the aggregate. Screenshots of a responsive layout crop wrong, leak UI chrome, and — worse — sometimes include someone's personal ballot if they voted before sharing. So last week I shipped a dedicated shareable results image: one PNG, same for everyone, generated with &lt;code&gt;next/og&lt;/code&gt;, handed to the native share sheet on mobile.&lt;/p&gt;

&lt;p&gt;This is the version that survived the quirks. Not the happy-path demo.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a route beats "just screenshot it"
&lt;/h2&gt;

&lt;p&gt;Open Graph meta tags (&lt;code&gt;og:image&lt;/code&gt;) are for link previews when someone pastes a URL. That is not the same product as "tap Share and attach a chart to WhatsApp / Instagram Stories / X". Meta cards are landscape-ish, often cached for days, and you don't control the crop once the client app decides to be clever. Instagram and WhatsApp Stories want a tall frame. Voters want a button that &lt;em&gt;feels&lt;/em&gt; like sharing a result, not sharing a browser tab.&lt;/p&gt;

&lt;p&gt;So the shape is two pieces:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A &lt;strong&gt;GET route&lt;/strong&gt; that returns a PNG built with &lt;code&gt;ImageResponse&lt;/code&gt; from &lt;code&gt;next/og&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;client button&lt;/strong&gt; that fetches that PNG as a &lt;code&gt;File&lt;/code&gt;, then either &lt;code&gt;navigator.share({ files })&lt;/code&gt; or falls back to a download.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The URL on pollitics is &lt;code&gt;/intencion-de-voto/imagen&lt;/code&gt;. Revalidate every 15 minutes — same cadence as the public results ISR — so the image and the page don't disagree for long.&lt;/p&gt;

&lt;h2&gt;
  
  
  The image: public aggregate only
&lt;/h2&gt;

&lt;p&gt;The hard product rule: &lt;strong&gt;the image never carries the viewer's vote&lt;/strong&gt;. It is the latest public snapshot, identical for every downloader. No "you voted X" badge, no personalised colour flash, no N in the corner that would let someone reverse-engineer a tiny sample. We even strip the "Basado en N respuestas" line and the snapshot date from the PNG (decided 2026-10-04) so a viral share doesn't become a forensic artefact.&lt;/p&gt;

&lt;p&gt;Dimensions are &lt;strong&gt;1080 × 1350&lt;/strong&gt; — the vertical that Instagram, X, and WhatsApp leave alone without cropping the bars. Monochrome chrome, party colours only on the bars, Inter subset for Latin glyphs. If there aren't enough votes yet, or Spain's electoral blackout is on, the route still returns an image: it just shows a freeze message instead of percentages. Diffusing polls during the LOREG blackout is illegal; the share UI is hidden on the page in that window, and the image route refuses to paint numbers even if someone hits the URL directly.&lt;/p&gt;

&lt;p&gt;Rough shape of the route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/intencion-de-voto/imagen/route.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;ImageResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;next/og&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;readFile&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:fs/promises&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;join&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:path&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;revalidate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;900&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;WIDTH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1080&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;HEIGHT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1350&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;GET&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;results&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;getPublicResults&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="cm"&gt;/* supabase public client */&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;blackout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;blackoutActive&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;ready&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="cm"&gt;/* snapshot exists &amp;amp;&amp;amp; votesToPublish(results) === 0 */&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;ImageResponse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;
        &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;100%&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;100%&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;flex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;flexDirection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;column&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;justifyContent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;space-between&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#ffffff&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;padding&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;72&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;fontFamily&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Inter&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;flex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;flexDirection&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;column&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;fontSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;28&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#595959&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            Intención de voto · Elecciones generales
          &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;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;fontSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lineHeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.08&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;marginTop&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            Si mañana hubiera elecciones
          &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;&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="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;ready&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;blackout&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="cm"&gt;/* bar rows from snapshot.decided — acronym, colour, pct */&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Bars&lt;/span&gt; &lt;span class="na"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;toRows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;snapshot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;decided&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;flex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fontSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;44&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;lineHeight&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.2&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
            &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;blackout&lt;/span&gt;
              &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Periodo electoral: resultados congelados.&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;Todavía no hay votos suficientes para publicar resultados.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="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;span class="si"&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;style&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="na"&gt;display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;flex&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;justifyContent&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;space-between&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;fontSize&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;borderTop&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2px solid #000&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;paddingTop&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;¿Y tú qué votarías?&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;pollitics.es&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;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;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;WIDTH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HEIGHT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;fonts&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="s2"&gt;Inter&lt;/span&gt;&lt;span class="dl"&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;readFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cwd&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;assets/fonts/Inter-Regular.ttf&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
          &lt;span class="na"&gt;weight&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="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;Two details that matter in production:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Load the font from disk&lt;/strong&gt;, not from a CDN fetch inside the handler. Cold starts and font CDNs love to race each other; bundling Inter under &lt;code&gt;assets/fonts&lt;/code&gt; made the route boringly reliable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cap the rows.&lt;/strong&gt; Spanish ballots are long. We show at most 12 bars and fold the rest into "Otros", otherwise the type collapses and Stories crop the bottom parties.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The button: Safari will punish you
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;navigator.share({ files: [png] })&lt;/code&gt; is the right API on mobile. Desktop browsers often don't support file shares, so you need a download path. The nasty part is &lt;strong&gt;Safari's user-gesture requirement&lt;/strong&gt;: if you &lt;code&gt;await fetch(image)&lt;/code&gt; &lt;em&gt;inside&lt;/em&gt; the click handler before calling &lt;code&gt;share()&lt;/code&gt;, Safari decides the gesture expired and throws &lt;code&gt;NotAllowedError&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix is to &lt;strong&gt;start the fetch on &lt;code&gt;pointerdown&lt;/code&gt; / &lt;code&gt;focus&lt;/code&gt;&lt;/strong&gt;, stash the &lt;code&gt;Promise&amp;lt;File&amp;gt;&lt;/code&gt;, and resolve it on click:&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&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;IMAGE_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/intencion-de-voto/imagen&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;ShareImage&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;pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useRef&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;File&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fileName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`pollitics-intencion-de-voto-&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;.png`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;??=&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;IMAGE_URL&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="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="nc"&gt;String&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="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;File&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;blob&lt;/span&gt;&lt;span class="p"&gt;()],&lt;/span&gt; &lt;span class="nx"&gt;fileName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;image/png&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="k"&gt;catch&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;e&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;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;download&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;File&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;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createObjectURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;a&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;href&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="na"&gt;download&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fileName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;click&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="nf"&gt;revokeObjectURL&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;share&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;File&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;file&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;prepare&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="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;canShare&lt;/span&gt;&lt;span class="p"&gt;?.({&lt;/span&gt; &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;file&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;download&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;navigator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;share&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;files&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Intención de voto en pollitics.es. ¿Y tú qué votarías?&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="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nx"&gt;DOMException&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;NotAllowedError&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;download&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="c1"&gt;// AbortError = user cancelled the sheet. Leave it alone.&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"button"&lt;/span&gt;
      &lt;span class="na"&gt;onPointerDown&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;prepare&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;onFocus&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;prepare&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;share&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      Compartir imagen
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;canShare({ files })&lt;/code&gt; is the gate. Don't call &lt;code&gt;share&lt;/code&gt; with files if it returns false — some browsers claim &lt;code&gt;navigator.share&lt;/code&gt; exists but reject file payloads. And always treat &lt;code&gt;AbortError&lt;/code&gt; as success-shaped: the user closed the sheet on purpose.&lt;/p&gt;

&lt;p&gt;On the page, the component is gated with &lt;code&gt;{!results.blackoutActive &amp;amp;&amp;amp; }&lt;/code&gt;. Legal constraint first, UX second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't publish noise: the 50-vote floor
&lt;/h2&gt;

&lt;p&gt;The first version of pollitics showed percentages from vote one. That looked clever in staging and looked broken in production: a 100% bar for whoever voted first is not a result, it's a bug wearing a chart. On 2026-10-06 we raised the public floor to &lt;strong&gt;50 votes in the rolling 90-day count&lt;/strong&gt; (&lt;code&gt;MIN_PUBLIC_SAMPLE&lt;/code&gt;). Below that, the UI says how many votes are still missing; the image route shows the same "not enough yet" copy instead of bars.&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;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MIN_PUBLIC_SAMPLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&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;votesToPublish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PublicResults&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="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;MIN_PUBLIC_SAMPLE&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;snapshot&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;sample_total&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&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;After 50, we still label anything under 1.000 as "Datos preliminares". We never invent a margin of error — this is open participation, not a representative poll, and the image footer says so in plain Spanish.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd steal for any product, not just polls
&lt;/h2&gt;

&lt;p&gt;If you're shipping a shareable artefact from a Next.js app, the checklist that actually moved the needle for me:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Concern&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;Render&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ImageResponse&lt;/code&gt; / &lt;code&gt;next/og&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Same deploy as the app; no Puppeteer farm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Size&lt;/td&gt;
&lt;td&gt;1080×1350 (or your Stories frame)&lt;/td&gt;
&lt;td&gt;Platforms stop fighting you&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;revalidate&lt;/code&gt; aligned with page ISR&lt;/td&gt;
&lt;td&gt;Image and page stay honest together&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Privacy&lt;/td&gt;
&lt;td&gt;One public payload, zero personalisation&lt;/td&gt;
&lt;td&gt;Screenshots of authenticated UI will betray you&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mobile share&lt;/td&gt;
&lt;td&gt;Prefetch file on pointerdown, then &lt;code&gt;canShare&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Safari's gesture clock is not optional&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fallback&lt;/td&gt;
&lt;td&gt;Object-URL download&lt;/td&gt;
&lt;td&gt;Desktop and stubborn WebViews&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Legal / policy&lt;/td&gt;
&lt;td&gt;Hide the button &lt;em&gt;and&lt;/em&gt; blank the numbers&lt;/td&gt;
&lt;td&gt;Deep links exist&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The whole feature is maybe two hundred lines. The product decisions around it — blackout, minimum sample, never stamp N on the PNG — took longer than the code, which is usually the tell that you built the right thing.&lt;/p&gt;

&lt;p&gt;If you want the live version, vote (or just look) on &lt;a href="https://pollitics.es/intencion-de-voto" rel="noopener noreferrer"&gt;pollitics.es/intencion-de-voto&lt;/a&gt; once the sample clears fifty. The share button is under the results. Your vote stays off the image. That was the entire point. And if you're about to ship something similar, &lt;a href="https://www.joancomadran.dev/blog/supabase-nextjs-pre-launch-security-checklist" rel="noopener noreferrer"&gt;the security pass I did before launch&lt;/a&gt; covers the rest of the site.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.joancomadran.dev/blog/shareable-poll-results-nextjs-og-image" rel="noopener noreferrer"&gt;joancomadran.dev&lt;/a&gt;. I'm Joan, an AI engineer in Barcelona — &lt;a href="https://www.joancomadran.dev/ai-engineering" rel="noopener noreferrer"&gt;work with me&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>vercel</category>
      <category>react</category>
    </item>
    <item>
      <title>A pre-launch security checklist for Next.js + Supabase (from a real launch)</title>
      <dc:creator>Joan Comadran</dc:creator>
      <pubDate>Wed, 14 Oct 2026 13:41:39 +0000</pubDate>
      <link>https://dev.to/joancomadran/a-pre-launch-security-checklist-for-nextjs-supabase-from-a-real-launch-6pa</link>
      <guid>https://dev.to/joancomadran/a-pre-launch-security-checklist-for-nextjs-supabase-from-a-real-launch-6pa</guid>
      <description>&lt;p&gt;Before opening &lt;a href="https://pollitics.es" rel="noopener noreferrer"&gt;pollitics.es&lt;/a&gt;, my Spanish vote-intention thermometer, to the public, it had to go from "works on my machine" to "strangers will poke at this". Political polls attract exactly the kind of visitor you don't want: people who'd love to stuff the ballot, scrape someone's vote, or embed your results page somewhere it doesn't belong. So before the public launch I did one boring, very large commit called &lt;em&gt;pre-launch security enhancements&lt;/em&gt;. A 461-line migration, a 537-line SQL test file, security headers and a handful of small fixes.&lt;/p&gt;

&lt;p&gt;None of it is clever, and that's sort of the point. Here's the checklist, with the code, in the order I'd do it again.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Close Supabase's default grants
&lt;/h2&gt;

&lt;p&gt;This is the one that surprised me most, so it goes first. Out of the box, Supabase grants privileges on everything in the &lt;code&gt;public&lt;/code&gt; schema to &lt;code&gt;anon&lt;/code&gt; and &lt;code&gt;authenticated&lt;/code&gt;. RLS is what usually saves you, but "a new table is reachable unless I remember to protect it" is a bad default for a project where the next migration will be written at midnight.&lt;/p&gt;

&lt;p&gt;So I flipped it. Anything the migration role (&lt;code&gt;postgres&lt;/code&gt;) creates from now on starts with zero client access:&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;default&lt;/span&gt; &lt;span class="k"&gt;privileges&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;postgres&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;
  &lt;span class="k"&gt;revoke&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;tables&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&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;default&lt;/span&gt; &lt;span class="k"&gt;privileges&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;postgres&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;
  &lt;span class="k"&gt;revoke&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;sequences&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&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;default&lt;/span&gt; &lt;span class="k"&gt;privileges&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;postgres&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;
  &lt;span class="k"&gt;revoke&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;functions&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- EXECUTE to PUBLIC is a global Postgres default: it can only be&lt;/span&gt;
&lt;span class="c1"&gt;-- revoked without a schema (this also covers the private schema).&lt;/span&gt;
&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;privileges&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;postgres&lt;/span&gt;
  &lt;span class="k"&gt;revoke&lt;/span&gt; &lt;span class="k"&gt;execute&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;functions&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Existing identity sequences: clients never insert, they don't need them.&lt;/span&gt;
&lt;span class="k"&gt;revoke&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; &lt;span class="n"&gt;sequences&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two gotchas. Default privileges only apply to objects created &lt;em&gt;afterwards&lt;/em&gt;, so existing explicit grants stay exactly as they were (which is what you want, and what the tests check). And a function used inside an RLS policy now needs its own &lt;code&gt;grant execute&lt;/code&gt;, otherwise the policy fails for that role instead of quietly passing.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. RLS policies that call one function, once
&lt;/h2&gt;

&lt;p&gt;The product rule on pollitics is that only people who have voted see the full evolution of the results. Everyone else, anonymous visitors included, sees just the latest published snapshots. On top of that there's Spanish electoral law: during the pre-election blackout nobody can see new polls, so nothing published from the blackout's start date onwards is readable.&lt;/p&gt;

&lt;p&gt;That rule touches eight aggregate tables. Instead of copy-pasting the logic into eight policies, there's one &lt;code&gt;security definer&lt;/code&gt; function that returns the readable time range for whoever is asking:&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;or&lt;/span&gt; &lt;span class="k"&gt;replace&lt;/span&gt; &lt;span class="k"&gt;function&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;readable_snapshots&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="n"&gt;tstzrange&lt;/span&gt;
&lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt;
&lt;span class="k"&gt;stable&lt;/span&gt;
&lt;span class="k"&gt;security&lt;/span&gt; &lt;span class="k"&gt;definer&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;search_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;
&lt;span class="k"&gt;as&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;v_until&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- exclusive upper bound; null = no limit&lt;/span&gt;
  &lt;span class="n"&gt;v_from&lt;/span&gt;  &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;begin&lt;/span&gt;
  &lt;span class="n"&gt;if&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;electoral_blackout_active&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'from'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;into&lt;/span&gt; &lt;span class="n"&gt;v_until&lt;/span&gt;
    &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;app_settings&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
    &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'electoral_blackout'&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="n"&gt;if&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;has_voted&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;pg_catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tstzrange&lt;/span&gt;&lt;span class="p"&gt;(&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;v_until&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;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;-- Non-voters: only the last two publishable snapshots.&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;snapshot_at&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;v_from&lt;/span&gt;
  &lt;span class="k"&gt;from&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;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;snapshot_at&lt;/span&gt;
    &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;snapshot_meta&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;
    &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;v_until&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;snapshot_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;v_until&lt;/span&gt;
    &lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;snapshot_at&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;
    &lt;span class="k"&gt;limit&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;l&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;v_from&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'empty'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;tstzrange&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;return&lt;/span&gt; &lt;span class="n"&gt;pg_catalog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tstzrange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v_until&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;end&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And every policy is a one-liner:&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="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"results_snapshots_read"&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;results_snapshots&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;snapshot_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;@&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&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;readable_snapshots&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The details that matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wrap the call in &lt;code&gt;(select ...)&lt;/code&gt;.&lt;/strong&gt; Postgres then evaluates it once per query as a scalar subquery instead of once per row. Same result, very different bill on a big table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;security definer&lt;/code&gt; plus &lt;code&gt;set search_path = ''&lt;/code&gt;.&lt;/strong&gt; The function reads &lt;code&gt;snapshot_meta&lt;/code&gt;, which has its own policy that calls the same function. Running as the owner avoids that recursion, and the empty search path (with fully qualified names) stops anyone from shadowing &lt;code&gt;public.has_voted&lt;/code&gt; with their own object.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Why two snapshots and not one.&lt;/strong&gt; Some pages read the latest &lt;code&gt;snapshot_at&lt;/code&gt; in one query (or from a 15-minute cache) and the rows in another. If the cron job writes a new snapshot in between, a "last one only" rule would make the page go blank. The second-to-last snapshot is the margin.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. A CSP that doesn't kill ISR
&lt;/h2&gt;

&lt;p&gt;The textbook answer is a nonce-based Content Security Policy. The catch in Next.js is that nonces are per request, so they force every page to render dynamically. The pollitics home page is ISR, and I'd rather keep it served from cache. So the CSP has no nonces and accepts &lt;code&gt;'unsafe-inline'&lt;/code&gt; for scripts, and everything else is locked down hard:&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;// next.config.ts&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;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;NEXT_PUBLIC_SUPABASE_URL&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;turnstile&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://challenges.cloudflare.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;isDev&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;NODE_ENV&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&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;vercelLive&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;VERCEL_ENV&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;preview&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; https://vercel.live&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;csp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;default-src 'self'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;`script-src 'self' 'unsafe-inline'&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;isDev&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; 'unsafe-eval'&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;turnstile&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;vercelLive&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="s2"&gt;style-src 'self' 'unsafe-inline'&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;img-src 'self' blob: data:&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;font-src 'self'&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="s2"&gt;`connect-src 'self' &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;vercelLive&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="s2"&gt;`frame-src &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;turnstile&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;vercelLive&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="s2"&gt;object-src 'none'&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;base-uri 'self'&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;form-action 'self'&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;frame-ancestors 'none'&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;isDev&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;upgrade-insecure-requests&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;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;securityHeaders&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;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Security-Policy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;csp&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-Frame-Options&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;DENY&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;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;X-Content-Type-Options&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;nosniff&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;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Referrer-Policy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;strict-origin-when-cross-origin&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;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Permissions-Policy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;camera=(), microphone=(), geolocation=(), browsing-topics=()&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;nextConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;poweredByHeader&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;headers&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;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/:path*&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="nx"&gt;securityHeaders&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 notes from getting it to actually work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The only external origins are Supabase (auth and RPC calls from the browser) and Cloudflare Turnstile (script plus iframe for the captcha). If you can't list your third parties on one hand, the CSP will tell you.&lt;/li&gt;
&lt;li&gt;Vercel's preview comment toolbar needs &lt;code&gt;vercel.live&lt;/code&gt;, so it's allowed only when &lt;code&gt;VERCEL_ENV&lt;/code&gt; is &lt;code&gt;preview&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;frame-ancestors 'none'&lt;/code&gt; and &lt;code&gt;X-Frame-Options: DENY&lt;/code&gt; say the same thing for new and old browsers: nobody gets to frame the voting page inside something that looks official.&lt;/li&gt;
&lt;li&gt;The CSP is built at build time from &lt;code&gt;NEXT_PUBLIC_SUPABASE_URL&lt;/code&gt;. Change the Supabase URL and you have to redeploy. I wrote that in the README because future me will forget.&lt;/li&gt;
&lt;li&gt;No HSTS header in the config, because Vercel already sends it on every HTTPS domain.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  4. An open-redirect-proof &lt;code&gt;?next=&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Login flows love &lt;code&gt;?next=/somewhere&lt;/code&gt;. They also love becoming open redirects: &lt;code&gt;?next=//evil.com&lt;/code&gt; is the classic, but the fun ones abuse the fact that browsers strip tabs and newlines and treat &lt;code&gt;\&lt;/code&gt; like &lt;code&gt;/&lt;/code&gt;. So &lt;code&gt;/\t/evil.com&lt;/code&gt; can end up on evil.com. The fix is small and paranoid:&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;BASE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://pollitics.invalid&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;safeNext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&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;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;fallback&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/intencion-de-voto#votar&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;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;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;value&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[\u&lt;/span&gt;&lt;span class="sr"&gt;0000-&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;001f&lt;/span&gt;&lt;span class="se"&gt;\u&lt;/span&gt;&lt;span class="sr"&gt;007f&lt;/span&gt;&lt;span class="se"&gt;\\]&lt;/span&gt;&lt;span class="sr"&gt;/&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="nx"&gt;value&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;fallback&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;BASE&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;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;BASE&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;fallback&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;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&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;search&lt;/span&gt; &lt;span class="o"&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;hash&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;fallback&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;Reject control characters and backslashes outright, then let the URL parser have the last word: resolve against a fake origin and make sure the origin didn't move. Use &lt;code&gt;.invalid&lt;/code&gt; as the TLD so the base can never be a real site.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. GDPR export and delete that go through the same doors
&lt;/h2&gt;

&lt;p&gt;If you collect political opinions in the EU, "email us to delete your data" doesn't cut it. Both rights are self-service now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Export&lt;/strong&gt; (GDPR article 20, portability) is a plain route handler, &lt;code&gt;/cuenta/exportar&lt;/code&gt;. It reads the user's data through the &lt;em&gt;same&lt;/em&gt; &lt;code&gt;get_my_*&lt;/code&gt; RPC functions the account page uses, so it can't see anything the user couldn't already see, and it returns a JSON attachment with &lt;code&gt;Cache-Control: private, no-store&lt;/code&gt; so no CDN ever keeps a copy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Delete&lt;/strong&gt; is a Supabase Edge Function. Deleting the &lt;code&gt;auth.users&lt;/code&gt; row cascades through an identity map and a trigger that removes the respondent, their vote and its history. One detail worth stealing: with Supabase's new API keys, the keys aren't JWTs, so the function runs with &lt;code&gt;verify_jwt = false&lt;/code&gt; and validates the user's token itself:&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="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="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&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="s2"&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="mi"&gt;405&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;jwt&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="s2"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)?.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^Bearer&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+/i&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="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;jwt&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;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="s2"&gt;not_authenticated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jwt&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&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="s2"&gt;not_authenticated&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

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

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="mi"&gt;200&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 function deletes whoever owns the token, and only them. There's no user id in the request body to tamper with.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Alert when the cron job quietly stops
&lt;/h2&gt;

&lt;p&gt;Not exactly security, but the failure mode is the same: something breaks and nobody notices. Public results come from a &lt;code&gt;pg_cron&lt;/code&gt; job that writes a snapshot every 15 minutes. If it stops, the site keeps serving old numbers with a straight face.&lt;/p&gt;

&lt;p&gt;The existing anomaly check (vote spikes per party, signup spikes) got one more rule: if the latest snapshot is more than an hour old &lt;em&gt;and&lt;/em&gt; the electoral blackout wasn't active in that window (during the blackout, not writing is the whole point), insert a &lt;code&gt;stale_snapshot&lt;/code&gt; alert. Alerts go out by email through Resend, called from Postgres with &lt;code&gt;pg_net&lt;/code&gt;, with the API key read from Supabase Vault. One alert per episode, so a dead job doesn't turn into an inbox flood.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Test it all in a transaction that always rolls back
&lt;/h2&gt;

&lt;p&gt;This is the part that made the rest safe to ship. A small Node script opens one connection to the remote project, runs &lt;code&gt;begin&lt;/code&gt;, applies any &lt;em&gt;pending&lt;/em&gt; migrations (the ones not yet in &lt;code&gt;supabase_migrations.schema_migrations&lt;/code&gt;), runs every file in &lt;code&gt;supabase/tests/&lt;/code&gt;, and always ends with &lt;code&gt;rollback&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;try&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;client&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;begin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;f&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`▸ &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&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="nx"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sql&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;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`✗ &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&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;client&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;rollback&lt;/span&gt;&lt;span class="dl"&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;failed&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tests are plain SQL that &lt;code&gt;raise exception&lt;/code&gt; on failure. The default-privileges test is my favourite because it checks the thing I actually care about, the &lt;em&gt;next&lt;/em&gt; table, not the current ones:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;t_prelaunch_new&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;generated&lt;/span&gt; &lt;span class="n"&gt;always&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;identity&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;function&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;f_prelaunch_new&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="nb"&gt;integer&lt;/span&gt; &lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="s1"&gt;'select 1'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;begin&lt;/span&gt;
  &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="n"&gt;has_table_privilege&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'anon'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'public.t_prelaunch_new'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'select'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;has_table_privilege&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'authenticated'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'public.t_prelaunch_new'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'insert'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;has_sequence_privilege&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'anon'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'public.t_prelaunch_new_id_seq'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'usage'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;has_function_privilege&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'anon'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'public.f_prelaunch_new()'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'execute'&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;'FAIL: a new object is exposed to clients by default'&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="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="n"&gt;has_table_privilege&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'service_role'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'public.t_prelaunch_new'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'select'&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;'FAIL: service_role lost its default access'&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;The same file seeds 400 random respondents with a fixed seed (&lt;code&gt;setseed(0.42)&lt;/code&gt;) and checks that the refactored snapshot function produces exactly the same aggregates as the original queries, table by table. That refactor computed each eligible population once into temp tables instead of up to three times per run, which is a performance change I'd never have dared to ship without a row-for-row diff.&lt;/p&gt;

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

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;What I did&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;Default grants&lt;/td&gt;
&lt;td&gt;Revoke default privileges for &lt;code&gt;anon&lt;/code&gt; / &lt;code&gt;authenticated&lt;/code&gt;, revoke &lt;code&gt;EXECUTE&lt;/code&gt; from &lt;code&gt;PUBLIC&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The next table is safe even if I forget&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RLS&lt;/td&gt;
&lt;td&gt;One &lt;code&gt;security definer&lt;/code&gt; function, called as &lt;code&gt;(select fn())&lt;/code&gt; in each policy&lt;/td&gt;
&lt;td&gt;One rule, evaluated once per query&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Headers&lt;/td&gt;
&lt;td&gt;CSP without nonces, frame-ancestors none, nosniff, strict referrer, no &lt;code&gt;x-powered-by&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Keeps ISR, still locks down third parties&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redirects&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;safeNext()&lt;/code&gt; with a control-char check and an origin check&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;?next=&lt;/code&gt; is an open redirect by default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Privacy&lt;/td&gt;
&lt;td&gt;Self-service JSON export and an Edge Function delete keyed on the caller's token&lt;/td&gt;
&lt;td&gt;GDPR, without a support inbox&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ops&lt;/td&gt;
&lt;td&gt;Stale-snapshot alert via &lt;code&gt;pg_net&lt;/code&gt; + Vault&lt;/td&gt;
&lt;td&gt;Silent failures are the worst kind&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tests&lt;/td&gt;
&lt;td&gt;Migrations + SQL tests in one transaction, always rolled back&lt;/td&gt;
&lt;td&gt;Validate against the real project without touching it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Seven items, one commit, zero clever tricks. If you're about to launch something on Next.js and Supabase, number 1 alone is worth the ten minutes. If you want someone to do this kind of pass on your product, that's &lt;a href="https://www.joancomadran.dev/ai-engineering" rel="noopener noreferrer"&gt;the kind of work I take on&lt;/a&gt;. And if you want to see the result, go and &lt;a href="https://pollitics.es/intencion-de-voto" rel="noopener noreferrer"&gt;vote on pollitics&lt;/a&gt;; the &lt;a href="https://www.joancomadran.dev/blog/shareable-poll-results-nextjs-og-image" rel="noopener noreferrer"&gt;shareable results image&lt;/a&gt; still won't show anyone what you picked.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Does Supabase expose new tables to anon and authenticated by default?
&lt;/h3&gt;

&lt;p&gt;Yes. Supabase grants privileges on objects in the public schema to the anon and authenticated roles by default, so a new table, sequence or function is reachable through the API unless RLS or a revoke stops it. On pollitics.es I removed that default with ALTER DEFAULT PRIVILEGES for the postgres role (tables, sequences and functions), plus a global revoke of EXECUTE on functions from PUBLIC, so every new object needs an explicit grant.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I use a strict nonce-based CSP with Next.js ISR?
&lt;/h3&gt;

&lt;p&gt;Not without giving up static rendering. Nonces have to be generated per request, which makes every page dynamic and breaks ISR. On pollitics.es I kept ISR and shipped a CSP without nonces: 'unsafe-inline' for scripts (Next needs it), and an explicit allowlist for Supabase and Cloudflare Turnstile, plus frame-ancestors 'none', object-src 'none', base-uri and form-action locked to 'self'.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I prevent open redirects with a ?next= parameter in Next.js?
&lt;/h3&gt;

&lt;p&gt;Only accept values that start with a single slash, reject any control character or backslash (browsers strip tabs and newlines and treat a backslash as a slash, so '/\t/evil.com' can end up on evil.com), then resolve the value against a fixed fake origin with new URL() and check the origin didn't change. Return pathname, search and hash, or a safe fallback.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I test Supabase migrations without touching the database?
&lt;/h3&gt;

&lt;p&gt;Run the pending migrations and your SQL test files inside a single transaction that always ends in ROLLBACK. Each test raises an exception on failure, so the script exits non-zero and nothing persists either way. On pollitics.es a small Node script with the pg client does this against the remote project.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.joancomadran.dev/blog/supabase-nextjs-pre-launch-security-checklist" rel="noopener noreferrer"&gt;joancomadran.dev&lt;/a&gt;. I'm Joan, an AI engineer in Barcelona — &lt;a href="https://www.joancomadran.dev/ai-engineering" rel="noopener noreferrer"&gt;work with me&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>nextjs</category>
      <category>postgres</category>
      <category>security</category>
    </item>
    <item>
      <title>AI agent development cost: what actually moves the number (2026)</title>
      <dc:creator>Joan Comadran</dc:creator>
      <pubDate>Wed, 14 Oct 2026 13:35:36 +0000</pubDate>
      <link>https://dev.to/joancomadran/ai-agent-development-cost-what-actually-moves-the-number-2026-2715</link>
      <guid>https://dev.to/joancomadran/ai-agent-development-cost-what-actually-moves-the-number-2026-2715</guid>
      <description>&lt;p&gt;Here's the short answer people actually want: &lt;strong&gt;AI agent development cost is mostly scope, not model fees.&lt;/strong&gt; The model bill is real and easy to measure. The number that surprises founders is the engineering around the model — the channel, the tools that write to real systems, multi-tenant isolation, evals, and the week you spend hardening the webhook so Meta doesn't double-send every reply.&lt;/p&gt;

&lt;p&gt;I don't publish a rate card here, and I'm not going to invent a "market average" with fake precision. What I can do is show what moves the quote, what it costs to &lt;em&gt;run&lt;/em&gt; an agent on Claude as of October 2026, and what I learned shipping &lt;a href="https://www.joancomadran.dev/projects/claient" rel="noopener noreferrer"&gt;Claient&lt;/a&gt; (WhatsApp lead agent), &lt;a href="https://www.joancomadran.dev/projects/adorea-ai" rel="noopener noreferrer"&gt;Adorea.ai&lt;/a&gt;, and &lt;a href="https://www.joancomadran.dev/projects/dairector" rel="noopener noreferrer"&gt;Dairector&lt;/a&gt;. If you're comparing agency cost guides that all land on $30k–$180k, treat those as their sales bands — useful as a sanity check, not as a standard.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three costs people mix up
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Who pays&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Build&lt;/td&gt;
&lt;td&gt;Engineering to ship the agent: product, tools, auth, channel, evals&lt;/td&gt;
&lt;td&gt;You, once (or in slices)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run&lt;/td&gt;
&lt;td&gt;Model tokens, hosting, channel fees, observability&lt;/td&gt;
&lt;td&gt;You, every month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Own&lt;/td&gt;
&lt;td&gt;Maintenance when models deprecate, prompts drift, APIs change&lt;/td&gt;
&lt;td&gt;You, forever&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Most "AI agent development cost" pages only talk about the first row, then bury run cost in a footnote. In production the second row is where a good architecture either stays cheap or quietly burns the margin — I wrote the token side up in &lt;a href="https://www.joancomadran.dev/blog/keeping-claudes-token-bill-from-ruining-your-month" rel="noopener noreferrer"&gt;keeping Claude's token bill from ruining your month&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually moves the build number
&lt;/h2&gt;

&lt;p&gt;These are the drivers I budget against, in the order that usually bites.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. How many systems the agent can write to.&lt;/strong&gt; A read-only FAQ bot over your docs is one shape. An agent that books appointments, creates CRM rows, or sends WhatsApp templates is another. Every write-action needs schemas, retries, idempotency, and a human-approval path when the stakes are high. Tool count is a weak proxy; &lt;em&gt;write&lt;/em&gt; count is the real one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The channel.&lt;/strong&gt; WhatsApp via Meta Cloud API is not "add a chat widget." You need webhook verification, signature checks, idempotency on &lt;code&gt;wamid&lt;/code&gt;, and a plan for Meta's retries when your serverless function times out. That alone ate more engineering time than the Claude loop on &lt;a href="https://www.joancomadran.dev/blog/whatsapp-bot-with-claude" rel="noopener noreferrer"&gt;Claient&lt;/a&gt;. Email or an in-app chat is usually cheaper. Voice is its own project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Single-tenant vs multi-tenant.&lt;/strong&gt; Solving the agent for one business is a focused build. Solving it so any tenant self-serves, with RLS on &lt;code&gt;account_id&lt;/code&gt;, encrypted BYOK keys, and one webhook routing by &lt;code&gt;phone_number_id&lt;/code&gt;, is a product. Claient only becomes interesting as a business once the second version exists.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Evals and observability.&lt;/strong&gt; Cheap agents skip them. Expensive incidents come from skipping them. Budget time for a golden set of real conversations, cost logging per account, and traces you can read when a lead gets the wrong answer at 23:00.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Who owns the model key.&lt;/strong&gt; Flat product price with your key absorbed (Claient defaults to Haiku) is a different quote from BYOK, where the tenant pays Anthropic directly. BYOK lowers your variable cost and raises onboarding and vault work.&lt;/p&gt;

&lt;p&gt;Rough shape from builds I've done: a single-task agent with a couple of tools and one channel is a few weeks of senior work to something clickable; hardening + multi-tenant + billing stretches that into months. I won't turn that into a fake dollar table — hours × whoever you hire is the only honest formula.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running cost: model rates you can verify (as of Oct 2026)
&lt;/h2&gt;

&lt;p&gt;List prices from &lt;a href="https://docs.claude.com/en/docs/about-claude/pricing" rel="noopener noreferrer"&gt;Anthropic's pricing page&lt;/a&gt;, per million tokens (input / output). They change; check before you budget.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Input&lt;/th&gt;
&lt;th&gt;Output&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Haiku 5.5 (≤100K prompt)&lt;/td&gt;
&lt;td&gt;$0.10&lt;/td&gt;
&lt;td&gt;$0.50&lt;/td&gt;
&lt;td&gt;Cheap high-volume tier&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Haiku 4.5&lt;/td&gt;
&lt;td&gt;$1&lt;/td&gt;
&lt;td&gt;$5&lt;/td&gt;
&lt;td&gt;What Claient still defaults to in production&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Sonnet 5.5&lt;/td&gt;
&lt;td&gt;$2&lt;/td&gt;
&lt;td&gt;$10&lt;/td&gt;
&lt;td&gt;Workhorse for harder reasoning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Opus 5.5&lt;/td&gt;
&lt;td&gt;$4&lt;/td&gt;
&lt;td&gt;$20&lt;/td&gt;
&lt;td&gt;Heavy tasks, drafting, research&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Fable 5.1&lt;/td&gt;
&lt;td&gt;$10&lt;/td&gt;
&lt;td&gt;$50&lt;/td&gt;
&lt;td&gt;Top tier&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Prompt cache reads are a fraction of input (0.05×–0.1× depending on model; Fable goes lower). Batch is 50% off when you can wait. Client-side tool use is just tokens; Anthropic web search is $10 per 1,000 searches on top.&lt;/p&gt;

&lt;p&gt;What that means in practice for a WhatsApp lead agent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Default to a cheap model for the conversation loop. Claient runs &lt;strong&gt;Haiku&lt;/strong&gt; by default; tenants who want Sonnet/Opus bring their own key.&lt;/li&gt;
&lt;li&gt;Cache the static system prompt and tool schemas. Don't resend the full history every turn — use a short verbatim window plus a rolling summary (&lt;a href="https://www.joancomadran.dev/blog/keeping-claudes-token-bill-from-ruining-your-month" rel="noopener noreferrer"&gt;details here&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;Log real USD per account. If you can't see cost per conversation, you can't price the product.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Hosting for a Next.js + Supabase agent is usually noise next to a bad model choice: tens to low hundreds of dollars a month until you have serious traffic. Channel fees matter more — Meta service conversations within 24h on Cloud API direct are free, which is why Claient can sit at &lt;strong&gt;€49/month&lt;/strong&gt; with ~0 € platform cost per customer. A BSP in the middle would have broken that price.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build vs buy vs hire
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Buy a SaaS&lt;/strong&gt; when your use case is generic (FAQ, basic lead capture, appointment booking templates) and you're fine renting their qualification rules. Fastest path; you pay forever and bend to their product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build in-house&lt;/strong&gt; when you already have a senior engineer who has shipped LLM tool-use, and the agent is core IP. Still budget for the channel and for the month after launch, not just the demo week.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hire&lt;/strong&gt; when you need production reliability and don't have that person on payroll. Fit for custom tools, multi-tenant SaaS, WhatsApp/CRM write-paths, or a first LLM product where a wrong architecture is expensive to unwind. Not fit for a slide-deck prototype.&lt;/p&gt;

&lt;p&gt;I ship the hire path from Barcelona, remote-friendly: TypeScript, Next.js, Supabase, &lt;code&gt;@anthropic-ai/sdk&lt;/code&gt; directly — no LangChain tax unless the project truly needs multi-provider orchestration. Details and process on &lt;a href="https://www.joancomadran.dev/ai-engineering" rel="noopener noreferrer"&gt;hire an AI engineer&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  When it's worth it / when it's not
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Worth building or hiring for when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Leads or revenue depend on speed (out-of-hours WhatsApp, inbound that dies in an inbox).&lt;/li&gt;
&lt;li&gt;The agent must call &lt;em&gt;your&lt;/em&gt; tools with real side effects, not just chat.&lt;/li&gt;
&lt;li&gt;You need multi-tenant isolation, BYOK, or channel economics a template can't give you.&lt;/li&gt;
&lt;li&gt;You're launching a product (not a one-off script) and need cost controls from day one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Not worth it when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A Zapier/Make/no-code bot already covers the happy path.&lt;/li&gt;
&lt;li&gt;You only need an internal demo for a stakeholder meeting.&lt;/li&gt;
&lt;li&gt;Nobody will own evals and model updates after the first deploy.&lt;/li&gt;
&lt;li&gt;The "agent" is really a single LLM call with no tools — that's a feature, not a project.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Verdict
&lt;/h2&gt;

&lt;p&gt;If someone quotes you AI agent development cost as one number with no breakdown of channel, write-tools, tenancy and run model, they're selling a vibe. Ask for: scope of write-actions, who pays for tokens, how evals work, and what the monthly run cost looks like at your expected volume on a named model and caching strategy.&lt;/p&gt;

&lt;p&gt;My default for a production lead or support agent in 2026: &lt;strong&gt;Claude via the official SDK, cheapest model that passes your eval, prompt caching on, conversation memory bounded, channel owned directly.&lt;/strong&gt; That's the shape behind Claient. The build cost is the engineering to make that boring and safe; the run cost is then a line item you can actually forecast.&lt;/p&gt;

&lt;p&gt;Want that built end-to-end — agent, product shell, tracking — talk to me on the &lt;a href="https://www.joancomadran.dev/ai-engineering" rel="noopener noreferrer"&gt;AI engineering&lt;/a&gt; page. Related reading: &lt;a href="https://www.joancomadran.dev/blog/claude-vs-gpt-for-ai-agents" rel="noopener noreferrer"&gt;Claude vs GPT for AI agents&lt;/a&gt;, &lt;a href="https://www.joancomadran.dev/blog/whatsapp-bot-with-claude" rel="noopener noreferrer"&gt;WhatsApp bot with Claude&lt;/a&gt;, &lt;a href="https://www.joancomadran.dev/blog/ship-an-ai-product-in-a-weekend" rel="noopener noreferrer"&gt;ship an AI product in a weekend&lt;/a&gt;.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  How much does it cost to build an AI agent in 2026?
&lt;/h3&gt;

&lt;p&gt;There is no single market price. What moves the number is scope: one channel vs many, how many write-actions the agent can take, multi-tenant isolation, evals, and who owns the channel (WhatsApp, CRM, calendar). A focused single-task agent with a few tools is weeks of senior engineering; a multi-tenant product with billing, dashboards and hardened webhooks is months. Vendor cost guides often quote $30k–$180k+ for production agents — treat those as rough bands from their sales pages, not a standard. Running cost is separate and usually dominated by model tokens.&lt;/p&gt;

&lt;h3&gt;
  
  
  What does it cost to run an AI agent after you build it?
&lt;/h3&gt;

&lt;p&gt;Mostly model tokens plus hosting. As of October 2026 on Anthropic's list prices, Claude Haiku 5.5 starts at $0.10 / $0.50 per million input/output tokens (prompts up to 100K), Haiku 4.5 is $1 / $5, Sonnet 5.5 is $2 / $10, and Opus 5.5 is $4 / $20. Prompt cache reads are a fraction of input. A WhatsApp lead agent on Haiku with caching and a rolling conversation window stays in the cents-per-conversation range for typical volumes; the same agent on a frontier model without caching does not. Add hosting (often tens to low hundreds of dollars a month on Vercel + a managed Postgres) and whatever the channel charges.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is it cheaper to buy a SaaS chatbot than to build a custom AI agent?
&lt;/h3&gt;

&lt;p&gt;Usually yes for generic FAQ or lead capture. Buy when a template covers your channel and qualification rules. Build (or hire) when you need custom tools, multi-tenant isolation, a channel you must own (for example Meta Cloud API direct), BYOK, or product economics that SaaS pricing breaks. Claient only made sense as a product because Meta service conversations within 24h are free on Cloud API direct — a BSP fee would have eaten the $49/month price.&lt;/p&gt;

&lt;h3&gt;
  
  
  When is it worth hiring an AI engineer to build an agent?
&lt;/h3&gt;

&lt;p&gt;When the agent has to touch real systems (WhatsApp, CRM, calendar, payments), when wrong answers cost you leads or trust, or when you need multi-tenant security and cost controls from day one. Not worth it for a one-off internal demo or a FAQ bot a no-code tool already covers. If you want someone who has shipped production agents end-to-end, see the &lt;a href="https://www.joancomadran.dev/ai-engineering" rel="noopener noreferrer"&gt;AI engineering&lt;/a&gt; page.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need LangChain or an agent framework in the budget?
&lt;/h3&gt;

&lt;p&gt;No. Production agents I ship call &lt;code&gt;@anthropic-ai/sdk&lt;/code&gt; directly: a tool-use loop is a few dozen lines. Frameworks help with multi-provider routing or complex graphs; they also hide the provider features (prompt caching breakpoints, server tools) that usually save the most money. Budget for evals, observability and cost logging before you budget for a framework.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.joancomadran.dev/blog/ai-agent-development-cost" rel="noopener noreferrer"&gt;joancomadran.dev&lt;/a&gt;. I'm Joan, an AI engineer in Barcelona — &lt;a href="https://www.joancomadran.dev/ai-engineering" rel="noopener noreferrer"&gt;work with me&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>claude</category>
      <category>anthropic</category>
    </item>
    <item>
      <title>Claude vs GPT for AI agents: what I actually ship with (2026)</title>
      <dc:creator>Joan Comadran</dc:creator>
      <pubDate>Wed, 14 Oct 2026 13:34:31 +0000</pubDate>
      <link>https://dev.to/joancomadran/claude-vs-gpt-for-ai-agents-what-i-actually-ship-with-2026-41gc</link>
      <guid>https://dev.to/joancomadran/claude-vs-gpt-for-ai-agents-what-i-actually-ship-with-2026-41gc</guid>
      <description>&lt;p&gt;Here's the verdict, so you can leave early if that's all you came for: &lt;strong&gt;I build my AI agents on Claude, and I'd still tell most people that the honest answer to "Claude vs GPT for agents" in late 2026 is "it matters less than you think."&lt;/strong&gt; As of October 2026 the two price ladders mirror each other almost to the cent, both have 1M-token context windows, both enforce tool schemas strictly, and both host web search for you. What's left to choose on is API shape, how much control you get over caching, which hosted tools you need, and which one your evals prefer on &lt;em&gt;your&lt;/em&gt; tool schemas.&lt;/p&gt;

&lt;p&gt;If you want a default: pick &lt;strong&gt;Claude&lt;/strong&gt; if your agent lives on long, growing context and you care about controlling exactly what gets cached. Pick &lt;strong&gt;GPT&lt;/strong&gt; if you're building a voice agent, want hosted file search over your documents, or your company already runs on OpenAI or Azure contracts.&lt;/p&gt;

&lt;p&gt;One disclosure before the table, because comparison posts rarely make it: every agent I run in production is on Claude. I've used OpenAI's API, but I don't have a shipped GPT agent to compare like-for-like, so the GPT column below comes from OpenAI's docs and pricing pages, not from war stories. The Claude column comes from code I can show you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claude vs GPT at a glance (as of October 2026)
&lt;/h2&gt;

&lt;p&gt;All prices are list prices per million tokens (input / output), taken from the &lt;a href="https://docs.claude.com/en/docs/about-claude/pricing" rel="noopener noreferrer"&gt;Anthropic&lt;/a&gt; and &lt;a href="https://platform.openai.com/docs/pricing" rel="noopener noreferrer"&gt;OpenAI&lt;/a&gt; pricing pages in October 2026. These change often; check before you budget.&lt;/p&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;Claude (Anthropic)&lt;/th&gt;
&lt;th&gt;GPT (OpenAI)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Current lineup&lt;/td&gt;
&lt;td&gt;Fable 5.1, Opus 5.5, Sonnet 5.5, Haiku 5.5&lt;/td&gt;
&lt;td&gt;GPT-6 Astra, GPT-6.1 Sol, GPT-6 Luna&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Top tier&lt;/td&gt;
&lt;td&gt;Fable 5.1: $10 / $50&lt;/td&gt;
&lt;td&gt;GPT-6 Astra: $10 / $50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Between top and mid&lt;/td&gt;
&lt;td&gt;Opus 5.5: $4 / $20&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workhorse tier&lt;/td&gt;
&lt;td&gt;Sonnet 5.5: $2 / $10&lt;/td&gt;
&lt;td&gt;GPT-6.1 Sol: $2 / $10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cheap, high-volume tier&lt;/td&gt;
&lt;td&gt;Haiku 5.5: from $0.10 / $0.50 (prompts up to 100K tokens)&lt;/td&gt;
&lt;td&gt;GPT-6 Luna: from $0.10 / $0.50 (short context)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Context window&lt;/td&gt;
&lt;td&gt;1M tokens on all current models&lt;/td&gt;
&lt;td&gt;1.05M tokens on the flagship three&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Long-context billing&lt;/td&gt;
&lt;td&gt;Full 1M at the standard rate (except Haiku 5.5 above 100K)&lt;/td&gt;
&lt;td&gt;Separate, higher long-context rate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max output&lt;/td&gt;
&lt;td&gt;128K tokens&lt;/td&gt;
&lt;td&gt;128K tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API you build agents on&lt;/td&gt;
&lt;td&gt;Messages API (&lt;code&gt;tool_use&lt;/code&gt; / &lt;code&gt;tool_result&lt;/code&gt; blocks)&lt;/td&gt;
&lt;td&gt;Responses API (required for tool calling on Astra and Sol)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema-enforced tool calls&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;strict: true&lt;/code&gt; on tool definitions&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;strict: true&lt;/code&gt;; Responses tries to make tools strict by default&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prompt caching&lt;/td&gt;
&lt;td&gt;Explicit &lt;code&gt;cache_control&lt;/code&gt; breakpoints or automatic; cache reads at 10% of input or less&lt;/td&gt;
&lt;td&gt;Discounted cached input (5–10% of input on the flagship three)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Batch discount&lt;/td&gt;
&lt;td&gt;50%&lt;/td&gt;
&lt;td&gt;50%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hosted tools&lt;/td&gt;
&lt;td&gt;Web search ($10 / 1K searches), web fetch, code execution, tool search, MCP connector, computer and browser use&lt;/td&gt;
&lt;td&gt;Web search ($10 / 1K calls), file search, hosted shell / code interpreter, computer use, MCP, tool search&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Voice / realtime&lt;/td&gt;
&lt;td&gt;Text and image in, text out&lt;/td&gt;
&lt;td&gt;Dedicated realtime and voice models&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Look at the price rows for a second. Not long ago, a "Claude vs GPT" post was half about cost. Now the cheap tiers start at the same price, the workhorse tiers are identical, and the top tiers are identical. Rate cards aren't where the difference is anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where they actually differ for agent builders
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The API shape
&lt;/h3&gt;

&lt;p&gt;On Claude, an agent turn is a Messages API call. If the model wants a tool, the response has &lt;code&gt;stop_reason: "tool_use"&lt;/code&gt; and one or more &lt;code&gt;tool_use&lt;/code&gt; blocks; you run them and send back a user turn full of &lt;code&gt;tool_result&lt;/code&gt; blocks. On GPT, the newest models require the Responses API for tool calling: the output is a list of items, tool calls come back as &lt;code&gt;function_call&lt;/code&gt; items, and you return &lt;code&gt;function_call_output&lt;/code&gt; items, optionally chaining with &lt;code&gt;previous_response_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Neither is better in the abstract. They are different enough that "we'll just swap the model later" is a week of work, not a config change, once you've built retries, caching, cost logging and error handling around one of them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Caching control
&lt;/h3&gt;

&lt;p&gt;This is the one that decided it for me. Claude lets you mark exactly where the cacheable prefix ends with &lt;code&gt;cache_control&lt;/code&gt; breakpoints (or let the API place them automatically). In a conversational agent, where the system prompt is static and the history grows every turn, being able to say "cache everything up to here" is the difference between cost that grows with the square of the conversation and cost that grows roughly linearly. I wrote up the full story in &lt;a href="https://www.joancomadran.dev/blog/keeping-claudes-token-bill-from-ruining-your-month" rel="noopener noreferrer"&gt;keeping Claude's token bill from ruining your month&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;OpenAI discounts cached input heavily too, so this isn't "Claude caches, GPT doesn't." It's about how much you get to steer it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Long context billing
&lt;/h3&gt;

&lt;p&gt;Both vendors offer roughly a million tokens of context. The difference is the bill: Anthropic charges the full 1M window at the standard rate on its current models (Haiku 5.5 aside), while OpenAI lists a separate, higher long-context price on Astra, Sol and Luna. If your agent stuffs whole codebases, contracts or long transcripts into context, model that difference before you pick. If your prompts are a few thousand tokens, it doesn't matter.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hosted tools
&lt;/h3&gt;

&lt;p&gt;Both now run web search on their side and support MCP. OpenAI has hosted &lt;strong&gt;file search&lt;/strong&gt; with managed storage and a full line of &lt;strong&gt;realtime voice&lt;/strong&gt; models; if you're building a phone or voice agent, that alone probably decides it. Anthropic has server-side web search and web fetch, code execution, a tool-search tool for large tool sets, and an MCP connector that talks to remote MCP servers straight from the Messages API.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually run in production (and why)
&lt;/h2&gt;

&lt;p&gt;None of my agents use LangChain or the Vercel AI SDK. Every one calls &lt;code&gt;@anthropic-ai/sdk&lt;/code&gt; directly, because the provider-specific features are exactly the ones I'm relying on. Here are two, straight from the repos.&lt;/p&gt;

&lt;h3&gt;
  
  
  Claient: a multi-tenant WhatsApp agent on Haiku
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://www.joancomadran.dev/projects/claient" rel="noopener noreferrer"&gt;Claient&lt;/a&gt; answers and qualifies WhatsApp leads for small businesses (&lt;a href="https://www.joancomadran.dev/blog/whatsapp-bot-with-claude" rel="noopener noreferrer"&gt;the build story is here&lt;/a&gt;). The model choice is gated by who pays:&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;// With the platform key (we pay), only Haiku is served.&lt;/span&gt;
&lt;span class="c1"&gt;// Tenants who bring their own key (BYOK) can pick Sonnet or Opus.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;MODELO_BASE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude-haiku-4-5-20251001&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;modeloEfectivo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;model&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&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;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;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;apiKey&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;MODELO_BASE&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;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nf"&gt;getClaudeConfig&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;model&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 reply runs in a tool-use loop with three booking tools (check availability, book, reschedule). Two guards matter more than the model: a hard cap of five loop iterations, and a time budget, because a serverless function killed mid-loop means the lead got a message but the webhook didn't finish, and Meta's retry sends it twice:&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;MAX_ITER_TOOLS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&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;MARGEN_VUELTA_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// SDK timeout 15 s + 1 retry must fit&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_ITER_TOOLS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;hayMargen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;deadline&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// fall back instead of dying mid-turn&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;respuesta&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;claude&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;system&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tools&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;respuesta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stop_reason&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tool_use&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// run each tool_use block, push the tool_result blocks, loop again&lt;/span&gt;
    &lt;span class="k"&gt;continue&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;textOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;respuesta&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;Lead qualification is a separate call that &lt;em&gt;forces&lt;/em&gt; a tool, &lt;code&gt;tool_choice: { type: "tool", name: "registrar_lead" }&lt;/code&gt;, with nullable fields and an enum for lead temperature (cold, warm, hot). Forcing a tool is the most reliable way I know to get structured data out of a conversation, and GPT can do the same thing with &lt;code&gt;tool_choice&lt;/code&gt; plus strict mode. The cache breakpoints sit on the system prompt and the latest turn, and the history sent is a window of the last 16 messages plus a rolling summary. Every call logs input, output, cache-read and cache-write tokens and their USD cost per tenant, with a monthly spend cap.&lt;/p&gt;

&lt;p&gt;An honest footnote: Claient still pins Haiku 4.5, which is now a legacy model. Haiku 5.5 lists at a tenth of the price. That's the obvious thing to test next, and it's also a good example of why you want evals: "cheaper and newer" is a hypothesis until your own conversations say otherwise.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pollitics: two models, two jobs
&lt;/h3&gt;

&lt;p&gt;For &lt;a href="https://pollitics.es" rel="noopener noreferrer"&gt;pollitics.es&lt;/a&gt;, a weekly job drafts candidate questions about the week's news for a human to review. It's split into two Claude calls on purpose:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Research&lt;/strong&gt; on Sonnet 5.5 with Anthropic's server-side web search (&lt;code&gt;max_uses: 10&lt;/code&gt;, effort &lt;code&gt;medium&lt;/code&gt;). Most of the cost here is input tokens from the pages it reads, so the cheaper model does the reading.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drafting&lt;/strong&gt; on Opus 5.5 with JSON-schema structured output (effort &lt;code&gt;high&lt;/code&gt;), where editorial nuance matters and the input is small.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It's two calls, not one, because structured output doesn't carry the search citations, so the research comes back as text with URLs and the drafter works from that. The code also handles &lt;code&gt;pause_turn&lt;/code&gt; (the server-side search loop paused, send it back to continue) and &lt;code&gt;refusal&lt;/code&gt; stop reasons, and the whole thing has to fit inside a 150-second Edge Function. A deterministic review step then flags anything that breaks the editorial rules before a person approves it.&lt;/p&gt;

&lt;h3&gt;
  
  
  So why Claude?
&lt;/h3&gt;

&lt;p&gt;Not because GPT couldn't run either of these. The reasons are concrete and a little boring:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cache breakpoints matched the architecture.&lt;/strong&gt; Static script plus growing history is exactly the shape explicit &lt;code&gt;cache_control&lt;/code&gt; is good at.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tiering within one vendor.&lt;/strong&gt; Haiku for every message, Sonnet for reading the web, Opus for the writing that matters. One SDK, one usage format, one cost table. &lt;a href="https://www.joancomadran.dev/projects/dairector" rel="noopener noreferrer"&gt;Dairector&lt;/a&gt;'s script doctor runs on Claude too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BYOK stays simple.&lt;/strong&gt; Tenants paste one kind of key. Supporting two providers' keys, models and error classes would double the surface for very little customer value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compounding.&lt;/strong&gt; Timeouts, retries, error classification (&lt;code&gt;AuthenticationError&lt;/code&gt; vs transient 429/5xx), cost logging: all written once, against one SDK. That's the real lock-in, and it's mine, not the vendor's.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When to use which
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Choose Claude when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your agent is conversational or long-running and context grows every turn, so caching control pays for itself.&lt;/li&gt;
&lt;li&gt;You need very long context and don't want a long-context surcharge on the mid and top tiers.&lt;/li&gt;
&lt;li&gt;You want one vendor with a clear cheap / workhorse / heavy ladder to route tasks across.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Choose GPT when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You're building a voice or phone agent and want first-party realtime models.&lt;/li&gt;
&lt;li&gt;You want hosted file search over your documents without running your own vector store.&lt;/li&gt;
&lt;li&gt;Your organisation already has OpenAI or Azure OpenAI contracts, data agreements and monitoring in place. That's a perfectly good reason; procurement is engineering too.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Either way:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Build a small eval set from real conversations (twenty is enough to start) and run both on your actual tool schemas before you commit.&lt;/li&gt;
&lt;li&gt;Put the model call behind one module of your own so a future switch touches one file.&lt;/li&gt;
&lt;li&gt;Cap loop iterations and spend from day one. Neither vendor will do that for you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're weighing this for a real product and want someone who has shipped agents end-to-end to make the call with you, that's &lt;a href="https://www.joancomadran.dev/ai-engineering" rel="noopener noreferrer"&gt;what I do&lt;/a&gt;. For the rest of the stack around the model, see &lt;a href="https://www.joancomadran.dev/blog/ship-an-ai-product-in-a-weekend" rel="noopener noreferrer"&gt;how I ship an AI product in a weekend&lt;/a&gt;.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Is Claude or GPT better for building AI agents in 2026?
&lt;/h3&gt;

&lt;p&gt;Neither wins across the board. As of October 2026 the price tiers mirror each other (Sonnet 5.5 and GPT-6.1 Sol are both $2/$10 per million tokens; Haiku 5.5 and GPT-6 Luna both start at $0.10/$0.50), both offer 1M-token context, 128K max output, strict schema-enforced tool calls and hosted web search. Claude is the better default if you want explicit control over prompt caching and long context billed at the standard rate; GPT is the better default for voice agents, hosted file search, or teams already on OpenAI or Azure. Run your own eval on your own tool schemas before committing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is Claude more expensive than GPT for agents?
&lt;/h3&gt;

&lt;p&gt;Not anymore at list price. As of October 2026 the mid tiers (Claude Sonnet 5.5 and GPT-6.1 Sol) are both $2 input / $10 output per million tokens, the cheap tiers (Claude Haiku 5.5 and GPT-6 Luna) both start at $0.10 / $0.50, and the top tiers (Claude Fable 5.1 and GPT-6 Astra) are both $10 / $50. Claude Opus 5.5 sits in between at $4 / $20. Both offer 50% off for batch work and discounted cached input. Real cost differences come from how much context you resend and how well you cache it, not the rate card.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I switch an agent from Claude to GPT later?
&lt;/h3&gt;

&lt;p&gt;Yes, but it is not a one-line change. The Claude Messages API uses &lt;code&gt;tool_use&lt;/code&gt; and &lt;code&gt;tool_result&lt;/code&gt; content blocks; OpenAI's Responses API, which GPT-6 Astra and GPT-6.1 Sol require for tool calling, uses &lt;code&gt;function_call&lt;/code&gt; and &lt;code&gt;function_call_output&lt;/code&gt; items. Caching, usage fields, error classes and hosted tools also differ. Keep the model call behind one small module of your own so a switch touches one file, and keep an eval set so you can tell whether the switch was an improvement.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which model does Joan Comadran use for production AI agents?
&lt;/h3&gt;

&lt;p&gt;Claude, called directly through the Anthropic TypeScript SDK with no agent framework. Claient, the WhatsApp lead agent, runs Claude Haiku 4.5 by default with tool use for appointment booking and forced tool calls for lead extraction, and lets tenants bring their own key to use Sonnet or Opus. The weekly-question pipeline for pollitics.es uses Claude Sonnet 5.5 with server-side web search for research and Claude Opus 5.5 with JSON-schema structured output for drafting.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need LangChain or another framework to build an agent with Claude or GPT?
&lt;/h3&gt;

&lt;p&gt;No. A tool-use agent is a loop: call the model with tool definitions, execute any tool calls it returns, send the results back, and stop when it answers in text. Both official SDKs make that a few dozen lines, and both vendors now offer optional helpers (Anthropic's Tool Runner, OpenAI's Agents SDK). A framework can help with many providers or complex orchestration, but it also hides the provider-specific features, like cache breakpoints and server tools, that most of the cost and reliability wins come from.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.joancomadran.dev/blog/claude-vs-gpt-for-ai-agents" rel="noopener noreferrer"&gt;joancomadran.dev&lt;/a&gt;. I'm Joan, an AI engineer in Barcelona — &lt;a href="https://www.joancomadran.dev/ai-engineering" rel="noopener noreferrer"&gt;work with me&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>claude</category>
      <category>openai</category>
    </item>
  </channel>
</rss>
