<?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>Vercel CI vs GitHub Actions for Next.js: which to use</title>
      <dc:creator>Joan Comadran</dc:creator>
      <pubDate>Mon, 02 Nov 2026 10:49:50 +0000</pubDate>
      <link>https://dev.to/joancomadran/vercel-ci-vs-github-actions-for-nextjs-which-to-use-280f</link>
      <guid>https://dev.to/joancomadran/vercel-ci-vs-github-actions-for-nextjs-which-to-use-280f</guid>
      <description>&lt;p&gt;&lt;strong&gt;Vercel CI is worth trying if your Next.js repo is private, already on Vercel, and your checks are slow because they rerun everything on every push.&lt;/strong&gt; It reads your Turborepo task graph, runs only what a change affects, and bills per CPU minute. GitHub Actions is still the general-purpose tool: any trigger, any OS, deploys, schedules, and free on public repos. For most teams the answer for now is both, with GitHub Actions keeping the jobs Vercel CI can't do.&lt;/p&gt;

&lt;p&gt;Everything below was checked against Vercel's and GitHub's own docs and pricing pages on 2 November 2026. Vercel CI is a beta from 28 October, so treat the numbers as a snapshot, not a contract.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Vercel launched on 28 October 2026
&lt;/h2&gt;

&lt;p&gt;Vercel announced &lt;a href="https://vercel.com/blog/introducing-vercel-ci" rel="noopener noreferrer"&gt;Vercel CI in beta&lt;/a&gt; on 28 October 2026 (also in the &lt;a href="https://vercel.com/changelog/vercel-ci-is-now-in-beta" rel="noopener noreferrer"&gt;changelog&lt;/a&gt;). The pitch is aimed squarely at people whose coding agents open more pull requests than humans can wait for: most CI reruns the whole workflow for a one-line change, and that gets expensive when agents push all day.&lt;/p&gt;

&lt;p&gt;How it works, according to the &lt;a href="https://vercel.com/docs/ci" rel="noopener noreferrer"&gt;Vercel CI docs&lt;/a&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You tag Turborepo tasks with &lt;code&gt;"tags": ["ci"]&lt;/code&gt; in &lt;code&gt;turbo.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;On every &lt;code&gt;git push&lt;/code&gt;, Turborepo works out which tagged tasks the change affects.&lt;/li&gt;
&lt;li&gt;Tasks whose inputs haven't changed come back from Vercel Remote Cache, on by default.&lt;/li&gt;
&lt;li&gt;The rest run in dependency order, each on its own isolated machine.&lt;/li&gt;
&lt;li&gt;Vercel posts a check to the pull request, so branch protection can block merges as it does today.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tasks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"lint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ci"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"typecheck"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ci"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"build"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tags"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"ci"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"outputs"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;".next/**"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"!.next/cache/**"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole config. No workflow YAML. Vercel's claim is that push-to-green in its own repos got 2 to 6 times faster, and that the system has run nearly 8 million tasks across its repos and the private beta. Those are Vercel's numbers about Vercel's repos; I haven't reproduced them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Vercel CI vs GitHub Actions: the comparison table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Vercel CI (beta)&lt;/th&gt;
&lt;th&gt;GitHub Actions&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Status&lt;/td&gt;
&lt;td&gt;Beta since 28 Oct 2026; "features and configuration may change"&lt;/td&gt;
&lt;td&gt;Generally available&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Config&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ci&lt;/code&gt; tags in &lt;code&gt;turbo.json&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;YAML in &lt;code&gt;.github/workflows/&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What runs&lt;/td&gt;
&lt;td&gt;Only tagged Turborepo tasks the change affects, plus their dependencies&lt;/td&gt;
&lt;td&gt;Whatever the workflow says, every time, unless you add filters&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Triggers&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;git push&lt;/code&gt; only&lt;/td&gt;
&lt;td&gt;Push, pull request, schedule, manual, issues, labels, releases and more&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operating systems&lt;/td&gt;
&lt;td&gt;Linux only (Amazon Linux 2023 by default, Ubuntu image optional)&lt;/td&gt;
&lt;td&gt;Linux, Windows, macOS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Repo support&lt;/td&gt;
&lt;td&gt;GitHub only, private repos only&lt;/td&gt;
&lt;td&gt;GitHub, public and private&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compute&lt;/td&gt;
&lt;td&gt;2 CPUs / 4 GB per task by default, up to 32 CPUs&lt;/td&gt;
&lt;td&gt;2-core Linux standard runner; larger runners on Team and Enterprise&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Caching&lt;/td&gt;
&lt;td&gt;Vercel Remote Cache, zero config&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;actions/cache&lt;/code&gt;, or Remote Cache with &lt;code&gt;TURBO_TOKEN&lt;/code&gt; secrets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Free allowance&lt;/td&gt;
&lt;td&gt;Hobby: 200 CPU minutes/month&lt;/td&gt;
&lt;td&gt;Free: $12/month of compute on private repos; standard runners free on public repos&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Paid rate&lt;/td&gt;
&lt;td&gt;Pro: $0.0035 per CPU minute, billed per second&lt;/td&gt;
&lt;td&gt;2-core Linux: $0.006 per minute, rounded up per job&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Logs and reruns&lt;/td&gt;
&lt;td&gt;Dashboard CI tab, &lt;code&gt;vercel ci&lt;/code&gt; CLI, Vercel MCP, REST API&lt;/td&gt;
&lt;td&gt;Actions tab, &lt;code&gt;gh&lt;/code&gt; CLI, REST API&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Sources: &lt;a href="https://vercel.com/docs/ci" rel="noopener noreferrer"&gt;Vercel CI docs&lt;/a&gt;, &lt;a href="https://vercel.com/kb/guide/github-actions-to-vercel-ci" rel="noopener noreferrer"&gt;Vercel's migration guide&lt;/a&gt;, &lt;a href="https://vercel.com/docs/ci/pricing" rel="noopener noreferrer"&gt;Vercel CI pricing&lt;/a&gt;, &lt;a href="https://vercel.com/pricing" rel="noopener noreferrer"&gt;Vercel pricing&lt;/a&gt;, &lt;a href="https://docs.github.com/en/billing/concepts/product-billing/github-actions" rel="noopener noreferrer"&gt;GitHub Actions billing&lt;/a&gt; and &lt;a href="https://docs.github.com/en/billing/reference/actions-runner-pricing" rel="noopener noreferrer"&gt;runner pricing&lt;/a&gt;, all as of 2 November 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing, with the arithmetic
&lt;/h2&gt;

&lt;p&gt;The units differ, which is where comparisons usually go wrong.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Vercel CI&lt;/strong&gt; bills CPU minutes: CPUs requested multiplied by run time, rounded up to the second. Each run pays for bootstrapping (cloning and installing dependencies, 4 CPUs by default) plus each task that actually runs (2 CPUs by default). Cached and skipped tasks cost nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub Actions&lt;/strong&gt; deducts a dollar amount from your plan's included compute. The standard 2-core Linux runner costs $0.006 a minute, and each job is rounded up to the whole minute. GitHub Free includes $12 a month, which is 2,000 minutes on that runner. Public repos on standard runners don't count at all.&lt;/p&gt;

&lt;p&gt;A made-up but realistic Next.js run, to show the shape (the durations are mine, not measured):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;Vercel CI&lt;/th&gt;
&lt;th&gt;GitHub Actions (one job)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Install deps, 1 min&lt;/td&gt;
&lt;td&gt;4 CPUs × 1 = 4 CPU min&lt;/td&gt;
&lt;td&gt;1 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lint, 1 min&lt;/td&gt;
&lt;td&gt;2 × 1 = 2 CPU min&lt;/td&gt;
&lt;td&gt;1 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type check, 1 min&lt;/td&gt;
&lt;td&gt;2 × 1 = 2 CPU min&lt;/td&gt;
&lt;td&gt;1 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;next build&lt;/code&gt;, 3 min&lt;/td&gt;
&lt;td&gt;2 × 3 = 6 CPU min&lt;/td&gt;
&lt;td&gt;3 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total, cold&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14 CPU min ≈ $0.049 on Pro&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;6 min = $0.036&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;On a cold run Vercel CI is not cheaper. It wins when most tasks come from cache, for example a docs-only change where only lint is affected. Then the bill is bootstrap plus one small task, and the wall clock is shorter because tasks run in parallel on separate machines.&lt;/p&gt;

&lt;p&gt;The free tiers are where the difference is stark. Vercel Hobby's 200 CPU minutes is about 14 cold runs of the example above. GitHub Free's $12 covers about 333 of them on a private repo. If you're a solo developer pushing all day, do that maths before you migrate.&lt;/p&gt;

&lt;h2&gt;
  
  
  The limits that decide it for most people
&lt;/h2&gt;

&lt;p&gt;Straight from Vercel's docs, as of 2 November 2026:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Public repositories aren't supported.&lt;/strong&gt; Open-source projects stay on GitHub Actions, where standard runners are free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub only.&lt;/strong&gt; No GitLab or Bitbucket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;git push&lt;/code&gt; is the only trigger.&lt;/strong&gt; No cron, no manual dispatch, no "run when someone adds a label".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Linux only.&lt;/strong&gt; macOS and Windows test jobs stay where they are.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turborepo 2.11.7+ and Vercel CLI 64.3.1+.&lt;/strong&gt; Older Turborepo rejects the &lt;code&gt;tags&lt;/code&gt; key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Node.js 24 image.&lt;/strong&gt; Vercel's guide says Vercel CI currently provides Node.js 24; if you pin Node 22 in your workflow, test that first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One Vercel team per repo.&lt;/strong&gt; A repo linked to projects on two teams won't enable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It tests the pushed commit, not GitHub's merge commit.&lt;/strong&gt; A &lt;code&gt;pull_request&lt;/code&gt; workflow in Actions tests the merge result by default, so the two can disagree on a branch that's behind main.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One beta-shaped caveat: on Vercel's community forum a user &lt;a href="https://community.vercel.com/t/introducing-vercel-ci/51548" rel="noopener noreferrer"&gt;reported cache misses across Git refs&lt;/a&gt; with identical task hashes (29 October). When I checked on 2 November the thread had no reply from Vercel, so I'd treat cross-branch cache reuse as "verify on your repo".&lt;/p&gt;

&lt;h2&gt;
  
  
  What GitHub Actions still does that Vercel CI doesn't
&lt;/h2&gt;

&lt;p&gt;Vercel's own &lt;a href="https://vercel.com/kb/guide/github-actions-to-vercel-ci" rel="noopener noreferrer"&gt;migration guide&lt;/a&gt; says to keep these in GitHub Actions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Deploys and package publishing&lt;/li&gt;
&lt;li&gt;Scheduled jobs&lt;/li&gt;
&lt;li&gt;Workflows triggered by issues or labels&lt;/li&gt;
&lt;li&gt;Anything that needs macOS or Windows&lt;/li&gt;
&lt;li&gt;Commands that aren't Turborepo tasks (or turn them into &lt;code&gt;package.json&lt;/code&gt; scripts and tasks first)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Add security scanning, release automation and anything that comments on PRs with a custom bot, and most real repos keep at least one workflow file. Vercel CI covers the checks, not the automation.&lt;/p&gt;

&lt;p&gt;Running a Next.js product where CI time or agent pull requests are piling up and you want the checks, caching and merge gates set up properly?&lt;/p&gt;

&lt;h2&gt;
  
  
  What this site actually runs
&lt;/h2&gt;

&lt;p&gt;For context, not as a benchmark: this site is a single Next.js 16 app deployed on Vercel. Its repo has no &lt;code&gt;.github/workflows&lt;/code&gt; folder and no &lt;code&gt;turbo.json&lt;/code&gt;, and the only checks on its commits are Vercel's deployment and a GitGuardian secret scan. The gate before merging is that the preview build passes and the preview renders. Vercel's own &lt;a href="https://vercel.com/kb/guide/how-can-i-use-github-actions-with-vercel" rel="noopener noreferrer"&gt;GitHub Actions guide&lt;/a&gt; is blunt that a Vercel build doesn't run your tests, so "the deploy went green" proves the site builds and nothing more.&lt;/p&gt;

&lt;p&gt;So for a repo like this one, the honest choice isn't between Vercel CI and GitHub Actions. It's whether to add a lint and type-check step at all. If I add one, Vercel CI is a few lines (&lt;code&gt;turbo&lt;/code&gt; as a dev dependency, a &lt;code&gt;turbo.json&lt;/code&gt;, &lt;code&gt;vercel ci enable&lt;/code&gt;), but the beta label, and the fact that nothing here runs tests yet, are why I'd not move anything important onto it yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one to pick
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Private Turborepo monorepo on Vercel, slow PR checks, lots of agent PRs:&lt;/strong&gt; try Vercel CI. Run it side by side with your workflow, as Vercel's guide suggests, then switch the required check.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single Next.js app, few checks, Hobby plan:&lt;/strong&gt; GitHub Actions. On private repos the free allowance is around 20 times larger for this kind of workload and the workflow file is one copy-paste.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Public or open-source repo:&lt;/strong&gt; GitHub Actions. Vercel CI doesn't support it yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduled jobs, releases, deploy gates, cross-OS tests:&lt;/strong&gt; GitHub Actions, whatever you pick for checks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Most teams in practice:&lt;/strong&gt; both. Vercel CI for lint, types and tests; GitHub Actions for everything with a trigger that isn't a push.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If agents are what's filling your queue, the CI speed-up only helps if review keeps pace too: see &lt;a href="https://www.joancomadran.dev/blog/how-to-review-ai-generated-code" rel="noopener noreferrer"&gt;how to review AI-generated code&lt;/a&gt; for the tiering I'd put in front of it, and &lt;a href="https://www.joancomadran.dev/blog/claude-code-vs-codex" rel="noopener noreferrer"&gt;Claude Code vs Codex&lt;/a&gt; if you're picking the agent that opens those PRs.&lt;/p&gt;

&lt;h2&gt;
  
  
  About the author
&lt;/h2&gt;

&lt;p&gt;I'm Joan Comadran, an industrial engineer turned AI engineer and full-stack developer in Barcelona. I build and deploy Next.js products on Vercel, this site included. This comparison comes from Vercel's and GitHub's documentation and pricing pages checked on 2 November 2026 and from what this site's own repo runs, not from a long production history with a beta that's five days old. If you want CI, previews and merge gates set up on a real product, that's &lt;a href="https://www.joancomadran.dev/ai-engineering" rel="noopener noreferrer"&gt;what I do&lt;/a&gt;.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Is Vercel CI a replacement for GitHub Actions?
&lt;/h3&gt;

&lt;p&gt;Only for part of it. Vercel CI runs Turborepo tasks tagged ci (lint, type checks, tests, builds) on every git push and posts a check to your pull request. It has no schedules, no issue or label triggers, no macOS or Windows runners and no deploy or publish steps, and Vercel's own migration guide says to keep those jobs in GitHub Actions. As of 2 November 2026 it is also in beta.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much does Vercel CI cost?
&lt;/h3&gt;

&lt;p&gt;As of 2 November 2026, Vercel's pricing page lists 200 CPU minutes a month included on Hobby and $0.0035 per CPU minute on Pro, with custom pricing on Enterprise. Each run bills the dependency install (4 CPUs by default) plus every task that actually runs (2 CPUs by default). Tasks restored from cache or skipped as unaffected use no CPU minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need Turborepo to use Vercel CI?
&lt;/h3&gt;

&lt;p&gt;Yes. Vercel CI reads turbo.json to decide what to run, and needs Turborepo 2.11.7 or later plus Vercel CLI 64.3.1 or later. Vercel says it also works in single-package repos, so a plain Next.js app can add turbo and a turbo.json with lint, typecheck or build tasks tagged ci.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Vercel CI work with public GitHub repositories?
&lt;/h3&gt;

&lt;p&gt;Not yet. As of 2 November 2026 the Vercel CI documentation lists public repositories as unsupported, along with any forge other than GitHub and any trigger other than git push. For open-source Next.js projects, GitHub Actions on standard runners remains free and is the practical choice.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.joancomadran.dev/blog/vercel-ci-vs-github-actions" 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>vercel</category>
      <category>githubactions</category>
      <category>ci</category>
    </item>
    <item>
      <title>How to review AI-generated code: a risk-tier checklist</title>
      <dc:creator>Joan Comadran</dc:creator>
      <pubDate>Fri, 30 Oct 2026 10:39:11 +0000</pubDate>
      <link>https://dev.to/joancomadran/how-to-review-ai-generated-code-a-risk-tier-checklist-111i</link>
      <guid>https://dev.to/joancomadran/how-to-review-ai-generated-code-a-risk-tier-checklist-111i</guid>
      <description>&lt;p&gt;&lt;strong&gt;To review AI-generated code without drowning in it, stop treating every pull request the same.&lt;/strong&gt; Sort each change by risk, let machines do the first read (tests, types, an AI reviewer, a security scan), and spend human attention only where a missed bug is expensive: auth, money, data, CI and releases. Then cap how many rounds an agent and a reviewer can bounce a PR between them, because left alone they won't stop.&lt;/p&gt;

