<?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: hiro@</title>
    <description>The latest articles on DEV Community by hiro@ (@hirodeath).</description>
    <link>https://dev.to/hirodeath</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%2F57464%2F7deedba2-079f-4a50-91e6-065b4e20a0c0.jpeg</url>
      <title>DEV Community: hiro@</title>
      <link>https://dev.to/hirodeath</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hirodeath"/>
    <language>en</language>
    <item>
      <title>When Wrangler-generated types collide with Next.js DOM types</title>
      <dc:creator>hiro@</dc:creator>
      <pubDate>Sat, 05 Sep 2026 01:35:37 +0000</pubDate>
      <link>https://dev.to/hirodeath/when-wrangler-generated-types-collide-with-nextjs-dom-types-242d</link>
      <guid>https://dev.to/hirodeath/when-wrangler-generated-types-collide-with-nextjs-dom-types-242d</guid>
      <description>&lt;p&gt;&lt;em&gt;This article is an English translation of the original Japanese article.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I deploy a Next.js application to Cloudflare Workers through &lt;code&gt;@opennextjs/cloudflare&lt;/code&gt;. After running &lt;code&gt;wrangler types&lt;/code&gt; to type the D1 binding, TypeScript errors appeared across the application.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error TS18046: 'body' is of type 'unknown'.
error TS2339: Property 'name' does not exist on type 'object'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The main symptom was that &lt;code&gt;request.json()&lt;/code&gt; suddenly returned &lt;code&gt;unknown&lt;/code&gt;. In my setup, Wrangler's generated runtime definitions collided with the DOM types used by Next.js. I fixed it by removing the generated file and writing a small declaration file that uses &lt;code&gt;import type&lt;/code&gt;. The exact behavior can vary with versions and &lt;code&gt;tsconfig&lt;/code&gt; scope, so this article describes the configuration where I reproduced it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happened
&lt;/h2&gt;

&lt;p&gt;By default, &lt;code&gt;wrangler types&lt;/code&gt; creates &lt;code&gt;worker-configuration.d.ts&lt;/code&gt;. It contains both the binding &lt;code&gt;Env&lt;/code&gt; interface and the complete Workers runtime definitions. Global types such as &lt;code&gt;Request&lt;/code&gt;, &lt;code&gt;Response&lt;/code&gt;, and &lt;code&gt;Body&lt;/code&gt; all enter the project.&lt;/p&gt;

&lt;p&gt;In the Workers runtime types, &lt;code&gt;Body#json()&lt;/code&gt; returns &lt;code&gt;Promise&amp;lt;unknown&amp;gt;&lt;/code&gt;. In the DOM library used by Next.js, it returns &lt;code&gt;Promise&amp;lt;any&amp;gt;&lt;/code&gt;. Once the generated runtime globals joined the same TypeScript project, every &lt;code&gt;await request.json()&lt;/code&gt; in a Route Handler became &lt;code&gt;unknown&lt;/code&gt;, and accesses such as &lt;code&gt;body.name&lt;/code&gt; failed type checking.&lt;/p&gt;

&lt;p&gt;Those Workers types are appropriate in a Worker-only project. A Next.js application also needs the DOM library, and both libraries define the same global names.&lt;/p&gt;

&lt;h2&gt;
  
  
  Attempt: &lt;code&gt;--include-runtime=false&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Wrangler can exclude runtime types from its generated output.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;wrangler types cloudflare-env.d.ts &lt;span class="nt"&gt;--env-interface&lt;/span&gt; CloudflareEnv &lt;span class="nt"&gt;--include-runtime&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The resulting file contains only the environment interface.&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;// Generated cloudflare-env.d.ts&lt;/span&gt;
&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;CloudflareEnv&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;DB&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;D1Database&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;ASSETS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Fetcher&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;This looked promising, but it introduced another error.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error TS2552: Cannot find name 'D1Database'. Did you mean 'IDBDatabase'?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;D1Database&lt;/code&gt; and &lt;code&gt;Fetcher&lt;/code&gt; are global names supplied by the runtime definitions. Including the runtime caused the DOM collision, while excluding it left the generated interface unable to resolve those names.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix: a handwritten declaration file with &lt;code&gt;import type&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;I stopped generating the file and imported only the binding types I needed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i &lt;span class="nt"&gt;-D&lt;/span&gt; @cloudflare/workers-types
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// cloudflare-env.d.ts, written manually&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;D1Database&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Fetcher&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@cloudflare/workers-types&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kr"&gt;declare&lt;/span&gt; &lt;span class="nb"&gt;global&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;CloudflareEnv&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;DB&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;D1Database&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;ASSETS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Fetcher&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works for three reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Types brought in with &lt;code&gt;import type&lt;/code&gt; stay scoped to this module. The Workers versions of &lt;code&gt;Request&lt;/code&gt; and &lt;code&gt;Response&lt;/code&gt; do not leak into the global namespace or collide with the DOM library.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;declare global&lt;/code&gt; exposes only &lt;code&gt;CloudflareEnv&lt;/code&gt;. &lt;code&gt;getCloudflareContext()&lt;/code&gt; from &lt;code&gt;@opennextjs/cloudflare&lt;/code&gt; returns its &lt;code&gt;env&lt;/code&gt; using this interface, which gives &lt;code&gt;env.DB&lt;/code&gt; the &lt;code&gt;D1Database&lt;/code&gt; type.&lt;/li&gt;
&lt;li&gt;The import already makes the declaration file a module. The final &lt;code&gt;export {}&lt;/code&gt; is included to make that status explicit, but it is not required.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The binding type is then available at the call site.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;getCloudflareContext&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@opennextjs/cloudflare&lt;/span&gt;&lt;span class="dl"&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;getDb&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;getCloudflareContext&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DB&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// inferred as D1Database&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Tradeoff
&lt;/h2&gt;

&lt;p&gt;The handwritten file must be updated when a binding is added. This gives up the main benefit of &lt;code&gt;wrangler types&lt;/code&gt;, which generates an interface from &lt;code&gt;wrangler.jsonc&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For my application, the bindings change infrequently and consist of one D1 database plus static assets. Maintaining a few lines in one file has not been a problem. A larger project with many bindings could generate &lt;code&gt;CloudflareEnv&lt;/code&gt; and then transform the output to add scoped type imports.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Wrangler's generated file can include Workers runtime globals that collide with the DOM library in a Next.js project. A sudden &lt;code&gt;unknown&lt;/code&gt; return type from &lt;code&gt;json()&lt;/code&gt; is one symptom.&lt;/li&gt;
&lt;li&gt;In my configuration, &lt;code&gt;--include-runtime=false&lt;/code&gt; left binding types such as &lt;code&gt;D1Database&lt;/code&gt; unresolved.&lt;/li&gt;
&lt;li&gt;A handwritten declaration file using &lt;code&gt;import type&lt;/code&gt; and &lt;code&gt;declare global&lt;/code&gt; types the bindings without adding Workers request and response globals.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Worker-only projects do not have the same DOM coexistence problem, which made the cause harder to find.&lt;/p&gt;

&lt;h2&gt;
  
  
  Environment
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;@opennextjs/cloudflare&lt;/code&gt; 1.20, Wrangler 4.113, and &lt;code&gt;@cloudflare/workers-types&lt;/code&gt; 5.x&lt;/li&gt;
&lt;li&gt;Next.js 16 App Router. Next.js 15 can have the same type structure&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cloudflare</category>
      <category>wrangler</category>
      <category>nextjs</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Removing handwritten API types with tRPC RouterInputs and RouterOutputs</title>
      <dc:creator>hiro@</dc:creator>
      <pubDate>Fri, 04 Sep 2026 01:34:22 +0000</pubDate>
      <link>https://dev.to/hirodeath/removing-handwritten-api-types-with-trpc-routerinputs-and-routeroutputs-1hl1</link>
      <guid>https://dev.to/hirodeath/removing-handwritten-api-types-with-trpc-routerinputs-and-routeroutputs-1hl1</guid>
      <description>&lt;p&gt;&lt;em&gt;This article is an English translation of the original Japanese article.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;tRPC can derive every procedure's input and output types from the router definition. The UI does not need to repeat the server response as an interface such as &lt;code&gt;interface Schedule { ... }&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I use tRPC v11 in a monorepo containing a Next.js web app and an Expo mobile app. The web side defines these two helper types:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;inferRouterInputs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;inferRouterOutputs&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@trpc/server&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;AppRouter&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;~/server/api/root&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="cm"&gt;/** Input types for every procedure */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;RouterInputs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;inferRouterInputs&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;AppRouter&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="cm"&gt;/** Output types for every procedure */&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;RouterOutputs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;inferRouterOutputs&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;AppRouter&lt;/span&gt;&lt;span class="o"&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;These are the patterns I use most often.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Get the item type from a list
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;findMany&lt;/code&gt;-style procedure returns an array. Indexing it with &lt;code&gt;[number]&lt;/code&gt; extracts the element type.&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;type&lt;/span&gt; &lt;span class="nx"&gt;Schedule&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;RouterOutputs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;schedule&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;list&lt;/span&gt;&lt;span class="dl"&gt;"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Changing the server-side &lt;code&gt;select&lt;/code&gt; or &lt;code&gt;include&lt;/code&gt; automatically updates this type.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Use the output type for child component props
&lt;/h2&gt;

