<?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: Daniel Pertu</title>
    <description>The latest articles on DEV Community by Daniel Pertu (@daniel_pertu).</description>
    <link>https://dev.to/daniel_pertu</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%2F3981169%2F83f3f6c9-9d75-47a7-9b7c-c28d13594696.jpg</url>
      <title>DEV Community: Daniel Pertu</title>
      <link>https://dev.to/daniel_pertu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/daniel_pertu"/>
    <language>en</language>
    <item>
      <title>Nine indexes on one queue table, five of them partial, three of them on a jsonb path</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Thu, 22 Oct 2026 10:19:50 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/nine-indexes-on-one-queue-table-five-of-them-partial-three-of-them-on-a-jsonb-path-3pf4</link>
      <guid>https://dev.to/daniel_pertu/nine-indexes-on-one-queue-table-five-of-them-partial-three-of-them-on-a-jsonb-path-3pf4</guid>
      <description>&lt;p&gt;&lt;a href="https://nakodo.app" rel="noopener noreferrer"&gt;Nakodo's&lt;/a&gt; background work is one Postgres table called &lt;code&gt;jobs&lt;/code&gt;, drained by a cron every five minutes. The whole pipeline it runs is described in plain English on &lt;a href="https://nakodo.app/how-it-works" rel="noopener noreferrer"&gt;how it works&lt;/a&gt;: research a brand's site, look for creators, check them, write and send email, read the replies. Every step of that is rows in this table.&lt;/p&gt;

&lt;p&gt;The schema has 25 tables and 55 indexes. Nine of them are on &lt;code&gt;jobs&lt;/code&gt;, which is more than any other table has, and five of the six partial indexes in the whole database are here. That distribution is not an accident, and it is the most useful thing I have learned about queue tables in Postgres.&lt;/p&gt;

&lt;h2&gt;
  
  
  A queue table is mostly history
&lt;/h2&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;jobs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;pgTable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jobs&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;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;primaryKey&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;defaultRandom&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="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JOB_TYPES&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;campaignId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;references&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;campaigns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;onDelete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cascade&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;$type&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;JobPayload&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;dedupeKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="c1"&gt;// e.g. "enrich:UC123"; only one open job per key&lt;/span&gt;
  &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;enum&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;pending&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;running&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;done&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;failed&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;notNull&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;attempts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;integer&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;runAfter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;withTimezone&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="nf"&gt;notNull&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;defaultNow&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;lockedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;withTimezone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;lastError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;runId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;references&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;runs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;onDelete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;set null&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
  &lt;span class="na"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;finishedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;withTimezone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At any moment a handful of these rows are pending or running and the rest are done or failed. Almost every question the app asks is about the handful: what can I claim now, does this key already have open work, what is this campaign still waiting for. Almost every row in the table is irrelevant to all of those questions.&lt;/p&gt;