&lt;p&gt;That's the short version. The rest is the data behind it and a checklist you can copy into a small team's repo this afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why code review is now the bottleneck
&lt;/h2&gt;

&lt;p&gt;Two things landed in October 2026 that say the same thing from opposite ends.&lt;/p&gt;

&lt;p&gt;The first is a Harvard paper by Fiona Chen and James Stratton, &lt;a href="https://fion.ac/jellyfish.pdf" rel="noopener noreferrer"&gt;Artificial Intelligence in the Firm: Bottlenecks in Software Production&lt;/a&gt;, built on Jellyfish analytics data: 445 million work events from 720,542 workers at 655 firms. The version I read (dated 28 October 2026, accessed 30 October) finds that after a firm adopts AI coding agents:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric (after AI agent adoption)&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lines of code added per worker&lt;/td&gt;
&lt;td&gt;+44.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pull requests opened&lt;/td&gt;
&lt;td&gt;+35.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jira issues resolved&lt;/td&gt;
&lt;td&gt;+13.7% (borderline significant)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time from pull request to merge&lt;/td&gt;
&lt;td&gt;+56.1% (baseline 3.56 days)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Review comments per pull request&lt;/td&gt;
&lt;td&gt;+38.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Their words: "Code review becomes the bottleneck." Code waits longer for a reviewer (+59.9% time in backlog) and spends longer in active review (+58.5%), which the authors read as both too little review capacity and lower quality code coming in. AI review tools don't fix it on their own: 79.1% of firms had used one by July 2026, but AI wrote only 23.3% of review comments.&lt;/p&gt;

&lt;p&gt;If you saw &lt;a href="https://arstechnica.com/ai/2026/10/ai-coding-agents-generate-more-code-but-not-more-software/" rel="noopener noreferrer"&gt;Ars Technica's coverage&lt;/a&gt; on 9 October, its numbers (+30% lines of code, +23% pull requests, +49% review time) match an earlier version of the paper (first posted in January and revised since; the current one is dated 28 October). Same direction, different decimals. I've used the current version.&lt;/p&gt;

&lt;p&gt;The second is the other end: a team that writes most of its code with agents and made review work anyway. On 15 October the Claude Code team published &lt;a href="https://claude.dev/blog/solving-code-review-at-anthropic/" rel="noopener noreferrer"&gt;How the Claude Code team does code review&lt;/a&gt; (Addy Osmani, Morgan Westlee Lunt and Qing Wang). Claude writes about 90% of Claude Code's own code. From June to September, merged pull requests in that codebase more than doubled while the share that had to be reverted fell by more than 40%. They didn't do it by reading harder. They moved most of the reading to machines and made mistakes cheap to undo.&lt;/p&gt;

&lt;p&gt;You are probably not Anthropic. But the parts that matter scale down to a team of two.&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea: the change sets the risk tier, not the author
&lt;/h2&gt;

&lt;p&gt;Anthropic's rule is that "the change sets the tier, not the author." A docs fix and an auto-updater change get different review, whoever (or whatever) wrote them. Their table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Risk&lt;/th&gt;
&lt;th&gt;Examples&lt;/th&gt;
&lt;th&gt;Human approvals&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Docs, copy, internal-only features, additive changes behind a flag&lt;/td&gt;
&lt;td&gt;None, once automated review and security checks are green&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Ordinary product changes, plus sensitive areas like caching, the auto-updater, system prompts&lt;/td&gt;
&lt;td&gt;One&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;The riskiest code, plus paths that always need two people&lt;/td&gt;
&lt;td&gt;Two&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;They use a classifier to pick the tier. A small team doesn't need one: a &lt;code&gt;CODEOWNERS&lt;/code&gt; file and a list of paths does 90% of the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  A risk-tier checklist for small teams
&lt;/h2&gt;

&lt;p&gt;Here is how I'd translate it for a team of one to five people shipping a typical web app (Next.js, Postgres, a payment provider, a few agents). Treat it as a starting point, then tighten or loosen it with evidence.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tier 0: can auto-merge
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Docs, README, blog content, copy changes.&lt;/li&gt;
&lt;li&gt;Dependency patch bumps with a clean lockfile diff.&lt;/li&gt;
&lt;li&gt;New code behind a feature flag that's off in production.&lt;/li&gt;
&lt;li&gt;Tests that only add coverage (no edited assertions).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Gate:&lt;/strong&gt; green CI (lint, types, tests, build), AI review with no open findings, a preview deploy that renders.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tier 1: one human
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Ordinary product features and bug fixes.&lt;/li&gt;
&lt;li&gt;UI changes, new endpoints that reuse existing auth helpers.&lt;/li&gt;
&lt;li&gt;Refactors inside one module.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Gate:&lt;/strong&gt; everything in Tier 0, plus one human approval. On an agent-written PR, the person who prompted the agent can be that human, which is exactly Anthropic's rule: they count as a required reviewer, "the same as if they'd typed the code themselves".&lt;/p&gt;

&lt;h3&gt;
  
  
  Tier 2: the owner, and a second pair of eyes if you have one
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Auth, sessions, permissions, row-level security policies.&lt;/li&gt;
&lt;li&gt;Payments, billing, anything that moves money.&lt;/li&gt;
&lt;li&gt;Database migrations and data deletion.&lt;/li&gt;
&lt;li&gt;CI workflows, deploy config, secrets handling.&lt;/li&gt;
&lt;li&gt;Agent instructions and system prompts that change what production agents do.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Gate:&lt;/strong&gt; CODEOWNERS approval from whoever knows that area, plus a written rollback plan. If you're a team of one, this is the tier where you sleep on it and read the diff yourself the next morning. For Supabase apps, my &lt;a href="https://www.joancomadran.dev/blog/supabase-nextjs-pre-launch-security-checklist" rel="noopener noreferrer"&gt;pre-launch security checklist&lt;/a&gt; lists most of the paths that belong here.&lt;/p&gt;

&lt;h3&gt;
  
  
  What to read in every tier
&lt;/h3&gt;

&lt;p&gt;The current top results for this keyword are long lists of AI failure modes. They're right, but you don't need 24 checks on a typo fix. These are the ones worth doing on every human-reviewed PR:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Scope.&lt;/strong&gt; Does the diff match the task? Unrelated "cleanups" go in another PR.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tests first.&lt;/strong&gt; Read the test diff before the code. A deleted assertion, a new skip or a looser matcher is the agent making the test pass, not the code work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invented things.&lt;/strong&gt; Every new import, function, config key and package must exist in the version you actually run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failure paths.&lt;/strong&gt; What happens when the lookup returns nothing or the call times out? Look for fallbacks that let a request through.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Evidence.&lt;/strong&gt; Ask for the command that ran and what it returned, or a screenshot. Anthropic asks its agents to "show the output instead of saying it's done".&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Two rules that keep humans honest
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An agent's approval never counts as a person's.&lt;/strong&gt; Automated review can clear a Tier 0 change. It can't fill a slot that calls for a human.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Approvals belong to a commit.&lt;/strong&gt; If an agent pushes after you approved, the approval is stale. On GitHub that's the "Dismiss stale pull request approvals when new commits are pushed" option in branch protection or rulesets. Turn it on.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cap the loops between agents and reviewers
&lt;/h2&gt;

&lt;p&gt;This one only shows up when both sides are machines. A human waves off a nitpick in two seconds; an agent fixes it, pushes, and triggers another review. Anthropic reports one CLI PR that went through more than fifty rounds of review and fixes overnight before its author stopped it. When they ran &lt;code&gt;git blame&lt;/code&gt; on what the reviewer kept flagging, about 59% was code that had been there since the first pass: old bugs, found a few at a time.&lt;/p&gt;

&lt;p&gt;Their fixes, which you can copy as-is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hard cap at three rounds.&lt;/strong&gt; After that, the reviewer asks the author to step back and rethink the approach.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Diff-only re-review&lt;/strong&gt; after the first passes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flag the class of bug&lt;/strong&gt;, so the author fixes every instance, not one line.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No repeated findings.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you run agents in CI, put the cap in the harness, not in a prompt. The same logic as budgets and turn limits in &lt;a href="https://www.joancomadran.dev/blog/durable-ai-agents" rel="noopener noreferrer"&gt;durable AI agents&lt;/a&gt;: a loop with no ceiling is a bill with no ceiling, and &lt;a href="https://www.joancomadran.dev/blog/claude-code-cost-per-month" rel="noopener noreferrer"&gt;Claude Code cost per month&lt;/a&gt; is already hard enough to predict.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write a REVIEW.md
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt; (or &lt;code&gt;AGENTS.md&lt;/code&gt;, see &lt;a href="https://www.joancomadran.dev/blog/claude-code-vs-codex" rel="noopener noreferrer"&gt;Claude Code vs Codex&lt;/a&gt; for using both) tells the agent what the project is. &lt;code&gt;REVIEW.md&lt;/code&gt; tells the reviewer what matters. Anthropic's good ones are short and do five things: say what failure looks like in this repo, cap the nitpicks, list paths to skip, ask for a file and line as evidence, and ask for a summary that starts with a count and says plainly when there's nothing to fix.&lt;/p&gt;

&lt;p&gt;A starting point:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# REVIEW.md&lt;/span&gt;

&lt;span class="gu"&gt;## What failure looks like here&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; A user can read or change another user's data.
&lt;span class="p"&gt;-&lt;/span&gt; A payment is charged twice or not recorded.
&lt;span class="p"&gt;-&lt;/span&gt; A migration can't be rolled back.

&lt;span class="gu"&gt;## Rules&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Every finding needs a file and line. No guesses from names.
&lt;span class="p"&gt;-&lt;/span&gt; Max 3 nits per review. Style is the linter's job.
&lt;span class="p"&gt;-&lt;/span&gt; Skip: content/&lt;span class="ge"&gt;**&lt;/span&gt;, &lt;span class="err"&gt;*&lt;/span&gt;.md, generated types, lockfiles.
&lt;span class="p"&gt;-&lt;/span&gt; Start the summary with the number of real issues. If it's 0, say so.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the reviewer keeps flagging the same thing, move it into &lt;code&gt;REVIEW.md&lt;/code&gt;, a lint rule or a test. That's how the noise goes down instead of up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Small-team setup on GitHub
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Branch protection or a ruleset on &lt;code&gt;main&lt;/code&gt;: required checks, required review, stale approvals dismissed.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CODEOWNERS&lt;/code&gt; for Tier 2 paths, including &lt;code&gt;.github/workflows/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A PR template with three headings: Testing, Risk, How to revert.&lt;/li&gt;
&lt;li&gt;One AI reviewer on one repo first. Tune &lt;code&gt;REVIEW.md&lt;/code&gt; for a few weeks, then widen. Code Review for Claude Code is generally available on Team and Enterprise plans, and &lt;code&gt;/code-review&lt;/code&gt; and &lt;code&gt;/security-review&lt;/code&gt; run locally.&lt;/li&gt;
&lt;li&gt;Previews, feature flags and one-click rollback, so a bad merge costs minutes. On Vercel, preview deployments and instant rollback are built in.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One caution from &lt;a href="https://www.infoq.com/news/2026/10/anthropic-code-review/" rel="noopener noreferrer"&gt;InfoQ's write-up&lt;/a&gt;: the open-source Claude security review GitHub Action says in its README that it "is not hardened against prompt injection attacks and should only be used to review trusted PRs". Don't point it at PRs from strangers.&lt;/p&gt;

&lt;p&gt;Shipping with coding agents and want review tiers, CI gates and rollback set up before the PR queue gets away from you?&lt;/p&gt;

&lt;h2&gt;
  
  
  What this doesn't solve
&lt;/h2&gt;

&lt;p&gt;Anthropic is candid that it doesn't yet have circuit breakers that tighten approval when revert or incident numbers rise. Neither will you. Watch two numbers instead: how long PRs wait on a person, and how often you revert. If the first drops and the second climbs, you didn't save review time, you moved it to incident time.&lt;/p&gt;

&lt;p&gt;And some PRs shouldn't land at all. Agents make starting things cheap, which also makes closing a PR cheap. "Should we build this?" is still a human question, and no reviewer, human or not, answers it by reading the diff. If you're designing the agents themselves, &lt;a href="https://www.joancomadran.dev/blog/single-agent-vs-multi-agent" rel="noopener noreferrer"&gt;single agent vs multi-agent&lt;/a&gt; covers where the boundaries go.&lt;/p&gt;




&lt;p&gt;I'm Joan Comadran, an AI engineer and full-stack developer in Barcelona (industrial engineering background). This guide is built from the Harvard paper and Anthropic's write-up, both checked on 30 October 2026, not from a review process I'm claiming to run at Anthropic's scale. If you want someone to set up the tiers, gates and rollback on a real product, that's &lt;a href="https://www.joancomadran.dev/ai-engineering" rel="noopener noreferrer"&gt;what I do&lt;/a&gt;.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  How do you review AI-generated code?
&lt;/h3&gt;

&lt;p&gt;Sort each pull request by risk before anyone reads it. Low-risk changes (docs, copy, additive code behind a flag) can merge on green CI plus an AI review. Ordinary product changes need one human. Auth, billing, data access, CI workflows and release code need a human who knows that area, ideally two. In every tier, read the tests before the code, check for invented APIs and dependencies, and confirm the change does what the task asked and nothing else.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can AI review AI-generated code?
&lt;/h3&gt;

&lt;p&gt;It can do most of the reading, but it should not approve. Anthropic's Claude Code team runs an AI review on every pull request and lets low-risk changes merge on automated checks, yet an agent's approval never counts as a person's, and the person who prompted the agent counts as a required reviewer. Use the AI reviewer to find issues and the human to own the decision.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is a REVIEW.md file?
&lt;/h3&gt;

&lt;p&gt;REVIEW.md is a short file, written by the people who own the code, that tells an AI reviewer what matters in that repository: what failure looks like, which paths to skip, how many nitpicks to allow, and that every finding needs a file and line as evidence. Anthropic's Code Review for Claude Code reads it; with other tools you can point the reviewer at it in its prompt.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is code review the bottleneck with AI coding agents?
&lt;/h3&gt;

&lt;p&gt;Agents speed up writing, not reviewing. In Chen and Stratton's Harvard study of Jellyfish data (655 firms, version of 28 October 2026), adopting AI agents raised lines of code by 44% and pull requests opened by 35.7%, while time from pull request to merge rose 56.1% and review comments per pull request rose 38.6%. Issues resolved grew far less than code did.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.joancomadran.dev/blog/how-to-review-ai-generated-code" 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>codereview</category>
      <category>claudecode</category>
      <category>pullrequests</category>
    </item>
    <item>
      <title>Single agent vs multi-agent: when subagents are worth it</title>
      <dc:creator>Joan Comadran</dc:creator>
      <pubDate>Wed, 28 Oct 2026 23:07:15 +0000</pubDate>
      <link>https://dev.to/joancomadran/single-agent-vs-multi-agent-when-subagents-are-worth-it-5146</link>
      <guid>https://dev.to/joancomadran/single-agent-vs-multi-agent-when-subagents-are-worth-it-5146</guid>
      <description>&lt;p&gt;&lt;strong&gt;Single agent vs multi-agent is a trade-off between one shared context and several isolated ones.&lt;/strong&gt; For many production setups in late 2026, Anthropic's Applied AI team now recommends defaulting to one agent loop with skills and tools loaded on demand, and keeping a subagent only when evals show it still beats the folded version — or when it truly needs its own context window, parallel runs, or permission isolation. That is the practical answer; the rest of this post is how to decide for your stack.&lt;/p&gt;

&lt;p&gt;On 22 October 2026 Anthropic published &lt;a href="https://claude.com/resources/articles/building-agents-when-to-fold-subagents-into-your-main-agent-loop" rel="noopener noreferrer"&gt;Building agents: When to fold subagents into your main agent loop&lt;/a&gt;, arguing that enterprise agents with routers, planners and a subagent per team often lose quality, voice and speed to handoffs and "Frankenprompt" system prompts. The same week they shipped the &lt;a href="https://github.com/anthropics/single-agent-loop-kit" rel="noopener noreferrer"&gt;&lt;code&gt;single-agent-loop-kit&lt;/code&gt;&lt;/a&gt; on GitHub (Apache 2.0; README: reference implementation, not maintained, not accepting contributions). &lt;a href="https://the-decoder.com/anthropic-says-one-ai-agent-beats-many-subagents-now-that-models-can-handle-complexity-alone/" rel="noopener noreferrer"&gt;THE DECODER&lt;/a&gt; summarised the piece on 25 October, including Anthropic's note that one customer saw roughly &lt;strong&gt;2× faster runtime&lt;/strong&gt; on a single-agent loop than on their multi-agent harness in an internal comparison.&lt;/p&gt;

&lt;p&gt;None of that means subagents are dead. It means the bar moved: newer models (Anthropic cites Opus 4.5 onward, plus Opus 5.5 and Fable 5.1) can carry more planning inside one loop, so a lot of harness code and org-chart architecture may be doing work the model no longer needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in the single agent vs multi-agent debate
&lt;/h2&gt;