&lt;p&gt;When a list page passes data to a child component, its props can come from the same output type.&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;type&lt;/span&gt; &lt;span class="nx"&gt;Props&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RouterOutputs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;schedule&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;list&lt;/span&gt;&lt;span class="dl"&gt;"&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;onSelect&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;void&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;ScheduleCard&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;onSelect&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;Props&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// schedule.title and the other fields are inferred&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API response and component props now have the same source. If the server response changes, TypeScript reports the mismatch in the child component too.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Index into nested properties
&lt;/h2&gt;

&lt;p&gt;For responses that include relations, regular indexed access reaches the nested type.&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;type&lt;/span&gt; &lt;span class="nx"&gt;Member&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;RouterOutputs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;membership&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;listByOrg&lt;/span&gt;&lt;span class="dl"&gt;"&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="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;MemberUser&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Member&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="c1"&gt;// Relation loaded through `with`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Build form values from &lt;code&gt;RouterInputs&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Deriving create and update form values from the mutation input avoids maintaining a second type beside the Zod schema.&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;type&lt;/span&gt; &lt;span class="nx"&gt;CreateScheduleInput&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;RouterInputs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;schedule&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;create&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="c1"&gt;// Remove fields that the form does not edit&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;ScheduleFormValues&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Omit&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CreateScheduleInput&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;orgId&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding a field to &lt;code&gt;.input(z.object({...}))&lt;/code&gt; produces a type error in the form code that must now supply it.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Keep cache updates type-safe
&lt;/h2&gt;

&lt;p&gt;The same inferred types flow into optimistic updates with TanStack Query.&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;utils&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;schedule&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setData&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;orgId&lt;/span&gt; &lt;span class="p"&gt;},&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="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;old&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;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;id&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;done&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="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// old is inferred as RouterOutputs["schedule"]["list"] | undefined&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;setData&lt;/code&gt; callback already has the correct argument type, so it needs no annotation. The fewer response types I write by hand, the more inference carries through the rest of the client.&lt;/p&gt;

&lt;h2&gt;
  
  
  Re-export the types from a monorepo package
&lt;/h2&gt;

&lt;p&gt;When web and mobile use the same tRPC client types, a small package such as &lt;code&gt;packages/api&lt;/code&gt; can re-export &lt;code&gt;AppRouter&lt;/code&gt;, &lt;code&gt;RouterInputs&lt;/code&gt;, and &lt;code&gt;RouterOutputs&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// packages/api/src/index.ts&lt;/span&gt;
&lt;span class="k"&gt;export&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;AppRouter&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;../../apps/web/src/server/api/root&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;RouterInputs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;RouterOutputs&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;../../apps/web/src/trpc/react&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;My repository still has places where mobile imports &lt;code&gt;AppRouter&lt;/code&gt; directly from the web app. The example above is the boundary I am moving toward. Because it still uses a relative path into the web implementation, moving the router definition itself into a shared package would make that boundary clearer later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Derive UI types from &lt;code&gt;RouterOutputs["router"]["procedure"]&lt;/code&gt; instead of rewriting them.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;[number]&lt;/code&gt; for an array element and indexed access for nested values.&lt;/li&gt;
&lt;li&gt;Derive form values from &lt;code&gt;RouterInputs&lt;/code&gt; to avoid duplicating the Zod input schema.&lt;/li&gt;
&lt;li&gt;Keep client type checks connected to server-side changes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Environment
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;tRPC v11, TypeScript 5, Next.js 15, and Expo in a Turborepo monorepo&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>trpc</category>
      <category>typescript</category>
      <category>nextjs</category>
      <category>react</category>
    </item>
    <item>
      <title>Turning Storybook stories into Vitest browser tests in Next.js</title>
      <dc:creator>hiro@</dc:creator>
      <pubDate>Thu, 03 Sep 2026 01:40:41 +0000</pubDate>
      <link>https://dev.to/hirodeath/turning-storybook-stories-into-vitest-browser-tests-in-nextjs-8i2</link>
      <guid>https://dev.to/hirodeath/turning-storybook-stories-into-vitest-browser-tests-in-nextjs-8i2</guid>
      <description>&lt;p&gt;&lt;em&gt;This article is an English translation of the original Japanese article.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Maintaining a UI component catalog and a separate component test suite costs too much in an indie Next.js project. I use a setup where writing a Storybook story also creates a browser test. The same artifact serves as both the catalog and the component test.&lt;/p&gt;

&lt;p&gt;The setup combines Storybook's Vitest addon with Vitest browser mode. Most of it lives in two configuration files.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Storybook uses &lt;code&gt;@storybook/nextjs-vite&lt;/code&gt; for the component catalog.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;@storybook/addon-vitest&lt;/code&gt; plugin turns each story into a test case.&lt;/li&gt;
&lt;li&gt;Vitest browser mode runs the tests in real Chromium through Playwright rather than jsdom.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Adding &lt;code&gt;Button.stories.tsx&lt;/code&gt; therefore adds a test that renders the button in a browser. If the story has a &lt;code&gt;play&lt;/code&gt; function, the same test runs those interactions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuration 1: &lt;code&gt;.storybook/main.ts&lt;/code&gt;
&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;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;StorybookConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@storybook/nextjs-vite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;StorybookConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;stories&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;../src/**/*.mdx&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;../src/**/*.stories.@(js|jsx|mjs|ts|tsx)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="na"&gt;addons&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;@chromatic-com/storybook&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;@storybook/addon-vitest&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;@storybook/addon-a11y&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;@storybook/addon-docs&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;framework&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@storybook/nextjs-vite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;staticDirs&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;../public&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;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;@storybook/nextjs-vite&lt;/code&gt;, components that use Next.js-specific features such as &lt;code&gt;next/image&lt;/code&gt; and &lt;code&gt;next/navigation&lt;/code&gt; still run in the Vite-based Storybook. In my project it also starts noticeably faster than the webpack version.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuration 2: &lt;code&gt;vitest.config.ts&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Vitest &lt;code&gt;projects&lt;/code&gt; separate regular unit tests from Storybook tests.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;defineConfig&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;vitest/config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;storybookTest&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@storybook/addon-vitest/vitest-plugin&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;playwright&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@vitest/browser-playwright&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="c1"&gt;// Regular unit tests for the logic layer&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;extends&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;test&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unit&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;include&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;src/**/*.test.ts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
          &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node&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="c1"&gt;// Storybook tests: stories running in a browser&lt;/span&gt;
      &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;extends&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;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
          &lt;span class="nf"&gt;storybookTest&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;configDir&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="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;.storybook&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="na"&gt;test&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;storybook&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;browser&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;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;headless&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;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;playwright&lt;/span&gt;&lt;span class="p"&gt;({}),&lt;/span&gt;
            &lt;span class="na"&gt;instances&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;browser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chromium&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
          &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;storybookTest()&lt;/code&gt; loads the story files and registers them as tests. A story needs no extra code for a render check. Add a &lt;code&gt;play&lt;/code&gt; function when you want to verify interactions.&lt;/li&gt;
&lt;li&gt;The Playwright provider runs the test in headless Chromium. CSS layout and focus behavior use a real browser implementation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Because the projects are separate, I can run only the fast logic tests with &lt;code&gt;vitest run --project unit&lt;/code&gt;, then use &lt;code&gt;vitest run&lt;/code&gt; when I want the full suite including stories.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I keep this setup
&lt;/h2&gt;

&lt;p&gt;The reason to write a test and the reason to write a catalog entry become the same. A separate component test suite is often the first thing I stop maintaining in a solo project. Here, the routine act of creating a story to inspect a component also adds a regression test.&lt;/p&gt;

&lt;p&gt;Running in a real browser has also been more useful than I expected. Dialog focus traps, portals, and similar behavior can be missed or reported incorrectly by jsdom. Chromium exercises those paths directly.&lt;/p&gt;

