<?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: João Cunha</title>
    <description>The latest articles on DEV Community by João Cunha (@jctools).</description>
    <link>https://dev.to/jctools</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%2F3940868%2Fbed8551f-dd08-415d-8b50-ff96e7dd8023.png</url>
      <title>DEV Community: João Cunha</title>
      <link>https://dev.to/jctools</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jctools"/>
    <language>en</language>
    <item>
      <title>Building a Shopify Sidekick app extension: what the docs don't tell you</title>
      <dc:creator>João Cunha</dc:creator>
      <pubDate>Mon, 13 Jul 2026 16:28:06 +0000</pubDate>
      <link>https://dev.to/jctools/building-a-shopify-sidekick-app-extension-what-the-docs-dont-tell-you-335n</link>
      <guid>https://dev.to/jctools/building-a-shopify-sidekick-app-extension-what-the-docs-dont-tell-you-335n</guid>
      <description>&lt;p&gt;Shopify quietly opened &lt;a href="https://shopify.dev/docs/apps/build/sidekick" rel="noopener noreferrer"&gt;Sidekick app extensions&lt;/a&gt; to all developers on June 17, 2026. If you build Shopify apps, this is a new surface worth grabbing early: your app's data becomes queryable from the AI assistant that ships in every admin, and almost nobody has shipped one yet.&lt;/p&gt;

&lt;p&gt;I added one to my B2B onboarding app, Tradelane, in an afternoon. The happy path is genuinely small — but I hit four walls the docs don't warn you about. Here's the whole thing, walls included.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you're building
&lt;/h2&gt;

&lt;p&gt;A Sidekick &lt;strong&gt;data extension&lt;/strong&gt; is three files and a backend route:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;shopify.extension.toml&lt;/code&gt; — declares a headless UI extension with the special target &lt;code&gt;admin.app.tools.data&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tools.json&lt;/code&gt; — JSON Schema for the tools Sidekick can call&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;src/index.js&lt;/code&gt; — registers the tools; each one fetches from your app's backend&lt;/li&gt;
&lt;li&gt;a backend route that returns the data&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When a merchant asks Sidekick something your extension's description matches ("do I have pending wholesale applications?"), Sidekick calls your tool in a sandbox, your code fetches your backend, and the answer lands in the chat — with clickable links into your app.&lt;/p&gt;

&lt;h2&gt;
  
  
  The extension
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;extensions/sidekick-tools/shopify.extension.toml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="py"&gt;api_version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"2026-07"&lt;/span&gt;

&lt;span class="nn"&gt;[[extensions]]&lt;/span&gt;
&lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Tradelane Wholesale Application Tools"&lt;/span&gt;
&lt;span class="py"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Search and summarize B2B wholesale applications: pending, approved, and rejected applications, applicant details, EU VAT (VIES) verification status."&lt;/span&gt;
&lt;span class="py"&gt;handle&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"sidekick-tools"&lt;/span&gt;
&lt;span class="py"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"ui_extension"&lt;/span&gt;

  &lt;span class="nn"&gt;[[extensions.targeting]]&lt;/span&gt;
  &lt;span class="py"&gt;module&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"./src/index.js"&lt;/span&gt;
  &lt;span class="py"&gt;target&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"admin.app.tools.data"&lt;/span&gt;
  &lt;span class="py"&gt;tools&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"./tools.json"&lt;/span&gt;
  &lt;span class="py"&gt;instructions&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"./instructions.md"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;description&lt;/code&gt; is not decoration — it's how Sidekick decides your extension is relevant. Write it like you're prompting a model, because you are.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;src/index.js&lt;/code&gt; is tiny. The sandbox gives you a global &lt;code&gt;shopify&lt;/code&gt; object and authenticated fetch to your own backend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default &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;shopify&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;search_wholesale_applications&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;status&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="nx"&gt;first&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;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;params&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;status&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&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;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;query&lt;/span&gt;&lt;span class="dl"&gt;"&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;first&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;first&lt;/span&gt;&lt;span class="dl"&gt;"&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;first&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="s2"&gt;`/api/sidekick/applications?&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="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;&lt;code&gt;tools.json&lt;/code&gt; declares the input schema (name, description, JSON Schema &lt;code&gt;inputSchema&lt;/code&gt;). Same rule as the toml: tool descriptions are prompts. "Search extension for your app" gets you nothing; name the domain and the questions it answers.&lt;/p&gt;

&lt;p&gt;You also must add a summary to your app's main &lt;code&gt;shopify.app.toml&lt;/code&gt; — deploy fails without it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[sidekick]&lt;/span&gt;
&lt;span class="py"&gt;extensions_summary&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"Search and summarize B2B wholesale account applications and buyer verification status."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The backend route returns results in MCP Resource Links format, so answers are clickable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;cors&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="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;applications&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;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;resource_link&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`gid://tradelane/CompanyApplication/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;app&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="s2"&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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;companyName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;mimeType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/vnd.tradelane.wholesale-application&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;_meta&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;vatCheckStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vatCheckStatus&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="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;That &lt;code&gt;cors(...)&lt;/code&gt; wrapper is wall #2. Let's do the walls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wall 1: the docs say you can't have this (you can)
&lt;/h2&gt;