&lt;p&gt;An ordinary index does not know that. It indexes the finished rows too, gets bigger every day, and makes the planner walk entries that can never match. A partial index does know it:&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="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jobs_open_campaign_idx&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;campaignId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&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; in ('pending', 'running')`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;which &lt;code&gt;drizzle-kit&lt;/code&gt; turns into exactly what you would have written by hand:&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;INDEX&lt;/span&gt; &lt;span class="nv"&gt;"jobs_open_campaign_idx"&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="nv"&gt;"jobs"&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;btree&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"campaign_id"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nv"&gt;"jobs"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"status"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'running'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The index now holds tens of rows rather than hundreds of thousands, it stays that size for ever (finished jobs are deleted after 14 days by the nightly maintenance run), and it costs nothing to maintain on the 99% of writes that are status changes into &lt;code&gt;done&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The catch, and it is a sharp one: the planner will only use a partial index if it can prove your query implies the index predicate. The predicate here is a literal &lt;code&gt;status in ('pending', 'running')&lt;/code&gt;, so a query that asks for &lt;code&gt;status &amp;lt;&amp;gt; 'done' and status &amp;lt;&amp;gt; 'failed'&lt;/code&gt; describes the same rows and gets a sequential scan. Partial indexes make the exact phrasing of a &lt;code&gt;WHERE&lt;/code&gt; clause load-bearing, which means the predicate has to be written once in the application and reused. Ours is, in both the TypeScript and the SQL that reads it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dedupe index does something a constraint cannot
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;uniqueIndex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jobs_open_dedupe_uq&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dedupeKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&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; in ('pending', 'running')`&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 sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;UNIQUE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="nv"&gt;"jobs_open_dedupe_uq"&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="nv"&gt;"jobs"&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;btree&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"dedupe_key"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nv"&gt;"jobs"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"status"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'running'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule is "only one open job per dedupe key". Not one job ever: the same creator is read again weeks later, the same campaign searches again tomorrow. A &lt;code&gt;UNIQUE&lt;/code&gt; table constraint cannot express that, because constraints cannot be partial in Postgres. Only a unique index can, which is the reason to reach for &lt;code&gt;uniqueIndex&lt;/code&gt; rather than &lt;code&gt;unique&lt;/code&gt; even when you are not writing an expression.&lt;/p&gt;

&lt;p&gt;Two things follow from it that are easy to get wrong.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;dedupeKey&lt;/code&gt; is nullable, and in a unique index nulls do not conflict with each other, so any number of jobs with no dedupe key can be open at once. That is the behaviour we want (one-off work has no key) and it is worth saying out loud, because "unique" and "at most one" are not the same statement when nulls are allowed.&lt;/p&gt;

&lt;p&gt;And the insert that relies on it is a bare conflict clause:&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;rows&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;db&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;jobs&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newJobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(...)).&lt;/span&gt;&lt;span class="nf"&gt;onConflictDoNothing&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;returning&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;jobs&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;return&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ON CONFLICT DO NOTHING&lt;/code&gt; with no conflict target handles a violation of any unique index, partial ones included, so enqueueing twenty jobs of which three already have open work inserts seventeen and returns seventeen ids. The moment you want &lt;code&gt;DO UPDATE&lt;/code&gt; instead, you have to name an inference target, and for a partial index that means repeating the index predicate in the statement (&lt;code&gt;ON CONFLICT (dedupe_key) WHERE status in (...) DO UPDATE&lt;/code&gt;), because Postgres has to pick the index out by its definition. That asymmetry between &lt;code&gt;DO NOTHING&lt;/code&gt; and &lt;code&gt;DO UPDATE&lt;/code&gt; is the single most surprising thing about partial unique indexes. The related trap is self-conflict inside one statement: two jobs with the same key in the same &lt;code&gt;VALUES&lt;/code&gt; list are fine under &lt;code&gt;DO NOTHING&lt;/code&gt; (the second one sees the first and is skipped), but the same batch under &lt;code&gt;DO UPDATE&lt;/code&gt; raises "ON CONFLICT DO UPDATE command cannot affect row a second time". If you ever move from one to the other, dedupe the batch in application code first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three indexes on expressions, two of them into the jsonb
&lt;/h2&gt;

&lt;p&gt;Some job types name their campaign in the column, and some only name it in the payload, because a job about a creator can serve several campaigns. Both have to be findable:&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="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jobs_open_payload_campaign_idx&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&amp;gt;&amp;gt;'campaignId')`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&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; in ('pending', 'running')`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jobs_open_payload_channel_idx&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&amp;gt;&amp;gt;'channelId')`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&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; in ('pending', 'running')`&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 sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="nv"&gt;"jobs_open_payload_campaign_idx"&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="nv"&gt;"jobs"&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;btree&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nv"&gt;"payload"&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'campaignId'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="nv"&gt;"jobs"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;"status"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'pending'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'running'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The double parentheses in the generated SQL are not style. An index on an expression needs the expression in its own parentheses, and getting that wrong is a syntax error rather than a silent problem, which is the one merciful thing here. What is not merciful is the matching: the query has to use the same expression, character for character in spirit. &lt;code&gt;payload-&amp;gt;&amp;gt;'campaignId'&lt;/code&gt; matches, &lt;code&gt;payload-&amp;gt;'campaignId'&lt;/code&gt; (returning jsonb rather than text) does not, and neither does a cast bolted onto one side. Our reader writes the same thing the index does:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-&amp;gt;&amp;gt;'campaignId' = &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;campaignId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An alternative was a GIN index on the whole &lt;code&gt;payload&lt;/code&gt;, which would have answered every possible payload question. It would also have been far larger, and we only ever ask two. Two small btree indexes on two known paths beat one general index you cannot predict the cost of.&lt;/p&gt;

&lt;p&gt;The third expression index is the one that pays for fairness between campaigns:&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="nf"&gt;index&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;jobs_served_idx&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&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="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`coalesce(&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;finishedAt&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;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lockedAt&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;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;campaignId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&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; in ('done', 'running')`&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The question it answers is "when was each campaign last served work of this type", and the answer has two sources: a finished job has a &lt;code&gt;finished_at&lt;/code&gt;, a job still running was last served when it was locked. Without the index that is a &lt;code&gt;CASE&lt;/code&gt; or a &lt;code&gt;coalesce&lt;/code&gt; in the query, evaluated per row over the whole history. With it, the coalesce is materialised in index order, and the predicate restricts it to &lt;code&gt;done&lt;/code&gt; and &lt;code&gt;running&lt;/code&gt; so failed and pending rows do not clutter it. Note that this index's predicate is a different pair of statuses from the other four, which is the clearest possible demonstration that the predicate belongs to the question, not to the table.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Drizzle gives you, and what to watch
&lt;/h2&gt;

&lt;p&gt;The whole vocabulary is &lt;code&gt;index()&lt;/code&gt;, &lt;code&gt;uniqueIndex()&lt;/code&gt;, &lt;code&gt;.on()&lt;/code&gt; with either columns or &lt;code&gt;sql&lt;/code&gt; fragments, &lt;code&gt;.where(sql\&lt;/code&gt;...&lt;code&gt;)&lt;/code&gt; and &lt;code&gt;.using("gin", ...)&lt;/code&gt;. The schema's two GIN indexes (one on a text array, one on a &lt;code&gt;to_tsvector&lt;/code&gt; expression) use the same builder.&lt;/p&gt;

&lt;p&gt;What to watch is how changes are migrated. In our history, one index was dropped and recreated three times across migrations, because an edit to an index's columns or predicate is not an alteration in Postgres; it is a &lt;code&gt;DROP INDEX&lt;/code&gt; plus a &lt;code&gt;CREATE INDEX&lt;/code&gt;. &lt;code&gt;drizzle-kit generate&lt;/code&gt; writes exactly that, which is correct and also a lock on a table big enough to matter. Read the generated SQL, and if the table is large, replace the pair with &lt;code&gt;CREATE INDEX CONCURRENTLY&lt;/code&gt; by hand and keep the migration honest about what it is doing.&lt;/p&gt;

&lt;p&gt;The last thing worth saying is the cost. Nine indexes on a write-heavy table is not free: every insert and every status change maintains all nine. The reason it is affordable here is that the table never grows, because finished rows are deleted on a 14-day window. A queue table with no retention policy and nine indexes is a different and much worse story, and the retention is the decision that makes the indexing strategy work at all.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Our design system is a markdown table of nineteen motifs, and the useful column is the one that says no</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Thu, 22 Oct 2026 10:17:33 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/our-design-system-is-a-markdown-table-of-nineteen-motifs-and-the-useful-column-is-the-one-that-fd4</link>
      <guid>https://dev.to/daniel_pertu/our-design-system-is-a-markdown-table-of-nineteen-motifs-and-the-useful-column-is-the-one-that-fd4</guid>
      <description>&lt;p&gt;Most design systems I have worked in document what a component is and what props it takes. The part that decides whether an interface looks made or assembled is the part they usually leave out: where each thing is allowed to appear, and what it is never allowed to mean.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://nakodo.app" rel="noopener noreferrer"&gt;Nakodo&lt;/a&gt; is a field guide for finding creators, and its visual language is lifted from naturalists' plates and ledgers. &lt;code&gt;docs/brand.md&lt;/code&gt; section 5.5 is a table of nineteen motifs with four columns: the motif, what it is, where it is used, and a rule. Some of those rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Catalogue numbers: "Always 4 digits for creators, 2 for guides. Sequence follows first sighted order, so numbers never change."&lt;/li&gt;
&lt;li&gt;Pointer arrows: "Marketing and explanatory views only; never inside tables."&lt;/li&gt;
&lt;li&gt;Hatching: "Always means not yet observed or locked. Never decorative in UI. Always paired with text."&lt;/li&gt;
&lt;li&gt;Double rule: "Max one per screen."&lt;/li&gt;
&lt;li&gt;Stamps: "Ink or cyano only, rotated at most -2 degrees. Never ochre, never red."&lt;/li&gt;
&lt;li&gt;Live dot: "The only pill shape in the app."&lt;/li&gt;
&lt;li&gt;Attention mark: "Max one kind of mark per row. Never used for status."&lt;/li&gt;
&lt;li&gt;Tally marks: "Above 25, switch to a plain bar meter with numbers."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;src/components/brand/marks.tsx&lt;/code&gt; is 228 lines and is that table turned into components. This post is about which of those rules the code can enforce and which ones it can only make easy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rules a type can hold
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Stamp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tone&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ink&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;label&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;date&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;tone&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ink&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cyano&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;className&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;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;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;cn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;inline-flex -rotate-2 items-center gap-1.5 rounded-sm border-[3px] border-double px-1.5 py-px&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;tone&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cyano&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;border-cyano-700 text-cyano-700 dark:border-cyano-300 dark:text-cyano-300&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;border-foreground/70 text-foreground/80&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;className&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="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"label-caps"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&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;date&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"catalogue"&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;date&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Never ochre, never red" is a union of two strings, so breaking it is a type error. The rotation is not a prop at all: &lt;code&gt;-rotate-2&lt;/code&gt; is baked in, because "at most -2 degrees" with a prop would have meant someone eventually passing -6. The double border is a real CSS &lt;code&gt;double&lt;/code&gt; 3px border rather than two elements, which is the same trick the app uses for its horizontal double rules.&lt;/p&gt;

&lt;p&gt;Same with the catalogue numbers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;CatalogueNo&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;creator&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="nl"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;kind&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;creator&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;guide&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;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;kind&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;creator&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`No. &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;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&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="s2"&gt;`Guide &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;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&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="p"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four digits or two, decided by the kind, not by the caller. &lt;a href="https://nakodo.app" rel="noopener noreferrer"&gt;The homepage&lt;/a&gt; carries seven of them; you can count the string &lt;code&gt;No. 0&lt;/code&gt; in the HTML.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rules only CSS can hold
&lt;/h2&gt;

&lt;p&gt;The specimen tag is "a small label with a notched left edge and a punched hole". It is one utility and one absolutely positioned dot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@utility&lt;/span&gt; &lt;span class="n"&gt;specimen-tag&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;position&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;relative&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;clip-path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;polygon&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;7px&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;7px&lt;/span&gt; &lt;span class="m"&gt;100%&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="m"&gt;50%&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nl"&gt;padding-left&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;14px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"absolute top-1/2 left-[6px] size-1 -translate-y-1/2 rounded-full bg-background"&lt;/span&gt; &lt;span class="na"&gt;aria-hidden&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"true"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hole is &lt;code&gt;bg-background&lt;/code&gt;, not transparent, so it reads as punched through the tag to the page behind it. That is a cheat that only works because the tag never sits on anything but the page colour, which is itself a rule nobody wrote down. Two of these are in the homepage's HTML and two more on &lt;a href="https://nakodo.app/pricing" rel="noopener noreferrer"&gt;the pricing page&lt;/a&gt;, where they are the plan kickers: Notebook, Field kit, Expedition.&lt;/p&gt;

&lt;p&gt;The plate marks ("small registration crosses at the corners of a framed area", marketing and ceremony only) are an array of four position class strings mapped over one SVG:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-top-1.5 -left-1.5&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;-top-1.5 -right-1.5&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;-bottom-1.5 -left-1.5&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;-right-1.5 -bottom-1.5&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="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;svg&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;viewBox&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"0 0 12 12"&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;cn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;absolute size-3 text-foreground/50&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;pos&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;aria-hidden&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"true"&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;path&lt;/span&gt; &lt;span class="na"&gt;d&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"M6 0v12M0 6h12"&lt;/span&gt; &lt;span class="na"&gt;stroke&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"currentColor"&lt;/span&gt; &lt;span class="na"&gt;strokeWidth&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"1"&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;circle&lt;/span&gt; &lt;span class="na"&gt;cx&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"6"&lt;/span&gt; &lt;span class="na"&gt;cy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"6"&lt;/span&gt; &lt;span class="na"&gt;r&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"2.5"&lt;/span&gt; &lt;span class="na"&gt;fill&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"none"&lt;/span&gt; &lt;span class="na"&gt;stroke&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"currentColor"&lt;/span&gt; &lt;span class="na"&gt;strokeWidth&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"1"&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;svg&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;))}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four crosses per plate, so the count in the live HTML divides by four: &lt;code&gt;M6 0v12&lt;/code&gt; appears twelve times on the homepage, which is three plates. The pricing page has none, because plates are for the landing and for ceremony, and nothing on the pricing page is a ceremony.&lt;/p&gt;

&lt;p&gt;Hatching is the one motif with a forced-colours fallback, because a repeating gradient that means "locked" disappears in Windows high contrast mode and would take the meaning with it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;forced-colors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;active&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.hatch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;background-image&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;none&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;border&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1px&lt;/span&gt; &lt;span class="nb"&gt;dashed&lt;/span&gt; &lt;span class="n"&gt;CanvasText&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;h2&gt;
  
  
  The tally meter, which is the rule table at its best
&lt;/h2&gt;

&lt;p&gt;"Usage meters for small allowances (up to 25). Above 25, switch to a plain bar meter with numbers."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Tally&lt;/span&gt; &lt;span class="na"&gt;used&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;shown&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;limit&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;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"h-1.5 overflow-hidden rounded-sm bg-paper-150 dark:bg-muted"&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"presentation"&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;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"grow-x h-full bg-cyano-700 dark:bg-cyano-400"&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${(&lt;/span&gt;&lt;span class="nx"&gt;shown&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That threshold is a product decision disguised as a design one. A paid plan on Nakodo contacts 25 new creators or businesses each weekday, which is exactly on the line, so a Pro account sees five groups of five strokes rather than a bar. Tally marks are countable; a bar at 72% is not. Above 25 the strokes would be unreadable and the bar wins.&lt;/p&gt;

&lt;p&gt;Each group of five is one SVG with four uprights and a diagonal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nb"&gt;Array&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="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&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;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Stroke&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;d&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`M&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; 1.5V12.5`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;n&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;used&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;used&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="nx"&gt;size&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Stroke&lt;/span&gt; &lt;span class="na"&gt;d&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"M1 11.5L22 2.5"&lt;/span&gt; &lt;span class="na"&gt;n&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;start&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;used&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;used&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and every stroke is drawn twice, once as a faint guide and once as the mark:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;path&lt;/span&gt; &lt;span class="na"&gt;d&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;strokeWidth&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"1.5"&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"stroke-paper-300 dark:stroke-ink-700"&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;used&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;path&lt;/span&gt; &lt;span class="na"&gt;d&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;pathLength&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;strokeWidth&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"1.5"&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"draw-in stroke-foreground"&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;--i&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;n&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rendering the unused strokes means the meter has a fixed width and shows the allowance, not just the usage. &lt;code&gt;pathLength={1}&lt;/code&gt; normalises every stroke to one unit so one dash rule covers both the uprights and the longer diagonal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="n"&gt;draw-in&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nt"&gt;from&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;stroke-dashoffset&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&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="c"&gt;/* A gap longer than the dash, so no round cap shows at the far end while
   a stroke waits to be drawn. */&lt;/span&gt;
&lt;span class="nc"&gt;.draw-in&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;stroke-dasharray&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;data-reveal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;"go"&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="nc"&gt;.draw-in&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;draw-in&lt;/span&gt; &lt;span class="m"&gt;0.16s&lt;/span&gt; &lt;span class="n"&gt;ease-out&lt;/span&gt; &lt;span class="n"&gt;calc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0.15s&lt;/span&gt; &lt;span class="err"&gt;+&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="err"&gt;*&lt;/span&gt; &lt;span class="m"&gt;45ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;both&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 comment above &lt;code&gt;stroke-dasharray: 1 2&lt;/code&gt; is the sort of thing you only write after seeing it: with &lt;code&gt;1 1&lt;/code&gt;, the round line cap of the waiting dash pokes out at the end of the path, and every tally shows a row of faint dots before it is drawn. The &lt;code&gt;--i&lt;/code&gt; on each stroke is its index in the whole allowance, so twenty-five strokes are drawn in counting order at 45ms apart rather than all at once, and &lt;code&gt;Reveal&lt;/code&gt; holds a meter below the fold until it is scrolled to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rules the code cannot hold
&lt;/h2&gt;

&lt;p&gt;"Max one per screen" is three of the nineteen rules (double rule, margin annotation, and effectively the help button). Nothing enforces them. A component cannot know what else is on the page, and a lint rule would need to understand routing and layouts.&lt;/p&gt;

&lt;p&gt;What the table does instead is make the limit cheap to check, because each motif has exactly one meaning, so counting them is counting a string. The homepage has three plates, seven catalogue numbers, two specimen tags and six field-mark bullets (&lt;code&gt;M0 5H9&lt;/code&gt;), and the pricing page has two pointer arrows (&lt;code&gt;M6 5H48&lt;/code&gt;) and no plates. Every one of those numbers came out of &lt;code&gt;curl&lt;/code&gt; and &lt;code&gt;grep&lt;/code&gt; on the live pages while writing this post, which is the whole argument for a vocabulary of fixed shapes: you can audit it from outside the app.&lt;/p&gt;

&lt;p&gt;Two things I found doing that. &lt;code&gt;Kicker&lt;/code&gt; is in this file and has three call sites, all of them in the campaigns list rather than above a marketing section heading, which matches the house rule that section names are a nav and page thing. And &lt;code&gt;choiceCardClass&lt;/code&gt;, over in the forms module, is exported with no consumers at all, which is what happens to a shared constant when the thing that was going to use it got written differently.&lt;/p&gt;

&lt;p&gt;The point of writing the rule column down is not governance. It is that a motif with one meaning can be used anywhere without thinking, and a motif with two meanings is just decoration with extra steps.&lt;/p&gt;

</description>
      <category>css</category>
      <category>react</category>
      <category>designsystems</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Eighteen useState calls in one help panel, and the key prop that throws them all away</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Thu, 22 Oct 2026 10:15:38 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/eighteen-usestate-calls-in-one-help-panel-and-the-key-prop-that-throws-them-all-away-oh8</link>
      <guid>https://dev.to/daniel_pertu/eighteen-usestate-calls-in-one-help-panel-and-the-key-prop-that-throws-them-all-away-oh8</guid>
      <description>&lt;p&gt;The help button at the bottom left of every signed-in page in &lt;a href="https://nakodo.app" rel="noopener noreferrer"&gt;Nakodo&lt;/a&gt; opens a panel that does three jobs in one surface: it walks the written manual, it offers an assistant when the manual did not have it, and it opens a conversation with a person when that did not either.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;src/components/support/panel.tsx&lt;/code&gt; is 818 lines, the fourth largest client component in the app (the results table, the proposal editor and the brief editor are bigger; its own code comment claiming second place is out of date). It holds eighteen &lt;code&gt;useState&lt;/code&gt; calls and two refs. I want to defend that, and then show the four pieces of it that are not obvious.&lt;/p&gt;

&lt;h2&gt;
  
  
  Nine steps, and a back stack that is not the browser's
&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;type&lt;/span&gt; &lt;span class="nx"&gt;Step&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;home&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;topics&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;answer&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ask&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;form&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;opened&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;solved&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;list&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;thread&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;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;go&lt;/span&gt; &lt;span class="o"&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="nx"&gt;Step&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="nf"&gt;setStack&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&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;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="nf"&gt;setError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;setStep&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;back&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;setError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;setStep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;home&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;setStack&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&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;s&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="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A panel with history in it is tempting to wire into the router, so that the back button works. Do not. The reader did not navigate anywhere: they are still on their campaign, with a panel over it. Pushing history entries means the back button out of the panel is also the back button out of the page they were working on, and the first time someone presses it twice they lose their place in the app to leave a help panel.&lt;/p&gt;

&lt;p&gt;So the stack is an array of steps in component state. The back arrow in the header renders only when &lt;code&gt;stack.length &amp;gt; 0&lt;/code&gt;, which is the same condition that decides whether the header shows the subtitle "Answered by a person, by email", because that line belongs to the panel's front door and not to a step inside it. Escape closes the whole panel, with &lt;code&gt;stopPropagation&lt;/code&gt; so the dialog under it (if the panel was opened from inside one) does not also close.&lt;/p&gt;

&lt;h2&gt;
  
  
  Eighteen states, grouped, and why they are not reducers or a machine
&lt;/h2&gt;

&lt;p&gt;They fall into five groups, in the order the panel uses them: where in the manual you are, the assistant's turns, the conversation form, the list of your conversations, and the thread you opened. Every group is small, none of them has a transition that depends on another group's internals, and the panel's steps are decided by the &lt;code&gt;step&lt;/code&gt; union rather than by combinations of booleans.&lt;/p&gt;

&lt;p&gt;A reducer would turn eighteen &lt;code&gt;useState&lt;/code&gt; into one object plus eighteen action types and a switch, which is more code for the same transitions. A state machine library would describe the nine steps precisely and then still need all of the data above as context. The honest reason this is a pile of hooks is that each piece is read in exactly one place and written in one or two, and the file's structure (manual, assistant, person, conversations, drawing it, each under its own banner comment) carries the organisation that a reducer would otherwise have to.&lt;/p&gt;

&lt;p&gt;One of those states is worth more than its line count: &lt;code&gt;message&lt;/code&gt;, the text someone has written to a person. It lives here rather than in the form step, so closing the panel mid-sentence and opening it again does not lose it. The widget keeps the panel mounted once it has ever been wanted, which is what makes that work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The chunk arrives on hover, and the panel is never server rendered
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SupportPanel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dynamic&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./panel&lt;/span&gt;&lt;span class="dl"&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="nx"&gt;m&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;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SupportPanel&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ssr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;want&lt;/span&gt; &lt;span class="o"&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;setWanted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;button&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="nx"&gt;onPointerEnter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;want&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;onFocus&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;want&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;onClick&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nf"&gt;want&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="nf"&gt;setOpen&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;o&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}}&lt;/span&gt; &lt;span class="nx"&gt;aria&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;expanded&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nobody is waiting on a help panel while a page paints, so it is a separate chunk and it is not server rendered. But a chunk that starts downloading on click gives you a button that does nothing for 200ms. &lt;code&gt;onPointerEnter&lt;/code&gt; and &lt;code&gt;onFocus&lt;/code&gt; set &lt;code&gt;wanted&lt;/code&gt;, which mounts the panel closed, so by the time the click lands the component is usually already there. Keyboard users get the same head start from &lt;code&gt;onFocus&lt;/code&gt;, and a touch user gets the pointer enter that precedes the tap.&lt;/p&gt;

&lt;p&gt;The panel renders &lt;code&gt;null&lt;/code&gt; while closed, so keeping it mounted costs one render and buys the half-written message.&lt;/p&gt;

&lt;h2&gt;
  
  
  A window event, caught by the thing that is always mounted
&lt;/h2&gt;

&lt;p&gt;Other parts of the app link into the panel: a campaign header can offer help about limits, a settings page can open the part of the manual about emails. That is a custom window event, and the listener is in the 89-line widget rather than in the panel:&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="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;asked&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;setRequest&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;last&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;n&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;last&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;n&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="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;of&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;event&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;CustomEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;SupportOpenRequest&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;detail&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="nf"&gt;setWanted&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="nf"&gt;setOpen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;addEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SUPPORT_OPEN_EVENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;asked&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeEventListener&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;SUPPORT_OPEN_EVENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;asked&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 panel is a lazy chunk that may not exist yet when a page asks for it, so the listener has to live in the component that is always mounted. That is the whole reason the widget and the panel are two files.&lt;/p&gt;

&lt;p&gt;Then the counter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;SupportPanel&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="err"&gt;...&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;n&lt;/code&gt; increments on every request, so the key changes, so React throws the old panel away and mounts a new one. This is the entire mechanism for "asking for the same part of the manual twice starts afresh rather than leaving you where you were". The alternative, an effect inside the panel that watches the request prop and calls eight setters to sync it, is the thing React's own docs call out as a smell, and it would have had to decide what to do about the half-written message, the assistant turns and the back stack one by one. A key is one line and gets all eighteen right.&lt;/p&gt;

&lt;p&gt;That is also why the initial state is computed by a pure function rather than patched in afterwards:&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;start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;startingPoint&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;step&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setStep&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Step&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;start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;step&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;stack&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setStack&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Step&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;start&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;startingPoint&lt;/code&gt; opens the panel on the assistant with the named section chosen and the line that page sent already in the transcript, seeds the stack with &lt;code&gt;["home"]&lt;/code&gt; so the back arrow leads somewhere instead of trapping the reader, and ignores a topic that does not belong to the named section, so an out-of-date deep link cannot file a conversation under the wrong heading. With no request it returns the front door.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one rule: the gate must never be a dead end
&lt;/h2&gt;

&lt;p&gt;A conversation with a person opens only after the assistant has had a go. That is deliberate and it is also the riskiest thing in the panel, because a gate with a broken gatekeeper is a wall.&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;exhausted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;turns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;SUPPORT_LIMITS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;assistantTurns&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 2&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;assistantFailed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;turns&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;step&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ask&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;leadWithPerson&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;exhausted&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;answered&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;assistantFailed&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A failed question counts as a go. If the assistant cannot be reached at all, the panel leads with the person instead of insisting on a step that cannot be completed. The three ways to reach a human are therefore: the assistant answered and said it did not get there, the assistant has used both of its turns, or the assistant could not be asked.&lt;/p&gt;

&lt;p&gt;The failure path in &lt;code&gt;ask()&lt;/code&gt; is the other half of that care:&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;result&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;askSupportAction&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;question&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;asked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;id&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="na"&gt;history&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;turns&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nf"&gt;setAsking&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;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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;setError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;setTranscript&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&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;t&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="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nf"&gt;setQuestion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;asked&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The question was appended to the transcript optimistically before the call, so a failure pops it off again and puts the text back in the box. Without the pop, the transcript that later travels with the conversation to a person would contain a question with no answer, and the reader would have to retype what they had just written.&lt;/p&gt;

&lt;h2&gt;
  
  
  Small things that make it feel like a panel and not a page
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;The scroller is &lt;code&gt;aria-live="polite"&lt;/code&gt;, and one effect pins it to the bottom on changes to &lt;code&gt;[step, transcript, asking, thread, loading]&lt;/code&gt;. Whatever just arrived is what you see.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;role="dialog"&lt;/code&gt;, &lt;code&gt;tabIndex={-1}&lt;/code&gt;, and focus taken inside a &lt;code&gt;requestAnimationFrame&lt;/code&gt; on open, with &lt;code&gt;cancelAnimationFrame&lt;/code&gt; in the cleanup so a panel closed in the same frame does not steal focus afterwards.&lt;/li&gt;
&lt;li&gt;On a phone it is &lt;code&gt;inset-0&lt;/code&gt; with a backdrop and the launcher hides itself (&lt;code&gt;max-sm:hidden&lt;/code&gt;); on a desktop it is a 23rem by 34rem card at &lt;code&gt;bottom-18 left-4&lt;/code&gt;, capped with &lt;code&gt;max-h-[calc(100svh-7rem)]&lt;/code&gt;. The header and footer pad with &lt;code&gt;env(safe-area-inset-top)&lt;/code&gt; and &lt;code&gt;env(safe-area-inset-bottom)&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Bottom left, because toasts live bottom right, and a help button underneath the thing telling you what just happened is no use.&lt;/li&gt;
&lt;li&gt;There are no illustrations in the panel, on purpose. It opens over whatever page you were on, that page may already have its one illustration, and the panel cannot know whether adding one would make it two.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The panel is behind sign-in, so there is nothing to click on from here, but the writing it serves is public: the same plain-answer style is what &lt;a href="https://nakodo.app/how-it-works" rel="noopener noreferrer"&gt;how it works&lt;/a&gt; and &lt;a href="https://nakodo.app/guides" rel="noopener noreferrer"&gt;the guides&lt;/a&gt; are written in, and the limits it explains are the ones on &lt;a href="https://nakodo.app/pricing" rel="noopener noreferrer"&gt;the pricing page&lt;/a&gt;. Eighteen &lt;code&gt;useState&lt;/code&gt; calls is not elegant. Losing someone's half-written message because a prop changed would be worse.&lt;/p&gt;

</description>
      <category>react</category>
      <category>webdev</category>
      <category>typescript</category>
      <category>a11y</category>
    </item>
    <item>
      <title>Four form components hold every form in our app, and writing this post found the one bug in them</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Thu, 22 Oct 2026 10:13:46 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/four-form-components-hold-every-form-in-our-app-and-writing-this-post-found-the-one-bug-in-them-5d2p</link>
      <guid>https://dev.to/daniel_pertu/four-form-components-hold-every-form-in-our-app-and-writing-this-post-found-the-one-bug-in-them-5d2p</guid>
      <description>&lt;p&gt;&lt;a href="https://nakodo.app" rel="noopener noreferrer"&gt;Nakodo&lt;/a&gt; has no form library. No React Hook Form, no Formik, no resolver. Forms are Server Actions with &lt;code&gt;useActionState&lt;/code&gt;, inputs are plain inputs, and everything the forms share lives in &lt;code&gt;src/components/forms.tsx&lt;/code&gt;, which is 162 lines and exports four components and two class name strings. Fifteen files import from it.&lt;/p&gt;

&lt;p&gt;That is the whole abstraction, and it is worth writing down because the interesting decisions in a form system are not validation. They are what happens after submit, what a hint is, and where the Save button goes.&lt;/p&gt;

&lt;h2&gt;
  
  
  FormResult: 19 banners, two ARIA roles, one of them deliberate
&lt;/h2&gt;

&lt;p&gt;Every form in the app ends with the same component, 19 times across 11 files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;FormResult&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;card&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Result&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;card&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;className&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;state&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="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;p&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"alert"&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;cn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text-sm text-danger&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;className&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;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;upgrade&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;planHref&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;limit_form&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;See plans&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&amp;lt;/&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;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;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;state&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="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;card&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;p&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;cn&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 gap-2 rounded-md border bg-card p-4 text-sm&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;CheckIcon&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"mt-0.5 size-4 shrink-0 text-cyano-700 dark:text-cyano-300"&lt;/span&gt; &lt;span class="na"&gt;aria-hidden&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"true"&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;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&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;p&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;p&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"status"&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;cn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text-sm text-muted-foreground&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;className&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;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&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;p&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;role="alert"&lt;/code&gt; for the failure and &lt;code&gt;role="status"&lt;/code&gt; for the success is not symmetry for its own sake. &lt;code&gt;alert&lt;/code&gt; maps to an assertive live region: a screen reader interrupts whatever it was reading, which is right for "we did not save your work". &lt;code&gt;status&lt;/code&gt; is polite: it waits for a pause, which is right for "saved". Getting those the other way round produces a form that shouts about success and whispers about failure.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;upgrade&lt;/code&gt; boolean is the other load-bearing part. A plan limit is not a validation error and not a server error; it is a product state with an answer. One &lt;code&gt;LimitError&lt;/code&gt; thrown anywhere in an action becomes &lt;code&gt;{ ok: false, error: e.message, upgrade: true }&lt;/code&gt; at the boundary, and the banner that renders it grows a link to the plans. No form has to know that a limit exists, and no second component renders limit messages differently from other errors.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;card&lt;/code&gt; exists for the two places where the confirmation is the entire outcome of the page rather than feedback beside fields: the sign-in and sign-up forms, where the result is "we sent a confirmation link to that address" and there is nothing else left on screen to look at.&lt;/p&gt;

&lt;h2&gt;
  
  
  Field: 43 call sites, and the bug
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Field&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;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="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;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;cn&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-y-1.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;className&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Label&lt;/span&gt; &lt;span class="na"&gt;htmlFor&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
        &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;optional&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;span&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"font-normal text-muted-foreground"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;(optional)&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="si"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Label&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;hint&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"text-[13px] text-muted-foreground"&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;hint&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;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;43 uses across 9 files, 18 of them passing a &lt;code&gt;hint&lt;/code&gt;. The label is wired to the control through &lt;code&gt;htmlFor&lt;/code&gt;, which is the part people remember. The hint is not wired to anything, which is the part people forget, and I am one of them: a &lt;code&gt;&amp;lt;p&amp;gt;&lt;/code&gt; after an input is read by a screen reader only if the user happens to keep walking the document, and the field's accessible description stays empty. Someone tabbing through the form hears "Views, edit text" and never hears "Use the median views of the creator's recent videos".&lt;/p&gt;

&lt;p&gt;I noticed because the sign-up form does not use this component. Its fields are hand-rolled to fit the password hint onto the label row, and in doing so they did it properly. You can check both live, without an account:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://nakodo.app/login?mode=signup" rel="noopener noreferrer"&gt;The sign-up form&lt;/a&gt; renders &lt;code&gt;&amp;lt;span id="password-hint" class="text-[13px] ..."&amp;gt;At least 8 characters&amp;lt;/span&amp;gt;&lt;/code&gt; and the input beside it carries &lt;code&gt;aria-describedby="password-hint"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://nakodo.app/tools/sponsorship-cost-calculator" rel="noopener noreferrer"&gt;The sponsorship cost calculator&lt;/a&gt; renders seven &lt;code&gt;text-[13px]&lt;/code&gt; hints through &lt;code&gt;Field&lt;/code&gt;, and the page contains zero occurrences of &lt;code&gt;aria-describedby&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix is to default the id (&lt;code&gt;${id}-hint&lt;/code&gt;), put it on the paragraph, and pass it down to the control, which the component cannot do today because it renders &lt;code&gt;children&lt;/code&gt; rather than owning the input. That last part is the actual design flaw: a field wrapper that takes an arbitrary child cannot add attributes to it, so the wiring has to be the caller's job, which means it will be forgotten. Either &lt;code&gt;Field&lt;/code&gt; takes the input's props and renders it, or the hint id has to be returned to the caller. I have left it as it is for now and written it down rather than quietly shipping a redesign in the middle of a post, but it is a real gap in an app whose public pages are otherwise checked for this sort of thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  ChoiceCards: the selected state never reaches React
&lt;/h2&gt;

&lt;p&gt;Four uses, and not one of them tracks which card looks selected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;choiceCardClass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;cursor-pointer rounded-lg border bg-card transition-colors hover:border-ink-400 has-checked:border-primary has-checked:bg-selected has-focus-visible:outline-2 has-focus-visible:outline-offset-2 has-focus-visible:outline-ring&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;The radio stays a real radio inside a real &lt;code&gt;&amp;lt;label&amp;gt;&lt;/code&gt;, so arrow key navigation, the form's own value, required validation and screen reader semantics all come free. Tailwind 4's &lt;code&gt;has-checked:&lt;/code&gt; and &lt;code&gt;has-focus-visible:&lt;/code&gt; variants compile to &lt;code&gt;:has(:checked)&lt;/code&gt; and &lt;code&gt;:has(:focus-visible)&lt;/code&gt; on the card, so the card paints itself from the input's state. In the &lt;code&gt;grid&lt;/code&gt; layout the radio is &lt;code&gt;sr-only&lt;/code&gt; and the whole card is the control; in the &lt;code&gt;list&lt;/code&gt; layout it stays visible with &lt;code&gt;accent-primary&lt;/code&gt;, because a narrow panel reads better with the dot showing.&lt;/p&gt;

&lt;p&gt;The one React subtlety is supporting controlled and uncontrolled use from one component:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;checked&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;value&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="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;o&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;defaultChecked&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;defaultValue&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="kc"&gt;undefined&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;defaultValue&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;o&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Passing &lt;code&gt;checked={false}&lt;/code&gt; instead of &lt;code&gt;undefined&lt;/code&gt; would make it a controlled input with no &lt;code&gt;onChange&lt;/code&gt; in the uncontrolled case, which React warns about and which freezes the radio. Passing &lt;code&gt;undefined&lt;/code&gt; leaves the attribute off entirely, so the same JSX is either controlled by a parent's state or left to the form's own &lt;code&gt;FormData&lt;/code&gt;. Two of the four call sites are controlled by a parent's state (the brief and proposal editors, which compare the choice against what is saved) and two are left to the form.&lt;/p&gt;

&lt;h2&gt;
  
  
  PanelForm: Save stays in view
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;form&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex min-h-0 flex-1 flex-col"&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;top&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;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;cn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;min-h-0 flex-1 overflow-y-auto px-4&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bodyClassName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;children&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="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;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"flex items-center justify-end gap-2 border-t px-4 py-3"&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;form&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Editors in the app open in a side sheet, and a long brief scrolls. The footer is not sticky, it is a flex sibling that is not allowed to shrink, which means no &lt;code&gt;position: sticky&lt;/code&gt;, no z-index, and no gap under the last field. The two &lt;code&gt;min-h-0&lt;/code&gt; are the trick: a flex child defaults to &lt;code&gt;min-height: auto&lt;/code&gt;, so the scrolling body would otherwise refuse to be smaller than its content and push the footer off the bottom of the sheet. The exported &lt;code&gt;panelContentClass&lt;/code&gt; sets the sheet's width in six places, including the &lt;code&gt;data-[side=right]:&lt;/code&gt; variants Radix needs, so one string decides how wide every editor panel is.&lt;/p&gt;

&lt;p&gt;While counting call sites for this post I also found that &lt;code&gt;choiceCardClass&lt;/code&gt; is exported and used only inside its own module. &lt;code&gt;eslint-config-next&lt;/code&gt; does not flag unused exports, so a public-looking name with no consumers can sit there indefinitely. It is two lines of cleanup, but it is also a good argument for counting your own call sites occasionally.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not a form library
&lt;/h2&gt;

&lt;p&gt;Because none of the four components above is about forms. They are about this product's answers to four questions: what does a failed save look like, what is a hint, what does a choice look like, and where does Save live. A library would have given me validation I do not need (the actions validate with Zod, server side, where it counts) and would not have given me a single opinion about any of those four. 162 lines, four components, one real bug found by writing them up in public.&lt;/p&gt;

</description>
      <category>react</category>
      <category>webdev</category>
      <category>a11y</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Our welcome card prints a five digit permit number that cannot tell you how many users we have</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Thu, 22 Oct 2026 10:10:35 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/our-welcome-card-prints-a-five-digit-permit-number-that-cannot-tell-you-how-many-users-we-have-5bfo</link>
      <guid>https://dev.to/daniel_pertu/our-welcome-card-prints-a-five-digit-permit-number-that-cannot-tell-you-how-many-users-we-have-5bfo</guid>
      <description>&lt;p&gt;&lt;a href="https://nakodo.app" rel="noopener noreferrer"&gt;Nakodo&lt;/a&gt; is dressed as a field guide. Plans are called Notebook, Field kit and Expedition on &lt;a href="https://nakodo.app/pricing" rel="noopener noreferrer"&gt;the pricing page&lt;/a&gt;, creators are catalogued with numbers, and contacted ones get a rubber stamp. So when someone finishes setting up their first campaign, what they get is a permit: a framed plate with their name, their plan, the date, and a stamp that lands on it a beat later.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Field permit No. 04182
Holder   you@example.com
Plan     Field kit, Pro
Issued   22 Oct 2026

Permit issued. Nakodo is out surveying now, and what it finds shows up below.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is 68 lines of component and it is the kind of thing that looks like pure decoration until you try to ship it. Three things in it are not decoration at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  A sequential number would publish our user count
&lt;/h2&gt;

&lt;p&gt;The obvious implementation of "No. 04182" is a counter. Do that and the first screen a new customer sees tells them exactly how many people signed up before them, which for an app in its first months is the one number you would rather not hand out. Worse, two customers comparing notes a week apart can measure the growth rate.&lt;/p&gt;

&lt;p&gt;So the number is derived, not counted:&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;// A stable five-digit number from the user ID, so it doesn't reveal how many users there are.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;permitNumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/-/g&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="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;8&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="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;100000&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&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;The account's UUID is random, the first eight hex digits are 32 bits of it, and the modulo flattens that into 00000 to 99999. The same account always gets the same permit, with no column to store and no sequence to keep.&lt;/p&gt;

&lt;p&gt;It collides, and that is fine. With a space of 100,000 and random inputs, the chance that two accounts share a number passes a half at a few hundred accounts, by the usual birthday arithmetic. Nothing looks this number up. Nothing joins on it. It appears once, in one card, for fifteen minutes. The thing to be deliberate about is not the collision rate; it is making sure nobody later decides that a number printed to users must obviously be unique and adds a column for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two gates, and they answer different questions
&lt;/h2&gt;

&lt;p&gt;The card is rendered from the signed-in layout:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;onboardedAt&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;justOnboarded&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onboardedAt&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="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;FieldPermit&lt;/span&gt; &lt;span class="na"&gt;permit&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;no&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;permitNumber&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="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fullName&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;plan&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;plan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;kicker&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;plan&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="na"&gt;issued&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onboardedAt&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="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Suspense&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;)}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;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;justOnboarded&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;at&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;at&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and then the client component decides again:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ENDINGS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;launched&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Permit issued. Nakodo is out surveying now, and what it finds shows up below.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;claimed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Permit issued. Your campaign and its results are below.&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;ending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ENDINGS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="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;welcome&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="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;ending&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The server gate answers "is this account newly set up", from a column, so no amount of URL fiddling brings the card back next week. The client gate answers "is this the page view that setup redirected to", and it does it through a lookup rather than a boolean, because the sentence depends on how setup ended: the first campaign was launched, or a campaign Nakodo had already built for the customer's website was taken over while running.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;Record&lt;/code&gt; lookup is also the validation. &lt;code&gt;?welcome=&lt;/code&gt;, &lt;code&gt;?welcome=yes&lt;/code&gt; and a copied link with a stale value all read as "no ending", and the component returns null rather than rendering a card with a blank sentence in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The boolean that puts the parameter there exists once
&lt;/h2&gt;

&lt;p&gt;Setup can be double submitted like anything else, so the fact that onboarding finished is written with a conditional update:&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;row&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;db&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;profiles&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;onboardedAt&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;profiles&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;userId&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;isNull&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;profiles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;onboardedAt&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;returning&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;profiles&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;row&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;audit&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;onboarding.completed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;entityType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;entityId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;isNull&lt;/code&gt; in the &lt;code&gt;WHERE&lt;/code&gt; makes it idempotent, &lt;code&gt;returning&lt;/code&gt; turns it into a compare and set, and the boolean that falls out is what the action appends to its redirect:&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;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/campaigns/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;campaignId&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;welcome&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;?welcome=launched&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One request wins, one audit row is written, and only the winner gets the ceremony. The second click lands on the same campaign with no parameter and therefore no card, which is the correct outcome: you did not finish onboarding twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dismissing it edits the URL rather than any state
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dismiss&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&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;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;welcome&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&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="nx"&gt;size&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;path&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="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="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;scroll&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no &lt;code&gt;dismissed&lt;/code&gt; state. The URL already is the state, so the close button removes the one parameter that makes the card render, copies the rest (the results table keeps its filters and sort in the query string, and losing them on a welcome card dismissal would be a real bug), uses &lt;code&gt;replace&lt;/code&gt; so the back button does not bring the card back, and passes &lt;code&gt;scroll: false&lt;/code&gt; so the page does not jump to the top as the card leaves.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;&amp;lt;Suspense&amp;gt;&lt;/code&gt; in the layout is not decoration either. &lt;code&gt;useSearchParams&lt;/code&gt; in a client component makes everything above it dynamic unless it sits behind a boundary, and this layout wraps every signed-in page in the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stamp is CSS, and it waits for the plate
&lt;/h2&gt;

&lt;p&gt;The reward reads as paperwork being done in order: the plate fades in and slides down from above (&lt;code&gt;motion-safe:animate-in fade-in-0 slide-in-from-top-2&lt;/code&gt;, 500ms), then the stamp hits it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@keyframes&lt;/span&gt; &lt;span class="n"&gt;stamp-land&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nt"&gt;from&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1.4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;rotate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;-6deg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="err"&gt;55&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;opacity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0.94&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;rotate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1deg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="err"&gt;75&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1.02&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;rotate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0deg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nt"&gt;to&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;transform&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;rotate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0deg&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;@media&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefers-reduced-motion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;no-preference&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nc"&gt;.stamp-land&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;animation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;stamp-land&lt;/span&gt; &lt;span class="m"&gt;0.45s&lt;/span&gt; &lt;span class="n"&gt;cubic-bezier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;var&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;--stamp-delay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;0s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nb"&gt;both&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 overshoot is the whole effect: 1.4 down past the resting size to 0.94, back up to 1.02, then 1. Real rubber stamps squash and rebound, and a stamp that eases politely into place looks like a sticker. The card passes &lt;code&gt;[--stamp-delay:0.7s]&lt;/code&gt; as a Tailwind arbitrary property, so the delay lives at the call site while the animation stays in one keyframe block. The whole rule sits inside &lt;code&gt;prefers-reduced-motion: no-preference&lt;/code&gt;, so a reader who has asked for less motion gets the stamp already landed, with no animation to opt out of.&lt;/p&gt;

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

&lt;p&gt;Because the end of setup is the one moment in a tool like this where nothing has happened yet. The campaign is launched, the first results are minutes away, and the screen would otherwise be an empty table. A card that says "permit issued" fills that gap with something that belongs to the product rather than a spinner, and it costs one derived number, two gates, one conditional update and twelve lines of CSS.&lt;/p&gt;

&lt;p&gt;You can see the vocabulary it is built from without an account: the plan kickers on &lt;a href="https://nakodo.app/pricing" rel="noopener noreferrer"&gt;the pricing page&lt;/a&gt;, and the surveying metaphor it refers to on &lt;a href="https://nakodo.app/how-it-works" rel="noopener noreferrer"&gt;how it works&lt;/a&gt;. The permit itself only exists for fifteen minutes, once, which is the point.&lt;/p&gt;

</description>
      <category>react</category>
      <category>nextjs</category>
      <category>webdev</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Our terms notice has no Agree button, because carrying on is the acceptance</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Thu, 22 Oct 2026 10:08:38 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/our-terms-notice-has-no-agree-button-because-carrying-on-is-the-acceptance-bgh</link>
      <guid>https://dev.to/daniel_pertu/our-terms-notice-has-no-agree-button-because-carrying-on-is-the-acceptance-bgh</guid>
      <description>&lt;p&gt;Terms change. The question nobody enjoys answering is what should happen to the accounts that are already signed in.&lt;/p&gt;

&lt;p&gt;The common answers are all a bit wrong. A blocking modal with an Agree button interrupts someone in the middle of their work to collect a click that proves nothing, and if they do not click it you have to decide whether to lock them out of a product they are paying for. An email nobody opens is not notice. Silence is not notice either.&lt;/p&gt;

&lt;p&gt;What &lt;a href="https://nakodo.app" rel="noopener noreferrer"&gt;Nakodo&lt;/a&gt; does is show a small notice under the header, say what changed and when, link the documents, and record the acceptance as the notice appears. There is nothing to click, because the thing being recorded is not a click. It is the fact that the person read a notice saying "by continuing to use Nakodo, you accept them" and then continued to use Nakodo.&lt;/p&gt;

&lt;p&gt;This post is about the plumbing that makes that honest rather than hand-wavy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two constants, and three places the version is written
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;src/lib/legal.ts&lt;/code&gt; is 43 lines and holds every fact about who runs the app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;LEGAL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;company&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PUBLISHER&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="na"&gt;registeredIn&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PUBLISHER&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;registeredIn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&gt;// A company's website must show its registered number, registered office&lt;/span&gt;
  &lt;span class="c1"&gt;// and the part of the UK it's registered in (gov.uk, "Running a limited&lt;/span&gt;
  &lt;span class="c1"&gt;// company: signs, stationery and promotional material"). The terms and the&lt;/span&gt;
  &lt;span class="c1"&gt;// privacy notice show them.&lt;/span&gt;
  &lt;span class="na"&gt;companyNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PUBLISHER&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;companyNumber&lt;/span&gt; &lt;span class="k"&gt;as&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="c1"&gt;// The registered office, also the address for legal notices. Until it's&lt;/span&gt;
  &lt;span class="c1"&gt;// set, the pages leave it out.&lt;/span&gt;
  &lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="k"&gt;as&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="na"&gt;tradingName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;BRAND&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="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;hello@nakodo.app&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;law&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;England and Wales&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;regulator&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;the Information Commissioner's Office (ICO)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&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://ico.org.uk/make-a-complaint/&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&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;TERMS_VERSION&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2026-10-17&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;UPDATED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;17 October 2026&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;LEGAL_DOCS&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;href&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/terms&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;terms of service&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/privacy&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;privacy notice&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those &lt;code&gt;string | null&lt;/code&gt; types are load-bearing. The registered office is not set yet, so the terms page renders the office sentence only when there is one (&lt;code&gt;LEGAL.address &amp;amp;&amp;amp; ...&lt;/code&gt;) and simply says less, rather than printing an empty sentence or the word "null". The company number is set, which is why &lt;a href="https://nakodo.app/terms" rel="noopener noreferrer"&gt;the terms page&lt;/a&gt; opens with "Nakodo is run by CogniPrep Ltd, a company registered in England and Wales (company number 17407266)" and &lt;a href="https://nakodo.app/privacy" rel="noopener noreferrer"&gt;the privacy notice&lt;/a&gt; says the same thing in its own voice. One &lt;code&gt;COMPANY&lt;/code&gt; template literal builds that clause; nothing in the app spells the company out by hand.&lt;/p&gt;

&lt;p&gt;At sign-up the accepted version goes to three places, on purpose:&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;acceptedAt&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;Date&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;accepted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;termsVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TERMS_VERSION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;termsAcceptedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;acceptedAt&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signUp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;parsed&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="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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;confirmUrl&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="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;terms_version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;...,&lt;/span&gt; &lt;span class="na"&gt;terms_accepted_at&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;then a &lt;code&gt;profiles&lt;/code&gt; row with the same two values, then an audit row (&lt;code&gt;user.signed_up&lt;/code&gt;) carrying them as metadata. The auth user's metadata is the copy that survives anything we do to our own tables, the profile is the copy a page can read in one query, and the audit log is the copy with a timestamp we never update. Three copies of two values is not duplication worth deleting; it is the difference between believing your own records and hoping.&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole decision is one strict inequality
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;termsVersion&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;TERMS_VERSION&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;TermsNotice&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the entire gate, in &lt;code&gt;src/app/(app)/layout.tsx&lt;/code&gt;, which already loads the profile for the user menu, so the notice costs no extra query.&lt;/p&gt;

&lt;p&gt;The optional chaining is the interesting half. An account created before we kept this column has &lt;code&gt;null&lt;/code&gt;, so it sees the notice once, which is the correct outcome for someone who agreed to an older document. And if the profile itself were missing, &lt;code&gt;undefined !== "2026-10-17"&lt;/code&gt; is also true, so the notice shows. Both unknown states fail towards telling the user something rather than towards silence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The acceptance is written on arrival, not on interaction
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;TermsNotice&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;open&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setOpen&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;startTransition&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;acceptTermsAction&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[]);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;open&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An effect that fires a Server Action and ignores the result looks like something a reviewer should object to. It is deliberate. The notice has no success state to render and nothing to wait for, so &lt;code&gt;startTransition&lt;/code&gt; keeps it out of the render path entirely: the write happens, the component does not re-render, and the panel is interactive the whole time.&lt;/p&gt;

&lt;p&gt;The cross in the corner is &lt;code&gt;onClick={() =&amp;gt; setOpen(false)}&lt;/code&gt; and nothing else. It hides the notice for this page view. It does not accept, refuse, or postpone anything, because by the time it is clickable the acceptance has already been recorded. That is also why the notice does not come back tomorrow, and why there is no "remind me later" to reason about.&lt;/p&gt;

&lt;p&gt;The sentence it shows is built from &lt;code&gt;LEGAL_DOCS&lt;/code&gt;, so adding a third document means adding one array entry rather than editing prose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;LEGAL_DOCS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Fragment&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&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;i&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;LEGAL_DOCS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; and &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;, &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;lt;&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"_blank"&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;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Link&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Fragment&lt;/span&gt;&lt;span class="p"&gt;&amp;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; &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;changed&lt;/span&gt; &lt;span class="nx"&gt;on&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;UPDATED&lt;/span&gt;&lt;span class="p"&gt;}.&lt;/span&gt; &lt;span class="nx"&gt;By&lt;/span&gt; &lt;span class="nx"&gt;continuing&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="nx"&gt;use&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;BRAND&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;you&lt;/span&gt; &lt;span class="nx"&gt;accept&lt;/span&gt; &lt;span class="nx"&gt;them&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The UPDATE is a compare and set, and the audit row depends on it
&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;accepted&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;db&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;profiles&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;termsVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TERMS_VERSION&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;termsAcceptedAt&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;and&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;profiles&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;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="nx"&gt;sql&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;profiles&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;termsVersion&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; is distinct from &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;TERMS_VERSION&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="nf"&gt;returning&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;profiles&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;accepted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;audit&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;terms.accepted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;userId&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="na"&gt;entityType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;entityId&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="na"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;termsVersion&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;TERMS_VERSION&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 carry this.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;is distinct from&lt;/code&gt; rather than &lt;code&gt;&amp;lt;&amp;gt;&lt;/code&gt;: the column is nullable, and &lt;code&gt;null &amp;lt;&amp;gt; '2026-10-17'&lt;/code&gt; is &lt;code&gt;null&lt;/code&gt;, which a &lt;code&gt;WHERE&lt;/code&gt; clause treats as false. With &lt;code&gt;&amp;lt;&amp;gt;&lt;/code&gt; the one group of accounts that most needs recording, the ones who accepted an older document and have nothing stored, would silently never be updated. &lt;code&gt;is distinct from&lt;/code&gt; is the null-safe comparison, and it is one of the few places where SQL's three-valued logic quietly changes behaviour rather than throwing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;returning&lt;/code&gt; turns the UPDATE into a compare and set. React in development mounts effects twice; a double-submitted action, a second tab, or a fast reload can all call this more than once. The second call matches zero rows, &lt;code&gt;accepted&lt;/code&gt; is &lt;code&gt;undefined&lt;/code&gt;, and no audit row is written. The log therefore holds exactly one &lt;code&gt;terms.accepted&lt;/code&gt; row per account per version, which is the property that makes it worth keeping at all. Writing the audit row first and the update second would have produced a log that counts tabs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it deliberately does not do
&lt;/h2&gt;

&lt;p&gt;It does not prove anyone read anything. Nothing can. It does not block the app, because the people most likely to be mid-task are the ones with conversations running, and locking them out to collect a click would cost them real work for no legal gain. And it is separate from marketing consent, which lives on a different table keyed by email address, because "you accepted the current terms" and "you asked for product news" are not the same fact and should never be stored as one.&lt;/p&gt;

&lt;p&gt;The version string is also a date, which means two constants have to move together when the documents change. That pairing is checked by a human and nothing else, so it is the honest weak point of the whole design: the gate is exact, the prose beside it is not.&lt;/p&gt;

&lt;p&gt;You can see the output of all of this without an account. &lt;a href="https://nakodo.app/terms" rel="noopener noreferrer"&gt;The terms&lt;/a&gt; and &lt;a href="https://nakodo.app/privacy" rel="noopener noreferrer"&gt;the privacy notice&lt;/a&gt; both print "Last updated 17 October 2026" and the company's registered details, from the same constants the notice reads. Change them and the notice fires for every signed-in account on the next page view, with no migration and no backfill.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>nextjs</category>
      <category>react</category>
      <category>typescript</category>
    </item>
    <item>
      <title>A feature shipped as 21 files of prose, and the word "five" had to survive in five other sentences</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Thu, 22 Oct 2026 09:51:28 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/a-feature-shipped-as-21-files-of-prose-and-the-word-five-had-to-survive-in-five-other-sentences-lec</link>
      <guid>https://dev.to/daniel_pertu/a-feature-shipped-as-21-files-of-prose-and-the-word-five-had-to-survive-in-five-other-sentences-lec</guid>
      <description>&lt;p&gt;Our app ships with built-in question packs so a venue can run a quiz night without writing a question first. We increased how many, from five to thirteen. The interesting part is not the feature.&lt;/p&gt;

&lt;p&gt;The code was dull in the way additive changes are dull: the seed became a directory of one module per pack with a shared types file and an index that exports the list, and the seed script itself shrank from 504 lines to 105 now that it contains logic rather than data. A test asserts every pack has enough questions for a full night, that no two packs ask the same question, and that nothing a player sees contains an em dash. That is the whole engineering story.&lt;/p&gt;

&lt;p&gt;Then there was a second commit, with no logic in it at all: &lt;strong&gt;21 files, every one of them prose&lt;/strong&gt;, titled "Update site copy from five to thirteen question packs".&lt;/p&gt;

&lt;h2&gt;
  
  
  Where a number actually lives
&lt;/h2&gt;

&lt;p&gt;Here is the diff by file, and it is a decent map of how a count spreads through a marketing site:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="p"&gt;app/features/question-packs/page.tsx         37 +   5 -
lib/content/registry.ts                      21 +  21 -
app/features/page.tsx                         5 +   5 -
app/compare/ahaslides-alternative/page.tsx    3 +   3 -
app/compare/speedquizzing-alternative/...     3 +   3 -
app/compare/best-pub-quiz-apps/page.tsx       1 +   1 -
app/compare/kahoot-for-pub-quiz/page.tsx      1 +   1 -
app/compare/sporcle-live-alternative/...      1 +   1 -
app/faq/page.tsx                              2 +   2 -
app/pricing/page.tsx                          2 +   2 -
app/quiz-questions/page.tsx                   2 +   2 -
app/quiz-questions/{christmas,halloween,hard,true-or-false,food-and-drink}  1 + 1 - each
app/solutions/pubs/page.tsx                   2 +   2 -
app/solutions/charity-fundraisers/page.tsx    1 +   1 -
components/FeaturesGrid.tsx                   1 +   1 -
docs/seo-plan.md                              2 +   2 -
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five of those are competitor comparison pages, where the count is part of an argument rather than a description, so leaving them stale would have left us arguing a weaker case than the product supports. One is a planning document, which nobody reads but which future-me will believe. One is a component, where the number sits in a feature grid.&lt;/p&gt;

&lt;p&gt;The registry line is the one worth expanding. Our content pages are built from a registry of nodes carrying the title, the heading, the meta description and a &lt;code&gt;related&lt;/code&gt; list for each path, so a number that appears in a page's &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;, its search snippet and its &lt;code&gt;&amp;lt;h1&amp;gt;&lt;/code&gt; is edited there rather than in the page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-        heading: 'Five question packs, included on every plan',
&lt;/span&gt;&lt;span class="gi"&gt;+        heading: 'Thirteen question packs, included on every plan',
&lt;/span&gt;         description:
&lt;span class="gd"&gt;-            'General Knowledge, Pop Culture, Music &amp;amp; Audio, Sports and Food &amp;amp; Drink come with every plan. How to spread them across a season and build a mixed night.',
&lt;/span&gt;&lt;span class="gi"&gt;+            'Thirteen packs and over 800 questions on every plan, from General Knowledge to a hard pack and seasonal specials. How to build a mixed night from them.',
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That old description had been a list of all five names, which only works while there are five of them. Thirteen names do not fit in a meta description, so the description had to change shape and not just change a word: the count replaced the enumeration. That is the kind of edit a find-and-replace cannot do, and the reason this commit is 21 files of hand editing rather than one &lt;code&gt;sed&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Thirteen live URLs now contain the word "thirteen". You can see the top of the chain on &lt;a href="https://pub-trivia.app/features/question-packs" rel="noopener noreferrer"&gt;the question packs page&lt;/a&gt;, whose heading and search snippet both come from that registry node.&lt;/p&gt;

&lt;h2&gt;
  
  
  The find-and-replace that would have been a disaster
&lt;/h2&gt;

&lt;p&gt;The first thing anybody wants to do here is replace "five" with "thirteen". Our copy still contains "five" in at least five other places, all of them unrelated facts about different parts of the product:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;lib/pricing/plans.ts      /** Ultimate: up to five host accounts, £100/month. */
registry.ts (plans)       'Ultimate adds five host accounts on one subscription and five sessions at once.'
registry.ts (builder)     'Pro includes five; Ultimate unlimited.'
registry.ts (timer)       'then five to fifteen seconds on the clock'
quiz-builder/page.tsx     'A 500-point closer really is worth five times a 100-point opener'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A global replace turns a five seat plan into a thirteen seat plan, a five second question timer into a thirteen second one, and a sentence about relative scoring into arithmetic that is simply wrong. Three of those are claims a customer could hold us to, and one of them is a number the billing code enforces, so the copy and the code would disagree about what a plan is.&lt;/p&gt;

&lt;p&gt;This is not an argument for being careful with &lt;code&gt;sed&lt;/code&gt;. It is the thing I actually took away: &lt;strong&gt;a number in prose carries no type and no name&lt;/strong&gt;. &lt;code&gt;PRO_PRICE_PENCE&lt;/code&gt; can only ever mean one thing, and the compiler knows where it is used. The string "five" in a sentence means whatever the sentence means, and the only tool that can tell two of them apart is a person reading both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why we spell it out, and why that defeats the test
&lt;/h2&gt;

&lt;p&gt;Our house style spells small numbers as words in prose, which is right for reading and awkward for everything else. &lt;code&gt;grep -r 13&lt;/code&gt; finds nothing. The count appears in three grammatical shapes in the same release: as a heading noun phrase ("Thirteen question packs, included on every plan"), as a bare count inside a sentence ("a venue running one night a week gets months out of the thirteen before anything repeats"), and as a determiner in a call to action ("All thirteen packs are on the free session too").&lt;/p&gt;

&lt;p&gt;The test that would catch the drift is easy to describe and annoying to write:&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="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;says in the copy how many packs actually ship&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;pageText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/features/question-packs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toContain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;numberToWord&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seedPacks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pack modules are pure data with no database or network import, exactly like our price module, so &lt;code&gt;seedPacks.length&lt;/code&gt; is readable from a test or even from a page. We do exactly that for money: prices are imported, formatted and rendered, never typed into a component, and there is a test that fails if any price surface describes our own price as an estimate.&lt;/p&gt;

&lt;p&gt;We did not do it here, for two reasons that I think hold up.&lt;/p&gt;

&lt;p&gt;First, a price is a token. It sits alone in a component and reads correctly as a numeral in every language of copy around it. A count is a word inside a sentence, with grammar on both sides of it, and interpolating one turns readable prose into a template: "13 packs are included" is not what a person would write, and rendering it from the data would mean writing every sentence around it to survive substitution.&lt;/p&gt;

&lt;p&gt;Second, counting an array means importing it. The pack modules are a couple of hundred kilobytes of question data on disk whose only purpose is to be inserted into a database once per account. Pulling all of it into the render path of a marketing page to learn the length of a list is a bad trade for a number that changes once a quarter.&lt;/p&gt;

&lt;p&gt;So the duplication is accepted, and the mitigation is in how the claims are written.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claims that stay true in one direction
&lt;/h2&gt;

&lt;p&gt;Look again at the new description: "Thirteen packs and &lt;strong&gt;over 800&lt;/strong&gt; questions". The seed contains comfortably more than 800. The number was rounded down, and down is the only safe direction: the sentence survives a question being pulled for being wrong, or a whole pack being rewritten, and it only becomes false if the product shrinks a lot. "Over 800" is a claim with slack in it; an exact total is a claim that goes wrong the first time somebody fixes an answer by deleting the question.&lt;/p&gt;

&lt;p&gt;That is the cheapest available form of this discipline. Where a count must be exact, like the number of packs, it is a word in thirteen pages and a human's job. Where it can be approximate, it is written as a bound rather than a figure, and bounds do not drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  The copy edit was also a crawl signal
&lt;/h2&gt;

&lt;p&gt;One more coupling, which caught me out pleasantly. Each registry node carries an &lt;code&gt;updated&lt;/code&gt; date, and our &lt;code&gt;sitemap.xml&lt;/code&gt; reads &lt;code&gt;lastModified&lt;/code&gt; from it rather than from the build clock, so the date in the sitemap is the date the page's content actually changed. That makes this copy commit eighteen date edits as well as a prose edit, and you can count the result in the live file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://pub-trivia.app/sitemap.xml | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'2026-10-15'&lt;/span&gt;
17
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seventeen URLs claiming the same &lt;code&gt;lastmod&lt;/code&gt;, which is honest, because they were genuinely all edited in the same sitting for the same reason. The only pages falling back to the build date are the three legal documents, which have no registry node.&lt;/p&gt;

&lt;p&gt;A copy change that moves a crawl signal is a copy change worth doing in one commit rather than drizzling across a week, which is an argument for the shape of this commit that I did not have when I started writing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check it yourself
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://pub-trivia.app/features/question-packs" rel="noopener noreferrer"&gt;pub-trivia.app/features/question-packs&lt;/a&gt;: the heading and the meta description both come from the registry node above, and the body explains how to build a night from the packs.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;curl -s https://pub-trivia.app/features/question-packs | grep -o '&amp;lt;title&amp;gt;[^&amp;lt;]*'&lt;/code&gt; to see the registry's title rendered.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pub-trivia.app/faq" rel="noopener noreferrer"&gt;pub-trivia.app/faq&lt;/a&gt; and &lt;a href="https://pub-trivia.app/pricing" rel="noopener noreferrer"&gt;pub-trivia.app/pricing&lt;/a&gt; both repeat the count in their own words, which is the duplication this whole post is about, visible in two tabs.&lt;/li&gt;
&lt;li&gt;The free tier at &lt;a href="https://pub-trivia.app/signup" rel="noopener noreferrer"&gt;pub-trivia.app/signup&lt;/a&gt; needs no card, and every built-in pack is on it, which is the claim all thirteen of those pages are making.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>seo</category>
      <category>typescript</category>
    </item>
    <item>
      <title>I wrote that the fix was not a deletion. Eight days later it was, and the old URL now answers 200</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Thu, 22 Oct 2026 09:49:02 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/i-wrote-that-the-fix-was-not-a-deletion-eight-days-later-it-was-and-the-old-url-now-answers-200-5dp9</link>
      <guid>https://dev.to/daniel_pertu/i-wrote-that-the-fix-was-not-a-deletion-eight-days-later-it-was-and-the-old-url-now-answers-200-5dp9</guid>
      <description>&lt;p&gt;Two weeks ago I wrote about our Stripe webhook answering at two URLs, &lt;code&gt;/webhook/stripe&lt;/code&gt; and &lt;code&gt;/api/webhook/stripe&lt;/code&gt;, with the legacy one kept alive as a re-export of the real handler. One of that post's section headings was "The fix is not a deletion", and the reasoning was that the only thing pointing at a webhook URL is a setting in a vendor's dashboard, which you cannot grep, so you keep the old path alive until you are certain nothing is using it.&lt;/p&gt;

&lt;p&gt;Eight days later it was a deletion. The post opened with two curls the reader could compare:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://pub-trivia.app/webhook/stripe     &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://pub-trivia.app/api/webhook/stripe &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is what those two print today:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Redirecting...
{"error":"Missing stripe-signature header"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second is the live webhook refusing an unsigned request, exactly as before. The first is more interesting than a 404, and it is the part of a URL removal I had not thought about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Six edits in four files
&lt;/h2&gt;

&lt;p&gt;The route handler was 25 lines, a re-export with its own segment config. Deleting the file was the small part. The path was also spelled out in five other places:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;API_ROUTES&lt;/code&gt;&lt;/strong&gt;, the list the proxy uses to decide what it must not touch: &lt;code&gt;['/api', '/webhook']&lt;/code&gt; became &lt;code&gt;['/api']&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The proxy matcher's negative lookahead&lt;/strong&gt;, which is one long literal regex: &lt;code&gt;...|webhook|api|monitoring).*)&lt;/code&gt; lost one alternative.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A second matcher entry&lt;/strong&gt;, &lt;code&gt;'/webhook/:path*'&lt;/code&gt;, which existed only so maintenance mode could answer those paths with a 503. A matcher has to be a literal, so it cannot follow a runtime flag, which is why the paths it excludes are also listed explicitly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;CRAWLER_DISALLOW&lt;/code&gt;&lt;/strong&gt;, the prefix list our &lt;code&gt;robots.txt&lt;/code&gt; is generated from.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A test&lt;/strong&gt;, which looped over four paths asserting each answered 503 while maintenance was on, and now loops over three.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Four files, six edits, for one path. None of them are hard, and that is the point: they are the sort of edit that a &lt;code&gt;grep&lt;/code&gt; for the string finds in a second and that a careful reviewer would not question, which makes the forgettable one the one nobody notices is missing.&lt;/p&gt;

&lt;p&gt;The one with a visible consequence is the robots list, because &lt;code&gt;robots.txt&lt;/code&gt; is a generated public document:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://pub-trivia.app/robots.txt | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; Disallow
7
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It was eight. If you have an old copy of our &lt;code&gt;robots.txt&lt;/code&gt; in a tab from a fortnight ago, &lt;code&gt;Disallow: /webhook&lt;/code&gt; is in it. Removing the route removed the only reason to ask crawlers to stay out of it, and the file shrank by a line. That is a small thing, except that the inverse is the common bug: a path is removed from the app, left in the crawler policy, and three years later nobody knows whether that line protects something or not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deleted URL does not 404, it redirects
&lt;/h2&gt;

&lt;p&gt;Removing &lt;code&gt;/webhook&lt;/code&gt; from the proxy's API list did not make the path disappear. It moved it from one namespace into another.&lt;/p&gt;

&lt;p&gt;Paths under &lt;code&gt;/api&lt;/code&gt; are handed straight through by the proxy: no rate limiter, no session refresh, no auth gate, because they do their own authentication. Everything else goes to the gate, and the gate works from a prefix allowlist of public routes. It cannot know the route table, so it cannot tell the difference between a path that was never a route and a path whose route file I deleted eight days ago. Both are "not public", so both get a redirect to the login form:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code} %{redirect_url}\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    https://pub-trivia.app/webhook/stripe &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt;
307 https://pub-trivia.app/login?next&lt;span class="o"&gt;=&lt;/span&gt;%2Fwebhook%2Fstripe
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And because the login page is a real page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code} %{num_redirects}\n'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    https://pub-trivia.app/webhook/stripe &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{}'&lt;/span&gt;
200 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A POST to a URL that no longer exists, followed one redirect, now answers &lt;strong&gt;200 OK&lt;/strong&gt; with a sign-in form in the body. For a human that is a mild annoyance. For a webhook sender it is the worst available answer, because any client that follows redirects and checks only the status code will mark that delivery as accepted and never retry it. The payload went into an HTML page and nothing read it.&lt;/p&gt;

&lt;p&gt;Stripe is not such a client. It treats a 3xx on a webhook endpoint as a failed delivery and keeps retrying, so a misconfigured endpoint shows up as failures in its dashboard rather than as silence. That is the only reason this removal is safe rather than a quiet data loss, and it is luck of a kind, in that it is a property of somebody else's client rather than of our code. If you are deleting a webhook path, the order that makes it safe is unglamorous: point the vendor's dashboard at the new URL, watch the deliveries succeed there for longer than feels necessary, and only then delete the route. The intermediate state, where both URLs work, is the whole value of the re-export I defended in the earlier post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which claim from the earlier post survived
&lt;/h2&gt;

&lt;p&gt;Two of them did, and one did not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Still true:&lt;/strong&gt; config that lives in a vendor's dashboard is config you cannot grep. That is why the deletion needed the dashboard change to come first, and why it is the only step in this that has no artefact in the repository.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Still true:&lt;/strong&gt; re-exporting a route handler does not carry its route segment config across. The old file had to restate &lt;code&gt;dynamic = 'force-dynamic'&lt;/code&gt; and &lt;code&gt;maxDuration&lt;/code&gt; itself, because those are read from the module that defines the route, not from the module it borrowed its handler from. Deleting the file deleted that duplication too, which is the cleanest kind of fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No longer true:&lt;/strong&gt; "the fix is not a deletion". It was the right call for a fortnight and the wrong call permanently, which is roughly the lifespan a compatibility shim is supposed to have. The failure mode of these shims is not that they are wrong, it is that nobody ever comes back to them, and a re-export that works has no symptoms at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you can check
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;curl -s -X POST https://pub-trivia.app/api/webhook/stripe -d '{}'&lt;/code&gt; still answers &lt;code&gt;{"error":"Missing stripe-signature header"}&lt;/code&gt;, which is the handler refusing an unsigned request before it looks at anything else.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;curl -s https://pub-trivia.app/robots.txt&lt;/code&gt; lists seven disallowed prefixes, and &lt;code&gt;/webhook&lt;/code&gt; is not one of them.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' https://pub-trivia.app/webhook/stripe&lt;/code&gt; shows the 307 to the login form with the original path preserved in &lt;code&gt;?next=&lt;/code&gt;, which nothing in our app reads, but that is a different post.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pages this all exists to protect are public and dull by design: &lt;a href="https://pub-trivia.app/pricing" rel="noopener noreferrer"&gt;the pricing page&lt;/a&gt;, where a plan is bought, and &lt;a href="https://pub-trivia.app/legal/terms" rel="noopener noreferrer"&gt;the terms&lt;/a&gt;, which say what happens to a subscription afterwards. Everything above is plumbing in service of a venue being able to pay for a quiz night without anybody at either end thinking about URLs.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>stripe</category>
      <category>webdev</category>
      <category>typescript</category>
    </item>
    <item>
      <title>I told readers to curl our pricing endpoint, and seventeen days later it answers 404</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Thu, 22 Oct 2026 09:47:29 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/i-told-readers-to-curl-our-pricing-endpoint-and-seventeen-days-later-it-answers-404-19a6</link>
      <guid>https://dev.to/daniel_pertu/i-told-readers-to-curl-our-pricing-endpoint-and-seventeen-days-later-it-answers-404-19a6</guid>
      <description>&lt;p&gt;On 5 October I published a post about our app having two copies of every price: the figures in a TypeScript module that the marketing pages render, and the Stripe Price objects that actually took the money. It ended with a section inviting readers to watch the second copy for themselves:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://pub-trivia.app/api/pricing
curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://pub-trivia.app/api/pricing | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s1"&gt;'cache|age'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run either of those today:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'%{http_code}\n'&lt;/span&gt; https://pub-trivia.app/api/pricing
404
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The endpoint was deleted on 15 October. Since the whole point of writing these posts against a live site is that a reader can check the claim, a dead demo deserves its own post rather than a quiet edit, so here is what that URL was, why it is gone, and what to curl instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it was
&lt;/h2&gt;

&lt;p&gt;Thirty-four lines, and every one of them earning its place at the time:&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;getCachedPricing&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;unstable_cache&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;getStripePricing&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;stripe-pricing&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;revalidate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// re-fetch from Stripe at most once every 5 minutes&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;request&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="nx"&gt;ip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getRequestIp&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="nx"&gt;headers&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;success&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;ratelimit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pricing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ip&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;success&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;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Too many requests. Please slow down.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;429&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;Retry-After&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;60&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;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;plans&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;getCachedPricing&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;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plans&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Cache-Control&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;public, s-maxage=300, stale-while-revalidate=60&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;error&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;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Pricing API error:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;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;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Failed to fetch pricing&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&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 cached read of the live product list from Stripe, rate limited at 20 requests per minute per IP so it could not be used as a free proxy to our payment provider, and cached again at the CDN. It made a decent demo precisely because it was so ordinary: curl it twice and you could watch &lt;code&gt;x-vercel-cache&lt;/code&gt; go from MISS to HIT while &lt;code&gt;age&lt;/code&gt; climbed, and see that the &lt;code&gt;s-maxage&lt;/code&gt; directive never reached the client because the CDN had consumed it.&lt;/p&gt;

&lt;p&gt;It existed for one reason. The authoritative prices lived in Stripe, the dashboard plan screen needed them in the browser to put a figure on a button, and a React component cannot call the Stripe SDK. So there was an endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it went
&lt;/h2&gt;

&lt;p&gt;The refactor that followed removed the authority, not the endpoint. Checkout now builds every line item inline from the same price module the marketing pages read, in the visitor's own currency, so there are no Stripe Price objects left to be authoritative about anything. There is no second copy of a price to go and fetch.&lt;/p&gt;

&lt;p&gt;The deletion shows up in three other files, and the shapes of those changes are the interesting part:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The component lost a prop and a failure state.&lt;/strong&gt; The plan cards used to take &lt;code&gt;initialPlans: PricingPlan[]&lt;/code&gt;, find the entry whose &lt;code&gt;planType&lt;/code&gt; matched, and read &lt;code&gt;plan.priceId&lt;/code&gt; to start a checkout. They now take the plan type and nothing else, and post that to the checkout route. The interface describing a plan's shape, eleven fields of it, went with them.&lt;/p&gt;

&lt;p&gt;More telling, this branch went too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt; &lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"lg"&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"rounded-full w-full"&lt;/span&gt; &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    Unavailable
  &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was the fallback for "Stripe did not answer, so we do not know what this plan costs". A disabled button on the only screen in the app whose job is to sell you something. It is not that we got better at handling the error: the request it was handling no longer exists, so the state is unreachable, and the honest thing was to delete the branch rather than keep a button nobody can ever see.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A rate limit bucket disappeared.&lt;/strong&gt; Our limiter config is a single object of named sliding windows, so removing an endpoint means removing its entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-    /**
-     * /api/pricing — 20 per minute per IP.
-     * Public endpoint that proxies Stripe API calls. 20/min stops scraping
-     * while allowing normal page refreshes and plan-page loads.
-     */
-    pricing: sliding(20, 60),
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth saying out loud because it is a cost people forget when they count the cost of an endpoint. That URL was not 34 lines. It was 34 lines, a limiter bucket, a Redis key namespace, a cache tag, an error branch in a component, a prop threaded down two levels, and an interface. Deleting it paid all of that back at once.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One comment is now half true.&lt;/strong&gt; The plans screen still says, of its currency resolution, that "Checkout still prices from Stripe itself" in the fallback case. It does not any more: Checkout prices from the module. The code around it is correct, the sentence is a fossil, and I am leaving it in this post rather than quietly fixing it first, because it is the standard way documentation rots. The comment described a dependency, the dependency was removed, and the comment was not where anybody was looking.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to curl instead
&lt;/h2&gt;

&lt;p&gt;The replacement demo is better, because it shows the thing the old one only described. The pricing page itself now carries the only copy of the price:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://pub-trivia.app/pricing | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"Prices in [A-Z]&lt;/span&gt;&lt;span class="se"&gt;\{&lt;/span&gt;&lt;span class="s2"&gt;3&lt;/span&gt;&lt;span class="se"&gt;\}&lt;/span&gt;&lt;span class="s2"&gt;[^&amp;lt;]*"&lt;/span&gt;
Prices &lt;span class="k"&gt;in &lt;/span&gt;EUR, including VAT where it applies. You pay the price shown.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That comes back in your own currency, resolved on the server from the request, which you can confirm by running it through a VPN exit in another country. The figures on &lt;a href="https://pub-trivia.app/pricing" rel="noopener noreferrer"&gt;the page itself&lt;/a&gt; are the figures Checkout charges.&lt;/p&gt;

&lt;p&gt;The caching story moved with it, in the opposite direction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-sI&lt;/span&gt; https://pub-trivia.app/pricing | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; cache-control
cache-control: private, no-cache, no-store, max-age&lt;span class="o"&gt;=&lt;/span&gt;0, must-revalidate
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The old endpoint was five minutes of shared CDN cache. The page that replaced it cannot be shared at all, because its content depends on where the request came from, so it renders per request and tells every cache to keep out. The old post's "watch it cache" paragraph is not merely dead, it is inverted, and that trade is deliberate: a page that renders fast and shows the wrong number is worse than one that renders correctly.&lt;/p&gt;

&lt;p&gt;Two claims from that October post do still hold, and you can check both in the same response. There is still exactly one price for a plan rather than two, and the structured data still quotes the plans in pounds, which keeps one fixed figure in search results whatever currency the page itself is rendered in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://pub-trivia.app/pricing | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s1"&gt;'priceCurrency":"[A-Z]*'&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-2&lt;/span&gt;
priceCurrency&lt;span class="s2"&gt;":"&lt;/span&gt;GBP&lt;span class="s2"&gt;"
priceCurrency"&lt;/span&gt;:&lt;span class="s2"&gt;"GBP"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The thing I would actually keep
&lt;/h2&gt;

&lt;p&gt;A published post is a dependency on a URL, and nothing in your repository knows about it. We have a test that regexes &lt;code&gt;export const metadata&lt;/code&gt; out of a hundred route files, a test that holds the sitemap and the auth allowlist to the same list of public paths, and a test that fails if any price surface calls our own price an estimate. None of them know that a blog post from 5 October tells strangers to make a GET request to a path that no longer has a route file.&lt;/p&gt;

&lt;p&gt;I am not going to build that, and I do not think anybody should. The practical version is cheaper: when a refactor deletes a public URL, grep your own writing for it before you ship, and if you find it, write the correction rather than editing the past quietly. The second option costs one post and buys back the only thing that makes "check it yourself" worth saying.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>stripe</category>
      <category>api</category>
    </item>
    <item>
      <title>Our plan change cancelled your old subscription first, so closing the Checkout tab left you on nothing</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Thu, 22 Oct 2026 09:38:36 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/our-plan-change-cancelled-your-old-subscription-first-so-closing-the-checkout-tab-left-you-on-3c90</link>
      <guid>https://dev.to/daniel_pertu/our-plan-change-cancelled-your-old-subscription-first-so-closing-the-checkout-tab-left-you-on-3c90</guid>
      <description>&lt;p&gt;A plan change in our app goes through Stripe Checkout, because that is also how a first purchase works and one flow is better than two. The old version of it had five lines that read perfectly sensibly:&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;// Cancel any existing active/trialing subscriptions in parallel&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;live&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;trialing&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;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscriptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cancel what the customer has, then send them to Checkout for what they want. Clean, and wrong, because the second half of that sentence is not guaranteed to happen. A customer who clicked Change plan, read the Stripe page, thought better of it and closed the tab had just lost the plan they were already paying for. Nobody declined anything. Nobody asked for a cancellation. The only thing that happened was a page being closed, and by then the money side of their account was already in the past tense.&lt;/p&gt;

&lt;p&gt;The fix inverts the order: cancel nothing up front, and let the new subscription carry a note of what it supersedes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The whole mechanism is one metadata key
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/** Metadata key on a new subscription: the comma-separated IDs it replaces. */&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;REPLACES_KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;replaces_subscriptions&lt;/span&gt;&lt;span class="dl"&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;replacementMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;live&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Pick&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Subscription&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;[]):&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;live&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;REPLACES_KEY&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="nx"&gt;live&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sub&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;sub&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="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="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="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;Checkout stamps that on the subscription it creates. Every subscription still live on the customer goes on the list, paid up or not, so a past-due one stops retrying as well. If there is nothing to replace, the key is absent rather than empty, so a first purchase carries no trace of the mechanism at all.&lt;/p&gt;

&lt;p&gt;The webhook does the cancelling, once the new subscription is actually paying. Abandoning Checkout now changes precisely nothing: no new subscription exists, so no list of replacements exists, so nothing gets cancelled, and the customer still has the plan they walked in with.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two live subscriptions, briefly
&lt;/h2&gt;

&lt;p&gt;The cost of that ordering is a window where the customer has two live subscriptions: the new paid one, and the old one that has not been cancelled yet. Stripe will happily tell you about both, in events that can arrive in either order, more than once each.&lt;/p&gt;

&lt;p&gt;So the webhook stopped trusting the event it was handed. It asks what is true now:&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;syncCustomer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customerId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ended&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Subscription&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;live&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;liveSubscriptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;customerId&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;current&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;currentSubscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;live&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;current&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handleSubscriptionActive&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="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;old&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;supersededBy&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="nx"&gt;live&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;cancelReplaced&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;old&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&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;ended&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;handleSubscriptionDeleted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ended&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;Every subscription event now routes through that, and the event's own payload is used for one thing only: the customer ID, plus the subscription object in the case where nothing is paying any more and we need to know what stopped.&lt;/p&gt;

&lt;p&gt;The interesting function is the one that decides which of two live subscriptions the account follows:&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;function&lt;/span&gt; &lt;span class="nf"&gt;currentSubscription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;live&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;paying&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;live&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isPaying&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;replaced&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;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;paying&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;replacedIds&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;candidates&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;paying&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;replaced&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&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;candidates&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;created&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;created&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;candidates&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="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read it as a rule rather than as code: the account follows a paying subscription that no other paying subscription claims to replace. Newest wins if somehow there are several, which there should not be.&lt;/p&gt;

&lt;p&gt;That single rule covers the cases I would otherwise have written four separate branches for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The new subscription is paid, the old one is still live. The old one is named in the new one's metadata, so it is excluded, and the account is on the new plan immediately.&lt;/li&gt;
&lt;li&gt;An event for the &lt;strong&gt;old&lt;/strong&gt; subscription arrives late, after the change. It is still replaced, so it is still excluded, and the late event cannot drag the account back to the previous plan.&lt;/li&gt;
&lt;li&gt;The new subscription exists but is unpaid. It is not paying, so it is not a candidate at all, and the customer keeps the plan they had. Charging has not succeeded, so nothing is granted.&lt;/li&gt;
&lt;li&gt;The last subscription ends. Nothing is paying, nothing is current, and the account falls back to the trial.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tests for the webhook read almost as a list of those:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ moves the venue to the new plan and only then cancels the old one
✓ keeps the new plan when the old one is deleted, rather than dropping to the trial
✓ ignores a late event for the old subscription once the new one pays
✓ leaves the old plan in place while the new subscription is unpaid
✓ drops to the trial when the last subscription ends
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Cancelling something twice
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;supersededBy&lt;/code&gt; returns only the subscriptions that are still live and are named by the current one, so a webhook delivered twice finds the second time that there is nothing left to cancel. That handles the common case by construction, but not the race where two deliveries overlap, and Stripe's own retry behaviour makes overlapping deliveries normal rather than exotic.&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;cancelReplaced&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Subscription&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="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;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscriptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &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="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;now&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;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subscriptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;retrieve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&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;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="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canceled&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="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A failed cancel is re-read rather than assumed. If the subscription is already cancelled, the error was somebody else doing our job and it is swallowed. Anything else rethrows, which the route turns into a 500, which makes Stripe retry the event later. Two tests cover exactly that pair: one takes a second delivery in its stride, and one insists we retry when the cancel genuinely failed.&lt;/p&gt;

&lt;p&gt;Deciding that from the error's shape rather than from a fresh read would have been shorter, and it would have meant classifying Stripe error codes correctly forever. A single extra API call is a better deal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the plan off metadata, not the price
&lt;/h2&gt;

&lt;p&gt;One thing holds the whole arrangement together. The plan is stamped on the subscription's metadata, not derived from its price:&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;function&lt;/span&gt; &lt;span class="nf"&gt;planTypeOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Pick&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Subscription&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;metadata&lt;/span&gt;&lt;span class="dl"&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;PaidPlanType&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;parsePaidPlanType&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sub&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;plan_type&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pro&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;We build Checkout line items from inline &lt;code&gt;price_data&lt;/code&gt; in the visitor's own currency, so there are no Stripe Products or Prices to map back from. A subscription might be 36.99 euros or several thousand yen, and no lookup table can turn an amount into a plan without becoming wrong the next time a rate changes. The metadata reads identically in every currency, and the webhook's currency tests exist to prove it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ provisions a euro Ultimate subscription as Ultimate
✓ provisions a yen Pro subscription as Pro, whatever its amount
✓ follows a plan change on a forint subscription
✓ downgrades a canceled krona subscription to the trial
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fallback to &lt;code&gt;pro&lt;/code&gt; is deliberate: a paid subscription with no readable plan should grant the cheaper paid plan, never the dearer one and never nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two refusals in the route
&lt;/h2&gt;

&lt;p&gt;Removing the up-front cancel left the checkout route with less to do and two things it now has to refuse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The plan you already pay for.&lt;/strong&gt; With the cancel gone, buying your current plan again would just create a second subscription at the same price, so the route answers 409 "You're already on this plan." A past-due subscriber is explicitly allowed through, because for them buying again is the repair, and there is a test with that in its name: "lets a past-due subscriber buy their plan again, to replace the one that isn't paid".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A currency we cannot quote.&lt;/strong&gt; Stripe holds a customer to one currency while a subscription lives, so a plan change has to be priced in the live subscription's currency. If that currency is not one we price in, which can only happen if somebody made a subscription by hand in the Stripe dashboard, the route stops with a 409 and an email address rather than guessing. It used to decide this before cancelling anything, and the comment explaining why no longer needs to exist, because nothing is cancelled anywhere in the route.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seeing it from outside
&lt;/h2&gt;

&lt;p&gt;Plan changes live behind a login, so there is no URL I can hand you for this one. What is public is the pricing it is built on: &lt;a href="https://pub-trivia.app/pricing" rel="noopener noreferrer"&gt;pub-trivia.app/pricing&lt;/a&gt; renders in your own currency, and the note under the cards tells you which one, which is the figure a plan change would be quoted in afterwards. &lt;a href="https://pub-trivia.app/legal/terms" rel="noopener noreferrer"&gt;The terms&lt;/a&gt; say the rule in prose, including that a plan stays in the currency it started in.&lt;/p&gt;

&lt;p&gt;If you want to watch the mechanism, the free tier at &lt;a href="https://pub-trivia.app/signup" rel="noopener noreferrer"&gt;pub-trivia.app/signup&lt;/a&gt; needs no card, and the plans screen will offer you a Checkout session you are welcome to close. That used to be the destructive path. Now it is the no-op it always looked like.&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>webdev</category>
      <category>typescript</category>
      <category>saas</category>
    </item>
    <item>
      <title>Two features both wanted to pick the currency, so one of them is switched off in code</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Thu, 22 Oct 2026 09:37:19 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/two-features-both-wanted-to-pick-the-currency-so-one-of-them-is-switched-off-in-code-mb6</link>
      <guid>https://dev.to/daniel_pertu/two-features-both-wanted-to-pick-the-currency-so-one-of-them-is-switched-off-in-code-mb6</guid>
      <description>&lt;p&gt;We price visitors in their own currency from a table of rates, and &lt;a href="https://pub-trivia.app/pricing" rel="noopener noreferrer"&gt;the figures on our pricing page&lt;/a&gt; are the figures Checkout charges. That claim survives only if nothing between the page and the card is allowed to change the number.&lt;/p&gt;

&lt;p&gt;Stripe has a feature whose whole job is to change the number. Adaptive Pricing re-prices a Checkout session into the customer's local currency at Stripe's own rate, which is a genuinely good feature if your prices are in one currency and your customers are not. For us it meant the customer would read one price on our page and then see Stripe's conversion of it on the next, which is the exact arrangement we had just spent a release deleting.&lt;/p&gt;

&lt;p&gt;So every session we create carries one of two shapes, and which one it gets is decided by something else entirely: whether the sale is routed through Stripe as merchant of record.&lt;/p&gt;

&lt;h2&gt;
  
  
  Off, or absent, never left alone
&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;function&lt;/span&gt; &lt;span class="nf"&gt;withoutAdaptivePricing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;adaptive_pricing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;withAdaptivePricingUnset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;adaptive_pricing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;_adaptivePricing&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;rest&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;rest&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two functions that look like the same function. The difference matters because Managed Payments, Stripe's merchant-of-record mode, rejects the &lt;code&gt;adaptive_pricing&lt;/code&gt; field outright and turns Adaptive Pricing on regardless. You cannot send it &lt;code&gt;enabled: false&lt;/code&gt;. You cannot send it at all.&lt;/p&gt;

&lt;p&gt;Which leaves the obvious question: if Adaptive Pricing is forced on for every merchant-of-record session, how is that safe when we have just said it must never be allowed to re-price anything?&lt;/p&gt;

&lt;p&gt;It is safe only because of an invariant kept in a completely different file. Our merchant-of-record allowlist is the EU-27, and every EU-27 country is priced in its own currency by our own table, so a Dutch customer's session is already in euros and there is nothing for Stripe to convert. Adaptive Pricing is on and has no work to do.&lt;/p&gt;

&lt;p&gt;That is a fragile kind of safety, so it is held down by a test rather than by a comment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ prices every Managed Payments country in its own currency
✓ has exactly 21 euro members and six with their own currency
✓ covers exactly the EU-27, and never GB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add a country to the merchant-of-record allowlist without adding its currency to the pricing table, and that first test goes red before the mismatch can reach a customer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why merchant of record at all, and why only there
&lt;/h2&gt;

&lt;p&gt;When Managed Payments is on for a session, Stripe becomes the legal seller: it calculates, collects, files and remits the VAT, and carries the liability. In exchange Stripe takes its published merchant-of-record fee on top of normal card processing, and the tax is withheld from the payout, so the money that arrives is net.&lt;/p&gt;

&lt;p&gt;That is worth paying for where foreign tax is genuinely due from the first sale, and not worth paying anywhere else, so the routing is an allowlist rather than a global setting:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;EU-27: on.&lt;/strong&gt; VAT is due in the customer's country from the first sale of an automated digital service by a UK seller.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UK: off.&lt;/strong&gt; We are under the £90,000 VAT registration threshold. Switching merchant of record on for UK sales would have Stripe charge 20% VAT immediately, which throws the threshold away for nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;US: off.&lt;/strong&gt; No sales tax nexus at this volume.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Everything not on the list: off.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The country comes from the visitor's IP at session creation, which is a coarse signal and is openly treated as one in the code. It is the same header that chose the currency the customer was shown, which is the real reason to read it once: the price and the tax routing cannot disagree about where the customer is, because they are reading the same byte. Once merchant of record is on, the authoritative tax country is the billing address the customer types on Stripe's own page, and Stripe computes the tax from that.&lt;/p&gt;

&lt;p&gt;Every line item carries &lt;code&gt;tax_behavior: 'inclusive'&lt;/code&gt;, merchant of record or not, because that is a property of the price we quote rather than of the routing: whatever tax applies sits inside the figure on the page. A test says it in those words, "keeps tax inside the price, so the price shown is the price charged".&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure mode that would have taken EU checkout down
&lt;/h2&gt;

&lt;p&gt;Managed Payments has to be activated on the Stripe account, with the merchant-of-record terms accepted and an eligible tax code. If any of that is not true, Stripe rejects every session that sets &lt;code&gt;managed_payments&lt;/code&gt;. Not some. Every one.&lt;/p&gt;

&lt;p&gt;Since the field is set from the customer's country, a misconfiguration would not fail loudly in one place. It would fail as "checkout is broken in the EU" while the UK and the US sold normally, which is the kind of outage you find out about from a support email.&lt;/p&gt;

&lt;p&gt;So the session creation helper retries once, without merchant of record, and only when Stripe's rejection was specifically about that field:&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;try&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;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;checkout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;managedPayments&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;withAdaptivePricingUnset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;withoutAdaptivePricing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &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="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;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;managedPayments&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nf"&gt;isManagedPaymentsRejection&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="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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[checkout] Managed Payments session rejected by Stripe, retrying without MoR&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;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;country&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="na"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;describeStripeError&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="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;checkout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessions&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="nf"&gt;withoutAdaptivePricing&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;stripManagedPayments&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two parts of that deserve the attention.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The detector has to be narrow.&lt;/strong&gt; A retry that swallowed a card decline, a rate limit or a bad amount would be worse than the outage, because the sale would silently complete under the wrong tax treatment or fail twice for a reason nobody logged. &lt;code&gt;isManagedPaymentsRejection&lt;/code&gt; only returns true for Stripe's request-validation error type, and then only when the &lt;code&gt;param&lt;/code&gt; is &lt;code&gt;managed_payments&lt;/code&gt; (or something nested under it), or the message mentions managed payments. It is duck typed rather than using &lt;code&gt;instanceof&lt;/code&gt;, which keeps it testable without the SDK's error classes, and the tests spell out each refusal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ recognises an explicit managed_payments param rejection
✓ recognises a nested managed_payments param
✓ recognises the phrase in an invalid-request message
✓ does NOT match a card decline, so a real failure is never retried away
✓ does NOT match transient network or API errors
✓ does NOT match an unrelated invalid-request error
✓ does NOT match an arbitrary Error that happens to mention the phrase
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The strip has to be exact.&lt;/strong&gt; Removing merchant of record from a session means removing precisely the fields that merchant of record added, which is the top level &lt;code&gt;managed_payments&lt;/code&gt; and the &lt;code&gt;tax_code&lt;/code&gt; on each line item's &lt;code&gt;product_data&lt;/code&gt;. &lt;code&gt;tax_behavior&lt;/code&gt; stays, because it belongs to the price. A line item built from a Price id carries none of those fields and passes through untouched. The input is cloned rather than mutated, and there is a test for that alone, because a mutating strip would quietly change the params the caller still holds a reference to.&lt;/p&gt;

&lt;p&gt;The result is a sale that completes with no tax collected where tax was due, which is not nothing. It is a flagged sale instead of a lost customer, and the comment says so out loud: a flagged sale we can fix is better than a customer we lost.&lt;/p&gt;

&lt;h2&gt;
  
  
  The flag, after the fact
&lt;/h2&gt;

&lt;p&gt;Flagging happens in the Stripe webhook, on &lt;code&gt;checkout.session.completed&lt;/code&gt;, and it is the only thing that event does. Provisioning is driven entirely by subscription events, so a tax routing miss can never delay anybody's plan.&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;country&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customer_details&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;taxAmount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;total_details&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;amount_tax&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;null&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="nf"&gt;isPossibleMoRMisroute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;taxAmount&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;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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;[stripe-webhook] EU sale completed with no tax collected, possible Managed Payments misroute&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It compares the billing country Stripe actually captured against the tax Stripe actually collected. An EU billing country with zero tax is the signal, which catches both the misconfiguration retry above and the ordinary case of a customer whose IP exits in another country and who then types a French address on Stripe's page.&lt;/p&gt;

&lt;p&gt;A genuine EU business-to-business sale with a valid VAT number also shows zero tax under reverse charge. That is a false positive, it is accepted in writing, and the handler is review-only, so the cost of being wrong is a log line rather than a refused sale. The log carries the session's own currency and amount alongside the GBP list price that checkout stamped on the session metadata, so the sale can be reconciled in pounds whatever it was charged in.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a reader can check
&lt;/h2&gt;

&lt;p&gt;Not much of this is visible from outside, which is the nature of billing code. Two things are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;curl -s https://pub-trivia.app/pricing | grep -o "Prices in [A-Z]\{3\}[^&amp;lt;]*"&lt;/code&gt; returns the currency your IP is priced in, and the note ends "including VAT where it applies. You pay the price shown." There is no second number later in the flow, which is what all of the above exists to protect.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://pub-trivia.app/legal/terms" rel="noopener noreferrer"&gt;Our terms&lt;/a&gt; state the billing currency rule, including that a plan stays in the currency it started in. A test asserts those sentences are still in the page, because the prose is now part of the contract the code implements.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The suites behind this are 27 tests on the merchant-of-record module and 22 on the session helper, including the four that say "does NOT retry" in various ways. Those four are the ones I would keep if I could only keep four.&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>nextjs</category>
      <category>typescript</category>
      <category>saas</category>
    </item>
    <item>
      <title>There are no Prices in our Stripe Dashboard, only 24 rates in a table</title>
      <dc:creator>Daniel Pertu</dc:creator>
      <pubDate>Thu, 22 Oct 2026 09:35:57 +0000</pubDate>
      <link>https://dev.to/daniel_pertu/there-are-no-prices-in-our-stripe-dashboard-only-24-rates-in-a-table-43ol</link>
      <guid>https://dev.to/daniel_pertu/there-are-no-prices-in-our-stripe-dashboard-only-24-rates-in-a-table-43ol</guid>
      <description>&lt;p&gt;Our pricing page used to be a claim and our Stripe Dashboard used to be the fact. The doc comment on top of &lt;code&gt;lib/pricing/plans.ts&lt;/code&gt; admitted it in as many words: the prices in the file and the prices on the Stripe Price objects were two copies of one fact and could drift, the file was what the site &lt;em&gt;advertised&lt;/em&gt;, the Stripe Price was what was &lt;em&gt;charged&lt;/em&gt;, and when you changed a price you had to change it in both places in the same sitting.&lt;/p&gt;

&lt;p&gt;That comment is gone, and so are the Price objects. There is nothing to set up in our Stripe Dashboard now: no Products, no Prices, no currency variants. Checkout builds every line item inline from one table, in the visitor's own currency, at the figure the page printed.&lt;/p&gt;

&lt;p&gt;You can see the output before reading any of the mechanism:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://pub-trivia.app/pricing | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"Prices in [A-Z]&lt;/span&gt;&lt;span class="se"&gt;\{&lt;/span&gt;&lt;span class="s2"&gt;3&lt;/span&gt;&lt;span class="se"&gt;\}&lt;/span&gt;&lt;span class="s2"&gt;[^&amp;lt;]*"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From a London IP that prints &lt;code&gt;Prices in GBP, including VAT where it applies. You pay the price shown.&lt;/code&gt; From where I am sitting it prints &lt;code&gt;Prices in EUR&lt;/code&gt;, and the page body contains no pound sign anywhere. Switch a VPN exit to Tokyo and the yen prices come back with no decimal places at all. The page is at &lt;a href="https://pub-trivia.app/pricing" rel="noopener noreferrer"&gt;pub-trivia.app/pricing&lt;/a&gt; if you would rather just look at it.&lt;/p&gt;

&lt;h2&gt;
  
  
  One table, two consumers
&lt;/h2&gt;

&lt;p&gt;The currency table has 24 entries. Each is a lowercase id (which is also the code Stripe takes), an ISO code, a locale to format with, and a mid-market rate expressed as units per one pound:&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;CURRENCIES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;gbp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GBP&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-GB&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;eur&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;EUR&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en-IE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.1696&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;sek&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SEK&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sv-SE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;12.8667&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="c1"&gt;// ... 21 more&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;satisfies&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;CurrencyDefinition&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rates are fixed, not live, and that is the whole design rather than laziness. A live feed would move a customer's price between two page loads, put a network call in the render path of the one number they came to read, and make the figure shown at 10:59 differ from the figure charged at 11:00. They were sourced from the ECB reference rates on a dated afternoon, and the note in the file says to refresh one when it has moved more than about three per cent.&lt;/p&gt;

&lt;p&gt;A second map takes a two letter country code to one of those currencies, with anything unlisted falling back to pounds. It leans towards Europe because that is where the venues are. Every EU-27 member is priced in its own currency on purpose, which matters later.&lt;/p&gt;

&lt;p&gt;One detail in the lookup that is easy to get wrong: the country comes from a request header, so it is attacker influenced even if only through a spoofed IP, and reading the map directly would let a country code of &lt;code&gt;constructor&lt;/code&gt; or &lt;code&gt;__proto__&lt;/code&gt; return a function. The lookup goes through &lt;code&gt;Object.prototype.hasOwnProperty.call&lt;/code&gt; for that reason and returns pounds for anything it does not own.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a local price actually is
&lt;/h2&gt;

&lt;p&gt;A plan is an integer number of GBP pence. Pro is &lt;code&gt;3000&lt;/code&gt;. The local price is that, converted, plus a margin, rounded up to a retail figure:&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;major&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pence&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;CURRENCIES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;rate&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;LOCAL_PRICE_MARGIN&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;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;ceilToRetail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;major&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;digits&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&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;pow&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="nx"&gt;digits&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;LOCAL_PRICE_MARGIN&lt;/code&gt; is &lt;code&gt;1.04&lt;/code&gt;. It is not a markup for its own sake: Stripe charges us to convert a payout in another currency back into pounds, and the fixed rates drift between refreshes, so without it a sale in krona can net less than the same sale in pounds. Four per cent covers both.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ceilToRetail&lt;/code&gt; rounds &lt;strong&gt;up&lt;/strong&gt; to the next &lt;code&gt;.99&lt;/code&gt;, or to the next whole unit in a currency written without decimals. Pro is £30, so euros come out as 30 x 1.1696 x 1.04 = 36.49, which becomes &lt;strong&gt;€36.99&lt;/strong&gt;. Ultimate is £100, which lands on 121.63 and becomes &lt;strong&gt;€121.99&lt;/strong&gt;. Those are the two figures on the live page right now from an EU address, and they are the two figures Checkout charges, which is the point of the exercise.&lt;/p&gt;

&lt;p&gt;The rounding has an epsilon in it, because a value sitting exactly on a boundary would otherwise be nudged up by a whole unit by binary float error.&lt;/p&gt;

&lt;h2&gt;
  
  
  The free plan cost 99 cents
&lt;/h2&gt;

&lt;p&gt;Here is the bug that came out of the rounding rule, and it is my favourite kind, because every individual step is correct.&lt;/p&gt;

&lt;p&gt;The free trial is a plan like any other, with a price of &lt;code&gt;0&lt;/code&gt; pence. Convert zero and you get zero. Round zero up to the next &lt;code&gt;.99&lt;/code&gt; and you get &lt;code&gt;0.99&lt;/code&gt;. So the one plan whose entire proposition is that it costs nothing displayed as &lt;strong&gt;€0.99&lt;/strong&gt; everywhere outside the United Kingdom, while still saying "no card needed" next to it.&lt;/p&gt;

&lt;p&gt;The fix is three lines and a comment longer than the 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;// Free is free in every currency. Without this guard the retail round-up turns&lt;/span&gt;
&lt;span class="c1"&gt;// the trial's £0 into "€0.99", because the next `.99` above zero is 0.99.&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;pence&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="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Formatting is part of the charge now
&lt;/h2&gt;

&lt;p&gt;When the price shown is also the price taken, the formatter stops being cosmetic.&lt;/p&gt;

&lt;p&gt;Two currencies in our table are written without decimal places where Stripe still counts them in hundredths, and one is written and counted in whole units. Those are two different lists in the file, not one, because conflating them would be wrong by two orders of magnitude in whichever direction you picked. The forint is the interesting one: JavaScript engines disagree about it, V8 writing it without decimals while engines following ISO 4217 strictly give it two. Our page renders once on the server and again in the browser on hydration, so a price that depends on the engine is a price that can disagree with itself inside one page view. The list is hardcoded and a test checks it against the server's own Intl data so a new zero decimal currency cannot slip past.&lt;/p&gt;

&lt;p&gt;Every &lt;code&gt;Intl.NumberFormat&lt;/code&gt; call passes its fraction digits explicitly, so the engine's idea of a currency never changes the figure. Whole amounts drop the decimals, because &lt;code&gt;.00&lt;/code&gt; on every list price reads as more precision than a round number has.&lt;/p&gt;

&lt;p&gt;The number that goes to Stripe gets its own function, because Stripe's units are not always the units the currency is written in:&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;function&lt;/span&gt; &lt;span class="nf"&gt;stripeUnitAmount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DisplayCurrency&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&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;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nf"&gt;localPrice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pence&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&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;pow&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="nf"&gt;stripeDigits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;minorUnitDigits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;currency&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;h2&gt;
  
  
  The line item
&lt;/h2&gt;

&lt;p&gt;With all of that in one module, the Checkout call is dull, which is the goal:&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;line_items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;price_data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;planPriceData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;planType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;mp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;productData&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;quantity&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and &lt;code&gt;planPriceData&lt;/code&gt; is nine lines: the currency, &lt;code&gt;stripeUnitAmount&lt;/code&gt; of the plan's pence, a monthly &lt;code&gt;recurring&lt;/code&gt;, a product name for the invoice, and a tax behaviour of &lt;code&gt;inclusive&lt;/code&gt; so any VAT sits inside the figure rather than appearing on top of it at the last step.&lt;/p&gt;

&lt;p&gt;The plan rides on the subscription's metadata rather than being inferred from the price, so a subscription in any currency reads back the same, and the GBP list price rides along too so that a sale in forint can still be accounted for in pounds.&lt;/p&gt;

&lt;p&gt;One more rule falls out of Stripe's own behaviour rather than ours: a subscription keeps the currency it started in for as long as it lives, because Stripe will not bill one customer in two currencies. So a signed in customer with a live subscription is priced in that subscription's currency, not in their country's, and a plan change has to be quoted in it. Our &lt;a href="https://pub-trivia.app/legal/terms" rel="noopener noreferrer"&gt;terms&lt;/a&gt; say this out loud, and a test asserts the sentence is still there: "billed in the currency it started in".&lt;/p&gt;

&lt;h2&gt;
  
  
  What the tests hold
&lt;/h2&gt;

&lt;p&gt;The pricing and billing suites are 159 tests across six files, 43 of them on the currency module alone. The ones I would not want to lose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A local price never nets less than the pound price, and never strays an unreasonable distance above the true conversion.&lt;/li&gt;
&lt;li&gt;Prices are monotonic, so the cheaper plan can never round up past the dearer one.&lt;/li&gt;
&lt;li&gt;The Stripe amount reads back as exactly the displayed price, in every currency.&lt;/li&gt;
&lt;li&gt;Zero renders as a figure containing no non-zero digit, whatever the currency.&lt;/li&gt;
&lt;li&gt;The hardcoded zero decimal list agrees with the runtime's Intl data.&lt;/li&gt;
&lt;li&gt;The rendered pricing cards carry the exact displayed price in six currencies, and the purchase button says "Purchase €36.99/mo" rather than a pound figure.&lt;/li&gt;
&lt;li&gt;No price surface describes our own price as a conversion, an estimate, or a guide to a pound figure. That one is a regex over the rendered text and over the source of eleven customer facing files, built from the words a weaselly price note tends to use: &lt;code&gt;approx&lt;/code&gt;, &lt;code&gt;convert&lt;/code&gt;, &lt;code&gt;estimat&lt;/code&gt;, &lt;code&gt;exchange&lt;/code&gt;, &lt;code&gt;rounded up&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last one is the test I like most, because it is the test that stops the design quietly rotting back into the old one. The moment a page says "approximately €36.99" we are back to advertising one number and charging another, and the suite goes red for a prose change.&lt;/p&gt;

&lt;p&gt;Have a look at &lt;a href="https://pub-trivia.app/pricing" rel="noopener noreferrer"&gt;pub-trivia.app/pricing&lt;/a&gt; in whatever country you are in. If the note at the bottom of the cards names your currency and the figures end in &lt;code&gt;.99&lt;/code&gt;, that is this table, and if you took a plan the figure on your statement would be the one you read.&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>nextjs</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