&lt;p&gt;The accessibility addon reports warnings in Storybook as well, so the catalog, component tests, and accessibility checks stay in one place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Browser mode is clearly slower than unit tests because it launches a browser. Splitting the projects lets me run only unit tests during most development work.&lt;/li&gt;
&lt;li&gt;A story without a &lt;code&gt;play&lt;/code&gt; function checks that rendering does not break. That is still useful, but important form components benefit from interaction tests.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Environment
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Storybook 10 with &lt;code&gt;@storybook/nextjs-vite&lt;/code&gt; and &lt;code&gt;@storybook/addon-vitest&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Vitest and &lt;code&gt;@vitest/browser-playwright&lt;/code&gt; with Chromium&lt;/li&gt;
&lt;li&gt;Next.js 15 App Router inside a Turborepo monorepo&lt;/li&gt;
&lt;li&gt;The configuration is shortened from a project in production&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>storybook</category>
      <category>vitest</category>
      <category>nextjs</category>
      <category>playwright</category>
    </item>
    <item>
      <title>I built a Qiita trend analyzer to decide what technical article to write next</title>
      <dc:creator>hiro@</dc:creator>
      <pubDate>Wed, 02 Sep 2026 01:36:17 +0000</pubDate>
      <link>https://dev.to/hirodeath/i-built-a-qiita-trend-analyzer-to-decide-what-technical-article-to-write-next-4487</link>
      <guid>https://dev.to/hirodeath/i-built-a-qiita-trend-analyzer-to-decide-what-technical-article-to-write-next-4487</guid>
      <description>&lt;p&gt;&lt;em&gt;This article is an English translation of the original Japanese article.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;After returning to technical writing, I wanted data rather than intuition to guide topic selection.&lt;/li&gt;
&lt;li&gt;I built a dependency-free tool of about 100 lines that uses only the Qiita API v2 to analyze tags and title patterns in recent high-rated articles.&lt;/li&gt;
&lt;li&gt;Repository: &lt;a href="https://github.com/takahiro-saeki/qiita-trend-analyzer" rel="noopener noreferrer"&gt;https://github.com/takahiro-saeki/qiita-trend-analyzer&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The results showed strong interest in practical AI articles, especially Claude Code, and list-style roundup titles.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why I built it
&lt;/h2&gt;

&lt;p&gt;I had not posted on Qiita for about four years when I decided to start writing technical articles again.&lt;/p&gt;

&lt;p&gt;The first problem was choosing what to write. Looking back at my older posts, the best performer combined a current topic with working code and reached 34 LGTM. Articles based mostly on my own attachment to a topic did not perform as well. Topic selection clearly mattered.&lt;/p&gt;

&lt;p&gt;Instead of relying only on instinct, I wanted to retrieve what people were currently reading and use that data when choosing a subject.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the tool does
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/takahiro-saeki/qiita-trend-analyzer" rel="noopener noreferrer"&gt;https://github.com/takahiro-saeki/qiita-trend-analyzer&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Analyze articles from the last seven days with at least five stocks&lt;/span&gt;
node analyze.mjs

&lt;span class="c"&gt;# Change the date range and minimum stock count&lt;/span&gt;
node analyze.mjs &lt;span class="nt"&gt;--days&lt;/span&gt; 14 &lt;span class="nt"&gt;--min-stocks&lt;/span&gt; 10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It produces three reports:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Total LGTM by tag, showing which areas receive attention.&lt;/li&gt;
&lt;li&gt;Frequency of title patterns such as "I tried", numbered lists, and introductions.&lt;/li&gt;
&lt;li&gt;A list of articles with the most LGTM.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It uses the built-in &lt;code&gt;fetch&lt;/code&gt; available in Node.js 18 and newer, with no package dependencies.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation details
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Qiita API v2 works without authentication
&lt;/h3&gt;