&lt;p&gt;The build guides still show a "Developer preview — we're selecting partners" banner with an application form. That banner is stale. Sidekick app extensions have been GA for every developer since June 17, 2026 — there's a &lt;a href="https://shopify.dev/changelog/sidekick-app-extensions-available-today" rel="noopener noreferrer"&gt;changelog entry&lt;/a&gt; that says so. I almost didn't build this because the docs told me I wasn't allowed to. Trust the changelog, not the banner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wall 2: CORS, and an error message that tells you nothing
&lt;/h2&gt;

&lt;p&gt;The extension sandbox runs on a Shopify origin. Your backend runs on yours. Every fetch your tool makes is cross-origin, and if your backend doesn't send CORS headers, Sidekick reports a vague "couldn't connect to the app" failure. Nothing in the extension guide mentions this.&lt;/p&gt;

&lt;p&gt;If you're on &lt;code&gt;shopify-app-remix&lt;/code&gt; / &lt;code&gt;shopify-app-react-router&lt;/code&gt;, the fix is one line — the auth helper hands you a &lt;code&gt;cors&lt;/code&gt; wrapper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;loader&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;request&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cors&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;authenticate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;admin&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="c1"&gt;// ... query your data ...&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;cors&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="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Authentication, by the way, just works: the sandbox attaches a session token to your fetches, so &lt;code&gt;authenticate.admin()&lt;/code&gt; resolves the shop like any embedded request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wall 3: you have one second
&lt;/h2&gt;

&lt;p&gt;Sidekick expects tool responses within 1 second, and extensions that consistently miss get skipped. If your app scales to zero (Fly.io, Cloud Run, Lambda...), a cold start eats your entire budget before your query runs.&lt;/p&gt;

&lt;p&gt;Practical mitigations: keep at least one machine warm, keep the query trivial (indexed lookups, small &lt;code&gt;take&lt;/code&gt;), and keep &lt;code&gt;_meta&lt;/code&gt; lean — there's also a 4,000-token response cap, and blowing it rejects the whole response.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wall 4: testing lies to you twice
&lt;/h2&gt;

&lt;p&gt;Two things made me think the extension was broken when it wasn't:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sidekick remembers the conversation.&lt;/strong&gt; I submitted new test data and re-asked in the same chat — Sidekick answered from memory: "no new applications since the last time we checked." It never called the tool again. Test every change in a &lt;strong&gt;fresh conversation&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The model summarizes.&lt;/strong&gt; Sometimes it got the data and simply didn't mention the fields I cared about (it offered them as a follow-up instead). That's not a bug in your extension; it's model discretion. Your &lt;code&gt;instructions.md&lt;/code&gt; — an optional file the docs undersell — is where you nudge which fields matter and when.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules of the road
&lt;/h2&gt;

&lt;p&gt;Before you ship, read requirements &lt;a href="https://shopify.dev/changelog/sidekick-app-extensions-app-store-requirements" rel="noopener noreferrer"&gt;2.2.8 and 2.2.9&lt;/a&gt;: tools must match your app's stated functionality, and no promotions, upsells, or review begging inside the extension — content is checked at deploy &lt;em&gt;and&lt;/em&gt; at runtime. Keep data tools read-only; anything that changes state belongs in action extensions where the merchant confirms.&lt;/p&gt;