&lt;p&gt;Multi-agent designs looked rational for a long time. Each team ships a specialist subagent; a central orchestrator routes intent; evals live next to each specialist. Conway's law, but for tokens.&lt;/p&gt;

&lt;p&gt;Anthropic's write-up lists what that buys you in pain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Context loss&lt;/strong&gt; at every handoff between agents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subagent sprawl&lt;/strong&gt; — hundreds of overlapping agents with no shared owner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conflicting system prompts&lt;/strong&gt; when every team adds a sentence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local evals&lt;/strong&gt; that optimise subtasks but dilute end-to-end quality.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency&lt;/strong&gt; from nested model loops; the ~2× runtime figure above is one datapoint from their customer comparison, not a guarantee for your workload.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Their counter-pattern is a &lt;strong&gt;single agent loop&lt;/strong&gt;: one model, one context window, batched tool calls per turn, turn and cost budgets in the harness, and the model revising its own plan as tool results arrive — instead of a planner agent handing a lossy summary to a worker agent.&lt;/p&gt;

&lt;p&gt;Progressive disclosure is the mechanism: short descriptions of skills and tools in context, full content only when the model selects them. Anthropic says that pattern let them cut Claude Code's system prompt for newer models by &lt;strong&gt;over 80%&lt;/strong&gt;, partly by moving procedure into skills and actions into tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  A single agent with skills and MCP tools
&lt;/h2&gt;

&lt;p&gt;Skills and tools answer different questions. Anthropic's article is explicit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Skills&lt;/strong&gt; — how a team does work: steps, rules, tone, edge cases. That is what used to sit in a subagent's long system prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tools&lt;/strong&gt; — what the agent can &lt;em&gt;do&lt;/em&gt;: fetch live data, write with guardrails, call an API, or temporarily wrap a subagent you have not folded yet.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;MCP fits on the tool side when the same integrations must work across hosts (Claude, ChatGPT, Cursor, your product). The model still chooses tools through ordinary function calling; MCP standardises discovery and execution. I wrote the layer cake in &lt;a href="https://www.joancomadran.dev/blog/mcp-vs-function-calling" rel="noopener noreferrer"&gt;MCP vs function calling&lt;/a&gt;; the short version is that MCP does not replace tool use — it packages tools for reuse.&lt;/p&gt;

&lt;p&gt;Anthropic also warns that &lt;strong&gt;tool choice degrades beyond roughly 30–50 tool definitions&lt;/strong&gt; in the prompt at once. Mitigate with tool search, grouping tools by user intent, and preconditions inside the tool so the model gets a readable refusal.&lt;/p&gt;

&lt;p&gt;One open-ended loop, procedure in &lt;strong&gt;skills&lt;/strong&gt;, side effects in &lt;strong&gt;tools&lt;/strong&gt;, and a &lt;strong&gt;small central team&lt;/strong&gt; on the system prompt plus shared end-to-end eval — domain teams ship skills and tools with their own small evals, vetted before merge. Without that ownership, you get skill sprawl instead of subagent sprawl.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a subagent still earns its place
&lt;/h2&gt;

&lt;p&gt;Anthropic keeps subagents on the table when:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Context bloat is measurable&lt;/strong&gt; — the single loop's quality drops as history and tool output accumulate, and trimming or summarising is not enough.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;True parallelism&lt;/strong&gt; — several heavy tasks need fresh context windows at the same time, then discard them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool or instruction conflict&lt;/strong&gt; — one prompt cannot reliably pick among overlapping tools, or teams have written incompatible rules.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;They also use subagents for &lt;strong&gt;verification&lt;/strong&gt;, &lt;strong&gt;parallel research paths&lt;/strong&gt;, and &lt;strong&gt;modular permissioning&lt;/strong&gt; in their own multi-agent research system. The guidance is not "never subagent"; it is "stop giving every team a subagent by default."&lt;/p&gt;

&lt;p&gt;Even when you keep one, the article prefers &lt;strong&gt;calling it as a tool&lt;/strong&gt; from the main loop so the primary agent keeps the user thread. Full handoff is for when the specialist must talk to the user directly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Single agent vs multi-agent at a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Single agent loop&lt;/th&gt;
&lt;th&gt;Multi-agent (orchestrator + subagents)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Context&lt;/td&gt;
&lt;td&gt;One window; shared history across steps&lt;/td&gt;
&lt;td&gt;Separate windows; summaries passed between agents&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Instructions&lt;/td&gt;
&lt;td&gt;One system prompt + skills loaded on demand&lt;/td&gt;
&lt;td&gt;Many prompts; risk of contradiction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Latency&lt;/td&gt;
&lt;td&gt;One nested loop; parallel &lt;em&gt;tools&lt;/em&gt; per turn&lt;/td&gt;
&lt;td&gt;Often nested loops per subagent call&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Parallelism&lt;/td&gt;
&lt;td&gt;Parallel tool calls; sequential reasoning&lt;/td&gt;
&lt;td&gt;Natural fit for parallel &lt;em&gt;agents&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ownership&lt;/td&gt;
&lt;td&gt;Central team on prompt + E2E eval&lt;/td&gt;
&lt;td&gt;Easy to mirror teams; hard to maintain voice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Migration&lt;/td&gt;
&lt;td&gt;Fold specialists into skills/tools incrementally&lt;/td&gt;
&lt;td&gt;Already sunk cost in many prompts and evals&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best when&lt;/td&gt;
&lt;td&gt;Most customer-facing and ops agents&lt;/td&gt;
&lt;td&gt;Deep research, parallel paths, strict isolation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Decision checklist: fold or keep?
&lt;/h2&gt;

&lt;p&gt;Use this before you add another subagent — or when Anthropic's migration story lands on your roadmap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Does this work need its own context window?&lt;/strong&gt; Deep research, long independent branches, or contexts you must discard after one task → subagent candidate. Everything else → try a skill first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the work truly parallel?&lt;/strong&gt; If tasks must run at the same time with separate token budgets, subagents (or separate loop invocations) still make sense. If work is sequential but owned by different teams, that is not automatically parallel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Isolation or compliance handoff?&lt;/strong&gt; Separate credentials, audit boundaries, or a verifier that must not see draft content → consider a dedicated loop or tool-gated subagent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Otherwise, fold it.&lt;/strong&gt; Turn the subagent prompt into a skill, wire APIs as tools (MCP where reuse matters), run the shared eval, and keep the subagent only if scores drop.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Anthropic's support-desk example in the kit is instructive in the negative direction: folding one subagent &lt;strong&gt;lowered latency&lt;/strong&gt; but &lt;strong&gt;broke a process&lt;/strong&gt; that had been passing — the main agent answered shipping questions without calling the specialist. Their fix is boring and essential: eval tasks for every production chain and every rule that must not break, run more than once per task.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to migrate one subagent at a time
&lt;/h2&gt;

&lt;p&gt;You do not rewrite the fleet in a weekend. Anthropic's staged path:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Task-completion evals&lt;/strong&gt; — shared end-to-end set plus per-skill sets; ten golden tasks beats zero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Combine&lt;/strong&gt; subagents that always run back-to-back on the same data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subagents as tools&lt;/strong&gt; — baseline on the eval before rewriting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fold&lt;/strong&gt; — prompt → skill, APIs → tools; compare scores; keep the subagent if the folded version loses.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The &lt;a href="https://github.com/anthropics/single-agent-loop-kit" rel="noopener noreferrer"&gt;&lt;code&gt;single-agent-loop-kit&lt;/code&gt;&lt;/a&gt; chains eval build, prompt audit, hillclimb and cost-optimize skills. README: reference implementation, not maintained.&lt;/p&gt;

&lt;p&gt;Folding does not replace caps, idempotency or replay rules — see &lt;a href="https://www.joancomadran.dev/blog/durable-ai-agents" rel="noopener noreferrer"&gt;durable AI agents&lt;/a&gt;. For coding harnesses, &lt;a href="https://www.joancomadran.dev/blog/claude-code-vs-codex" rel="noopener noreferrer"&gt;Claude Code vs Codex&lt;/a&gt; is the parallel "one loop vs delegated queue" question.&lt;/p&gt;

&lt;p&gt;Designing an agent architecture and want the eval set, tool boundaries and cost caps settled before you ship?&lt;/p&gt;

&lt;h2&gt;
  
  
  Costs and latency
&lt;/h2&gt;

&lt;p&gt;Multi-agent setups pay nested model rounds and lossy handoffs — extra &lt;strong&gt;latency&lt;/strong&gt; and &lt;strong&gt;input tokens&lt;/strong&gt; even when the final answer looks fine. A single loop still needs good skill and tool descriptions and tool search; it is not automatic savings.&lt;/p&gt;

&lt;p&gt;For build budgets see &lt;a href="https://www.joancomadran.dev/blog/ai-agent-development-cost" rel="noopener noreferrer"&gt;AI agent development cost&lt;/a&gt;; for token spend inside one loop, &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 under control&lt;/a&gt;.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  What is the difference between a single agent and a multi-agent system?
&lt;/h3&gt;

&lt;p&gt;A single agent is one model in one context window running an open-ended tool loop: it loads skills and tools on demand and keeps intermediate results in the same thread. A multi-agent system adds subagents — separate loops with their own prompts, tools and context — usually coordinated by a router or orchestrator. Multi-agent setups can parallelise work and isolate permissions; they also add handoffs, latency and duplicated instructions unless each subagent earns its place.&lt;/p&gt;

&lt;h3&gt;
  
  
  When should I use subagents?
&lt;/h3&gt;

&lt;p&gt;Anthropic's guidance (October 2026) keeps subagents when a single loop shows measurable degradation from context bloat, when several tasks must run in parallel with fresh context windows, or when one agent cannot reliably choose tools or follow conflicting instructions in one prompt. If none of those apply, prefer folding specialist prompts into skills and their API calls into tools in the main loop, then compare on the same eval.&lt;/p&gt;

&lt;h3&gt;
  
  
  Are agent skills the same as MCP tools?
&lt;/h3&gt;

&lt;p&gt;No. Skills hold procedural knowledge — steps, rules and judgment calls that used to live in a subagent's system prompt — and load when the model needs them. MCP tools (or plain function tools) are how the agent acts: read live data, call APIs, enforce permissions, or invoke a subagent you have not folded yet. Anthropic recommends skills for how a team works and tools grouped by user intent for what the agent can do.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I migrate from multi-agent to single agent without breaking production?
&lt;/h3&gt;

&lt;p&gt;Build or reuse a shared end-to-end eval, then change one subagent at a time: turn its prompt into a skill and its integrations into tools, run the eval before and after, and keep the subagent only if the folded version scores worse. Anthropic's single-agent-loop-kit chains Claude API skills for evals, prompt audit and hillclimbing; it is Apache 2.0 and labelled a reference implementation that is not maintained.&lt;/p&gt;




&lt;p&gt;I'm Joan Comadran, an AI engineer and full-stack developer in Barcelona (industrial engineering background). I write guides like this from vendor docs and shipped agent work — not from a fictional subagent migration on this site. If you want someone to draw the single-loop vs subagent line on a real product, that's &lt;a href="https://www.joancomadran.dev/ai-engineering" rel="noopener noreferrer"&gt;what I do&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.joancomadran.dev/blog/single-agent-vs-multi-agent" 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>claude</category>
      <category>anthropic</category>
      <category>subagents</category>
    </item>
    <item>
      <title>Claude Code vs Codex: pick by task, then set up both</title>
      <dc:creator>Joan Comadran</dc:creator>
      <pubDate>Fri, 23 Oct 2026 18:47:58 +0000</pubDate>
      <link>https://dev.to/joancomadran/claude-code-vs-codex-pick-by-task-then-set-up-both-2bp3</link>
      <guid>https://dev.to/joancomadran/claude-code-vs-codex-pick-by-task-then-set-up-both-2bp3</guid>
      <description>&lt;p&gt;On October 19, Theo Browne &lt;a href="https://x.com/theo/status/2112035326625730981" rel="noopener noreferrer"&gt;posted&lt;/a&gt; that over the previous two weeks, &lt;strong&gt;more than 60% of T3 Code users who used Codex also used Claude Code&lt;/strong&gt;. By October 23 the post had 2,511 likes and about 118,000 views. &lt;a href="https://github.com/pingdotgg/t3code" rel="noopener noreferrer"&gt;T3 Code&lt;/a&gt; is his open-source control surface for coding agents (MIT, roughly 29,600 GitHub stars as of today). You bring your own Claude Code, Codex, Cursor, Grok Build, OpenCode or Antigravity login and drive them from one desktop, web or mobile app.&lt;/p&gt;

&lt;p&gt;One caveat before anyone puts that number in a slide. People who install a tool built for switching agents are, unsurprisingly, people who switch agents. The 60% says very little about developers in general.&lt;/p&gt;

&lt;p&gt;What it does show is that "Claude Code vs Codex" is turning into the wrong question for a lot of people. The better question is which jobs go to which agent, what that costs, and how to stop maintaining two sets of instructions. That's what this post covers.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually differs
&lt;/h2&gt;

&lt;p&gt;Both are coding agents. You describe a task, and the agent reads the repo, edits files, runs commands and shows you the diff. Both have a CLI, an IDE extension, a desktop app and MCP support. Each vendor keeps shipping the other's features, so any feature table goes stale fast.&lt;/p&gt;

&lt;p&gt;The difference that lasts is the default working style:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Claude Code is built around steering.&lt;/strong&gt; It works in your terminal and your repo, shows its plan, asks before risky actions, and expects you to interrupt. You shape it with &lt;code&gt;CLAUDE.md&lt;/code&gt;, skills, subagents and hooks. A &lt;code&gt;PreToolUse&lt;/code&gt; hook is the way to &lt;em&gt;block&lt;/em&gt; something, because Anthropic's own &lt;a href="https://code.claude.com/docs/en/memory" rel="noopener noreferrer"&gt;memory docs&lt;/a&gt; say &lt;code&gt;CLAUDE.md&lt;/code&gt; is context, not enforced configuration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Codex is built around delegation.&lt;/strong&gt; It runs locally too, but its strength is handing off a task: cloud tasks in a sandbox, &lt;code&gt;@codex&lt;/code&gt; on a GitHub issue or PR, &lt;code&gt;codex exec&lt;/code&gt; in scripts, and several tasks at once. You shape it with &lt;code&gt;AGENTS.md&lt;/code&gt; and its sandbox and approval settings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Roughly, Claude Code feels like pairing and Codex feels like assigning a ticket. Both can do either; that's just what each one is designed for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The benchmark numbers
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://artificialanalysis.ai/agents/coding-agents/comparisons/claude-code-vs-codex" rel="noopener noreferrer"&gt;Artificial Analysis&lt;/a&gt; runs both harnesses on the same three benchmarks. Their figures, checked October 23, 2026:&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 Code (Opus 5.5, max)&lt;/th&gt;
&lt;th&gt;Codex (GPT-6 Astra, max)&lt;/th&gt;
&lt;th&gt;Codex (GPT-6.1 Sol, max)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Coding Agent Index&lt;/td&gt;
&lt;td&gt;62&lt;/td&gt;
&lt;td&gt;55&lt;/td&gt;
&lt;td&gt;54&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API cost per task&lt;/td&gt;
&lt;td&gt;$11.42&lt;/td&gt;
&lt;td&gt;$7.04&lt;/td&gt;
&lt;td&gt;$1.50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time per task&lt;/td&gt;
&lt;td&gt;1.0 h&lt;/td&gt;
&lt;td&gt;24.8 min&lt;/td&gt;
&lt;td&gt;23.7 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tokens per task&lt;/td&gt;
&lt;td&gt;14.5M&lt;/td&gt;
&lt;td&gt;3.2M&lt;/td&gt;
&lt;td&gt;3.9M&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things stand out. Claude Code scores higher and spends a lot more to get there: more turns, more tokens, more wall time. And Codex on GPT-6.1 Sol gets within a point of Astra at about a fifth of the cost, which says more about model choice than about harnesses.&lt;/p&gt;

&lt;p&gt;Note what "cost per task" means here: pay-per-token API cost, not what you pay on a subscription. On a flat plan, those tokens turn into how fast you hit your limit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What they cost (October 23, 2026)
&lt;/h2&gt;