&lt;p&gt;Article search queries can be sent without authentication. The unauthenticated limit is 60 requests per hour, while a token raises it to 1,000.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`created:&amp;gt;=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;since&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; stocks:&amp;gt;=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;MIN_STOCKS&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://qiita.com/api/v2/items?page=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;page&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;per_page=100&amp;amp;query=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&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="nx"&gt;items&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Combining &lt;code&gt;created:&amp;gt;=date&lt;/code&gt; with &lt;code&gt;stocks:&amp;gt;=count&lt;/code&gt; narrows the data to recent articles that have crossed a minimum engagement threshold. A few API calls are enough to build the sample.&lt;/p&gt;

&lt;h3&gt;
  
  
  Weight tags by total LGTM
&lt;/h3&gt;

&lt;p&gt;Counting articles alone pushes high-volume tags to the top. I wanted to know how much engagement each tag's articles received, so the script sums &lt;code&gt;likes_count&lt;/code&gt; for every tag.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tagStats&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;Map&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;it&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tags&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;key&lt;/span&gt; &lt;span class="o"&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;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tagStats&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="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;name&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;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;count&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;likes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&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="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;likes&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;likes_count&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;tagStats&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="nx"&gt;key&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="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Count title patterns with regular expressions
&lt;/h3&gt;

&lt;p&gt;Popular articles often use recognizable title formats. The script uses simple regular expressions to count which ones are still common.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;patterns&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Tried it&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/してみた/&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Roundup or numbered list&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/まとめ|&lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="sr"&gt;+選/&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Introduction or beginner&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/入門|初心者/&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The expressions analyze Japanese Qiita titles, so the labels above are English descriptions of the matched patterns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results from late July 2026
&lt;/h2&gt;

&lt;p&gt;I analyzed 75 articles created in the previous seven days with at least five stocks.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Tag&lt;/th&gt;
&lt;th&gt;Articles&lt;/th&gt;
&lt;th&gt;Total LGTM&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;初心者 (beginner)&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;438&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Security&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;404&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;AI&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;381&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;未経験エンジニア (new engineer)&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;372&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;新人プログラマ応援 (support for new programmers)&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;372&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;ClaudeCode&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;236&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Several details stood out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AI had both a high article count and a high total LGTM. Claude Code had nine articles and 236 LGTM, which was efficient per article.&lt;/li&gt;
&lt;li&gt;The beginner and Security totals were heavily influenced by one roundup article about 138 sites for learning security, which received 330 LGTM. A successful roundup can dominate a tag.&lt;/li&gt;
&lt;li&gt;High-ranked posts included practical operational material such as collections of &lt;code&gt;CLAUDE.md&lt;/code&gt; design patterns.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At that point in summer 2026, concrete accounts of using AI tools in real development were receiving attention on Qiita. A surface-level library introduction still had to compete with many similar posts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trends are input, not a writing template
&lt;/h2&gt;

&lt;p&gt;I use this analysis as a topic sensor, not as a reason to attach popular tags to thin articles. Qiita readers have become more critical of mass-produced articles that look AI-generated. Following a trend without original experience or verification can work against the author.&lt;/p&gt;

&lt;p&gt;My current rule is to use data for choosing the subject, then fill the article with my own implementation, measurements, and mistakes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing note
&lt;/h2&gt;

&lt;p&gt;I built the tool with Claude Code in about an hour. I also plan to write about the workflow that connects topic selection, drafting, and publication.&lt;/p&gt;

</description>
      <category>qiita</category>
      <category>node</category>
      <category>api</category>
      <category>javascript</category>
    </item>
    <item>
      <title>The stack I use to run two indie web and iOS apps</title>
      <dc:creator>hiro@</dc:creator>
      <pubDate>Tue, 01 Sep 2026 02:13:54 +0000</pubDate>
      <link>https://dev.to/hirodeath/the-stack-i-use-to-run-two-indie-web-and-ios-apps-522o</link>
      <guid>https://dev.to/hirodeath/the-stack-i-use-to-run-two-indie-web-and-ios-apps-522o</guid>
      <description>&lt;p&gt;&lt;em&gt;This article is an English translation of the original Japanese article.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I develop and operate two services by myself, each with a web app and an iOS app. One manages community group activities, while the other records voice training.&lt;/p&gt;

&lt;p&gt;The second project could reuse parts of the first. Authentication and deployment still need project-specific work, but fewer decisions start from zero. This article lists the current stack by layer, including the parts I have not fully cleaned up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Overall structure
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;├── apps/
│   ├── web/      Next.js 15 (App Router) + React 19
│   └── mobile/   Expo SDK 54 (React Native 0.81)
└── packages/
    ├── api/            shared tRPC AppRouter type
    └── design-tokens/  color, spacing, and font constants
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Monorepo: Turborepo and pnpm workspace&lt;/li&gt;
&lt;li&gt;API: tRPC v11, with the router implementation in Next.js and only its types shared with mobile&lt;/li&gt;
&lt;li&gt;Authentication: NextAuth v5 beta, with a JWT bridge for mobile&lt;/li&gt;
&lt;li&gt;Database: Drizzle ORM and Cloudflare D1&lt;/li&gt;
&lt;li&gt;Web UI: Tailwind CSS v4, a shadcn/ui-style component layer, and Base UI (&lt;code&gt;@base-ui/react&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Mobile UI: React Native &lt;code&gt;StyleSheet&lt;/code&gt; and shared design tokens&lt;/li&gt;
&lt;li&gt;Tests: Vitest for unit tests and Storybook browser mode tests&lt;/li&gt;
&lt;li&gt;Infrastructure: Cloudflare Workers through &lt;code&gt;@opennextjs/cloudflare&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  API: remove duplicate types with tRPC v11
&lt;/h2&gt;

&lt;p&gt;I do not run a separate API server. The Expo app calls the Next.js &lt;code&gt;/api/trpc&lt;/code&gt; endpoint. &lt;code&gt;packages/api&lt;/code&gt; is a small package that re-exports the router type. Some parts of the codebase still import &lt;code&gt;AppRouter&lt;/code&gt; directly from the web app, so the package boundary is not completely settled, but the type-sharing direction is consistent.&lt;/p&gt;

&lt;p&gt;The reason for choosing tRPC is simple. When one person writes both the web and mobile apps, maintaining API types in two places wastes time. I also considered REST with OpenAPI generation, but avoiding a generation step made tRPC a better fit for these projects.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authentication: NextAuth v5 on web and JWT on mobile
&lt;/h2&gt;

&lt;p&gt;The web app uses NextAuth cookie sessions with Google and Apple OAuth. Cookies are awkward on mobile, so the server issues a JWT after OAuth completes. Expo stores it in SecureStore and sends it in a Bearer header. The server context accepts either a cookie session or a Bearer token.&lt;/p&gt;

&lt;p&gt;This was the part of the stack that took the most design work. Once the bridge was in place, the rest of the mobile API integration was much more direct.&lt;/p&gt;

&lt;h2&gt;
  
  
  Database: Drizzle and D1
&lt;/h2&gt;

&lt;p&gt;The database is Cloudflare D1, which is SQLite-compatible, and the ORM is Drizzle. I like this combination for two reasons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Drizzle relational queries make it easier to build SQL that fetches related records together.&lt;/li&gt;
&lt;li&gt;drizzle-kit migrations fit D1's migration flow. I can apply the same SQL locally and in production.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;D1 also has constraints. It does not expose interactive transactions, so multiple statements use &lt;code&gt;batch()&lt;/code&gt;. Some PRAGMAs are unavailable as well. These differences matter if you approach it like a local SQLite connection, but they have not blocked the CRUD-heavy features in these apps.&lt;/p&gt;

&lt;h2&gt;
  
  
  UI: Tailwind and Base UI on web, shared tokens across platforms
&lt;/h2&gt;

&lt;p&gt;The main web app has a shadcn/ui-style setup. Components live in my repository, cva manages variants, and Base UI provides the primitives. The other app uses a simpler setup centered on plain Tailwind. I adjust the UI layer to the size of each project.&lt;/p&gt;

&lt;p&gt;The web and mobile apps share semantic names for colors, spacing, and fonts. Mobile imports the tokens directly into &lt;code&gt;StyleSheet&lt;/code&gt;, while web repeats the same values as CSS variables. The web values are synchronized manually, so this is not a perfect single source of truth, but it removes many one-off color choices.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tests: stories double as tests
&lt;/h2&gt;

&lt;p&gt;Vitest handles logic-level unit tests. Storybook stories also run as browser mode tests in real Chromium. Writing a component catalog and writing a component test become the same task, which makes the test suite grow more naturally in a one-person project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Infrastructure: mostly Cloudflare
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Next.js deploys to Workers through &lt;code&gt;@opennextjs/cloudflare&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;D1 stores application data, and Workers serves static assets.&lt;/li&gt;
&lt;li&gt;Production and development use separate deployments and separate D1 databases.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I chose this setup for cost and low operational overhead. Current usage fits within the free tiers for Workers and D1, and I do not manage a server.&lt;/p&gt;

&lt;p&gt;Deployment is one command chain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;opennextjs-cloudflare build &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; wrangler deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mobile app follows the standard Expo build and App Store submission flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Weak points in the stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;NextAuth v5 is still beta, so I accept the risk of breaking changes.&lt;/li&gt;
&lt;li&gt;D1 has less community material than older databases. When search results are thin, I read the official documentation and source.&lt;/li&gt;
&lt;li&gt;Mobile store review cannot be removed by a stack choice. Web changes deploy immediately, while app releases require review, so the API needs backward compatibility.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reuse matters more on the second project
&lt;/h2&gt;

&lt;p&gt;I did not evaluate this stack only by how fast it built the first project. I cared about how much could carry into the second one. The monorepo structure, authentication bridge, design tokens, and deployment procedure all became reusable starting points. That let the second project begin with its product-specific features instead of another round of infrastructure selection.&lt;/p&gt;

&lt;p&gt;I have separate articles that go deeper into tRPC type sharing, D1 migration, and Base UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Environment
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Turborepo, pnpm, Next.js 15, React 19, Expo SDK 54, and React Native 0.81&lt;/li&gt;
&lt;li&gt;tRPC v11, TanStack Query v5, superjson, NextAuth v5 beta, and jose&lt;/li&gt;
&lt;li&gt;Drizzle ORM 0.41, Cloudflare D1, Tailwind CSS v4, &lt;code&gt;@base-ui/react&lt;/code&gt;, and cva&lt;/li&gt;
&lt;li&gt;Vitest, Storybook with nextjs-vite, Playwright, &lt;code&gt;@opennextjs/cloudflare&lt;/code&gt;, and Wrangler&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>nextjs</category>
      <category>expo</category>
      <category>cloudflare</category>
      <category>trpc</category>
    </item>
    <item>
      <title>Three problems I hit deploying Next.js 16 to Cloudflare Workers with OpenNext</title>
      <dc:creator>hiro@</dc:creator>
      <pubDate>Mon, 31 Aug 2026 01:51:15 +0000</pubDate>
      <link>https://dev.to/hirodeath/three-problems-i-hit-deploying-nextjs-16-to-cloudflare-workers-with-opennext-hac</link>
      <guid>https://dev.to/hirodeath/three-problems-i-hit-deploying-nextjs-16-to-cloudflare-workers-with-opennext-hac</guid>
      <description>&lt;p&gt;&lt;em&gt;This article is an English translation of the original Japanese article.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I deployed a Next.js application with App Router, API routes, and D1 to Cloudflare Workers through &lt;code&gt;@opennextjs/cloudflare&lt;/code&gt;. The basic process followed the official documentation, but I ran into three problems that were not covered there. This article records the errors and the fixes.&lt;/p&gt;

&lt;p&gt;The basic deployment flow looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i @opennextjs/cloudflare@latest
npm i &lt;span class="nt"&gt;-D&lt;/span&gt; wrangler@latest
&lt;span class="c"&gt;# Add wrangler.jsonc and open-next.config.ts&lt;/span&gt;
npx opennextjs-cloudflare build
npx opennextjs-cloudflare deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Problem 1: Next.js 16.2.10 fails the peer dependency check
&lt;/h2&gt;

&lt;p&gt;Installing &lt;code&gt;@opennextjs/cloudflare&lt;/code&gt; immediately failed with a peer dependency error.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm error Could not resolve dependency:
npm error peer next@"&amp;gt;=15.5.21 &amp;lt;16 || &amp;gt;=16.2.11" from @opennextjs/cloudflare@1.20.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The project used Next.js 16.2.10, while the adapter required 16.2.11 or newer for the 16.x line. The project missed the accepted range by one patch version.&lt;/p&gt;

&lt;p&gt;The fix was a Next.js patch update.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i next@16.2 eslint-config-next@16.2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--legacy-peer-deps&lt;/code&gt; could force the installation, but I saw no reason to keep a version that the adapter explicitly excludes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Problem 2: generated Wrangler types collide with DOM types
&lt;/h2&gt;

&lt;p&gt;I ran &lt;code&gt;wrangler types&lt;/code&gt; to add types for the D1 binding. TypeScript errors then appeared throughout the application.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error TS18046: 'body' is of type 'unknown'.
error TS2339: Property 'name' does not exist on type 'object'.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generated &lt;code&gt;worker-configuration.d.ts&lt;/code&gt; includes global Workers runtime types such as &lt;code&gt;Request&lt;/code&gt;, &lt;code&gt;Response&lt;/code&gt;, and &lt;code&gt;Body&lt;/code&gt;. Those names overlap with the DOM types expected by Next.js. Once both sets of globals entered the same project, &lt;code&gt;request.json()&lt;/code&gt; returned &lt;code&gt;unknown&lt;/code&gt; instead of the DOM definition's type.&lt;/p&gt;

&lt;p&gt;I cover the details in a separate article. The short version is to stop generating the full runtime definitions and write a small declaration file that imports only the required binding types.&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;// cloudflare-env.d.ts, written manually&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;D1Database&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Fetcher&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@cloudflare/workers-types&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kr"&gt;declare&lt;/span&gt; &lt;span class="nb"&gt;global&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;CloudflareEnv&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;DB&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;D1Database&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nl"&gt;ASSETS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Fetcher&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Problem 3: a temporary 404 with error code 1042 after deployment
&lt;/h2&gt;

&lt;p&gt;The deploy command succeeded and printed the URL, but the first curl request returned a 404. The response body contained only this Cloudflare error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error code: 1042
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Error 1042 normally means that a Worker tried to make a subrequest to its own hostname. For a moment, I suspected that OpenNext asset loading was broken.&lt;/p&gt;

&lt;p&gt;In this deployment, all routes returned 200 after a short wait and the error did not return. I also observed a period where a HEAD request returned 200 while GET returned 404. Error 1042 is still a real subrequest error, so a persistent failure requires checking Worker routes and request targets.&lt;/p&gt;

&lt;p&gt;If it happens only immediately after deployment, retrying after one or two minutes is worth doing. If it continues, do not assume propagation is the cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  Additional notes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;wrangler.jsonc&lt;/code&gt; needs the &lt;code&gt;nodejs_compat&lt;/code&gt; compatibility flag for OpenNext.&lt;/li&gt;
&lt;li&gt;For local testing, building first and then running &lt;code&gt;wrangler dev&lt;/code&gt; reproduced the Workers runtime more directly than &lt;code&gt;opennextjs-cloudflare preview&lt;/code&gt; in my setup.&lt;/li&gt;
&lt;li&gt;With D1, adding &lt;code&gt;initOpenNextCloudflareForDev()&lt;/code&gt; to &lt;code&gt;next.config.ts&lt;/code&gt; lets &lt;code&gt;next dev&lt;/code&gt; use local D1 and keeps development and production code paths closer.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Problem&lt;/th&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Peer dependency&lt;/td&gt;
&lt;td&gt;Next.js 16.2.10 is excluded&lt;/td&gt;
&lt;td&gt;Update to Next.js 16.2.11 or newer&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;wrangler types&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;json()&lt;/code&gt; becomes &lt;code&gt;unknown&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Use a handwritten declaration file with &lt;code&gt;import type&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error 1042&lt;/td&gt;
&lt;td&gt;Temporary 404 after deployment&lt;/td&gt;
&lt;td&gt;Retry, then inspect routing if it persists&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each problem has a small fix once identified, but the first error message does not always point to the correct layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Environment
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Next.js 16.2, &lt;code&gt;@opennextjs/cloudflare&lt;/code&gt; 1.20, and Wrangler 4.113&lt;/li&gt;
&lt;li&gt;Cloudflare Workers and D1&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>nextjs</category>
      <category>cloudflare</category>
      <category>workers</category>
      <category>opennext</category>
    </item>
    <item>
      <title>Adding Expo authentication to a NextAuth-based web service</title>
      <dc:creator>hiro@</dc:creator>
      <pubDate>Sun, 30 Aug 2026 01:55:49 +0000</pubDate>
      <link>https://dev.to/hirodeath/adding-expo-authentication-to-a-nextauth-based-web-service-1dhp</link>
      <guid>https://dev.to/hirodeath/adding-expo-authentication-to-a-nextauth-based-web-service-1dhp</guid>
      <description>&lt;p&gt;&lt;em&gt;This article is an English translation of the original Japanese article.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I added an Expo app to an existing Next.js web service that already used NextAuth, now Auth.js, for authentication. NextAuth assumes a cookie-based session, which does not fit a mobile app particularly well.&lt;/p&gt;

&lt;p&gt;Replacing the authentication platform was one option. Instead, I kept NextAuth on the web and added a JWT issuing endpoint for mobile. This article records the structure I use in a live service and the problems that remain. It is not a finished authentication blueprint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Overall flow
&lt;/h2&gt;

&lt;p&gt;There are three parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Web, built with Next.js: NextAuth with Google and Apple OAuth, using a cookie session&lt;/li&gt;
&lt;li&gt;Mobile, built with Expo: a Bearer JWT rather than cookies&lt;/li&gt;
&lt;li&gt;Bridge: run the web OAuth flow in an in-app browser and return a JWT to the app when it completes
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Mobile app                Web (Next.js)
  │ openAuthSessionAsync     │
  ├────────────────────────→ │ /api/auth/mobile-callback?action=login&amp;amp;redirect=exp://...
  │                          │   → NextAuth sign-in → Google OAuth
  │                          │   → OAuth completes and cookie session is created
  │                          │   → issue JWT
  │ ←──────────────────────  │   → redirect to exp://...?token=xxx
  │ save token in SecureStore│
  │                          │
  │ Later API calls: Authorization: Bearer xxx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Mobile: use &lt;code&gt;openAuthSessionAsync&lt;/code&gt; as the bridge
&lt;/h2&gt;

&lt;p&gt;The Expo login button only needs to open the web login flow with &lt;code&gt;expo-web-browser&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;WebBrowser&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;expo-web-browser&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;redirectUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Linking&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createURL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// exp:// or squadnote://&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;loginUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;apiUrl&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/api/auth/mobile-callback?action=login&amp;amp;redirect=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;redirectUrl&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;WebBrowser&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;openAuthSessionAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;loginUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;redirectUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// result.url contains exp://...?token=xxx&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;openAuthSessionAsync&lt;/code&gt; opens a browser session for authentication and returns to the app when a redirect to the requested scheme occurs. That makes it a practical bridge for the OAuth flow.&lt;/p&gt;

&lt;p&gt;I store the returned token with &lt;code&gt;expo-secure-store&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;SecureStore&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;expo-secure-store&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;setToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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;await&lt;/span&gt; &lt;span class="nx"&gt;SecureStore&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItemAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;squadnote_jwt&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Server: let &lt;code&gt;mobile-callback&lt;/code&gt; issue the JWT
&lt;/h2&gt;

&lt;p&gt;I added a mobile-specific callback Route Handler to the web app. It does three things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;On &lt;code&gt;?action=login&lt;/code&gt;, save the mobile redirect target in a cookie and continue to NextAuth sign-in.&lt;/li&gt;
&lt;li&gt;After OAuth returns, call &lt;code&gt;auth()&lt;/code&gt; to identify the user from the new cookie session.&lt;/li&gt;
&lt;li&gt;Issue a JWT and redirect to the mobile scheme with &lt;code&gt;?token=xxx&lt;/code&gt;.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// /api/auth/mobile-callback, shortened&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;Request&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;session&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;auth&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// Cookie session exists after OAuth&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="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="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* continue to sign-in */&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;token&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;signJwt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;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;secret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// signed with jose&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;mobileRedirect&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;?token=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="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;h3&gt;
  
  
  Redirect validation and its limitations
&lt;/h3&gt;

&lt;p&gt;If the &lt;code&gt;redirect&lt;/code&gt; parameter accepts any URL, the server could send the JWT somewhere unintended. My current implementation limits redirects to schemes used by 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="c1"&gt;// Allow Expo development schemes or the production custom scheme&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isAllowedMobileRedirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&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="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;exp://&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nx"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;exps://&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
    &lt;span class="nx"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;squadnote://&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I do not consider this prefix check sufficient. Parsing the URL and validating the host and path as well as the scheme would be safer. The development &lt;code&gt;exp://&lt;/code&gt; allowance is broad, so production should accept only a fixed callback under the custom scheme.&lt;/p&gt;

&lt;p&gt;The other problem is placing the JWT itself in the redirect URL. Expo AuthSession can receive an authorization code instead. I plan to return a short-lived code to the app and exchange it for a JWT on the server. I would not use the current code unchanged as an authentication template.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let the API accept cookies and Bearer tokens
&lt;/h2&gt;

&lt;p&gt;The tRPC or API context first checks the cookie session. If no session exists, it falls back to a Bearer token.&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;// tRPC context, shortened&lt;/span&gt;
&lt;span class="c1"&gt;// Web: cookie session / Mobile: Authorization: Bearer &amp;lt;JWT&amp;gt;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&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;auth&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;authHeader&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;authorization&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;authHeader&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Bearer &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;payload&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;verifyJwt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;authHeader&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// Build a session-like value from the user ID in payload&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;Web and mobile can then call the same procedures. The mobile tRPC client adds the token in its &lt;code&gt;headers()&lt;/code&gt; callback for every request. The authentication paths differ, but authorization stays shared at the procedure layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two operational pieces worth adding early
&lt;/h2&gt;

&lt;p&gt;The first is automatic logout on 401. Tokens eventually expire. Wrapping &lt;code&gt;fetch&lt;/code&gt; so it deletes the token and returns to the login screen avoids adding the same error handling to every screen.&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;authAwareFetch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;fetch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;init&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;init&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&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="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;handleUnauthorized&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apple sign-in uses a separate native path through &lt;code&gt;expo-apple-authentication&lt;/code&gt;. The app posts the native credential to the server, which verifies it and returns the same JWT format. Token storage and subsequent API calls are shared with the browser-based path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tradeoffs in the current design
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;JWT revocation relies only on expiration. Immediate remote logout would require storing token state in the database or adding a version number.&lt;/li&gt;
&lt;li&gt;I left NextAuth's session strategy and callbacks alone, so the web authentication path did not need to change. Preserving the working web login was the main constraint.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This structure let me add the app without replacing the existing web authentication. The JWT in the redirect URL and the redirect validation still need work. I found it useful to separate the structure needed to get the app running from the security work required before treating it as a reusable design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Environment
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Expo SDK 54 with expo-web-browser, expo-secure-store, and expo-apple-authentication&lt;/li&gt;
&lt;li&gt;Next.js 15, NextAuth v5 beta, jose, and tRPC v11&lt;/li&gt;
&lt;li&gt;The code is shortened from a service in production&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>expo</category>
      <category>nextauth</category>
      <category>reactnative</category>
      <category>jwt</category>
    </item>
    <item>
      <title>Cloudflare D1 has no BEGIN TRANSACTION, so I tested its limits and batch API</title>
      <dc:creator>hiro@</dc:creator>
      <pubDate>Sat, 29 Aug 2026 04:27:23 +0000</pubDate>
      <link>https://dev.to/hirodeath/cloudflare-d1-has-no-begin-transaction-so-i-tested-its-limits-and-batch-api-5813</link>
      <guid>https://dev.to/hirodeath/cloudflare-d1-has-no-begin-transaction-so-i-tested-its-limits-and-batch-api-5813</guid>
      <description>&lt;p&gt;&lt;em&gt;This article is an English translation of the original Japanese article.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Cloudflare D1 is commonly described as SQLite-compatible, and most SQL works as expected. After migrating from local SQLite, however, I found several places where treating D1 like a normal SQLite connection fails.&lt;/p&gt;

&lt;p&gt;The biggest difference is that D1 does not expose explicit transactions. I ran the commands myself to see the exact errors and then adjusted the application design around &lt;code&gt;batch()&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test 1: &lt;code&gt;BEGIN TRANSACTION&lt;/code&gt; is rejected
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;wrangler d1 execute my-db &lt;span class="nt"&gt;--local&lt;/span&gt; &lt;span class="nt"&gt;--command&lt;/span&gt; &lt;span class="s2"&gt;"BEGIN TRANSACTION"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;D1 runs your SQL in a transaction for you.
Please export an SQL file from your SQLite database and try again.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The error is direct. D1 wraps an individual query, or a batch, in a transaction automatically. It does not let application code keep a connection open and choose an arbitrary &lt;code&gt;BEGIN&lt;/code&gt; and &lt;code&gt;COMMIT&lt;/code&gt; boundary.&lt;/p&gt;

&lt;p&gt;Long interactive transactions are also a poor match for a serverless database connection model, so this limitation is part of the design rather than a missing SQLite command.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test 2: most PRAGMAs are restricted
&lt;/h2&gt;

&lt;p&gt;I also tried applying a setting from the local SQLite setup.&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;wrangler d1 execute my-db &lt;span class="nt"&gt;--local&lt;/span&gt; &lt;span class="nt"&gt;--command&lt;/span&gt; &lt;span class="s2"&gt;"PRAGMA journal_mode = WAL"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✘ [ERROR] not authorized: SQLITE_AUTH
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Low-level settings such as journal mode belong to D1's managed environment. They cannot be changed by the application. During migration, remove those &lt;code&gt;PRAGMA&lt;/code&gt; statements from the schema. Foreign key enforcement is enabled by default, so &lt;code&gt;PRAGMA foreign_keys = ON&lt;/code&gt; is not needed either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use &lt;code&gt;batch()&lt;/code&gt; for atomic groups of statements
&lt;/h2&gt;

&lt;p&gt;A common requirement is to insert one record, update another, and roll everything back if either statement fails. D1 handles that with &lt;code&gt;batch()&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Raw D1 API&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&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;batch&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;DB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;INSERT INTO orders (id, user_id) VALUES (?, ?)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orderId&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;env&lt;/span&gt;&lt;span class="p"&gt;.&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;prepare&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;UPDATE inventory SET stock = stock - 1 WHERE item_id = ?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;bind&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;itemId&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;D1 runs the statements in a batch as one transaction and rolls back the entire batch when a statement fails. It also sends the statements in one round trip, which matters because each D1 query adds network latency.&lt;/p&gt;

&lt;p&gt;Drizzle ORM exposes the same model through &lt;code&gt;db.batch([...])&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="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;batch&lt;/span&gt;&lt;span class="p"&gt;([&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;orders&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="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;orderId&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;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;inventory&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;stock&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;inventory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stock&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; - 1`&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;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;inventory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;itemId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;itemId&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;
  
  
  Read-then-write logic is the real design boundary