&lt;p&gt;Also: 20 tools per app, 5 intents, 512 chars per tool description. You won't hit these in v1.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was it worth it?
&lt;/h2&gt;

&lt;p&gt;The whole thing — extension, backend route, deploy, debugging all four walls — was one afternoon. In exchange, my app's data answers questions in the surface every merchant already uses, and "Works with Sidekick" goes on the listing while that sentence is still rare.&lt;/p&gt;

&lt;p&gt;If you maintain a Shopify app with any queryable data in it, this is the cheapest differentiation you'll ship this year.&lt;/p&gt;

&lt;p&gt;Questions welcome — I kept the war wounds fresh.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>6 things I learned building an app for Shopify's new native B2B</title>
      <dc:creator>João Cunha</dc:creator>
      <pubDate>Wed, 08 Jul 2026 09:05:21 +0000</pubDate>
      <link>https://dev.to/jctools/6-things-i-learned-building-an-app-for-shopifys-new-native-b2b-3d3p</link>
      <guid>https://dev.to/jctools/6-things-i-learned-building-an-app-for-shopifys-new-native-b2b-3d3p</guid>
      <description>&lt;p&gt;This spring, Shopify quietly did something big: native B2B — Companies, catalogs, net terms — became available on &lt;strong&gt;all plans&lt;/strong&gt;, not just Plus. Millions of stores can now sell wholesale without a $2k/month subscription or a heavyweight app suite.&lt;/p&gt;

&lt;p&gt;I spent the last month building a small app on top of these APIs (Tradelane, it handles the wholesale application/approval flow that Shopify left out). Here's what I wish I'd known on day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. One email = one company contact. Plan for it.
&lt;/h2&gt;

&lt;p&gt;The constraint that bit me hardest: a customer email can only be the &lt;strong&gt;main contact of one company&lt;/strong&gt;. If a buyer applies twice, or a merchant re-approves someone whose email is already attached to another company, &lt;code&gt;companyCreate&lt;/code&gt; succeeds but the contact assignment fails silently unless you handle it.&lt;/p&gt;

&lt;p&gt;My fix: treat "approved, but contact not assigned" as a first-class state with a clear message to the merchant, instead of pretending the whole operation failed. Partial success with an explanation beats a rollback nobody understands.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The app proxy is underrated for public-facing features
&lt;/h2&gt;

&lt;p&gt;If your app needs a public page on the merchant's storefront (forms, portals, status pages), Shopify's app proxy gives you a route at &lt;code&gt;store.com/apps/your-app/...&lt;/code&gt; that forwards to your server with an HMAC signature. Same origin as the storefront, no CORS, no theme code.&lt;/p&gt;

&lt;p&gt;Two practical notes: &lt;code&gt;authenticate.public.appProxy(request)&lt;/code&gt; (in the Remix/React Router template) gives you the shop &lt;em&gt;and&lt;/em&gt; an admin API client, and because the page is same-origin you can later embed it in a theme app extension with a plain &lt;code&gt;&amp;lt;iframe&amp;gt;&lt;/code&gt; — no postMessage gymnastics.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. React escapes your &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; tags. Yes, really.
&lt;/h2&gt;

&lt;p&gt;I shipped a form whose fonts silently fell back to Times New Roman. The culprit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;style&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;`
  body { font-family: -apple-system, "Segoe UI", sans-serif; }
`&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;style&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;React escapes text children — including inside &lt;code&gt;&amp;lt;style&amp;gt;&lt;/code&gt; — so &lt;code&gt;"Segoe UI"&lt;/code&gt; becomes &lt;code&gt;&amp;amp;quot;Segoe UI&amp;amp;quot;&lt;/code&gt;, which is invalid CSS, which invalidates the whole declaration. Border-radius from the same stylesheet worked fine, so it took me days to notice. Same thing breaks &lt;code&gt;url("data:...")&lt;/code&gt; values.&lt;/p&gt;