&lt;p&gt;Neither one is sold on its own. You get it through a plan.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude Code&lt;/strong&gt; (&lt;a href="https://claude.com/pricing?mode=code" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pro: $20/month, or $17/month billed annually&lt;/li&gt;
&lt;li&gt;Max: from $100/month, at 5x or 20x Pro's usage per five-hour session&lt;/li&gt;
&lt;li&gt;Team and Enterprise seats, plus pay-per-token through a Console account&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Limits reset on a rolling five-hour window, paid plans add weekly limits on top, and Claude Code shares one pool with your Claude chats. One gotcha from &lt;a href="https://support.claude.com/en/articles/11145838-use-claude-code-with-your-pro-or-max-plan" rel="noopener noreferrer"&gt;Anthropic's help center&lt;/a&gt;: if &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt; is set in your shell, Claude Code bills that key instead of your subscription. That's one more reason to keep keys out of global env, which I covered in &lt;a href="https://www.joancomadran.dev/blog/secure-openai-api-key" rel="noopener noreferrer"&gt;securing your API keys&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Codex&lt;/strong&gt; (&lt;a href="https://developers.openai.com/codex/pricing/" rel="noopener noreferrer"&gt;pricing&lt;/a&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Free and Go ($8): GPT-6 Luna in the desktop app, subject to rollout&lt;/li&gt;
&lt;li&gt;Plus: $20/month&lt;/li&gt;
&lt;li&gt;Pro: $100, $200 or $500/month. Only Pro 500 gets Ultrafast mode&lt;/li&gt;
&lt;li&gt;Business: $20/user/month billed annually, $25 monthly&lt;/li&gt;
&lt;li&gt;API key: pay per token, but no cloud features (no GitHub reviews, no Slack)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On Plus, OpenAI estimates 15–160 local messages per five hours on GPT-6.1 Sol and 5–45 on GPT-6 Astra. Fast mode burns that allowance 2.5x faster. Pro plans currently have no five-hour limit, though weekly limits apply. Type &lt;code&gt;/status&lt;/code&gt; in the CLI to see what's left.&lt;/p&gt;

&lt;p&gt;Something to know this week if you're on Pro 200: according to &lt;a href="https://help.openai.com/en/articles/9793128-about-chatgpt-pro-tiers" rel="noopener noreferrer"&gt;OpenAI's Pro tiers article&lt;/a&gt;, subscribers who were on Pro 200 between September 22 and 29 keep the old, larger allowance only &lt;strong&gt;through October 29, 2026&lt;/strong&gt;. After that they move to the new, smaller one, still at $200. If Codex has been your workhorse at that tier, expect to hit limits sooner from next Friday.&lt;/p&gt;

&lt;p&gt;For budgeting either one at team scale, the logic in &lt;a href="https://www.joancomadran.dev/blog/claude-code-cost-per-month" rel="noopener noreferrer"&gt;Claude Code cost per month&lt;/a&gt; applies to Codex too: measure cost per finished task, not cost per seat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which job goes to which
&lt;/h2&gt;

&lt;p&gt;Choose by how well you can define the task and how closely you need to watch it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude Code&lt;/strong&gt; for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Exploring a codebase you didn't write, when you don't know yet what the change should be.&lt;/li&gt;
&lt;li&gt;Multi-file refactors and migrations that need decisions along the way.&lt;/li&gt;
&lt;li&gt;Anything where you want to stop the agent the moment it goes the wrong way.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Codex&lt;/strong&gt; for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Small, clear, testable tasks: a bug with a repro, a dependency bump, a missing test.&lt;/li&gt;
&lt;li&gt;A queue of those, where reviewing five diffs beats typing through five sessions.&lt;/li&gt;
&lt;li&gt;Work where a failing test already defines done, so you don't have to watch it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Neither, unsupervised&lt;/strong&gt;, for auth, payments, secrets and data migrations you can't verify yourself. That isn't a limitation of either tool, it's a limitation of reviewing code you didn't read.&lt;/p&gt;

&lt;p&gt;If you'd rather have one vendor and one bill, that works fine. The split above is a default, not a law. For the model side of the same decision, see &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;/p&gt;

&lt;p&gt;Setting up coding agents across a team and want the instructions, limits and review rules right from the start?&lt;/p&gt;

&lt;h2&gt;
  
  
  One repo, two agents: the AGENTS.md setup
&lt;/h2&gt;

&lt;p&gt;This is where most two-agent setups quietly break.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://developers.openai.com/codex/guides/agents-md" rel="noopener noreferrer"&gt;Codex reads &lt;code&gt;AGENTS.md&lt;/code&gt;&lt;/a&gt;. It starts with a global file in &lt;code&gt;~/.codex&lt;/code&gt;, then walks from the repo root down to your working directory, taking at most one file per directory. In each one, &lt;code&gt;AGENTS.override.md&lt;/code&gt; wins over &lt;code&gt;AGENTS.md&lt;/code&gt;. It stops at 32 KiB combined by default (&lt;code&gt;project_doc_max_bytes&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Claude Code reads &lt;code&gt;CLAUDE.md&lt;/code&gt;. Since v2.1.277 it can also read &lt;code&gt;AGENTS.md&lt;/code&gt;, but by default &lt;strong&gt;only when there's no &lt;code&gt;CLAUDE.md&lt;/code&gt;&lt;/strong&gt; in your working directory or above it. If both exist, it reads &lt;code&gt;CLAUDE.md&lt;/code&gt; only, unless &lt;code&gt;CLAUDE.md&lt;/code&gt; imports the other file. It never reads &lt;code&gt;AGENTS.override.md&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Next.js 16.2+ already sets this up: &lt;code&gt;create-next-app&lt;/code&gt; writes an &lt;code&gt;AGENTS.md&lt;/code&gt; with a managed block pointing agents to the docs bundled in &lt;code&gt;node_modules/next/dist/docs/&lt;/code&gt;, plus a &lt;code&gt;CLAUDE.md&lt;/code&gt; containing &lt;code&gt;@AGENTS.md&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Here's the trap, and this site's repo has it. Its &lt;code&gt;CLAUDE.md&lt;/code&gt; starts with &lt;code&gt;@AGENTS.md&lt;/code&gt;, exactly as scaffolded, followed by 173 lines of real project rules: stack, design tokens, blog frontmatter, image rules, SEO setup. Its &lt;code&gt;AGENTS.md&lt;/code&gt; is the 5-line Next.js block. Claude Code sees everything. Codex, opening the same repo, would see five lines telling it to read the Next.js docs, and nothing about the project. My other Next.js repos follow the same pattern.&lt;/p&gt;

&lt;p&gt;The fix is to flip which file holds the content:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- AGENTS.md: the shared rules, read by both agents --&amp;gt;&lt;/span&gt;
&lt;span class="c"&gt;&amp;lt;!-- BEGIN:nextjs-agent-rules --&amp;gt;&lt;/span&gt;
...managed by Next.js, leave it alone...
&lt;span class="c"&gt;&amp;lt;!-- END:nextjs-agent-rules --&amp;gt;&lt;/span&gt;

&lt;span class="gu"&gt;## Stack&lt;/span&gt;
&lt;span class="gu"&gt;## Commands&lt;/span&gt;
npm run build   # also runs the TS check
&lt;span class="gu"&gt;## Conventions&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- CLAUDE.md: import, then only Claude-specific extras --&amp;gt;&lt;/span&gt;
@AGENTS.md

&lt;span class="gu"&gt;## Claude Code&lt;/span&gt;
Use plan mode for changes under src/app/(site)/blog/.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then check that each agent actually loaded it. In Claude Code, &lt;code&gt;/memory&lt;/code&gt; or &lt;code&gt;/context&lt;/code&gt; lists the loaded files. In Codex, run &lt;code&gt;codex --ask-for-approval never "Summarize the current instructions."&lt;/code&gt; from the repo root.&lt;/p&gt;

&lt;p&gt;Two more habits help:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Keep the shared file short.&lt;/strong&gt; Anthropic says long instruction files get followed less and suggests under 200 lines. OpenAI lists a smaller &lt;code&gt;AGENTS.md&lt;/code&gt; as a way to make Codex limits last longer, and truncates at 32 KiB anyway. Put procedures in skills and folder-specific rules in nested files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put the done condition in the file.&lt;/strong&gt; "Run &lt;code&gt;npm run build&lt;/code&gt; before you say you're finished" works for both agents, and it matters most for the one you aren't watching.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you also share tools between them, the same idea applies to MCP servers: one server, two clients. That's covered in &lt;a href="https://www.joancomadran.dev/blog/mcp-vs-function-calling" rel="noopener noreferrer"&gt;MCP vs function calling&lt;/a&gt;. Every MCP server adds context to each message, though, so connect only the ones the task needs. And if you start running agents for hours unattended, &lt;a href="https://www.joancomadran.dev/blog/durable-ai-agents" rel="noopener noreferrer"&gt;what happens when an agent dies mid-task&lt;/a&gt; becomes your problem too.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Is Claude Code better than Codex?
&lt;/h3&gt;

&lt;p&gt;On Artificial Analysis's Coding Agent Index (checked October 23, 2026), Claude Code with Opus 5.5 scores 62 and Codex with GPT-6 Astra scores 55. Codex finished tasks in about 25 minutes versus an hour, with fewer tokens and lower API cost per task. So Claude Code scores higher on hard tasks and Codex is faster and cheaper. Which matters more depends on the job.&lt;/p&gt;

&lt;h3&gt;
  
  
  How much do Claude Code and Codex cost?
&lt;/h3&gt;

&lt;p&gt;As of October 23, 2026, both start at $20 a month. Claude Code comes with Claude Pro ($20, or $17 billed annually) and Max (from $100, at 5x or 20x Pro's usage). Codex comes with ChatGPT Plus ($20) and Pro ($100, $200 or $500). Both can also run on an API key and bill per token.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can Claude Code and Codex share the same instructions file?
&lt;/h3&gt;

&lt;p&gt;Yes. Codex reads AGENTS.md. Claude Code reads AGENTS.md only when there's no CLAUDE.md, so if you have both files, put the shared rules in AGENTS.md and start CLAUDE.md with the line &lt;code&gt;@AGENTS.md&lt;/code&gt;. Claude Code then imports it, and Codex reads the same rules.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I pay for both Claude Code and Codex?
&lt;/h3&gt;

&lt;p&gt;Only if you keep hitting one plan's limits, or you have two kinds of work: interactive changes you want to steer, and a queue of small, testable tasks you'd rather delegate. Two $20 plans cost the same as one plan doubled, and each one has its own five-hour and weekly limits.&lt;/p&gt;

&lt;p&gt;The agent you pick matters less than whether it knows your repo's rules and whether you check its work. If you want help building that around AI coding agents, or the software they're writing, &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;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.joancomadran.dev/blog/claude-code-vs-codex" 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>claudecode</category>
      <category>codex</category>
      <category>aicodingagents</category>
      <category>agentsmd</category>
    </item>
    <item>
      <title>Durable AI agents: what happens when your agent dies mid-task</title>
      <dc:creator>Joan Comadran</dc:creator>
      <pubDate>Wed, 21 Oct 2026 09:45:17 +0000</pubDate>
      <link>https://dev.to/joancomadran/durable-ai-agents-what-happens-when-your-agent-dies-mid-task-3bm</link>
      <guid>https://dev.to/joancomadran/durable-ai-agents-what-happens-when-your-agent-dies-mid-task-3bm</guid>
      <description>&lt;p&gt;On October 19, Vercel published &lt;a href="https://vercel.com/blog/making-every-harness-durable" rel="noopener noreferrer"&gt;Making every harness durable&lt;/a&gt; and open-sourced &lt;a href="https://github.com/vercel-labs/durable-harness" rel="noopener noreferrer"&gt;&lt;code&gt;durable-harness&lt;/code&gt;&lt;/a&gt;, a package that keeps coding-agent harnesses (Pi, OpenCode, fx, Claude Code and Codex) running through crashes, function timeouts and redeploys. The fine print first, because it matters: the README calls it experimental, it's on &lt;code&gt;0.0.x&lt;/code&gt; (&lt;code&gt;0.0.1-beta.6&lt;/code&gt; on npm as of October 21, 2026), and the API may change between releases. The Claude Code and Codex adapters are labelled experimental on top of that.&lt;/p&gt;

&lt;p&gt;So don't rebuild your stack on it this week. But the problem it solves is old and boring and will outlive the package: &lt;strong&gt;an agent is a loop that keeps its progress in memory, and memory dies with the process.&lt;/strong&gt; Durable AI agents are the fix for that, and you can apply the idea with or without Vercel.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a durable AI agent actually is
&lt;/h2&gt;

&lt;p&gt;A durable agent records what it has done somewhere other than the process doing it. Vercel's &lt;a href="https://vercel.com/kb/guide/durable-ai-agents" rel="noopener noreferrer"&gt;durable agents guide&lt;/a&gt; puts the distinction well: conversation history preserves model context, execution records establish which operations completed.&lt;/p&gt;

&lt;p&gt;That second part is the one people skip. A saved transcript lets the agent remember what was said. It doesn't tell you whether the refund went through or the email left before the function got killed. An agent restarting with only its transcript can redo everything (paying for the tokens again, sending the email again) or guess. Both are bad.&lt;/p&gt;

&lt;h2&gt;
  
  
  How agents actually die
&lt;/h2&gt;

&lt;p&gt;Nothing exotic. A function hits its &lt;code&gt;maxDuration&lt;/code&gt;, the instance runs out of memory, you deploy mid-turn, or the browser tab closes and takes the stream with it. The longer the turn, the sooner it meets one of these.&lt;/p&gt;

&lt;p&gt;I hit the cheap version of this with &lt;a href="https://www.joancomadran.dev/projects/claient" rel="noopener noreferrer"&gt;Claient&lt;/a&gt;, my WhatsApp bot. Its webhook runs on a serverless function with &lt;code&gt;maxDuration = 60&lt;/code&gt;, and one incoming message can trigger several Claude calls. When the function got killed after sending the reply but before marking the message as handled, Meta retried the webhook and the lead got the same answer twice. I wrote up the fix, a time budget plus idempotency per message, in &lt;a href="https://www.joancomadran.dev/blog/whatsapp-bot-with-claude" rel="noopener noreferrer"&gt;the WhatsApp bot post&lt;/a&gt;. That's a 60-second turn. Coding agents run for minutes or hours and wait on CI, so they get the same bug with more zeros.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern: intent, outcome, queue
&lt;/h2&gt;

&lt;p&gt;Here's how &lt;code&gt;durable-harness&lt;/code&gt; does it, per Vercel's &lt;a href="https://vercel.com/docs/agent-harnesses" rel="noopener noreferrer"&gt;harness docs&lt;/a&gt; as of October 21, 2026. It's the same shape Inngest, Restate and Temporal use for &lt;a href="https://www.inngest.com/docs/durable-execution/durable-agents" rel="noopener noreferrer"&gt;their durable agents&lt;/a&gt;, so it's worth understanding once.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Before&lt;/strong&gt; each model call or tool call, write an intent to a durable log.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After&lt;/strong&gt; the call finishes, write the outcome.&lt;/li&gt;
&lt;li&gt;Back the whole turn with a queue message. If the function dies, the queue hands the turn to a new function, which reads the log and continues.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After a crash, the log tells you three things: what finished, what was running, and what never started. Finished calls are read back from the log and never run again, which is why you aren't billed twice for a model call that already completed. A model call that was running just runs again. Between calls, nothing repeats.&lt;/p&gt;

&lt;p&gt;The interesting case is the tool that was running. The docs are honest about an edge you should design for: a call that finished just before the crash may not be in the log yet, and then it counts as running. In other words, "running" means "maybe done". That's the whole reason replay rules exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  The replay rule is the actual decision
&lt;/h2&gt;

&lt;p&gt;In &lt;code&gt;durable-harness&lt;/code&gt;, every tool you give the agent declares what happens if a crash interrupts it. &lt;code&gt;'safe'&lt;/code&gt; means it can run again. &lt;code&gt;'never'&lt;/code&gt; means it won't run twice: the model gets told the call may or may not have happened, and it decides whether to check first. Here's what that looks like for an agent that publishes blog posts, where a duplicate is embarrassing rather than expensive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;pi&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;durable-harness/pi&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;HostTool&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;durable-harness&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;findPostBySlug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;publishPost&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./cms&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// your own code&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;HostTool&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;find_post&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Checks whether a post with this slug already exists&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;replay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;safe&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// a read: running it twice costs nothing&lt;/span&gt;
    &lt;span class="na"&gt;inputSchema&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="s1"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="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="s1"&gt;string&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;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;slug&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;execute&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;slug&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;findPostBySlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;publish_post&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Publishes a post. Call find_post first if unsure.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;replay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;never&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// a write: never rerun automatically&lt;/span&gt;
    &lt;span class="na"&gt;inputSchema&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="s1"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="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="s1"&gt;string&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;required&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;slug&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;execute&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;slug&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;publishPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pi&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;anthropic/claude-sonnet-5.5&lt;/span&gt;&lt;span class="dl"&gt;'&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice the pair. A &lt;code&gt;'never'&lt;/code&gt; tool without a &lt;code&gt;'safe'&lt;/code&gt; way to check its effect leaves the model with "possibly ran" and nothing to do about it, so it either retries blindly or gives up. Give every write a matching read.&lt;/p&gt;

&lt;p&gt;Then admit that &lt;code&gt;'never'&lt;/code&gt; isn't exactly-once. It means the harness won't rerun the call on its own. It can't un-send something that left the building half a second before the crash. Vercel's guide answers its own FAQ "Does durable execution prevent duplicate tool actions?" with "No", and recommends an idempotency key the receiving service enforces. Its Workflow SDK example uses the step ID, which stays stable across retries of the same step:&lt;br&gt;
&lt;/p&gt;

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

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sendInvoice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;invoiceId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use step&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;stepId&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getStepMetadata&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;BILLING_API&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Idempotency-Key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`invoice:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;stepId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;invoiceId&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Billing failed: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step IDs differ between runs, so to dedupe across separate runs you need a business identifier or a unique constraint in your database. Claient does the latter: each WhatsApp message ID is claimed atomically in Postgres before processing, with a cap of three attempts so a message that always fails gets abandoned instead of retried forever. It's also one more argument for what I wrote in &lt;a href="https://www.joancomadran.dev/blog/mcp-vs-function-calling" rel="noopener noreferrer"&gt;MCP vs function calling&lt;/a&gt;: tools that write to your own systems are easier to make replay-safe when they live in your own code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where durable-harness runs each harness
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Harness&lt;/th&gt;
&lt;th&gt;Runs in&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pi, OpenCode, fx&lt;/td&gt;
&lt;td&gt;Your function, with files in a virtual workspace or a Vercel Sandbox&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code, Codex (experimental)&lt;/td&gt;
&lt;td&gt;Only a Vercel Sandbox&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;eve&lt;/td&gt;
&lt;td&gt;Its own app, durable by default&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The log lives behind a World, the storage-and-queue interface of the open-source Workflow SDK: Vercel's on Vercel, files on disk locally, or Postgres if you self-host, which per the blog post takes two environment variables. Claude Code and Codex are kept out of shared functions because they expect a real shell and filesystem and aren't safe for multi-tenant use. The virtual workspace is a bash-compatible shell with no network and no real binaries, and the docs say it holds about 500 files, so it's for small tasks, not your monorepo.&lt;/p&gt;

&lt;p&gt;One documented limitation worth reading before you trust it with builds: with Pi and OpenCode, a turn starts in the chat route's own request, which drives it for up to 60 seconds before the queue route takes over, and a command still running at that handover is stopped, not rerun. A long &lt;code&gt;npm install&lt;/code&gt; early in a turn can get cut off. The workaround in the docs is &lt;code&gt;drive: 'queue'&lt;/code&gt; on &lt;code&gt;agent.chatRoute()&lt;/code&gt;, and Vercel says a fix is on the way.&lt;/p&gt;

&lt;p&gt;If you're writing your own agent loop rather than wrapping a coding harness, the same ideas come as &lt;code&gt;'use workflow'&lt;/code&gt; and &lt;code&gt;'use step'&lt;/code&gt; in the Workflow SDK, with &lt;code&gt;WorkflowAgent&lt;/code&gt; from &lt;code&gt;@ai-sdk/workflow&lt;/code&gt; for the AI SDK loop. Or Inngest, Restate or Temporal if you're not on Vercel. The rule that carries over everywhere: one step boundary per model call and per tool call. Wrapping the whole loop in a single step records nothing until the loop ends, which is durability in name only.&lt;/p&gt;

&lt;p&gt;Building an agent that writes to real systems and need the retries, idempotency and caps right before it ships?&lt;/p&gt;

&lt;h2&gt;
  
  
  When you don't need any of this
&lt;/h2&gt;

&lt;p&gt;Plenty of agents are request-response: a message comes in, a few model calls and tools run, an answer goes out, all in seconds. For those, a queue plus a step log is infrastructure you'll maintain for a failure you could handle with a retry. Claient's webhook doesn't use a workflow engine. Its 60-second webhook gets a time budget, an iteration cap on the tool loop and message-level idempotency, and the project notes list a queue as the next step only if load grows.&lt;/p&gt;

&lt;p&gt;You need durability when a turn can outlive its process: tasks measured in minutes or hours, waits on CI or on a human approval, or crashes that would burn real money in repeated tokens. Idempotent side effects you need either way. That part isn't optional at any size.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist before an agent gets write access
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;List every tool and mark it safe or never. Reads are safe; anything that sends, charges, publishes or appends is never.&lt;/li&gt;
&lt;li&gt;Give every never-tool a safe tool that checks whether its effect already happened.&lt;/li&gt;
&lt;li&gt;Push dedup to the receiver: an idempotency key, or a unique constraint on a business ID.&lt;/li&gt;
&lt;li&gt;One step boundary per model call and tool call.&lt;/li&gt;
&lt;li&gt;Cap iterations and spend, because a resumed agent can still loop. I covered caps in &lt;a href="https://www.joancomadran.dev/blog/claude-code-cost-per-month" rel="noopener noreferrer"&gt;Claude Code cost per month&lt;/a&gt; and &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 under control&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Kill it on purpose. Vercel's guide suggests interrupting a run between steps and retrying an external action to confirm nothing duplicates. If you've never seen your agent recover, you don't know that it does.&lt;/li&gt;
&lt;li&gt;Plan for deploys. On Vercel, existing runs stay pinned to the deployment that started them, so your database schema has to stay compatible with code that may resume later.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;h3&gt;
  
  
  What is a durable AI agent?
&lt;/h3&gt;

&lt;p&gt;An agent whose progress is recorded outside the process running it. Each model call and tool call goes to a log, so after a crash, timeout or redeploy another process reuses the finished results and continues from the first unfinished call. Chat history alone isn't durability: it records what was said, not what actually ran.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does durable execution prevent duplicate tool calls?
&lt;/h3&gt;

&lt;p&gt;Not on its own. It stops completed steps from running again, but a call in flight when the process died may already have reached the outside world. For emails, payments or publishing you still need an idempotency key or a unique constraint on the receiving side, and a way for the agent to check.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is Vercel durable-harness?
&lt;/h3&gt;

&lt;p&gt;An experimental open-source package from Vercel (Apache 2.0, 0.0.1-beta.6 on npm as of October 21, 2026) that makes Pi, OpenCode, fx, Claude Code and Codex durable. It logs every call to a Workflow SDK World (Vercel, local files or Postgres), backs each turn with a queue message, and lets each tool be marked safe or never to replay.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need durable execution for my AI agent?
&lt;/h3&gt;

&lt;p&gt;Only if a turn can outlive its process: tasks that take minutes or hours, waits for CI or a human, or crashes that would cost real money in repeated tokens. A request-response agent that finishes in seconds usually needs a time budget, an iteration cap and idempotent side effects, not a workflow engine.&lt;/p&gt;

&lt;p&gt;Durable execution is one of those things that sounds like a framework feature and is really a discipline: write down what you did before you claim you did it, and never trust "probably ran". It's also why every write-action shows up in &lt;a href="https://www.joancomadran.dev/blog/ai-agent-development-cost" rel="noopener noreferrer"&gt;what an AI agent costs to build&lt;/a&gt;. If you want an agent built that way from day one, &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;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.joancomadran.dev/blog/durable-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>vercel</category>
      <category>durableexecution</category>
      <category>workflowsdk</category>
    </item>
    <item>
      <title>MCP vs function calling: which one your AI agent actually needs (2026)</title>
      <dc:creator>Joan Comadran</dc:creator>
      <pubDate>Wed, 21 Oct 2026 08:27:59 +0000</pubDate>
      <link>https://dev.to/joancomadran/mcp-vs-function-calling-which-one-your-ai-agent-actually-needs-2026-5hdg</link>
      <guid>https://dev.to/joancomadran/mcp-vs-function-calling-which-one-your-ai-agent-actually-needs-2026-5hdg</guid>
      <description>&lt;p&gt;The verdict first: &lt;strong&gt;MCP vs function calling is the wrong fight, because MCP runs on top of function calling.&lt;/strong&gt; Function calling is how a model asks for a tool. MCP is how tools get packaged so that many apps can find and run them. If you're building one agent whose tools are your own product's logic, plain function calling is the right default in October 2026: fewer moving parts, and your code keeps control of identity, tenancy and side effects. Add an MCP server when the same tools need to live in several hosts, when a vendor already ships an MCP server for the system you need, or when another team owns the tools.&lt;/p&gt;

&lt;p&gt;The disclosure comparison posts usually skip: every agent I ship to production uses plain tool use through &lt;code&gt;@anthropic-ai/sdk&lt;/code&gt;, with the tool definitions sitting in my own code. I use MCP every day, but as a consumer: coding agents and my personal assistants reach Supabase, Gmail, Google Calendar and Notion through MCP servers. I have not shipped an MCP server to production. So the function-calling column below comes from code I can show you, and the MCP column comes from the &lt;a href="https://modelcontextprotocol.io/specification/latest" rel="noopener noreferrer"&gt;spec&lt;/a&gt;, the vendor docs, and the receiving end.&lt;/p&gt;

&lt;h2&gt;
  
  
  MCP vs function calling at a glance (as of October 2026)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Function calling (tool use)&lt;/th&gt;
&lt;th&gt;MCP (Model Context Protocol)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What it is&lt;/td&gt;
&lt;td&gt;A feature of the model API&lt;/td&gt;
&lt;td&gt;An open client-server protocol (JSON-RPC 2.0)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where tool definitions live&lt;/td&gt;
&lt;td&gt;In your app's code, sent with every request&lt;/td&gt;
&lt;td&gt;Published by a server, fetched by the client with &lt;code&gt;tools/list&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Who runs the tool&lt;/td&gt;
&lt;td&gt;Your code, in your process&lt;/td&gt;
&lt;td&gt;The MCP server: a local subprocess (stdio) or a remote service (Streamable HTTP)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Format&lt;/td&gt;
&lt;td&gt;Provider-specific: Claude &lt;code&gt;tool_use&lt;/code&gt; / &lt;code&gt;tool_result&lt;/code&gt; blocks, OpenAI &lt;code&gt;function_call&lt;/code&gt; / &lt;code&gt;function_call_output&lt;/code&gt; items&lt;/td&gt;
&lt;td&gt;One format for every MCP-capable host&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Identity and auth&lt;/td&gt;
&lt;td&gt;Whatever your app already knows (session, tenant, API keys)&lt;/td&gt;
&lt;td&gt;The server's own: env vars for local servers, OAuth 2.1 for remote ones&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Beyond tools&lt;/td&gt;
&lt;td&gt;Tools only&lt;/td&gt;
&lt;td&gt;Tools, resources and prompts, plus opt-in extensions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reuse across apps&lt;/td&gt;
&lt;td&gt;Copy the code&lt;/td&gt;
&lt;td&gt;Point another host at the same server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extra infrastructure&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;A server to run, or a hosted one to trust&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token cost&lt;/td&gt;
&lt;td&gt;The tools you define&lt;/td&gt;
&lt;td&gt;Every tool the server exposes, unless you filter them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best fit&lt;/td&gt;
&lt;td&gt;Tools that are part of one product&lt;/td&gt;
&lt;td&gt;Tools that many hosts or agents share&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The short version of the table: function calling is a call inside your app; MCP is an integration boundary. Boundaries are useful when there's actually something on the other side.&lt;/p&gt;

&lt;h2&gt;
  
  
  They're layers, not alternatives
&lt;/h2&gt;

&lt;p&gt;Here's a tool from Claient's booking flow, defined for Claude's API. Field names are in Spanish because the product is:&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="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&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;consultar_disponibilidad&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Returns the free slots for a given date (YYYY-MM-DD)...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;input_schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&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;object&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;fecha&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;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Day to check, YYYY-MM-DD.&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;required&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;fecha&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same tool published by an MCP server shows up in &lt;code&gt;tools/list&lt;/code&gt; looking almost the same:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"consultar_disponibilidad"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"description"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Returns the free slots for a given date (YYYY-MM-DD)..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"inputSchema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"properties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"fecha"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"string"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"required"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"fecha"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's no accident. An MCP host (Claude Code, Cursor, ChatGPT, your own client) takes those definitions, passes them to the model as ordinary tools, and when the model returns a tool call, forwards it to the server as &lt;code&gt;tools/call&lt;/code&gt;. The model never "speaks MCP". So the real question isn't which one to use. It's &lt;strong&gt;where should this tool's definition and execution live: inside my app, or behind a protocol boundary that other apps can also cross?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in MCP this year
&lt;/h2&gt;

&lt;p&gt;If the last time you read about MCP was 2025, the protocol has moved. The current spec revision is &lt;a href="https://modelcontextprotocol.io/specification/2026-07-28/changelog" rel="noopener noreferrer"&gt;2026-07-28&lt;/a&gt;, and the changes matter for anyone deploying a server:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's stateless now.&lt;/strong&gt; The &lt;code&gt;initialize&lt;/code&gt; handshake and the &lt;code&gt;Mcp-Session-Id&lt;/code&gt; header are gone. Every request carries its protocol version and client capabilities in &lt;code&gt;_meta&lt;/code&gt;, and servers must implement &lt;code&gt;server/discover&lt;/code&gt; to advertise what they support. That makes remote MCP servers much friendlier to serverless hosting and load balancers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Roots, Sampling and Logging are deprecated&lt;/strong&gt; (eligible for removal from the first revision on or after 2027-07-28). The suggested migration for Sampling is literally "integrate directly with LLM provider APIs".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tasks moved to an official extension&lt;/strong&gt;, alongside others like MCP Apps for interactive UI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Servers should return &lt;code&gt;tools/list&lt;/code&gt; in a deterministic order&lt;/strong&gt; so clients get better prompt-cache hit rates, and list results now carry &lt;code&gt;ttlMs&lt;/code&gt; and &lt;code&gt;cacheScope&lt;/code&gt; hints.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The old HTTP+SSE transport is deprecated&lt;/strong&gt; in favour of Streamable HTTP.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The prompt-cache point is a nice tell. Tool definitions sit at the front of every request, so if a server reshuffles them, everything after them misses the cache. If you've read &lt;a href="https://www.joancomadran.dev/blog/keeping-claudes-token-bill-from-ruining-your-month" rel="noopener noreferrer"&gt;how I keep Claude's token bill under control&lt;/a&gt;, you'll know why that's worth a line in the spec.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using MCP straight from the model APIs
&lt;/h2&gt;

&lt;p&gt;You don't need to write an MCP client to use a remote MCP server any more. Both big providers will act as the client for you, with caveats worth reading before you ship.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Anthropic's &lt;a href="https://platform.claude.com/docs/en/agents-and-tools/mcp-connector" rel="noopener noreferrer"&gt;MCP connector&lt;/a&gt;&lt;/strong&gt; (beta header &lt;code&gt;mcp-client-2025-11-20&lt;/code&gt; as of October 2026) takes an &lt;code&gt;mcp_servers&lt;/code&gt; array in the Messages API and an &lt;code&gt;mcp_toolset&lt;/code&gt; entry in &lt;code&gt;tools&lt;/code&gt;. You can allowlist or denylist tools per server and set &lt;code&gt;defer_loading&lt;/code&gt; so definitions are only loaded through the tool search tool. The limits: tool calls only (no resources or prompts), the server must be reachable over public HTTP (no local stdio servers), and the feature is not eligible for zero data retention. A newer beta, &lt;code&gt;mcp-client-2026-09-15&lt;/code&gt;, lets you pin a server's tool list so the API doesn't fetch it on every request.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OpenAI's &lt;a href="https://developers.openai.com/api/docs/guides/tools-connectors-mcp" rel="noopener noreferrer"&gt;MCP tool&lt;/a&gt;&lt;/strong&gt; in the Responses API is a &lt;code&gt;tools&lt;/code&gt; entry with &lt;code&gt;type: "mcp"&lt;/code&gt;, a &lt;code&gt;server_url&lt;/code&gt;, an optional &lt;code&gt;allowed_tools&lt;/code&gt; filter and &lt;code&gt;require_approval&lt;/code&gt;. By default OpenAI asks for your approval before sharing data with a remote MCP server. There's no per-call fee; you pay for the tokens of the imported tool definitions and the calls. For private servers there's Secure MCP Tunnel.&lt;/p&gt;

&lt;p&gt;Both docs say the same thing in different words: an MCP server with dozens of tools gets expensive and slow if you expose all of them, so filter. And both are explicit that you should only connect servers you trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why my production agent uses plain function calling
&lt;/h2&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, many of them on one deployment (&lt;a href="https://www.joancomadran.dev/blog/whatsapp-bot-with-claude" rel="noopener noreferrer"&gt;the build story&lt;/a&gt;). It has three booking tools (check availability, book, reschedule) and one forced extraction tool. None of that is MCP, for reasons that have nothing to do with fashion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tenant scoping happens outside the model.&lt;/strong&gt; The model never sees or chooses which business it's booking for. The webhook resolves the tenant from the WhatsApp number that received the message, and the executor gets that context from my code:&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;// The model only sends { fecha } or { inicio_iso }.&lt;/span&gt;
&lt;span class="c1"&gt;// Who the booking is for comes from the webhook, never from the model.&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;ToolContext&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;leadId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nl"&gt;ejecutarTool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;input&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;ejecutarTool&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="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tenant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;accountId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;leadId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;lead&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;efectos&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the most important line in the agent. If the account id were a tool argument, a cleverly worded message could try to steer it. With MCP you can get the same guarantee, but the server now needs a per-tenant identity, which means OAuth or minted tokens, which means another service to secure. For tools that only exist inside one product, that's cost without benefit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Side effects flow back into the app.&lt;/strong&gt; When the model books a slot, the executor records it in &lt;code&gt;efectos&lt;/code&gt;, and after the turn the webhook marks the lead as booked and sends a calendar invite. Keeping that in one process is a function call. Across an MCP boundary it's a result you have to parse and trust.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The time budget is mine.&lt;/strong&gt; The tool loop runs inside a serverless function with a hard cap of five iterations and a deadline, because a function killed mid-turn means Meta retries the webhook and the lead gets a duplicate message. Every network hop to a tool server eats that budget.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three tools don't need discovery.&lt;/strong&gt; Discovery solves "which tools exist?". In Claient I wrote all of them, they ship with the code, and they're versioned with git.&lt;/p&gt;

&lt;p&gt;None of this is a knock on MCP. It's what happens when the tool and the agent are the same product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where MCP earns its place
&lt;/h2&gt;

&lt;p&gt;The other side is just as concrete. The assistants I use every day are connected to Gmail, Google Calendar, Notion and Supabase through MCP servers. Nobody at those companies wrote a tool for &lt;em&gt;my&lt;/em&gt; assistant, and I didn't write one for theirs. That's the problem MCP solves, and it solves it well: one server, many hosts.&lt;/p&gt;

&lt;p&gt;It also showed me the failure mode. In one personal project, the Supabase MCP server my coding agent had was authenticated against a different organisation than the one that owned the project, so every call came back &lt;em&gt;permission denied&lt;/em&gt;. The fix wasn't clever: a note in the repo's &lt;code&gt;CLAUDE.md&lt;/code&gt; telling the agent not to use those MCP tools there, and a small script that goes through Supabase's Management API with an access token for the right account. The lesson generalises: &lt;strong&gt;an MCP server brings its own identity&lt;/strong&gt;. Whatever it authenticated as is what your agent can touch, which is fine in a dev tool with a human watching and something to design deliberately in a product.&lt;/p&gt;

&lt;p&gt;Treat MCP servers the way the spec tells hosts to: tools are arbitrary code execution, and tool descriptions from a server you don't trust are untrusted input. Approvals, allowlists and scoped credentials aren't optional extras. For the credential side, the same rules as &lt;a href="https://www.joancomadran.dev/blog/secure-openai-api-key" rel="noopener noreferrer"&gt;securing your API keys&lt;/a&gt; apply.&lt;/p&gt;

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

&lt;p&gt;&lt;strong&gt;Use plain function calling when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The tools are part of your product and only your agent calls them.&lt;/li&gt;
&lt;li&gt;Identity, tenancy or permissions come from your app's session, and the model shouldn't be able to choose them.&lt;/li&gt;
&lt;li&gt;Tool results trigger side effects your app needs to act on in the same request.&lt;/li&gt;
&lt;li&gt;You run on serverless with a tight time budget.&lt;/li&gt;
&lt;li&gt;You have a handful of tools you'd version with your code anyway.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Use MCP when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You want the same tools available in several hosts: Claude, ChatGPT, Cursor, Claude Code, your own agent.&lt;/li&gt;
&lt;li&gt;A vendor already publishes an MCP server for the system you need, and building your own integration would just duplicate it.&lt;/li&gt;
&lt;li&gt;Another team owns the tools and needs to ship changes without redeploying every agent.&lt;/li&gt;
&lt;li&gt;You're exposing &lt;em&gt;your&lt;/em&gt; product to other people's agents. That's a real distribution channel now, and an MCP server is how you show up in it.&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;Expose as few tools as the job needs. Filter MCP servers with allowlists; don't hand the model forty tools to use three.&lt;/li&gt;
&lt;li&gt;Keep anything that decides &lt;em&gt;who&lt;/em&gt; or &lt;em&gt;which account&lt;/em&gt; out of the model's arguments.&lt;/li&gt;
&lt;li&gt;Cap the loop and the spend from day one. Neither the protocol nor the provider will do it for you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most production agents I'd design today end up with mostly function calling plus, at most, a couple of MCP servers for third-party systems. If you need an agent that touches real systems, and you want someone who's shipped them to draw that line with you, &lt;a href="https://www.joancomadran.dev/ai-engineering" rel="noopener noreferrer"&gt;that's what I do&lt;/a&gt;. If budget is the next question, read &lt;a href="https://www.joancomadran.dev/blog/ai-agent-development-cost" rel="noopener noreferrer"&gt;what AI agent development actually costs&lt;/a&gt;; if you're still choosing the model, &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;/p&gt;

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

&lt;h3&gt;
  
  
  What is the difference between MCP and function calling?
&lt;/h3&gt;

&lt;p&gt;Function calling (tool use) is a feature of a model's API: you send tool definitions with the request, the model replies with a tool name and JSON arguments, and your code runs the tool and sends the result back. MCP (Model Context Protocol) is an open client-server protocol that lets an application discover tools published by a separate server (&lt;code&gt;tools/list&lt;/code&gt;) and invoke them (&lt;code&gt;tools/call&lt;/code&gt;). An MCP client still hands those tools to the model through function calling, so MCP sits one layer above it rather than replacing it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use MCP or function calling for my AI agent?
&lt;/h3&gt;

&lt;p&gt;If the tools belong to your product and only your agent calls them, use plain function calling: no extra server, and your code keeps control of identity, tenancy and side effects. Use MCP when the same tools must be reused across several agents or hosts (Claude, ChatGPT, Cursor, Claude Code), when a third party already publishes an MCP server for the system you need, or when another team owns the tools. Most production agents end up with mostly function calling and, at most, a couple of MCP servers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does MCP replace function calling?
&lt;/h3&gt;

&lt;p&gt;No. MCP standardises how tools are packaged, discovered, authorised and executed; the model still selects them through its provider's function-calling mechanism. Anthropic's MCP connector turns MCP tools into &lt;code&gt;mcp_tool_use&lt;/code&gt; blocks in the Messages API, and OpenAI's &lt;code&gt;mcp&lt;/code&gt; tool type imports them into the Responses API, but in both cases the model is doing ordinary tool use underneath.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I call an MCP server directly from the Claude or OpenAI API?
&lt;/h3&gt;

&lt;p&gt;Yes, for remote servers. As of October 2026, Anthropic's MCP connector (beta header &lt;code&gt;mcp-client-2025-11-20&lt;/code&gt;) connects the Messages API to MCP servers exposed over HTTP; it supports tool calls only, not local stdio servers, and is not eligible for zero data retention. OpenAI's Responses API has an &lt;code&gt;mcp&lt;/code&gt; tool type with &lt;code&gt;server_url&lt;/code&gt;, &lt;code&gt;allowed_tools&lt;/code&gt; and &lt;code&gt;require_approval&lt;/code&gt;, plus Secure MCP Tunnel for private servers, and charges no per-call fee beyond the tokens of the imported tool definitions and calls. On both, every tool you import takes up context, so filter.&lt;/p&gt;

&lt;h3&gt;
  
  
  What changed in the MCP spec in 2026?
&lt;/h3&gt;

&lt;p&gt;The current revision, 2026-07-28, made MCP stateless: the &lt;code&gt;initialize&lt;/code&gt; handshake and the &lt;code&gt;Mcp-Session-Id&lt;/code&gt; header are gone, every request carries its protocol version and client capabilities in &lt;code&gt;_meta&lt;/code&gt;, and servers must implement &lt;code&gt;server/discover&lt;/code&gt;. It also deprecated Roots, Sampling and Logging, moved tasks into an official extension, and asks servers to return &lt;code&gt;tools/list&lt;/code&gt; in a deterministic order so clients get better prompt-cache hit rates.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does Joan Comadran use MCP in production agents?
&lt;/h3&gt;

&lt;p&gt;No. The agents he ships to clients and his own products, such as Claient, the WhatsApp lead agent, use plain tool use through the Anthropic TypeScript SDK, with tool definitions in code and tenant scoping enforced by the executor. He uses MCP as a consumer: coding agents and personal assistants connected to services like Supabase, Gmail, Google Calendar and Notion. He has not shipped an MCP server to production.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.joancomadran.dev/blog/mcp-vs-function-calling" 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>claude</category>
      <category>openai</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Secure your OpenAI API key (and your Claude and Gemini keys): a checklist</title>
      <dc:creator>Joan Comadran</dc:creator>
      <pubDate>Mon, 19 Oct 2026 09:48:21 +0000</pubDate>
      <link>https://dev.to/joancomadran/secure-your-openai-api-key-and-your-claude-and-gemini-keys-a-checklist-1cn8</link>
      <guid>https://dev.to/joancomadran/secure-your-openai-api-key-and-your-claude-and-gemini-keys-a-checklist-1cn8</guid>
      <description>&lt;p&gt;On October 17, OpenCode &lt;a href="https://x.com/opencode/status/2111519637049319818" rel="noopener noreferrer"&gt;posted on X&lt;/a&gt; that it "had a security breach last night" affecting &lt;strong&gt;1.4% of OpenCode Console users&lt;/strong&gt;, "a subset of users who had external providers linked", and that it was working with Anthropic, OpenAI and Google to remediate. The email those users received, as &lt;a href="https://thehackernews.com/2026/10/opencode-console-breach-may-have.html" rel="noopener noreferrer"&gt;reported by The Hacker News&lt;/a&gt;, says provider keys saved in Console workspaces may have been exposed. Within hours, Anthropic's Lydia Hallie &lt;a href="https://x.com/lydiahallie/status/2111563956666110287" rel="noopener noreferrer"&gt;said&lt;/a&gt; Anthropic had revoked the Claude API keys it believes were affected, and Google's Evan Otero &lt;a href="https://x.com/EvanOtero/status/2111591525876474100" rel="noopener noreferrer"&gt;said&lt;/a&gt; Google had blocked the exposed Gemini keys. As of October 19, 2026, OpenCode calls the incident ongoing and hasn't said how it happened. I'm not going to guess.&lt;/p&gt;

&lt;p&gt;What matters for the rest of us is simpler. &lt;strong&gt;An LLM API key is a bearer credential: whoever has the string can spend your money until the provider revokes it.&lt;/strong&gt; Securing it means fewer places where the string lives, and less damage when one of them leaks anyway.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;One key per tool, project and environment.&lt;/li&gt;
&lt;li&gt;Keys that expire, and a rotation you've actually rehearsed.&lt;/li&gt;
&lt;li&gt;Hard spend limits per project, not just alerts.&lt;/li&gt;
&lt;li&gt;Keys only on the server: route handlers, server actions, environment variables. Never &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A separate, capped, short-lived key for any hosted tool you paste a key into.&lt;/li&gt;
&lt;li&gt;A first-hour plan for when one leaks.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Provider details below come from OpenAI's, Anthropic's and Google's own docs as of October 19, 2026. They change often.&lt;/p&gt;

&lt;h2&gt;
  
  
  One key per tool, project and environment
&lt;/h2&gt;

&lt;p&gt;If every script, agent and SaaS shares one key, any leak means revoking the key production runs on. With one key per consumer you revoke one thing, and the usage page tells you which consumer was abused.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI.&lt;/strong&gt; Keys live in projects. &lt;a href="https://help.openai.com/en/articles/5112595-best-practices-for-api-key-safety" rel="noopener noreferrer"&gt;OpenAI's key safety article&lt;/a&gt; says sharing keys is against the Terms of Use and that you can assign permissions to individual keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anthropic.&lt;/strong&gt; Workspaces separate environments and spend. New keys are either personal keys, which act as you, or service account keys, and either can be scoped to a single workspace. Personal keys die when their owner leaves the organization, which is what you want. Workspace keys are now &lt;a href="https://platform.claude.com/docs/en/manage-claude/authentication" rel="noopener noreferrer"&gt;documented as legacy&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google.&lt;/strong&gt; Every Gemini key belongs to a Google Cloud project, and usage from all its keys counts toward the project's spend cap (&lt;a href="https://ai.google.dev/gemini-api/docs/billing" rel="noopener noreferrer"&gt;billing docs&lt;/a&gt;). Separate caps per tool means separate projects.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Keys that expire, and a rotation you've rehearsed
&lt;/h2&gt;

&lt;p&gt;A key that never expires stays valid for as long as anyone has a copy.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI&lt;/strong&gt; project keys can be created with an expiration date, and admins can enforce a maximum key lifetime at organization or project level.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anthropic&lt;/strong&gt; asks for an expiration when you create a key: 3 hours, 1 day, 7 days, 30 days, a custom duration, or Never. It can't be changed afterwards, an organization policy can remove Never, and Anthropic emails the creator 7 days before expiry for keys that live at least 14 days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google&lt;/strong&gt;'s &lt;a href="https://ai.google.dev/gemini-api/docs/api-key" rel="noopener noreferrer"&gt;Gemini key docs&lt;/a&gt; don't mention expiry. They tighten keys another way: since May 28, 2026, new AI Studio keys are bound to a service account and restricted to the Gemini API by default, and unrestricted standard keys are rejected.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;OpenAI and Google describe the same rotation order: create the replacement, deploy it, verify it works, then revoke the old one. Rehearse it on a quiet day to learn where the key actually lives (production, previews, CI, that cron job) before you have to do it under pressure. OpenAI and Anthropic also document workload identity federation, which swaps the stored key for short-lived tokens on supported cloud workloads: rotation with nothing to rotate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hard spend limits, not just alerts
&lt;/h2&gt;

&lt;p&gt;OpenAI's article says it plainly: "Spend alerts alone don't stop API traffic." An email at 3 a.m. is a receipt for money that's already gone.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Hard cap&lt;/th&gt;
&lt;th&gt;What the docs warn about&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI&lt;/td&gt;
&lt;td&gt;Monthly spend limits per organization or project, with hard enforcement&lt;/td&gt;
&lt;td&gt;Enforcement isn't instantaneous; hard limits can also block legitimate requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anthropic&lt;/td&gt;
&lt;td&gt;Monthly &lt;a href="https://platform.claude.com/docs/en/manage-claude/workspaces" rel="noopener noreferrer"&gt;spend limit per workspace&lt;/a&gt;, below the organization limit&lt;/td&gt;
&lt;td&gt;Can't be set on the Default Workspace&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google&lt;/td&gt;
&lt;td&gt;Monthly project spend cap in AI Studio, plus tier caps per billing account&lt;/td&gt;
&lt;td&gt;Experimental, around 10 minutes of latency; batch jobs and agent sessions can overshoot&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same for auto-reload: Anthropic's &lt;a href="https://support.claude.com/en/articles/9767949-api-key-best-practices-keeping-your-keys-safe-and-secure" rel="noopener noreferrer"&gt;key best practices&lt;/a&gt; treat auto-reload limits as a safeguard against leaked keys, and Google's Prepay plan has a monthly auto-charge limit. Uncapped auto-reload is an uncapped card.&lt;/p&gt;

&lt;p&gt;For agents you build yourself, add a cap in code too. In &lt;a href="https://www.joancomadran.dev/projects/claient" rel="noopener noreferrer"&gt;Claient&lt;/a&gt;, when an account uses the platform's key, the webhook reserves budget atomically before each turn and stops replying when the monthly cap is hit. I described the same kill-switch idea 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;, and how to set caps for coding agents in &lt;a href="https://www.joancomadran.dev/blog/claude-code-cost-per-month" rel="noopener noreferrer"&gt;Claude Code cost per month&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keys stay on the server
&lt;/h2&gt;

&lt;p&gt;OpenAI and Google both say it: no keys in browser or mobile code, route calls through your backend. In Next.js the line is the &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; prefix. Next.js inlines those values into the JavaScript bundle at build time, so an LLM key with that prefix is a published key.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/llm.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;server-only&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// importing this from a Client Component fails the build&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Anthropic&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@anthropic-ai/sdk&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;anthropic&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;Anthropic&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ANTHROPIC_API_KEY&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/chat/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;anthropic&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/llm&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Authenticate and rate-limit first: a public route that proxies&lt;/span&gt;
  &lt;span class="c1"&gt;// your key is still your key.&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;message&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;anthropic&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="na"&gt;model&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="c1"&gt;// whatever you pin in config&lt;/span&gt;
    &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the pattern in Claient: the platform's key is read from &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt; in the server-side engine config, and the only key with a &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; prefix is Supabase's anon key, which is meant to be public. More on that split in the &lt;a href="https://www.joancomadran.dev/blog/supabase-nextjs-pre-launch-security-checklist" rel="noopener noreferrer"&gt;Next.js + Supabase pre-launch security checklist&lt;/a&gt; and in &lt;a href="https://www.joancomadran.dev/blog/ship-an-ai-product-in-a-weekend" rel="noopener noreferrer"&gt;shipping an AI product in a weekend&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If your backend has fixed egress IPs, restrict by IP too. &lt;a href="https://developers.openai.com/api/docs/guides/ip-allowlist" rel="noopener noreferrer"&gt;OpenAI's IP allowlist&lt;/a&gt; works per organization or project for all API customers and rejects other IPs with a 401 &lt;code&gt;ip_not_authorized&lt;/code&gt;, valid key or not. Google lets you restrict a key to IP addresses under Application restrictions in the Cloud console. Anthropic's help center lists IP allowlisting for Enterprise plans, set up through support.&lt;/p&gt;

&lt;h2&gt;
  
  
  Think twice before pasting a key into a hosted dashboard
&lt;/h2&gt;

&lt;p&gt;Anthropic's help center has the most honest sentence on this: when you upload your API key to a third-party tool, "you are giving the developer of that tool access to your Claude Console account." That isn't a comment on OpenCode. It's how every hosted tool that calls a model with your key works, including the one I build. And deleting a key inside the tool doesn't revoke it. Only the provider can.&lt;/p&gt;

&lt;p&gt;Claient accepts customers' own Claude keys (BYOK), so I'm on the other side of this. In its code the key goes into Supabase Vault and the settings table keeps only the secret's id. The function that returns the decrypted key can only be executed by the &lt;code&gt;service_role&lt;/code&gt; the webhook uses, so no dashboard user, owner included, can read it back, and the UI never shows it again. If a customer's key gets revoked, the bot falls back to the platform key and notifies the business instead of going quiet. That limits what a dashboard bug can leak. It doesn't make a key unstealable from a compromised server; nothing does.&lt;/p&gt;

&lt;p&gt;So on the customer side: give each hosted tool a dedicated key in its own project or workspace, with a low cap and an expiry, and ask the vendor where the key is stored, whether it's encrypted, and what can decrypt it.&lt;/p&gt;

&lt;p&gt;Building an agent that holds customer API keys and want the storage, caps and fallbacks right from day one?&lt;/p&gt;

&lt;h2&gt;
  
  
  The first hour after a key leaks
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Kill it at the provider, not in the tool.&lt;/strong&gt; OpenAI says to rotate immediately from the API keys page; in the Claude Console, Disable is reversible and Delete is permanent. If the key is being abused right now, revoke first and accept a few minutes of downtime. Create-then-revoke is for planned rotations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Issue the replacement properly.&lt;/strong&gt; Right project or workspace, with an expiry, and deploy it everywhere the old one lived.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check usage per key&lt;/strong&gt; back to the earliest date the key could have leaked, not just today.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lower the spend limit&lt;/strong&gt; while you investigate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Find the leak, then fix it.&lt;/strong&gt; Git history (deleting the file in a later commit doesn't remove it), CI and error logs, any tool you pasted it into.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contact billing support&lt;/strong&gt; with the key ID, the dates and what happened. OpenAI's article points to help.openai.com.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;h3&gt;
  
  
  How do I secure my OpenAI API key?
&lt;/h3&gt;

&lt;p&gt;Keep it on the server (environment variables or a secrets manager, never in client code or git), use one key per tool and project, create project keys with an expiration date, set a monthly spend limit with hard enforcement, and use IP allowlisting if your backend has fixed IPs. All of it is in OpenAI's key safety article as of October 2026.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I rotate an Anthropic API key?
&lt;/h3&gt;

&lt;p&gt;Create a new key in the Claude Console with an expiration, deploy it everywhere the old one is used, confirm it works, then disable (reversible) or delete (permanent) the old key. Expiration is chosen at creation and can't be changed later.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do spend alerts protect me if my API key leaks?
&lt;/h3&gt;

&lt;p&gt;No. An alert tells you money is being spent; it doesn't stop it. Use hard caps: OpenAI spend limits with hard enforcement, Anthropic workspace spend limits, Google AI Studio project spend caps. None are instant, but a cap turns an open-ended bill into a bounded one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is it safe to paste my API key into a third-party tool?
&lt;/h3&gt;

&lt;p&gt;Only if you'd trust that vendor with your provider account, because that's what you're giving them. If you do, give the tool its own key in its own project or workspace, with a low cap and an expiry. Deleting the key inside the tool doesn't revoke it; only the provider can.&lt;/p&gt;

&lt;p&gt;None of this is exciting, which is the point: the boring setup is what keeps a breach somebody else's headline instead of your invoice. If you want an AI product built that way from the start, &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;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.joancomadran.dev/blog/secure-openai-api-key" 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>llm</category>
      <category>security</category>
      <category>openai</category>
      <category>anthropic</category>
    </item>
    <item>
      <title>Supabase email confirmation not working in Next.js: every cause and the fix</title>
      <dc:creator>Joan Comadran</dc:creator>
      <pubDate>Mon, 19 Oct 2026 08:05:32 +0000</pubDate>
      <link>https://dev.to/joancomadran/supabase-email-confirmation-not-working-in-nextjs-every-cause-and-the-fix-7c3</link>
      <guid>https://dev.to/joancomadran/supabase-email-confirmation-not-working-in-nextjs-every-cause-and-the-fix-7c3</guid>
      <description>&lt;p&gt;If Supabase email confirmation isn't working in your Next.js app, &lt;strong&gt;the most likely cause is PKCE&lt;/strong&gt;: the default link ends in &lt;code&gt;exchangeCodeForSession&lt;/code&gt;, which only works in the browser where the user signed up. Open the email on a phone and you get &lt;code&gt;PKCE code verifier not found in storage&lt;/code&gt;. The fix is to change the &lt;strong&gt;Confirm signup&lt;/strong&gt; template to link to your own route and verify the &lt;code&gt;token_hash&lt;/code&gt; there:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&amp;amp;type=email&amp;amp;next=/onboarding"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  Confirm your email
&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/auth/confirm/route.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verifyOtp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;token_hash&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="s1"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that's not your symptom, the table below maps the other ones. All of this comes from the sign-up flow of &lt;a href="https://www.joancomadran.dev/projects/claient" rel="noopener noreferrer"&gt;Claient&lt;/a&gt;, a multi-tenant WhatsApp lead agent on Next.js and Supabase, where I hit most of these between June and July 2026. Facts about Supabase defaults are as of October 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one do you have?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Likely cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;PKCE code verifier not found in storage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Link opened in another browser, app or device&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;token_hash&lt;/code&gt; + &lt;code&gt;verifyOtp&lt;/code&gt; route&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Email link is invalid or has expired&lt;/code&gt; / &lt;code&gt;otp_expired&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Link already used, older than 1 hour, or opened by a mail scanner&lt;/td&gt;
&lt;td&gt;Resend flow; click-to-confirm page for corporate mail&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Link lands on &lt;code&gt;localhost:3000&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Site URL never changed, or &lt;code&gt;emailRedirectTo&lt;/code&gt; not on the allow list or relative&lt;/td&gt;
&lt;td&gt;Fix URL Configuration, absolute URLs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No email at all&lt;/td&gt;
&lt;td&gt;Built-in sender's 2 emails/hour limit, or the address is already confirmed&lt;/td&gt;
&lt;td&gt;Custom SMTP; check &lt;code&gt;identities&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Clicked the link, still &lt;code&gt;email_not_confirmed&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Confirm route behind auth middleware, or a trigger on &lt;code&gt;auth.users&lt;/code&gt; failing&lt;/td&gt;
&lt;td&gt;Make the route public, debug the trigger&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Why does the confirmation link fail in another browser?
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;@supabase/ssr&lt;/code&gt;, a Next.js app uses the PKCE flow. When the user signs up, the client stores a &lt;strong&gt;code verifier&lt;/strong&gt; in a cookie. The default &lt;code&gt;{{ .ConfirmationURL }}&lt;/code&gt; goes to Supabase's &lt;code&gt;/verify&lt;/code&gt; endpoint, which redirects back to your app with &lt;code&gt;?code=...&lt;/code&gt;, and &lt;code&gt;exchangeCodeForSession(code)&lt;/code&gt; sends the code plus that verifier to get a session.&lt;/p&gt;

&lt;p&gt;No verifier, no session. That happens whenever the click doesn't come from the sign-up browser: the Gmail app's in-app browser, a phone when the user signed up on a laptop, a private window. The auth code also expires after 5 minutes and works once.&lt;/p&gt;

&lt;p&gt;My first version of the Claient callback (commit &lt;code&gt;5cf4697&lt;/code&gt;, June 2026) was exactly the textbook PKCE handler:&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;// The first version: works only in the sign-up browser&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;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;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="nf"&gt;supabaseServer&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exchangeCodeForSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&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;origin&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="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;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&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;origin&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/login?error=Could not confirm the account`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It works as long as the click comes from the sign-up browser, which is exactly what you do when you test it yourself. Two days later (&lt;code&gt;436f7d0&lt;/code&gt;) it learned the &lt;code&gt;token_hash&lt;/code&gt; path, which doesn't depend on cookies at all. &lt;code&gt;verifyOtp&lt;/code&gt; sends the hashed token to Supabase and gets a session back, so it works from any device. That's why Supabase's own Next.js examples use an &lt;code&gt;/auth/confirm&lt;/code&gt; route with &lt;code&gt;verifyOtp&lt;/code&gt; for email links.&lt;/p&gt;

&lt;h2&gt;
  
  
  The route I run in production
&lt;/h2&gt;

&lt;p&gt;This is the Claient route, translated and trimmed (in Claient it lives at &lt;code&gt;/auth/callback&lt;/code&gt;). It handles both link shapes, because which one arrives depends on the template, and it reads the error Supabase attaches when its own &lt;code&gt;/verify&lt;/code&gt; step fails:&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;// app/auth/confirm/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;EmailOtpType&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@supabase/supabase-js&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/supabase/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&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;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="p"&gt;}&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;nextUrl&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;tokenHash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;token_hash&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="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;EmailOtpType&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;code&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;internalPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/onboarding&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;fail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&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;origin&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/login?error=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;msg&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="c1"&gt;// Supabase redirects with ?error_description=... when the link was expired or used&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabaseError&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error_description&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&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;supabaseError&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;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;friendly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;supabaseError&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// token_hash: stateless, works on any device&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;tokenHash&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verifyOtp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;token_hash&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;tokenHash&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;error&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;friendly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&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;origin&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="s2"&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;// code: PKCE, only works in the browser that started the flow&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;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exchangeCodeForSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;friendly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&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;origin&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="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;return&lt;/span&gt; &lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;This confirmation link is not valid.&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;// `next` comes from the URL: only allow internal paths ('//evil.com' is an open redirect)&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;internalPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&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="nx"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&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="s1"&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;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;raw&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="s1"&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;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;raw&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="s1"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\\&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;raw&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;friendly&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&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;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;expired&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;This link has expired. Sign in and we will send a new one.&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;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;invalid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;used&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;This link was already used. If you confirmed, just sign in.&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Could not confirm the account. Please sign in.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three details that each cost me a commit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;next&lt;/code&gt; guard.&lt;/strong&gt; A &lt;code&gt;?next=&lt;/code&gt; parameter you redirect to is an open redirect unless you check it. Claient's callback has had this guard since the audit fixes, and &lt;code&gt;03f1753&lt;/code&gt; moved it into one shared helper after a code review found the same protocol-relative hole (&lt;code&gt;//other-site.com&lt;/code&gt;) in another redirect. I wrote about the same trap in the &lt;a href="https://www.joancomadran.dev/blog/supabase-nextjs-pre-launch-security-checklist" rel="noopener noreferrer"&gt;pre-launch security checklist&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Async client.&lt;/strong&gt; On Next.js 15 and later &lt;code&gt;cookies()&lt;/code&gt; is async, so the server client factory is &lt;code&gt;await createClient()&lt;/code&gt;. Claient predates that and its helper is synchronous.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same route for password recovery.&lt;/strong&gt; &lt;code&gt;resetPasswordForEmail&lt;/code&gt; points at it with &lt;code&gt;next=/reset-password&lt;/code&gt;, so one route verifies every email link.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How do I fix "Email link is invalid or has expired"?
&lt;/h2&gt;

&lt;p&gt;This one (&lt;code&gt;otp_expired&lt;/code&gt;, a 403 on &lt;code&gt;/verify&lt;/code&gt;) is about the token, not your code. Three causes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;It was already used.&lt;/strong&gt; Users double-click, or click the second of two emails after the first one already confirmed them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It expired.&lt;/strong&gt; Email links last &lt;strong&gt;1 hour by default&lt;/strong&gt;. You can raise it under Authentication &amp;gt; Sign In / Providers &amp;gt; Email &amp;gt; Email OTP expiration, but Supabase discourages anything over a day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A scanner opened it first.&lt;/strong&gt; Corporate mail security (Microsoft Defender Safe Links is the usual example in Supabase's docs) fetches every link to check it. With a link that confirms on GET, the scanner confirms for you and the user's click fails. Your own &lt;code&gt;/auth/confirm&lt;/code&gt; route has the same problem, since it verifies on GET too. If your users are on corporate email, link to a page with a "Confirm" button that does the &lt;code&gt;verifyOtp&lt;/code&gt; call, or send the 6-digit &lt;code&gt;{{ .Token }}&lt;/code&gt; code and verify it with &lt;code&gt;verifyOtp({ email, token, type: 'email' })&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fix that mattered more for Claient was the dead end. The login page used to say "confirm your account, check your email", and if that email was expired there was no way to get another one. Since &lt;code&gt;d8f1009&lt;/code&gt;, signing in with an unconfirmed account resends the email on the spot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signInWithPassword&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;password&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;email_not_confirmed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;resendError&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resend&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="s1"&gt;signup&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;emailRedirectTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;appUrl&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;/auth/confirm`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="c1"&gt;// tell the user it was resent, or to wait a minute if resend was rate limited&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before that, Claient showed the same "wrong email or password" message for both cases (&lt;code&gt;18678b9&lt;/code&gt; split them). Telling users their account exists but isn't confirmed, and handing them a fresh link, is the difference between a stuck user and a signed-in one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does the confirmation email redirect to localhost?
&lt;/h2&gt;

&lt;p&gt;Supabase uses &lt;code&gt;emailRedirectTo&lt;/code&gt; only if it matches an entry in the &lt;strong&gt;Redirect URLs&lt;/strong&gt; allow list. Otherwise it falls back to the &lt;strong&gt;Site URL&lt;/strong&gt;, and a new project's Site URL is &lt;code&gt;http://localhost:3000&lt;/code&gt;. So:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Set the Site URL to your production domain in Authentication &amp;gt; URL Configuration.&lt;/li&gt;
&lt;li&gt;Add each redirect you use. Matching is exact unless you use wildcards: &lt;code&gt;https://example.com/**&lt;/code&gt; for a whole domain, a Vercel preview pattern if you test confirmation on previews.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always send an absolute URL.&lt;/strong&gt; Claient originally built the redirect from &lt;code&gt;NEXT_PUBLIC_APP_URL&lt;/code&gt; with an empty-string fallback, so without the variable it became a relative &lt;code&gt;/auth/callback&lt;/code&gt;. Supabase ignores relative redirects, and the password recovery flow broke silently. &lt;code&gt;03f1753&lt;/code&gt; replaced it with an &lt;code&gt;appUrl()&lt;/code&gt; helper that always returns an absolute URL.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your template uses &lt;code&gt;{{ .SiteURL }}&lt;/code&gt;, the link goes to the Site URL no matter what you pass. Use &lt;code&gt;{{ .RedirectTo }}&lt;/code&gt; in the template if you need per-environment links.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is the Supabase confirmation email not arriving?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The built-in sender is for testing.&lt;/strong&gt; As of October 2026 it allows &lt;strong&gt;2 emails per hour per project&lt;/strong&gt;. Two test sign-ups and you're done for the hour. Set up custom SMTP before you launch; the limit becomes configurable after that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-user cooldown.&lt;/strong&gt; The same address has to wait 60 seconds between confirmation requests, so a quick "resend" gets rate limited.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The address is already confirmed.&lt;/strong&gt; To avoid revealing which emails have accounts, &lt;code&gt;signUp&lt;/code&gt; on a confirmed address returns a fake user with an empty &lt;code&gt;identities&lt;/code&gt; array and no session. Claient checks &lt;code&gt;data.user.identities.length&lt;/code&gt; and shows the same neutral "check your email" screen, with links to sign in or reset the password.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spam folder.&lt;/strong&gt; Default sender, default template, no custom domain: it happens. Custom SMTP on your own domain helps here too.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The user clicked, but still gets "Email not confirmed"
&lt;/h2&gt;

&lt;p&gt;Then the verify step didn't succeed. Check in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Is the confirm route public?&lt;/strong&gt; If your &lt;code&gt;middleware.ts&lt;/code&gt; (renamed &lt;code&gt;proxy.ts&lt;/code&gt; in Next.js 16) redirects signed-out users to &lt;code&gt;/login&lt;/code&gt;, and its matcher covers &lt;code&gt;/auth/confirm&lt;/code&gt;, the user is bounced before anything verifies. Claient's matcher only covers &lt;code&gt;/dashboard/:path*&lt;/code&gt;; in pollitics.es the proxy matcher lists the few routes that need the session and nothing else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Does a trigger on &lt;code&gt;auth.users&lt;/code&gt; fail?&lt;/strong&gt; A function that creates a profile on sign-up or confirmation and throws (a renamed column, a text-to-uuid cast) makes the confirmation fail with a database error. Supabase's GitHub issues are full of this one. Claient avoids the trigger: the sign-up server action runs an idempotent account setup with the service key instead, which is easier to debug and retry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is it the right project?&lt;/strong&gt; A preview deployment pointing at a different Supabase project than the one that sent the email. Boring, but worth ruling out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the Auth logs.&lt;/strong&gt; The &lt;code&gt;/verify&lt;/code&gt; request and its error code tell you which of the sections above you're in.&lt;/li&gt;
&lt;/ol&gt;

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

&lt;ol&gt;
&lt;li&gt;Confirm signup template links to your route with &lt;code&gt;token_hash&lt;/code&gt; and &lt;code&gt;type=email&lt;/code&gt;, not &lt;code&gt;{{ .ConfirmationURL }}&lt;/code&gt; plus &lt;code&gt;exchangeCodeForSession&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The route calls &lt;code&gt;verifyOtp&lt;/code&gt;, validates &lt;code&gt;next&lt;/code&gt;, and turns Supabase's errors into messages users understand.&lt;/li&gt;
&lt;li&gt;Site URL is production; every &lt;code&gt;emailRedirectTo&lt;/code&gt; is absolute and on the allow list.&lt;/li&gt;
&lt;li&gt;Custom SMTP before launch.&lt;/li&gt;
&lt;li&gt;Sign-in with &lt;code&gt;email_not_confirmed&lt;/code&gt; resends the email instead of leaving users stuck.&lt;/li&gt;
&lt;li&gt;The confirm route isn't behind your auth middleware or proxy.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Once confirmation works, the next Supabase error people hit is usually the first insert right after sign-up. That's &lt;a href="https://www.joancomadran.dev/blog/supabase-new-row-violates-row-level-security-policy" rel="noopener noreferrer"&gt;new row violates row-level security policy&lt;/a&gt;. If you're putting Claude behind that login, &lt;a href="https://www.joancomadran.dev/blog/whatsapp-bot-with-claude" rel="noopener noreferrer"&gt;building a WhatsApp bot with Claude&lt;/a&gt; covers the part of Claient after onboarding.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Why is Supabase email confirmation not working in my Next.js app?
&lt;/h3&gt;

&lt;p&gt;Usually because a Next.js app using &lt;code&gt;@supabase/ssr&lt;/code&gt; runs the PKCE flow, and the default confirmation link ends in &lt;code&gt;exchangeCodeForSession&lt;/code&gt;, which needs a code verifier cookie stored in the browser where the user signed up. If the email is opened in another browser, app or device, the exchange fails with "PKCE code verifier not found in storage". Point the Confirm signup email template at your own route with &lt;code&gt;token_hash&lt;/code&gt; and &lt;code&gt;type&lt;/code&gt;, and call &lt;code&gt;supabase.auth.verifyOtp({ token_hash, type: 'email' })&lt;/code&gt; there. That works on any device.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I fix "Email link is invalid or has expired" in Supabase?
&lt;/h3&gt;

&lt;p&gt;The token was already used, has expired (email links last 1 hour by default, configurable under Email OTP expiration), or was consumed by an email security scanner that opened the link before the user did. Check the Auth logs for &lt;code&gt;otp_expired&lt;/code&gt;. Give users a way to request a new email, for example by calling &lt;code&gt;supabase.auth.resend({ type: 'signup', email })&lt;/code&gt; when sign-in returns &lt;code&gt;email_not_confirmed&lt;/code&gt;, and if your users are on corporate mail, make the link land on a page that only verifies after a button click, or send the 6-digit &lt;code&gt;{{ .Token }}&lt;/code&gt; code instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does my Supabase confirmation email redirect to localhost?
&lt;/h3&gt;

&lt;p&gt;Supabase falls back to the Site URL when no valid redirect is provided, and new projects start with &lt;code&gt;http://localhost:3000&lt;/code&gt;. Set the Site URL to your production domain under Authentication &amp;gt; URL Configuration, add every &lt;code&gt;emailRedirectTo&lt;/code&gt; you use to the Redirect URLs allow list (it is an exact match unless you use wildcards), and always pass an absolute URL. A relative &lt;code&gt;emailRedirectTo&lt;/code&gt; is ignored.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is the Supabase confirmation email not arriving?
&lt;/h3&gt;

&lt;p&gt;As of October 2026, the built-in email provider is limited to 2 emails per hour per project and is meant for testing, and the same user has to wait 60 seconds between confirmation requests. Configure custom SMTP before launch. Also check whether the address was already confirmed: in that case &lt;code&gt;signUp&lt;/code&gt; returns an obfuscated user with an empty &lt;code&gt;identities&lt;/code&gt; array and there is nothing to confirm.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use verifyOtp or exchangeCodeForSession for email confirmation?
&lt;/h3&gt;

&lt;p&gt;For email links in a server-rendered Next.js app, &lt;code&gt;verifyOtp&lt;/code&gt; with &lt;code&gt;token_hash&lt;/code&gt;. It is stateless and does not depend on cookies from the sign-up browser. &lt;code&gt;exchangeCodeForSession&lt;/code&gt; is for flows that start and finish in the same browser, such as OAuth sign-in, and its auth code is valid for 5 minutes and single use.&lt;/p&gt;

&lt;h3&gt;
  
  
  The user confirmed but still gets "Email not confirmed". Why?
&lt;/h3&gt;

&lt;p&gt;The verify step never succeeded. Common reasons: the confirm route is behind your auth middleware or proxy and redirects to login before verifying, a trigger on &lt;code&gt;auth.users&lt;/code&gt; throws during confirmation, or the user clicked an old link. Check the Auth logs for the &lt;code&gt;/verify&lt;/code&gt; request, make sure the route is public, and test the trigger by inserting into the tables it writes.&lt;/p&gt;

&lt;p&gt;Auth is the unglamorous part of every product I build, and the part users notice first when it breaks. If you want a sign-up flow like this one built into your product, &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;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.joancomadran.dev/blog/supabase-email-confirmation-not-working" 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>auth</category>
      <category>pkce</category>
    </item>
    <item>
      <title>Claude Code cost per month: how to budget it before finance does</title>
      <dc:creator>Joan Comadran</dc:creator>
      <pubDate>Fri, 16 Oct 2026 21:53:48 +0000</pubDate>
      <link>https://dev.to/joancomadran/claude-code-cost-per-month-how-to-budget-it-before-finance-does-1nmi</link>
      <guid>https://dev.to/joancomadran/claude-code-cost-per-month-how-to-budget-it-before-finance-does-1nmi</guid>
      <description>&lt;p&gt;On October 15, &lt;a href="https://www.404media.co/accenture-tells-employees-they-can-now-spend-only-29-on-claude-per-month/" rel="noopener noreferrer"&gt;404 Media reported&lt;/a&gt; that Accenture told many employees it will now spend only &lt;strong&gt;$29 a month per person on Claude&lt;/strong&gt;. Some of those people had been spending as much as &lt;strong&gt;$1,000 a month&lt;/strong&gt;, and are now, in the article's words, scrambling to keep the same level of usage. &lt;a href="https://x.com/josephfcox/status/2110774735252435435" rel="noopener noreferrer"&gt;Joseph Cox's post&lt;/a&gt; about it went past two million impressions on X, which tells you how many people looked at their own bill and felt something.&lt;/p&gt;

&lt;p&gt;The article also points out the irony: Accenture has spent years telling clients to invest heavily in AI. Fine. Irony is free. Tokens aren't.&lt;/p&gt;

&lt;p&gt;I'm not going to argue about whether $29 is the right number for a consultancy. The useful question is the boring one: &lt;strong&gt;what does Claude Code actually cost per month, and how do you budget it so the cap is a decision instead of a reaction?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What Claude Code costs in October 2026
&lt;/h2&gt;

&lt;p&gt;Claude Code doesn't have its own price tag. It comes with Anthropic's paid plans, and it shares the usage pool with regular Claude chat. From &lt;a href="https://claude.com/pricing" rel="noopener noreferrer"&gt;Anthropic's pricing page&lt;/a&gt;, checked on October 16, 2026:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Price&lt;/th&gt;
&lt;th&gt;What you're really buying&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;No Claude Code&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pro&lt;/td&gt;
&lt;td&gt;$20/month, or $17/month billed annually&lt;/td&gt;
&lt;td&gt;Claude Code with usage limits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max&lt;/td&gt;
&lt;td&gt;From $100/month&lt;/td&gt;
&lt;td&gt;5x or 20x Pro's usage per session, plus monthly API credits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Team&lt;/td&gt;
&lt;td&gt;Per seat&lt;/td&gt;
&lt;td&gt;Admin, central billing, user and org spend controls&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Enterprise&lt;/td&gt;
&lt;td&gt;$20/seat/month + usage at API rates&lt;/td&gt;
&lt;td&gt;Everything in Team, billed annually&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Console (API)&lt;/td&gt;
&lt;td&gt;Per token&lt;/td&gt;
&lt;td&gt;No plan limits, no ceiling unless you set one&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two details matter more than the headline prices.&lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;subscription limits reset on a rolling five-hour window, with weekly limits on top&lt;/strong&gt;, and Anthropic doesn't publish a fixed message count. How far a plan goes depends on the model, the length of the context and what the agent is doing. So "Pro is $20" is true and also only half the answer.&lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;the API has no ceiling by default&lt;/strong&gt;. For reference, the same page lists Opus 5.5 at $4 input / $20 output per million tokens, Sonnet 5.5 at $2 / $10, and Haiku 5.5 at $0.10 / $0.50 for prompts up to 100K tokens ($0.50 / $2.50 above that). Claude Code's fast mode for Opus 5.5 is listed at $8 / $40. Those look like small numbers until an agent rereads your repo forty times in an afternoon.&lt;/p&gt;

&lt;p&gt;That second point is probably how somebody gets to $1,000.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why coding agents burn more than chat
&lt;/h2&gt;

&lt;p&gt;A chat costs what you type plus what you read. An agent costs what it &lt;em&gt;reads to decide what to do&lt;/em&gt;, over and over, and you don't see most of it.&lt;/p&gt;

&lt;p&gt;I wrote about the same mechanics on the API side 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;: LLMs are stateless, so every turn resends the whole conversation. A coding agent makes that worse in three ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Context is big by default.&lt;/strong&gt; Files, diffs, test output, tool definitions. The agent pulls them in because that's its job.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loops.&lt;/strong&gt; Run the tests, read the failure, edit, run again. Each lap resends the growing context.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model choice is sticky.&lt;/strong&gt; If the default is the most capable model, every trivial rename gets frontier pricing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that is a bug. It's the product working as designed, which is exactly why it's easy to stop noticing the meter.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to budget it: start from the work, not the price list
&lt;/h2&gt;

&lt;p&gt;A per-seat number like $29 is a budget built from the price list. It's simple to administer and it will be wrong for almost everyone: too much for people who open Claude twice a week, far too little for the person whose whole job is now reviewing what an agent wrote.&lt;/p&gt;

&lt;p&gt;A better order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Measure two normal weeks.&lt;/strong&gt; Not a demo week. Normal. On Team and Enterprise there's usage analytics; on the API every response tells you the tokens it used.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Group people by how they work, not by title.&lt;/strong&gt; Occasional users, daily users, "the agent is my pair programmer" users. You'll usually find a long tail and a small heavy group.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Give each group the cheapest option that doesn't interrupt them.&lt;/strong&gt; Occasional users on Pro. Heavy users on Max or a premium seat, because a fixed monthly price is easier to defend than an open-ended API bill. Automated pipelines on the API, with a hard limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-check monthly.&lt;/strong&gt; Models and prices move. A budget you set once is a budget you'll get surprised by.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you're solo, the same logic applies with smaller numbers: if you keep hitting the five-hour limit on Pro, that's the signal to try Max for a month and compare, not to quietly add API credits on top and forget about them.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to measure it
&lt;/h2&gt;

&lt;p&gt;You can't cap what you don't measure, and "the invoice" is not a measurement, it's a post-mortem.&lt;/p&gt;

&lt;p&gt;For agents I build on the API, the cost comes straight off the response: input tokens, output tokens, cache reads and writes. Log those per call, tag them with the user or tenant and the task, and "who is expensive and why" stops being a guess and becomes a &lt;code&gt;GROUP BY&lt;/code&gt;. That's the setup I described in the &lt;a href="https://www.joancomadran.dev/blog/keeping-claudes-token-bill-from-ruining-your-month" rel="noopener noreferrer"&gt;token bill post&lt;/a&gt;, and it works just as well for internal tools as for products.&lt;/p&gt;

&lt;p&gt;The useful metric isn't cost per user. It's &lt;strong&gt;cost per finished task&lt;/strong&gt;: per merged PR, per fixed bug, per qualified lead. $1,000 a month is expensive if it buys a lot of rerun test loops. It's cheap if it replaces a week of someone's time. Accenture's cap treats both cases the same, which is the part I'd question if I worked there.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to cap it without killing the useful part
&lt;/h2&gt;

&lt;p&gt;Caps work best in layers, each set where the money is actually spent:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Plan limits.&lt;/strong&gt; Subscriptions already cap you; the five-hour window is a budget whether you like it or not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Admin spend controls.&lt;/strong&gt; Team and Enterprise plans list user and organization-level spend controls. Use them per group, not one flat number for everyone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API hard limits.&lt;/strong&gt; For anything on Console keys, set a workspace limit. An automation with an uncapped key is how a typo becomes an invoice.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In-code limits for your own agents.&lt;/strong&gt; A daily budget check before each call and an iteration cap on tool loops. In my WhatsApp bot, an iteration cap of five on the tool loop is what stops a runaway loop from turning into a runaway bill. I wrote that up in &lt;a href="https://www.joancomadran.dev/blog/keeping-claudes-token-bill-from-ruining-your-month" rel="noopener noreferrer"&gt;the Claude token post&lt;/a&gt; as "a kill switch".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Building an agent and want the cost controls in from day one instead of after the first scary invoice?&lt;/p&gt;

&lt;h2&gt;
  
  
  The cheap wins before anyone caps anything
&lt;/h2&gt;

&lt;p&gt;Most of the savings come from not paying for the same tokens twice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prompt caching&lt;/strong&gt; for the static part of the context. Cache reads are a small fraction of the input price on every model on Anthropic's list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Right model per task.&lt;/strong&gt; Classification, renames and boilerplate don't need the top model. Route them to Haiku or Sonnet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shorter context.&lt;/strong&gt; Point the agent at the files that matter instead of letting it rediscover the repo every session.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch what isn't urgent.&lt;/strong&gt; Anthropic's Batch tier exists for exactly the work nobody's waiting on.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're estimating the full cost of an agent you're building, not just the coding tool you use, I broke the build and run costs down in &lt;a href="https://www.joancomadran.dev/blog/ai-agent-development-cost" rel="noopener noreferrer"&gt;AI agent development cost&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  So, is $29 a month enough?
&lt;/h2&gt;

&lt;p&gt;For someone who uses Claude occasionally to draft or explain things, probably yes. Pro alone is $20. For someone running Claude Code as a daily coding agent, no, and the 404 Media piece suggests the people affected already know that.&lt;/p&gt;

&lt;p&gt;The lesson isn't "AI is too expensive" or "companies are cheap". It's that usage-based tools need a budget built from usage. Measure for two weeks, split people by how they actually work, cap in layers, and track cost per finished task. That's less dramatic than a memo, and it doesn't leave your best people scrambling for tokens on a Tuesday.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  How much does Claude Code cost per month?
&lt;/h3&gt;

&lt;p&gt;As of October 2026, Claude Code has no standalone price. It's included in Anthropic's paid plans: Pro at $20/month ($17/month billed annually), Max from $100/month (5x Pro usage) up to the 20x tier, plus Team and Enterprise seats. Enterprise is listed at $20 per seat per month plus usage at API rates. You can also run Claude Code with a Console account and pay per token at API prices, with no monthly ceiling unless you set one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Is a subscription or the API cheaper for Claude Code?
&lt;/h3&gt;

&lt;p&gt;For one person coding most days, a subscription is usually the predictable option: a fixed price with usage limits that reset on a rolling five-hour window plus weekly caps. The API is cheaper for light or bursty use and is the only option where the ceiling is entirely yours to set, which also means nobody sets it for you. Measure a couple of weeks of real usage before deciding.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why did Accenture cap Claude at $29 a month?
&lt;/h3&gt;

&lt;p&gt;According to 404 Media (October 15, 2026), Accenture told many employees it would now spend only $29 a month per person on Claude, after some had been spending as much as $1,000 a month. The article frames it as part of a wider trend of companies curbing AI tool spend once usage outgrew budgets. It doesn't publish an internal rationale beyond that.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I stop an AI coding agent from blowing the budget?
&lt;/h3&gt;

&lt;p&gt;Set the cap where the money is spent, not in a spreadsheet: per-user or per-org spend limits in the Team/Enterprise admin, a hard limit on the API workspace, and in your own agents a per-day budget check and an iteration cap on tool loops. Then log cost per task so you know which work is worth the tokens.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.joancomadran.dev/blog/claude-code-cost-per-month" 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>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>
  </channel>
</rss>