&lt;/h2&gt;

&lt;p&gt;A batch works when the complete list of statements is known before execution. A typical interactive SQLite transaction may instead do this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the current value with &lt;code&gt;SELECT&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Make a decision in application code.&lt;/li&gt;
&lt;li&gt;Run a different &lt;code&gt;UPDATE&lt;/code&gt; based on that result.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There is no place for application-side branching inside a D1 batch. I usually handle that in one of these ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fold the condition into one statement. For example, run &lt;code&gt;UPDATE inventory SET stock = stock - 1 WHERE item_id = ? AND stock &amp;gt; 0&lt;/code&gt;, then inspect whether &lt;code&gt;meta.changes&lt;/code&gt; is zero.&lt;/li&gt;
&lt;li&gt;Use optimistic locking. Include the version read earlier in the &lt;code&gt;UPDATE&lt;/code&gt; condition and retry if no row changes.&lt;/li&gt;
&lt;li&gt;Move the part that needs strict serialized access to Durable Objects. A single-entity coordination problem can be a better fit for DO storage than D1.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the CRUD applications I migrated, moving the condition into one statement covered nearly every case. I did not end up with a feature that required an interactive transaction.&lt;/p&gt;

&lt;h2&gt;
  
  
  SQLite habits and their D1 equivalents
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;SQLite habit&lt;/th&gt;
&lt;th&gt;D1 behavior&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;BEGIN&lt;/code&gt; ... &lt;code&gt;COMMIT&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Unavailable. D1 uses automatic transactions.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;PRAGMA journal_mode&lt;/code&gt; and similar settings&lt;/td&gt;
&lt;td&gt;Unavailable with &lt;code&gt;SQLITE_AUTH&lt;/code&gt;. D1 manages them.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Atomic execution of multiple statements&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;batch()&lt;/code&gt; for one transaction and one round trip.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Read, decide, then write&lt;/td&gt;
&lt;td&gt;Fold into &lt;code&gt;WHERE&lt;/code&gt;, use optimistic locking, or use Durable Objects when needed.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"SQLite-compatible" describes the SQL dialect, not an identical transaction model. Knowing that distinction before migration prevents designing around a connection model D1 does not provide.&lt;/p&gt;