&lt;p&gt;The fix is the escape hatch that exists precisely for this (spaces added between the braces so this renders on dev.to — in real code they'd be together):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;style&lt;/span&gt; &lt;span class="na"&gt;dangerouslySetInnerHTML&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;__html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`...`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Validate EU VAT numbers with VIES — it's free and merchants love it
&lt;/h2&gt;

&lt;p&gt;If your app touches B2B in Europe, the EU's VIES service validates VAT numbers (and returns the registered business name) via a free API. Fake wholesale applications are more common than I expected, and "VAT valid ✓" next to an application turned out to be one of the most-mentioned features in feedback.&lt;/p&gt;

&lt;p&gt;Cache the result — VIES is slow and occasionally down, so check once per application and store &lt;code&gt;valid | invalid | unavailable&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. hCaptcha over Turnstile for multi-tenant storefronts
&lt;/h2&gt;

&lt;p&gt;My form renders on &lt;em&gt;any&lt;/em&gt; merchant's domain via the app proxy. Cloudflare Turnstile wants per-hostname configuration; hCaptcha sitekeys work on any hostname by default. For a multi-tenant Shopify app, that's the whole decision. (Also: append &lt;code&gt;?hl=en&lt;/code&gt; to the script URL, or the widget renders in the visitor's browser language while the rest of your form is English.)&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Fire-and-forget your emails, but never your writes
&lt;/h2&gt;

&lt;p&gt;Every notification email in my submit path is &lt;code&gt;void sendEmail().catch(log)&lt;/code&gt; — if the email provider hiccups, the application still saves and the buyer still sees the success page. A missed email is a degraded state; a lost application is a lost customer. Decide explicitly which failures are allowed to break the request and which aren't.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;The gap, if you're looking for one:&lt;/strong&gt; native B2B handles pricing, catalogs and payment terms well, but onboarding is still rough. The free Forms app can take applications, yet it creates the Company before approval (junk piles up in the admin), skips VAT validation, and doesn't assign catalogs. That's the hole I built &lt;a href="https://apps.shopify.com/tradelane" rel="noopener noreferrer"&gt;Tradelane&lt;/a&gt; into. If you're building on the Companies API and hit something weird, my DMs are open. Happy to compare notes.&lt;/p&gt;

</description>
      <category>api</category>
      <category>saas</category>
      <category>showdev</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I built an offline invoice generator in a single HTML file</title>
      <dc:creator>João Cunha</dc:creator>
      <pubDate>Tue, 19 May 2026 18:12:45 +0000</pubDate>
      <link>https://dev.to/jctools/i-built-an-offline-invoice-generator-in-a-single-html-file-3pn4</link>
      <guid>https://dev.to/jctools/i-built-an-offline-invoice-generator-in-a-single-html-file-3pn4</guid>
      <description>&lt;p&gt;I recently built a small offline invoice generator using a single HTML file.&lt;/p&gt;

&lt;p&gt;The idea was simple: I wanted something that could run directly in the browser without login, without a backend, and without a subscription.&lt;/p&gt;

&lt;p&gt;The tool lets you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Add your business details&lt;/li&gt;
&lt;li&gt;Add client details&lt;/li&gt;
&lt;li&gt;Add invoice items&lt;/li&gt;
&lt;li&gt;Calculate subtotal, tax, and total&lt;/li&gt;
&lt;li&gt;Preview the document&lt;/li&gt;
&lt;li&gt;Save it as a PDF&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why offline?&lt;/p&gt;

&lt;p&gt;Most invoice tools are either online platforms, subscription-based, or require creating an account. For simple use cases, that can feel like too much.&lt;/p&gt;

&lt;p&gt;So I wanted to test a different approach:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;download once, open in the browser, use whenever needed.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is not accounting software. It is just a lightweight document generator for simple client documents.&lt;/p&gt;

&lt;p&gt;After building the invoice generator, I also packaged it with a quote generator and receipt generator as a small Freelancer PDF Kit.&lt;/p&gt;

&lt;p&gt;If you want to check it out, here it is:&lt;/p&gt;

&lt;p&gt;Gumroad: &lt;a href="https://jctools.gumroad.com/l/freelancer-pdf-kit" rel="noopener noreferrer"&gt;https://jctools.gumroad.com/l/freelancer-pdf-kit&lt;/a&gt;&lt;br&gt;&lt;br&gt;
Payhip: &lt;a href="https://payhip.com/b/M58nJ" rel="noopener noreferrer"&gt;https://payhip.com/b/M58nJ&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I’d appreciate any feedback on the idea, positioning, or what would make this more useful.&lt;/p&gt;

</description>
      <category>productivity</category>
      <category>sideprojects</category>
      <category>webdev</category>
      <category>freelance</category>
    </item>
  </channel>
</rss>