&lt;h2&gt;
  
  
  Environment
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Cloudflare D1 and Wrangler 4.113, tested with &lt;code&gt;--local&lt;/code&gt;; remote D1 has the same restriction&lt;/li&gt;
&lt;li&gt;Drizzle ORM 0.41 batch API&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cloudflare</category>
      <category>d1</category>
      <category>sqlite</category>
      <category>database</category>
    </item>
    <item>
      <title>Keeping colors consistent between Tailwind on the web and React Native</title>
      <dc:creator>hiro@</dc:creator>
      <pubDate>Fri, 28 Aug 2026 07:15:56 +0000</pubDate>
      <link>https://dev.to/hirodeath/keeping-colors-consistent-between-tailwind-on-the-web-and-react-native-3o35</link>
      <guid>https://dev.to/hirodeath/keeping-colors-consistent-between-tailwind-on-the-web-and-react-native-3o35</guid>
      <description>&lt;p&gt;&lt;em&gt;This article is an English translation of the original Japanese article.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I maintain an indie service with both a Next.js web app and an Expo iOS app. Design consistency does not happen by itself because the web app uses Tailwind utilities while React Native uses &lt;code&gt;StyleSheet&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I added a small &lt;code&gt;packages/design-tokens&lt;/code&gt; package to the monorepo so both apps use the same names for colors, typography, spacing, and border radii. React Native imports the package directly. The web app defines the same values as CSS variables. It is not a fully generated single source of truth, but it has been enough to reduce color drift in a small project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Project structure
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;├── apps/
│   ├── web/      ← Next.js + Tailwind CSS v4
│   └── mobile/   ← Expo (React Native StyleSheet)
└── packages/
    └── design-tokens/
        └── src/
            ├── colors.ts      ← semantic colors
            ├── typography.ts  ← font sizes and weights
            ├── spacing.ts     ← spacing and layout
            └── radius.ts      ← border radii
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tokens are plain TypeScript constants. I did not add a build tool or a transformation layer such as Style Dictionary.&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;// packages/design-tokens/src/colors.ts&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;colors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="cm"&gt;/** Main color for buttons, links, and active states */&lt;/span&gt;
  &lt;span class="na"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#0891b2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;primaryForeground&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#ffffff&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

  &lt;span class="cm"&gt;/** Page background */&lt;/span&gt;
  &lt;span class="na"&gt;background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#f9fafb&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;foreground&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#111827&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

  &lt;span class="cm"&gt;/** Cards and panels */&lt;/span&gt;
  &lt;span class="na"&gt;card&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#ffffff&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;cardForeground&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#111827&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;

  &lt;span class="cm"&gt;/** Destructive actions */&lt;/span&gt;
  &lt;span class="na"&gt;destructive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#ef4444&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;destructiveForeground&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;#ffffff&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="c1"&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;The important part is using semantic names such as &lt;code&gt;primary&lt;/code&gt; and &lt;code&gt;destructive&lt;/code&gt;. Calling a token "main color" rather than "cyan 500" lets its consumers think about meaning instead of a particular shade. Changing a web color still requires synchronizing the CSS variables described below.&lt;/p&gt;

&lt;h2&gt;
  
  
  React Native: import the tokens directly
&lt;/h2&gt;

&lt;p&gt;The mobile side is straightforward because &lt;code&gt;StyleSheet&lt;/code&gt; can use the TypeScript constants directly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// apps/mobile/src/app/login.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;colors&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;textColors&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@squadnote/design-tokens&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;styles&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;StyleSheet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;container&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;backgroundColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;colors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;background&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;color&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;textColors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;button&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;backgroundColor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;colors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;primary&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 &lt;code&gt;as const&lt;/code&gt; assertion preserves literal types, so the editor can suggest every available token. On the React Native side, the lack of extra machinery is one of the best parts of this setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tailwind v4: define the same values as CSS variables
&lt;/h2&gt;

&lt;p&gt;The web side needs one extra layer because Tailwind classes cannot read JavaScript constants. CSS variables provide the meeting point.&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="c"&gt;/* apps/web/src/styles/globals.css */&lt;/span&gt;
&lt;span class="nd"&gt;:root&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c"&gt;/* Light theme: same HEX values as @squadnote/design-tokens */&lt;/span&gt;
  &lt;span class="py"&gt;--background&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#f9fafb&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--foreground&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#111827&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--card&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#ffffff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--primary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#0891b2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--primary-foreground&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#ffffff&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c"&gt;/* ... */&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tailwind v4 can map CSS variables to utilities through &lt;code&gt;@theme&lt;/code&gt;. Classes such as &lt;code&gt;bg-primary&lt;/code&gt; and &lt;code&gt;text-foreground&lt;/code&gt; then use the same HEX values as the mobile app.&lt;/p&gt;

&lt;p&gt;I synchronize this part manually. A comment next to the CSS variables says that they match &lt;code&gt;design-tokens&lt;/code&gt;, and I update both places when a color changes. I could generate CSS from TypeScript, but color changes are infrequent enough that another build step would cost more than it saves. If the token set grows much larger, I will probably switch to generation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The main benefit is removing color selection from routine work
&lt;/h2&gt;

&lt;p&gt;Since adding the package, I make fewer one-off color decisions on new screens. Instead of deciding which color a border should use, I start with &lt;code&gt;colors.border&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The same semantic names can also be used for Figma variables. That removes some ambiguity when moving from a mockup to implementation, especially when the same person handles both design and code.&lt;/p&gt;

&lt;h2&gt;
  
  
  When this setup fits
&lt;/h2&gt;

&lt;p&gt;This works well for a small team building both web and React Native apps without enough tokens to justify a dedicated transformation pipeline. The tradeoff is that the web values still require manual synchronization.&lt;/p&gt;

&lt;p&gt;For extensive theme switching, including dark mode, or two-way synchronization with design tools, I would start with a system such as Style Dictionary or Tokens Studio. Manually duplicated CSS variables become difficult to maintain as the number of themes grows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Environment
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Turborepo and pnpm workspace&lt;/li&gt;
&lt;li&gt;Web: Next.js 15 and Tailwind CSS v4&lt;/li&gt;
&lt;li&gt;Mobile: Expo SDK 54 and React Native 0.81&lt;/li&gt;
&lt;li&gt;The code samples are shortened extracts from a service in production&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>designtokens</category>
      <category>reactnative</category>
      <category>tailwindcss</category>
      <category>expo</category>
    </item>
    <item>
      <title>Protecting a personal web app with Cloudflare Access and email OTP</title>
      <dc:creator>hiro@</dc:creator>
      <pubDate>Thu, 27 Aug 2026 05:17:44 +0000</pubDate>
      <link>https://dev.to/hirodeath/protecting-a-personal-web-app-with-cloudflare-access-and-email-otp-7bn</link>
      <guid>https://dev.to/hirodeath/protecting-a-personal-web-app-with-cloudflare-access-and-email-otp-7bn</guid>
      <description>&lt;p&gt;&lt;em&gt;This article is an English translation of the original Japanese article.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I deployed a personal task management app to Cloudflare Workers, but I did not want anyone with the URL to be able to open it. Building and maintaining a login system for an app with one user felt excessive.&lt;/p&gt;

&lt;p&gt;Cloudflare Access fit this use case. It can place email one-time-code authentication in front of a &lt;code&gt;workers.dev&lt;/code&gt; URL without any application code changes, and the setup is available on the free plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I chose Access instead of an application login
&lt;/h2&gt;

&lt;p&gt;My decision came down to three points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For an app with static assets and APIs, adding a login screen does not stop someone from downloading the JavaScript bundle directly. Any data included in the bundle remains visible.&lt;/li&gt;
&lt;li&gt;Access authenticates the request at the edge before it reaches the application. HTML, JavaScript, and API routes receive the same protection.&lt;/li&gt;
&lt;li&gt;There is no login implementation or password store to maintain. The user receives a code by email.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Putting authentication in front of the application usually requires infrastructure work. Since this app already runs on Cloudflare, it was a dashboard setting.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Enable Access on the &lt;code&gt;workers.dev&lt;/code&gt; URL
&lt;/h3&gt;

&lt;p&gt;In the Cloudflare dashboard, open Workers &amp;amp; Pages, select the Worker, and open Domains &amp;amp; Routes. The &lt;code&gt;workers.dev&lt;/code&gt; URL has a visibility dropdown. Choose the option to protect it with Access.&lt;/p&gt;

&lt;p&gt;Cloudflare then confirms that visitors must sign in and match an Access policy before the Worker loads. Once enabled, Access handles the request before the Worker.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Allow only your email address
&lt;/h3&gt;

&lt;p&gt;Open the Cloudflare Access management screen and restrict the policy to your own email address. Opening the URL now follows this flow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Access redirects to its sign-in page.&lt;/li&gt;
&lt;li&gt;Entering the email address sends a six-digit one-time code.&lt;/li&gt;
&lt;li&gt;Entering the code opens the application.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The same flow works from a phone.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Close the preview URL too
&lt;/h3&gt;

&lt;p&gt;The same dashboard also includes the preview URL, which looks like &lt;code&gt;*-project-name.subdomain.workers.dev&lt;/code&gt;. Protecting the main URL while leaving the preview public can expose the latest deployment through that route. Disable the preview URL if you do not use it, or protect it with Access as well.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Complete Zero Trust setup if needed
&lt;/h3&gt;

&lt;p&gt;The first visit to the Access management screen may ask you to select a plan. The Free plan covers up to 50 users at no charge. Cloudflare may ask for a payment method during setup, but the Free plan itself does not create a charge.&lt;/p&gt;

&lt;p&gt;You can also change the session duration under Access, Applications, and the selected application. For a personal app, extending it up to one month avoids entering a code every day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify that Access is active
&lt;/h2&gt;

&lt;p&gt;Opening the app in a private browser window should show the Access login page. You can also check with curl.&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="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; https://my-app.example.workers.dev/
302
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An unauthenticated request should receive a 302 redirect to &lt;code&gt;*.cloudflareaccess.com&lt;/code&gt; instead of reaching the application. Check an &lt;code&gt;/api/...&lt;/code&gt; route as well to confirm that the protection applies to both pages and APIs.&lt;/p&gt;

&lt;h2&gt;
  
  
  About the Access JWT warning
&lt;/h2&gt;

&lt;p&gt;When enabling Access, the dashboard warns that Workers handling sensitive data should validate the Access JWT and reject requests that bypass Access.&lt;/p&gt;

&lt;p&gt;This protects against future routes that do not pass through the configured Access application. My current setup exposes only the protected &lt;code&gt;workers.dev&lt;/code&gt; URL. Adding a custom domain or another route would change that assumption. For sensitive data, validating the Access JWT inside the Worker adds another boundary instead of relying only on the entry route configuration. The AUD tag and JWK public key URL used for validation are not secrets like passwords or API keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this setup provides
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Access restricts a personal app without adding application login code.&lt;/li&gt;
&lt;li&gt;A &lt;code&gt;workers.dev&lt;/code&gt; URL can be protected directly from the dashboard without a custom domain.&lt;/li&gt;
&lt;li&gt;Preview URLs need their own protection or should be disabled.&lt;/li&gt;
&lt;li&gt;A longer session duration keeps the app convenient for daily personal use.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Environment
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Cloudflare Workers on a &lt;code&gt;workers.dev&lt;/code&gt; domain and Cloudflare Access on the Zero Trust Free plan&lt;/li&gt;
&lt;li&gt;The application uses Next.js and &lt;code&gt;@opennextjs/cloudflare&lt;/code&gt;, but the Access setup does not depend on the application framework&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>cloudflare</category>
      <category>workers</category>
      <category>zerotrust</category>
      <category>security</category>
    </item>
    <item>
      <title>Using Base UI with a shadcn/ui-style component layer in production</title>
      <dc:creator>hiro@</dc:creator>
      <pubDate>Wed, 26 Aug 2026 00:30:33 +0000</pubDate>
      <link>https://dev.to/hirodeath/using-base-ui-with-a-shadcnui-style-component-layer-in-production-43cd</link>
      <guid>https://dev.to/hirodeath/using-base-ui-with-a-shadcnui-style-component-layer-in-production-43cd</guid>
      <description>&lt;p&gt;&lt;em&gt;This article is an English translation of the original Japanese article.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I use Base UI (&lt;code&gt;@base-ui/react&lt;/code&gt;) for the UI primitives in an indie web service. Base UI is an unstyled React component library.&lt;/p&gt;

&lt;p&gt;I like the shadcn/ui approach of combining Tailwind, cva, and components that live in my own repository, but I wanted Base UI underneath that component layer. This article shows the structure I use and the differences I noticed after working with Radix-based components.&lt;/p&gt;

&lt;h2&gt;
  
  
  Overall structure
&lt;/h2&gt;

&lt;p&gt;Following the same idea as shadcn/ui, I keep thin wrappers that I own under &lt;code&gt;components/ui/&lt;/code&gt;. The only major change is that the primitives come from Base UI instead of Radix.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;components/ui/
├── button.tsx        ← @base-ui/react/button
├── dialog.tsx        ← @base-ui/react/dialog
├── tabs.tsx          ← @base-ui/react/tabs
├── sheet.tsx         ← built on dialog
├── avatar.tsx        ← @base-ui/react/avatar
├── dropdown-menu.tsx
├── select.tsx
└── ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is a shortened version of the button wrapper. It defines variants with cva and applies them to the Base UI primitive, which should look familiar if you have used shadcn/ui.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Button&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;ButtonPrimitive&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@base-ui/react/button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;cva&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;VariantProps&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;class-variance-authority&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buttonVariants&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cva&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 items-center justify-center rounded-lg text-sm font-medium transition-all focus-visible:ring-3 focus-visible:ring-ring/50 active:scale-[0.98] disabled:opacity-50 ...&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;variants&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;variant&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&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;border-primary bg-primary text-primary-foreground ...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;outline&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-border bg-background hover:bg-muted ...&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;destructive&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bg-destructive/10 text-destructive ...&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;size&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&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;h-8 px-2.5&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;sm&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;h-7 px-2.5&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;icon&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;size-8&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;h2&gt;
  
  
  Differences you notice when coming from Radix
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Imports use component subpaths
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Dialog&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;DialogPrimitive&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@base-ui/react/dialog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Button&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;ButtonPrimitive&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@base-ui/react/button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of packages such as &lt;code&gt;@radix-ui/react-dialog&lt;/code&gt;, Base UI uses one package with subpath imports. This has made dependency management simpler for me.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Types are organized into namespaces
&lt;/h3&gt;

&lt;p&gt;Base UI exposes types through namespaces such as &lt;code&gt;DialogPrimitive.Root.Props&lt;/code&gt;.&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;function&lt;/span&gt; &lt;span class="nf"&gt;DialogContent&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="nx"&gt;props&lt;/span&gt;
&lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;DialogPrimitive&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Popup&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Props&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;showCloseButton&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="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For wrappers, this is more direct than writing &lt;code&gt;React.ComponentProps&amp;lt;typeof DialogPrimitive.Content&amp;gt;&lt;/code&gt; and keeps the type declarations shorter.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Some parts have different names
&lt;/h3&gt;

&lt;p&gt;Radix calls the dialog overlay &lt;code&gt;Overlay&lt;/code&gt;, while Base UI calls it &lt;code&gt;Backdrop&lt;/code&gt;. The dialog body is &lt;code&gt;Popup&lt;/code&gt; rather than &lt;code&gt;Content&lt;/code&gt;. When porting a shadcn/ui dialog to Base UI, this is the mapping to remember.&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;DialogPortal&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;DialogPrimitive&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Backdrop&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;"fixed inset-0 bg-black/10 ..."&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;DialogPrimitive&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Popup&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;"fixed top-1/2 left-1/2 z-50 ..."&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="nc"&gt;DialogPrimitive&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Popup&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;DialogPortal&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. State attributes are &lt;code&gt;data-open&lt;/code&gt; and &lt;code&gt;data-closed&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Radix uses a single attribute such as &lt;code&gt;data-state="open"&lt;/code&gt;. Base UI adds attributes for each state, including &lt;code&gt;data-open&lt;/code&gt; and &lt;code&gt;data-closed&lt;/code&gt;. The Tailwind classes change accordingly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Radix:   data-[state=open]:animate-in  data-[state=closed]:animate-out
Base UI: data-open:animate-in          data-closed:animate-out
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is a small difference, but the class strings become noticeably shorter. That helps when a shadcn-style component layer contains many animated components.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it has been like to use
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Dialog, Popover, and Tooltip have structures that feel close to their Radix counterparts. The part names, data attributes, and type references still need to be translated.&lt;/li&gt;
&lt;li&gt;Radix still has more documentation and accumulated community knowledge. Searching an error rarely finds an article about Base UI, so I expect to read the official documentation and GitHub issues.&lt;/li&gt;
&lt;li&gt;Most shadcn/ui structure and class design can be reused. Replacing the primitives and mapping the names account for most of the work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If Radix already works for your project, there is no need to replace it in a hurry. I tried Base UI in a new project and adopted it while checking the differences in each component I needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Environment
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;@base-ui/react&lt;/code&gt; 1.3, Next.js 15 App Router, Tailwind CSS v4, and class-variance-authority&lt;/li&gt;
&lt;li&gt;The code samples are shortened extracts from a service in production&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>react</category>
      <category>baseui</category>
      <category>shadcn</category>
      <category>tailwindcss</category>
    </item>
    <item>
      <title>API Design That Allows Partial Failures and Saves Only Successful Assets</title>
      <dc:creator>hiro@</dc:creator>
      <pubDate>Tue, 25 Aug 2026 00:29:20 +0000</pubDate>
      <link>https://dev.to/hirodeath/api-design-that-allows-partial-failures-and-saves-only-successful-assets-212j</link>
      <guid>https://dev.to/hirodeath/api-design-that-allows-partial-failures-and-saves-only-successful-assets-212j</guid>
      <description>&lt;p&gt;&lt;em&gt;This article is an English translation of the original Japanese article.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;When generating, saving, and updating multiple assets to adopted state, treating one failure as total failure makes re-execution difficult. It also creates a mismatch where the external API has images but local history does not.&lt;/p&gt;

&lt;p&gt;In Pixel Studio, I finalize results per item and include both success count and failure details in the response.&lt;/p&gt;

&lt;h2&gt;
  
  
  Don't wrap everything in try/catch
&lt;/h2&gt;

&lt;p&gt;Process each item individually instead of the entire batch.&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;successes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;failures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;inputs&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;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;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;saveHistory&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;successes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&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;failures&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="na"&gt;sourceKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sourceKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&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="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If generation succeeds, save the history immediately. Saving everything after the loop means a late exception loses earlier results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Return stable identifiers
&lt;/h2&gt;

&lt;p&gt;Failure results include &lt;code&gt;sourceKey&lt;/code&gt; instead of array index.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"succeeded"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"failures"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"sourceKey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"history:image-42"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rate limited"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The UI can identify which asset failed even after sorting or re-fetching. Retries can target individual &lt;code&gt;sourceKey&lt;/code&gt; values.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separate saved from adopted
&lt;/h2&gt;

&lt;p&gt;Saving an image file is different from deciding to adopt it for the game. Assets have states like &lt;code&gt;review&lt;/code&gt;, &lt;code&gt;adopted&lt;/code&gt;, &lt;code&gt;rejected&lt;/code&gt; and an &lt;code&gt;assetPath&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Saved generation results start as review candidates. After human confirmation and adoption, they reconcile with the game manifest. Without this distinction, download success would be treated as adoption.&lt;/p&gt;

&lt;h2&gt;
  
  
  Only treat zero successes as request failure
&lt;/h2&gt;

&lt;p&gt;Return results if at least one item succeeded. Return an error response only when all items failed. The client can add successes to the list and notify about failures.&lt;/p&gt;

&lt;p&gt;Adopting partial success requires making the process closer to idempotent. Decide upfront how to handle re-saving the same &lt;code&gt;sourceKey&lt;/code&gt;, whether to reuse external job IDs, and whether to allow duplicate file names.&lt;/p&gt;

&lt;p&gt;Returning batch completion as only true or false makes the retry unit the entire batch. Finalizing successful items immediately and returning failures in an identifiable form lets you narrow the retry scope even when processing involves external APIs.&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>api</category>
      <category>batch</category>
      <category>nextjs</category>
    </item>
  </channel>
</rss>
