<?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: seller-mind</title>
    <description>The latest articles on DEV Community by seller-mind (@sellermind).</description>
    <link>https://dev.to/sellermind</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%2F4057392%2Ff957127a-1b21-4e16-852b-1e0afce9fcd3.png</url>
      <title>DEV Community: seller-mind</title>
      <link>https://dev.to/sellermind</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sellermind"/>
    <language>en</language>
    <item>
      <title>Why I Chose App Blocks Over Custom Pages for Shopify Tracking</title>
      <dc:creator>seller-mind</dc:creator>
      <pubDate>Wed, 04 Nov 2026 02:26:11 +0000</pubDate>
      <link>https://dev.to/sellermind/why-i-chose-app-blocks-over-custom-pages-for-shopify-tracking-1mgm</link>
      <guid>https://dev.to/sellermind/why-i-chose-app-blocks-over-custom-pages-for-shopify-tracking-1mgm</guid>
      <description>&lt;p&gt;When I first started helping a friend manage their Shopify store, I assumed the hardest part was driving initial traffic and getting that first sale. I was completely wrong. The real headache started &lt;em&gt;after&lt;/em&gt; the checkout was complete. &lt;/p&gt;

&lt;p&gt;My inbox was suddenly flooded with "Where is my order?" (WISMO) emails. We were spending hours manually checking tracking numbers across different carrier websites and replying to anxious customers. It was exhausting, unscalable, and felt incredibly unprofessional. I looked at the existing tracking apps on the Shopify App Store, but many required complex setups, felt bloated, or hijacked the post-purchase experience with heavy third-party branding. &lt;/p&gt;

&lt;p&gt;So, I decided to build something.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Post-Purchase Problem
&lt;/h2&gt;

&lt;p&gt;The post-purchase experience is often an afterthought in e-commerce. Merchants spend thousands on ads and checkout optimization, only to leave the customer in the dark for two weeks while waiting for shipping. &lt;/p&gt;

&lt;p&gt;The cost of ignoring this isn't just the time spent answering WISMO support tickets. It's the loss of customer trust. If a buyer has to email you to find out their package is delayed, or if they are redirected to a clunky, unbranded third-party page to check their status, the brand experience is broken. They won't come back for a second purchase.&lt;/p&gt;

&lt;p&gt;To solve this, store owners and developers generally have two technical approaches: building a completely custom tracking page on a separate subdomain (or headless setup), or using Shopify's native Theme App Extensions (App Embed blocks). Let's analyze the trade-offs of both.&lt;/p&gt;

&lt;h3&gt;
  
  
  Approach 1: The Custom Subdomain Route
&lt;/h3&gt;

&lt;p&gt;You build a separate React or Next.js application, fetch order data via the Shopify Admin API, and link to it in your post-purchase emails. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt; You have total control over the UI/UX and aren't constrained by theme layouts.&lt;br&gt;
&lt;strong&gt;Cons:&lt;/strong&gt; High maintenance. It breaks the native feel of the store, requires managing separate authentication, and adds friction for the merchant to set up DNS and hosting.&lt;/p&gt;
&lt;h3&gt;
  
  
  Approach 2: Shopify App Embed Blocks
&lt;/h3&gt;

&lt;p&gt;This approach injects the tracking widget directly into the native Shopify order status page using Theme App Extensions. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pros:&lt;/strong&gt; Seamless, native feel. It's much easier for merchants to install and configure without touching code.&lt;br&gt;
&lt;strong&gt;Cons:&lt;/strong&gt; You are restricted to the layout constraints of the Shopify order status page, and you have to ensure your CSS doesn't conflict with the merchant's theme.&lt;/p&gt;

&lt;p&gt;I ultimately chose App Embed blocks for Parcelglance because the native integration drastically reduces friction for the merchant, and the post-purchase page is already a trusted environment.&lt;/p&gt;
&lt;h2&gt;
  
  
  The Technical Approach
&lt;/h2&gt;

&lt;p&gt;Building an App Embed block requires a shift in how you think about frontend architecture. You aren't building a standalone app; you are building a widget that needs to live harmoniously inside someone else's DOM.&lt;/p&gt;

&lt;p&gt;Here is how the frontend injection works. We use Liquid to pass the order context securely to the client-side script, and then lazy-load the widget.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight liquid"&gt;&lt;code&gt;&lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;comment&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;&lt;span class="c"&gt;
  Renders shipment tracking widget on order status pages
&lt;/span&gt;&lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;endcomment&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;

&amp;lt;div id="parcelglance-widget" 
     data-order-id="&lt;span class="cp"&gt;{{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;}}&lt;/span&gt;"
     data-order-number="&lt;span class="cp"&gt;{{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;}}&lt;/span&gt;"
     data-store-name="&lt;span class="cp"&gt;{{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;shop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;}}&lt;/span&gt;"
     style="min-height: 200px;"&amp;gt;
&amp;lt;/div&amp;gt;

&amp;lt;script&amp;gt;
(function() {
  const script = document.createElement('script');
  script.src = 'https://cdn.parcelglance.com/widget.js';
  script.async = true;
  script.onload = function() {
    Parcelglance.init({
      orderId: document.getElementById('parcelglance-widget').dataset.orderId,
      orderNumber: document.getElementById('parcelglance-widget').dataset.orderNumber,
      storeName: document.getElementById('parcelglance-widget').dataset.storeName,
      container: 'parcelglance-widget',
    });
  };
  document.head.appendChild(script);
})();
&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By passing the &lt;code&gt;order.id&lt;/code&gt; and &lt;code&gt;order.name&lt;/code&gt; via data attributes, we avoid making unnecessary API calls on the frontend to figure out which order we are looking at. The script then lazy-loads the tracking widget, ensuring we don't block the main thread or slow down the order status page.&lt;/p&gt;

&lt;p&gt;But how do we get the tracking data in the first place? We need to listen to Shopify webhooks to keep our database in sync with carrier updates. Security here is paramount.&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;crypto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;crypto&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;verifyShopifyWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hmacHeader&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&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="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;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;hmacHeader&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Handle fulfillment update webhook&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhooks/orders/fulfilled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&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;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;verified&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;verifyShopifyWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-shopify-hmac-sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SHOPIFY_WEBHOOK_SECRET&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;verified&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="c1"&gt;// Store fulfillment data for tracking lookup&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fulfillments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;trackingNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fulfillments&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;tracking_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;carrier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fulfillments&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;tracking_company&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shipped&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OK&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;We verify the HMAC signature to ensure the webhook actually came from Shopify. Then, we parse the fulfillment data and store it. This architecture allows our widget to fetch real-time status from our own database, rather than constantly hammering the Shopify Admin API on the frontend, which would quickly hit rate limits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical Results and Trade-offs
&lt;/h2&gt;

&lt;p&gt;Moving to App Embed blocks was a massive win for adoption. Merchants could install the widget without touching their theme code, which is a huge barrier to entry for non-technical store owners.&lt;/p&gt;

&lt;p&gt;What surprised me the most during development was how much the customizable styling mattered. Even with a native feel, store owners wanted the tracking widget to match their exact brand colors and typography. I had to build a robust CSS variable injection system so the widget seamlessly adapts to any theme without leaking styles or getting overridden by the theme's global CSS.&lt;/p&gt;

&lt;p&gt;If I could change one thing about my initial architecture, I would have started with a more robust caching layer for carrier API calls from day one. Early on, I was querying carrier APIs in real-time for every widget load. I quickly had to implement a Redis caching layer to store tracking statuses and only poll the carriers periodically, which saved a lot of overhead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;Building for the Shopify ecosystem requires balancing developer control with merchant simplicity. By choosing App Embed blocks over custom subdomains, we traded a bit of UI control for a much smoother installation and native user experience.&lt;/p&gt;

&lt;p&gt;If you're a Shopify developer or store owner looking to improve your post-purchase experience and reduce those WISMO tickets, I've made the core widget available as a free tool. You can check out the Shipment Tracking Widget at &lt;a href="https://parcelglance.com" rel="noopener noreferrer"&gt;parcelglance.com&lt;/a&gt; to see how App Embed blocks can transform your order status page.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Full disclosure: I'm the developer of Parcelglance. I built this because I needed it myself.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Taming Shopify Post-Purchase: Building a Custom Tracking Widget</title>
      <dc:creator>seller-mind</dc:creator>
      <pubDate>Wed, 04 Nov 2026 02:24:48 +0000</pubDate>
      <link>https://dev.to/sellermind/taming-shopify-post-purchase-building-a-custom-tracking-widget-a6f</link>
      <guid>https://dev.to/sellermind/taming-shopify-post-purchase-building-a-custom-tracking-widget-a6f</guid>
      <description>&lt;p&gt;When I first started helping merchants optimize their Shopify stores, I noticed a glaring gap in the customer journey. The checkout experience was polished, the product pages were beautiful, but the moment a customer clicked "Pay," they were thrown into a post-purchase black hole. I was struggling with the exact same issue in my own projects: a relentless flood of "Where is my order?" (WISMO) emails. It felt like all the hard work of converting a customer was being undone by a clunky, anxiety-inducing waiting experience. So I decided to build something.&lt;/p&gt;

&lt;h2&gt;
  
  
  The WISMO Problem
&lt;/h2&gt;

&lt;p&gt;The WISMO problem is a silent killer for e-commerce margins. It is not just about the annoyance of checking emails; it is a resource drain. Every support ticket costs time, and for small teams, it pulls them away from growth-oriented tasks. When a customer does not know where their package is, they assume the worst. &lt;/p&gt;

&lt;p&gt;The default Shopify order status page is functional, but it does not always surface real-time carrier milestones clearly, and it rarely matches the store's unique branding. If you ignore the post-purchase experience, you risk damaging brand trust and losing repeat buyers. Customers expect the same level of polish after they pay as they experienced before they paid.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Technical Approach
&lt;/h2&gt;

&lt;p&gt;To fix this, I needed a way to inject a branded, real-time tracking widget directly into the Shopify order status page. The modern Shopify architecture uses Theme App Extensions—specifically App Embed blocks—for this. This approach is ideal because it allows merchants to enable the widget via the theme editor without manually editing their theme code.&lt;/p&gt;

&lt;p&gt;Here is the core App Embed block we use to render the widget. It leverages Liquid to pull the necessary order context and passes it to a lightweight vanilla JavaScript initialization function.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight liquid"&gt;&lt;code&gt;&lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;comment&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;&lt;span class="c"&gt;
  Renders shipment tracking widget on order status pages
&lt;/span&gt;&lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;endcomment&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;

&amp;lt;div id="parcelglance-widget" 
     data-order-id="&lt;span class="cp"&gt;{{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;}}&lt;/span&gt;"
     data-order-number="&lt;span class="cp"&gt;{{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;}}&lt;/span&gt;"
     data-store-name="&lt;span class="cp"&gt;{{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;shop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;}}&lt;/span&gt;"
     style="min-height: 200px;"&amp;gt;
&amp;lt;/div&amp;gt;

&amp;lt;script&amp;gt;
(function() {
  const script = document.createElement('script');
  script.src = 'https://cdn.parcelglance.com/widget.js';
  script.async = true;
  script.onload = function() {
    Parcelglance.init({
      orderId: document.getElementById('parcelglance-widget').dataset.orderId,
      orderNumber: document.getElementById('parcelglance-widget').dataset.orderNumber,
      storeName: document.getElementById('parcelglance-widget').dataset.storeName,
      container: 'parcelglance-widget',
    });
  };
  document.head.appendChild(script);
})();
&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Breaking Down the Frontend
&lt;/h3&gt;

&lt;p&gt;There are a few deliberate technical choices here. First, we use an Immediately Invoked Function Expression (IIFE) to scope our variables and avoid polluting the global namespace. Second, the script is loaded asynchronously (&lt;code&gt;script.async = true&lt;/code&gt;) to prevent render-blocking, which is critical for maintaining good Core Web Vitals on the order status page. Finally, we pass the Liquid variables via &lt;code&gt;data-*&lt;/code&gt; attributes. This keeps the HTML clean and allows the JavaScript to read the context safely without relying on inline Liquid inside the JS logic.&lt;/p&gt;

&lt;p&gt;For the widget UI itself, I chose to write it in vanilla JavaScript rather than React. While React is fantastic for complex state management, injecting a heavy framework into a merchant's order status page risks CSS clashes and performance hits. Vanilla JS keeps the payload tiny and makes it easier to isolate styles using scoped classes or Shadow DOM.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keeping Data Fresh with Webhooks
&lt;/h3&gt;

&lt;p&gt;A tracking widget is only as good as the data behind it. To ensure real-time tracking status from major carriers, the backend needs to listen to Shopify webhooks. Here is a simplified version of the Node.js webhook handler we use to process fulfillment updates.&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;crypto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;crypto&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;verifyShopifyWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hmacHeader&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&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="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;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;hmacHeader&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Handle fulfillment update webhook&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhooks/orders/fulfilled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&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;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;verified&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;verifyShopifyWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-shopify-hmac-sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SHOPIFY_WEBHOOK_SECRET&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;verified&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="c1"&gt;// Store fulfillment data for tracking lookup&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fulfillments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;trackingNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fulfillments&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;tracking_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;carrier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fulfillments&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;tracking_company&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shipped&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OK&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;Security is paramount when handling webhooks. The &lt;code&gt;verifyShopifyWebhook&lt;/code&gt; function computes an HMAC SHA256 hash of the raw request body using your app's webhook secret, then compares it to the &lt;code&gt;x-shopify-hmac-sha256&lt;/code&gt; header provided by Shopify. If they match, you know the request is genuinely from Shopify. We use &lt;code&gt;express.raw&lt;/code&gt; to ensure we are hashing the exact byte payload Shopify sent. Once verified, we parse the JSON and upsert the tracking data into our database, which the frontend widget then queries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical Results and Trade-offs
&lt;/h2&gt;

&lt;p&gt;The App Embed approach worked beautifully for distribution. Merchants can simply toggle the widget on in their theme editor, and it instantly appears on their order status pages. No code deployment required.&lt;/p&gt;

&lt;p&gt;What surprised me the most during this build was how much the post-purchase experience actually matters for overall brand perception. A customizable tracking widget matching the store theme makes the customer feel like they are still in a familiar environment, rather than being bounced to a generic carrier site. For example, support teams often report a noticeable drop in routine WISMO inquiries once customers have a clear, branded place to check their status.&lt;/p&gt;

&lt;p&gt;If I were to start over, I would change how I handle carrier API polling. Initially, I relied heavily on webhooks for every minor status change. I would now implement a more robust caching layer and a fallback polling mechanism right out of the gate, as some smaller regional carriers do not push webhooks as reliably as the major ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It Yourself
&lt;/h2&gt;

&lt;p&gt;If you are dealing with WISMO tickets or just want to improve your post-purchase flow without wrestling with theme code, I have packaged this exact logic into a free tool. You can check out the Shipment Tracking Widget and see how it integrates with your store at &lt;a href="https://parcelglance.com" rel="noopener noreferrer"&gt;parcelglance.com&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Full disclosure: I'm the developer of Parcelglance. I built this because I needed it myself.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>tutorial</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>From Spreadsheets to Code: Calculating True Etsy Margins</title>
      <dc:creator>seller-mind</dc:creator>
      <pubDate>Wed, 04 Nov 2026 02:23:25 +0000</pubDate>
      <link>https://dev.to/sellermind/from-spreadsheets-to-code-calculating-true-etsy-margins-23p1</link>
      <guid>https://dev.to/sellermind/from-spreadsheets-to-code-calculating-true-etsy-margins-23p1</guid>
      <description>&lt;p&gt;When I first started selling handcrafted goods on Etsy, I did what almost every new seller does: I opened a spreadsheet. &lt;/p&gt;

&lt;p&gt;I built a massive, 14-column monstrosity to track my costs, pricing, and expected profits. For the first few months, it felt fine. But as my catalog grew from 10 items to over 150, the spreadsheet became a fragile house of cards. One accidental keystroke in a formula, or a simple copy-paste error when duplicating a row, and my projected margins were completely wrong. &lt;/p&gt;

&lt;p&gt;The breaking point came when I sold a batch of custom mugs. I checked my bank deposit a week later and realized I had actually &lt;em&gt;lost&lt;/em&gt; money on the sale. The spreadsheet had failed to account for a nuanced combination of payment processing fees and offsite ad deductions. I was essentially paying customers for the privilege of making my crafts.&lt;/p&gt;

&lt;p&gt;So I decided to build something better, stepping away from brittle spreadsheets and moving my profit calculations into code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem with Static Spreadsheets
&lt;/h2&gt;

&lt;p&gt;Etsy’s fee structure is notoriously complex. It’s not just a flat percentage taken off the top. When you sell an item, you are hit with a $0.20 listing fee, a 6.5% transaction fee, a 3% + $0.25 payment processing fee, and potentially a 12% offsite ads fee if the buyer came from an external campaign.&lt;/p&gt;

&lt;p&gt;For a $40 item, these fees are manageable. But for a $12 item, that fixed $0.25 payment processing fee eats a massive percentage of your revenue. &lt;/p&gt;

&lt;p&gt;When you rely on a spreadsheet to manage this, you run into a few major issues:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Human Error:&lt;/strong&gt; It is incredibly easy to break a formula when dragging it down 200 rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rigidity:&lt;/strong&gt; Spreadsheets are terrible at handling conditional logic. If I wanted to see "What if I run offsite ads?" vs "What if I don't?", I had to build entirely new columns and toggle cells manually.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lack of Integration:&lt;/strong&gt; A spreadsheet can't talk to your listing data. If I changed a price in my shop, I had to manually update the spreadsheet, which I inevitably forgot to do.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The cost of ignoring these limitations is simple: you either price your items too low and work for free, or you price them too high and lose sales to competitors who actually understand their margins.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Technical Approach: Code vs. Spreadsheets
&lt;/h2&gt;

&lt;p&gt;To solve this, I needed to compare my old approach (static spreadsheets) with a programmatic one. &lt;/p&gt;

&lt;p&gt;The trade-off with moving to code is the initial time investment. A spreadsheet is visual and requires zero setup. Code requires you to define your variables, handle edge cases, and build an interface. However, the payoff is a system that is testable, reusable, and easily integrated with other tools.&lt;/p&gt;

&lt;p&gt;I started by writing a pure JavaScript function to handle the core fee logic. By isolating the math, I could easily test it against edge cases (like very cheap items or bulk orders) without breaking a spreadsheet formula.&lt;/p&gt;

&lt;p&gt;Here is the core logic I built to replace my spreadsheet:&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="c1"&gt;// Etsy fee calculation logic&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;calculateEtsyFees&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quantity&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;hasOffsiteAds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;listingFee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.20&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;quantity&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;transactionFee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.065&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// 6.5%&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;paymentFee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.03&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// 3% + $0.25&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;offsiteAdFee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hasOffsiteAds&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.12&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;totalFees&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;listingFee&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;transactionFee&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;paymentFee&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;offsiteAdFee&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;netProfit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;totalFees&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;listingFee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;transactionFee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;paymentFee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;offsiteAdFee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;totalFees&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;netProfit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;netProfit&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;N/A&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Example: $25 handmade candle&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fees&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculateEtsyFees&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fees&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; 
&lt;span class="c1"&gt;// Output: netProfit: $21.30, margin: 85.2%&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This function immediately highlighted the flaws in my spreadsheet. By passing different &lt;code&gt;price&lt;/code&gt; and &lt;code&gt;hasOffsiteAds&lt;/code&gt; parameters, I could instantly see how sensitive my margins were to external factors. &lt;/p&gt;

&lt;p&gt;Once I had the profit logic solidified, I realized that calculating fees was only half the battle. I also needed to ensure my listings were actually visible. I ended up writing a secondary script to analyze listing titles for SEO quality before I even published them:&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="c1"&gt;// Analyze listing title for SEO quality&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;analyzeTitleSEO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;words&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&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;issues&lt;/span&gt; &lt;span class="o"&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;words&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;issues&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Title too short — add more keywords&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;words&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;issues&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Title too long — may get truncated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="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="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;issues&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;No numbers — consider adding quantity/size&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;title&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="nx"&gt;issues&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ALL CAPS — looks spammy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;wordCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;words&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;20&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;Combining these two approaches—strict financial calculation and programmatic SEO analysis—gave me a complete picture of a listing's potential before it ever went live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical Results and Lessons Learned
&lt;/h2&gt;

&lt;p&gt;Moving from a spreadsheet to a code-based calculator completely changed how I ran my shop. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What worked:&lt;/strong&gt; The immediate clarity on margins eliminated the "negative profit" surprises. I could confidently adjust my prices knowing exactly how the fixed $0.25 payment fee impacted my cheaper items versus my expensive ones. &lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What surprised me:&lt;/strong&gt; How often the offsite ads fee actually mattered. In my spreadsheet, I had just averaged the fees out. In code, toggling the &lt;code&gt;hasOffsiteAds&lt;/code&gt; boolean showed me that a 12% deduction on a low-margin item literally wiped out my profit. It forced me to rethink my pricing strategy for items under $20.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What I would change:&lt;/strong&gt; Initially, I hardcoded the fee percentages directly into the function. While Etsy's fees don't change every day, it's a bad practice. If I were starting over, I would pull those constants from a configuration object or environment variables so the tool wouldn't break if Etsy updated their fee structure.&lt;/p&gt;

&lt;p&gt;This personal project eventually grew into a broader toolkit. I realized other sellers were likely struggling with the same spreadsheet fatigue, so I packaged these utilities into a cohesive platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the Free Calculator
&lt;/h2&gt;

&lt;p&gt;If you are currently relying on a spreadsheet to track your Etsy profits, I highly recommend testing your numbers against a programmatic calculator. It takes two minutes and might save you from accidentally selling at a loss.&lt;/p&gt;

&lt;p&gt;You can use the exact logic I built for my own shop in the free Etsy Fee Calculator here: &lt;a href="https://thesellermind.com/tools/fee-calculator" rel="noopener noreferrer"&gt;thesellermind.com/tools/fee-calculator&lt;/a&gt;. It breaks down the listing, transaction, payment, and offsite ad fees so you can see your true net profit and margin instantly.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Full disclosure: I'm the developer of SellerMind. I built this because I needed it myself.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>etsy</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>How I Built a JavaScript Engine to Calculate Etsy Fees</title>
      <dc:creator>seller-mind</dc:creator>
      <pubDate>Wed, 04 Nov 2026 02:21:57 +0000</pubDate>
      <link>https://dev.to/sellermind/how-i-built-a-javascript-engine-to-calculate-etsy-fees-30pl</link>
      <guid>https://dev.to/sellermind/how-i-built-a-javascript-engine-to-calculate-etsy-fees-30pl</guid>
      <description>&lt;p&gt;When I first started selling my handmade goods on Etsy, I thought I understood the fee structure. I figured it was a simple percentage cut, like most payment gateways. But after my first month, I looked at my payout and realized my margins were significantly lower than I had projected. I was losing money on items I thought were profitable.&lt;/p&gt;

&lt;p&gt;I started digging into the receipts. It wasn't just one fee; it was a stack of them. Listing fees, transaction fees, payment processing fees, and the looming threat of offsite ads fees. Trying to calculate my true net profit in my head, or even in a messy spreadsheet, became a frustrating chore. Every time I wanted to test a new price point, I had to manually recalculate a dozen variables.&lt;/p&gt;

&lt;p&gt;So I decided to build something.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Hidden Complexity of Marketplace Fees
&lt;/h3&gt;

&lt;p&gt;The problem with marketplace fees isn't just that they exist; it's that they are highly variable and often poorly understood by new sellers. When you ignore the granular breakdown of these costs, the consequences are immediate. You might price a $15 item thinking you'll make $10, only to realize that after the $0.20 listing fee, the 6.5% transaction fee, the 3% plus $0.25 payment processing fee, and a potential 12% offsite ad fee, your actual profit is barely covering the cost of materials.&lt;/p&gt;

&lt;p&gt;For a small business or an indie maker, pricing incorrectly isn't just a minor annoyance—it's a direct threat to sustainability. You end up subsidizing your customers' purchases with your own time and labor. Understanding the exact mathematical impact of every fee tier is critical, but doing it manually for dozens of listings is unsustainable.&lt;/p&gt;

&lt;h3&gt;
  
  
  Engineering the Fee Calculator
&lt;/h3&gt;

&lt;p&gt;To solve this, I needed a reliable, programmatic way to calculate these fees. I started by mapping out the exact fee structure and translating it into JavaScript. The goal was to create a pure function that takes the item price, quantity, and ad status, and returns a comprehensive breakdown.&lt;/p&gt;

&lt;p&gt;Here is the core logic I built for the fee calculator:&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="c1"&gt;// Etsy fee calculation logic&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;calculateEtsyFees&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quantity&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;hasOffsiteAds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;listingFee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.20&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;quantity&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;transactionFee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.065&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// 6.5%&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;paymentFee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.03&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// 3% + $0.25&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;offsiteAdFee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hasOffsiteAds&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.12&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;totalFees&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;listingFee&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;transactionFee&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;paymentFee&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;offsiteAdFee&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;netProfit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;totalFees&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;listingFee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;transactionFee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;paymentFee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;offsiteAdFee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;totalFees&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;netProfit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;netProfit&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;N/A&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Example: $25 handmade candle&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fees&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculateEtsyFees&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fees&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; 
&lt;span class="c1"&gt;// Output: netProfit: 21.30, margin: '85.2%'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Breaking this down, the &lt;code&gt;listingFee&lt;/code&gt; is a flat rate multiplied by quantity. The &lt;code&gt;transactionFee&lt;/code&gt; and &lt;code&gt;paymentFee&lt;/code&gt; are percentages of the total price, with the payment fee including a fixed $0.25 component. This fixed component is crucial—it disproportionately affects lower-priced items. The &lt;code&gt;offsiteAdFee&lt;/code&gt; is conditional. By isolating these variables, the function returns not just the total fees, but the exact net profit and the profit margin as a percentage.&lt;/p&gt;

&lt;p&gt;But calculating fees is only half the battle. To truly optimize a shop, you also need to ensure your listings are actually visible. This is where I expanded the technical scope to include SEO analysis.&lt;/p&gt;

&lt;p&gt;When building the broader SellerMind toolkit, I needed a way to programmatically evaluate listing quality. I wrote a script to fetch active listings via the Etsy API and analyze their titles for basic SEO heuristics.&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="c1"&gt;// Etsy API v3 — Fetch active listings&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;fetchShopListings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;shopId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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;`https://openapi.etsy.com/v3/application/shops/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;shopId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/listings/active?limit=100&amp;amp;includes=Images`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-api-key&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;apiKey&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Etsy API error: &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="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;data&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;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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Analyze listing title for SEO quality&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;analyzeTitleSEO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;words&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&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;issues&lt;/span&gt; &lt;span class="o"&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;words&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;issues&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Title too short — add more keywords&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;words&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;issues&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Title too long — may get truncated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="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="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;issues&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;No numbers — consider adding quantity/size&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;title&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="nx"&gt;issues&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ALL CAPS — looks spammy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;wordCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;words&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;20&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;This approach separates data fetching from analysis. The &lt;code&gt;fetchShopListings&lt;/code&gt; function handles the authentication and network request, returning a clean array of listing objects. The &lt;code&gt;analyzeTitleSEO&lt;/code&gt; function then applies a set of heuristic rules. It checks word count, looks for numeric values (which often indicate size or quantity and perform well in search), and flags aggressive formatting like ALL CAPS. It returns a simple score and an array of actionable issues.&lt;/p&gt;

&lt;h3&gt;
  
  
  Practical Results and Iterations
&lt;/h3&gt;

&lt;p&gt;Building these tools fundamentally changed how I approached my shop. The immediate result was clarity. I could instantly see that a $12 item with offsite ads enabled was barely breaking even, prompting me to adjust my pricing strategy for lower-tier items.&lt;/p&gt;

&lt;p&gt;What surprised me most was the impact of the fixed payment processing fee. Seeing the math laid out in code made it obvious that bundling low-cost items into higher-priced sets was a much more profitable strategy than selling them individually.&lt;/p&gt;

&lt;p&gt;If I were to refactor this today, the biggest change would be abstracting the fee percentages into a configuration object. Initially, I hardcoded the &lt;code&gt;0.065&lt;/code&gt; and &lt;code&gt;0.03&lt;/code&gt; values directly into the function. While this works fine for a simple script, marketplaces occasionally update their fee structures. Moving these constants to a separate config file would make the codebase much more maintainable and easier to update without risking the core logic.&lt;/p&gt;

&lt;p&gt;I also realized that while the SEO analyzer is helpful for catching obvious mistakes, true keyword optimization requires understanding search volume and competition. That realization led to the development of more advanced Etsy SEO tools and an AI-powered listing generator within the broader SellerMind platform, which helps suggest tags and generate descriptions based on actual search trends and seasonal data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Putting It All Together
&lt;/h3&gt;

&lt;p&gt;Understanding your margins and optimizing your listings are the two pillars of a sustainable e-commerce shop. You don't need to be a mathematician or a search engine expert to get it right, but having the right tools to do the heavy lifting makes a massive difference.&lt;/p&gt;

&lt;p&gt;If you're an Etsy seller looking to get a clear picture of your actual profits, I've packaged the fee calculation logic into a free, easy-to-use web interface. You can plug in your prices, toggle the offsite ads settings, and see exactly where your money is going.&lt;/p&gt;

&lt;p&gt;You can try the free Etsy Fee Calculator here: &lt;a href="https://thesellermind.com/tools/fee-calculator" rel="noopener noreferrer"&gt;https://thesellermind.com/tools/fee-calculator&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Full disclosure: I'm the developer of SellerMind. I built this because I needed it myself.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>tutorial</category>
      <category>etsy</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Why I Chose Edge Inference for My Podcast AI Tools</title>
      <dc:creator>seller-mind</dc:creator>
      <pubDate>Wed, 04 Nov 2026 02:20:22 +0000</pubDate>
      <link>https://dev.to/sellermind/why-i-chose-edge-inference-for-my-podcast-ai-tools-317g</link>
      <guid>https://dev.to/sellermind/why-i-chose-edge-inference-for-my-podcast-ai-tools-317g</guid>
      <description>&lt;p&gt;When I first started podcasting, I thought the hard part was hitting "record." I quickly learned that the actual bottleneck isn't the recording; it's the aftermath. After a long session, the last thing I want to do is stare at a blank screen trying to write a catchy title, format messy show notes, or draft an engaging episode description. &lt;/p&gt;

&lt;p&gt;I was spending hours just trying to do the administrative work that makes a podcast discoverable. I tried using generic AI wrappers to help, but they were often slow, felt disconnected from the actual context of podcasting, and required a lot of manual copy-pasting to get the formatting right. I needed something tailored specifically to the audio-to-text workflow. So I decided to build something.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem with Podcast Post-Production
&lt;/h2&gt;

&lt;p&gt;The cost of ignoring post-production admin is higher than most indie podcasters realize. Listeners absolutely judge a book by its cover. If your episode title is bland, your click-through rate drops. If your show notes are a wall of unformatted text, your SEO suffers and listeners can't easily find the resources you mentioned.&lt;/p&gt;

&lt;p&gt;From a technical perspective, solving this with AI introduces a new set of trade-offs. When you process a 5,000-word transcript to generate structured show notes, you are dealing with significant input and output token counts. &lt;/p&gt;

&lt;p&gt;If you rely on traditional centralized cloud APIs, every request has to travel to a central data center, be processed by a massive model, and return. For a tool generating show notes, this latency adds up quickly, making the user experience feel sluggish. On the flip side, running local models is great for privacy but incredibly difficult to scale and often lacks the nuanced language understanding required for good copywriting.&lt;/p&gt;

&lt;p&gt;There was a clear gap for a specialized, fast, and context-aware AI pipeline that could handle the heavy lifting of podcast formatting without making the user wait.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evaluating the Technical Approach: Centralized vs. Edge
&lt;/h2&gt;

&lt;p&gt;When building PodCrisp, I had to choose an architecture for the AI pipeline. The two main approaches I evaluated were centralized cloud APIs (like calling a massive proprietary model from a standard serverless function) versus edge inference (running optimized models directly on Cloudflare Workers).&lt;/p&gt;

&lt;p&gt;Centralized APIs offer massive model sizes, which is great for complex reasoning. However, they introduce latency and egress costs. Every request travels across the backbone network. &lt;/p&gt;

&lt;p&gt;Edge inference, on the other hand, runs the model on servers geographically closer to the user. The trade-off is that you are generally limited to smaller, highly optimized models (like Llama 3 8B). But for specific, structured tasks like formatting show notes or generating titles, a well-prompted 8B model often outperforms a poorly prompted 70B model, with a fraction of the latency.&lt;/p&gt;

&lt;p&gt;I decided to build the pipeline on Cloudflare Workers using their AI inference capabilities. This allowed me to keep the compute close to the user while leveraging models fine-tuned for instruction following.&lt;/p&gt;

&lt;p&gt;Here is a look at the core of the show notes generation pipeline:&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="c1"&gt;// Show notes generation pipeline (Cloudflare Workers + AI)&lt;/span&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="k"&gt;async&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;request&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="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;transcript&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;request&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="c1"&gt;// Call AI model for structured show notes&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="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@cf/meta/llama-3-8b-instruct&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;messages&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;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;system&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Convert podcast transcripts to show notes:
## Episode Summary (2-3 sentences)
## Key Takeaways (5-7 bullet points)
## Timestamps (estimate from content flow)
## Resources Mentioned`&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;transcript&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="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 magic here isn't just in the infrastructure; it's in the prompt engineering. By forcing the system prompt to output specific Markdown headers, the model is constrained to a structure that is immediately useful for podcasters. I also set &lt;code&gt;max_tokens&lt;/code&gt; to 800 to ensure the model doesn't ramble, keeping the output concise and focused.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical Results and Trade-offs
&lt;/h2&gt;

&lt;p&gt;Moving to edge inference yielded some interesting results. What worked exceptionally well was the latency. Because the inference happens at the edge, the time-to-first-token is noticeably faster, making the UI feel much snappier when generating episode descriptions or script outlines.&lt;/p&gt;

&lt;p&gt;What surprised me was the capability of the 8B model. I initially worried that a smaller model would hallucinate resources or fail to capture the nuance of the episode summary. However, by strictly defining the output structure in the system prompt, the model stayed highly focused. It reliably extracted key takeaways without inventing facts, provided the transcript was clear.&lt;/p&gt;

&lt;p&gt;Of course, there are trade-offs. If I had to do it over, I would change how the model handles timestamps. Currently, the AI estimates timestamps based on the content flow and text length. While it's a helpful rough guide, it isn't a perfect substitute for actual audio-aligned timestamps. I'm currently exploring ways to feed chunk-level metadata into the prompt to improve timestamp accuracy without sacrificing the speed of the edge pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the Free Title Generator
&lt;/h2&gt;

&lt;p&gt;If you are currently struggling with the blank page syndrome for your next episode, I put together a free tool to help you get unstuck. It uses a similar edge-based approach to generate multiple title options based on your topic and desired tone, giving you a solid starting point so you can get back to recording.&lt;/p&gt;

&lt;p&gt;You can try the free Podcast Title Generator here: &lt;a href="https://podcrisp.com/free-tools/title-generator" rel="noopener noreferrer"&gt;https://podcrisp.com/free-tools/title-generator&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Full disclosure: I'm the developer of PodCrisp. I built this because I needed it myself.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>podcast</category>
      <category>ai</category>
      <category>showdev</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Building an Edge AI Pipeline to Generate Podcast Titles</title>
      <dc:creator>seller-mind</dc:creator>
      <pubDate>Wed, 04 Nov 2026 02:18:58 +0000</pubDate>
      <link>https://dev.to/sellermind/building-an-edge-ai-pipeline-to-generate-podcast-titles-2pgn</link>
      <guid>https://dev.to/sellermind/building-an-edge-ai-pipeline-to-generate-podcast-titles-2pgn</guid>
      <description>&lt;p&gt;When I first started recording episodes for my indie dev podcast, I thought the hardest part would be securing guests or editing out the "ums" and "ahs". I was completely wrong. The real bottleneck turned out to be the post-production metadata.&lt;/p&gt;

&lt;p&gt;Writing compelling titles, structuring show notes, and crafting SEO-friendly descriptions took longer than the actual recording. I was spending three hours on metadata for every one hour of audio. I tried using generic AI chat interfaces, but it was a messy workflow. I had to manually copy-paste transcripts, tweak prompts every time, and format the output.&lt;/p&gt;

&lt;p&gt;So I decided to build something.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem with Podcast Metadata
&lt;/h2&gt;

&lt;p&gt;It is easy to focus entirely on audio quality and guest selection, but metadata is the packaging of your content. A great episode with a weak title will get zero clicks. Without structured show notes, you miss out on search engine traffic and make it harder for listeners to find specific topics within a long episode.&lt;/p&gt;

&lt;p&gt;The cost of ignoring this is high. Podcasts die in obscurity not because the content is bad, but because the discoverability layer is missing. Doing this manually is exhausting, and treating AI like a simple chatbot leads to inconsistent, generic results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building PodCrisp: An Edge AI Approach
&lt;/h2&gt;

&lt;p&gt;To solve this, I built PodCrisp. I wanted a tool that could handle the heavy lifting of prompt engineering and formatting, running entirely at the edge for low latency. I chose Cloudflare Workers combined with their AI inference capabilities.&lt;/p&gt;

&lt;p&gt;Running AI at the edge means the inference happens close to the user, eliminating the cold starts and latency spikes you often get with traditional serverless functions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prompt Engineering for Titles
&lt;/h3&gt;

&lt;p&gt;The first feature I tackled was the title generator. The challenge with LLMs is that they naturally drift toward clickbait if you do not constrain them. I had to build a strict system prompt to enforce quality.&lt;/p&gt;

&lt;p&gt;Here is the core logic for building the title prompt:&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="c1"&gt;// Prompt engineering for podcast title generation&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildTitlePrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`You are an expert podcast producer. Generate compelling titles.
Rules:
- 5-10 words each
- Use curiosity gaps, numbers, or strong hooks
- Match the tone: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;tone&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
- No clickbait that overpromises
- Format as numbered list`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;user&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Episode topic: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;

Generate &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; title options that make someone want to click play.`&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Example: generate 5 titles for a bootstrapping episode&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;buildTitlePrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;How to start a podcast with zero budget&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;inspirational&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="mi"&gt;5&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By explicitly defining the rules in the system message, the AI understands the boundaries. The tone variable allows creators to match their show's vibe, whether it is educational, comedic, or inspirational.&lt;/p&gt;

&lt;h3&gt;
  
  
  Structuring Show Notes at the Edge
&lt;/h3&gt;

&lt;p&gt;Once the title generator was stable, I moved on to show notes. This requires processing a full transcript and extracting structured data.&lt;/p&gt;

&lt;p&gt;Here is the Cloudflare Worker implementation for the show notes pipeline:&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="c1"&gt;// Show notes generation pipeline (Cloudflare Workers + AI)&lt;/span&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="k"&gt;async&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;request&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="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;transcript&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;request&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="c1"&gt;// Call AI model for structured show notes&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="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;AI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@cf/meta/llama-3-8b-instruct&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;messages&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;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;system&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Convert podcast transcripts to show notes:
## Episode Summary (2-3 sentences)
## Key Takeaways (5-7 bullet points)
## Timestamps (estimate from content flow)
## Resources Mentioned`&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;transcript&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;800&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="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;Notice the &lt;code&gt;max_tokens&lt;/code&gt; limit. When processing long transcripts, you want to prevent the model from rambling. The system prompt forces a specific Markdown structure, which makes it incredibly easy to parse the response and render it directly into a CMS or podcast host.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical Results and Trade-offs
&lt;/h2&gt;

&lt;p&gt;Moving to edge AI changed the user experience completely. The generation feels instantaneous compared to traditional API calls.&lt;/p&gt;

&lt;p&gt;What surprised me most was how much the system prompt constraints mattered. In my early tests without the "No clickbait" rule, the AI generated titles like "You Will Not Believe What Happened When I Started a Podcast!" Adding that single constraint brought the quality back to a professional level.&lt;/p&gt;

&lt;p&gt;If I were to change one thing, I would integrate a dedicated speech-to-text model specifically fine-tuned for podcast audio before passing it to the text LLM. While the current pipeline works well with standard transcripts, handling the raw audio directly at the edge would reduce the pipeline steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the Free Title Generator
&lt;/h2&gt;

&lt;p&gt;If you are struggling with the metadata side of podcasting, I have packaged the core title generation logic into a free, standalone tool. You can use it to generate multiple title options for your next episode without needing to sign up or configure any prompts.&lt;/p&gt;

&lt;p&gt;You can try the &lt;a href="https://podcrisp.com/free-tools/title-generator" rel="noopener noreferrer"&gt;Podcast Title Generator&lt;/a&gt; right now.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Full disclosure: I'm the developer of PodCrisp. I built this because I needed it myself.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>tutorial</category>
      <category>showdev</category>
      <category>podcast</category>
    </item>
    <item>
      <title>Serverless vs Edge: Architecting a Modern SaaS Boilerplate</title>
      <dc:creator>seller-mind</dc:creator>
      <pubDate>Wed, 04 Nov 2026 02:17:26 +0000</pubDate>
      <link>https://dev.to/sellermind/serverless-vs-edge-architecting-a-modern-saas-boilerplate-e53</link>
      <guid>https://dev.to/sellermind/serverless-vs-edge-architecting-a-modern-saas-boilerplate-e53</guid>
      <description>&lt;p&gt;When I first started building SaaS products, I kept running into the same architectural bottleneck. I would spend weeks perfecting the frontend, only to hit a wall when deciding how to handle backend API routes. Should I use traditional serverless functions, or push logic to the edge? Every time I started a new project, I had to re-evaluate this trade-off, configure Stripe webhooks, set up NextAuth, and figure out deployment pipelines from scratch. It was exhausting to make these foundational decisions on day one of every new idea. So I decided to build something.&lt;/p&gt;

&lt;p&gt;The problem with building SaaS tools today isn't just about writing code; it's about making the right architectural choices early on. When you are deciding between serverless and edge computing, you are actually deciding how your application will scale globally. Traditional serverless functions are fantastic for heavy compute tasks and complex Node.js dependencies. However, they often suffer from cold starts and are typically routed to a single geographic region. This means a user in Tokyo might experience higher latency when hitting an API hosted in Virginia.&lt;/p&gt;

&lt;p&gt;On the other hand, edge computing runs your code in data centers all over the world, drastically reducing latency. But it comes with strict constraints: smaller bundle sizes, limited execution time, and sometimes a lack of support for specific Node.js APIs. Ignoring these differences early on can lead to a poor user experience for international customers, or unexpected infrastructure costs as your traffic grows. You end up rewriting core logic when you finally realize your chosen paradigm doesn't fit your actual workload.&lt;/p&gt;

&lt;p&gt;To solve this, I took a hybrid approach for my SaaS boilerplate, SaaSLaunch. I wanted to leverage the strengths of both paradigms without forcing developers into a single box. For the core application, I used Next.js 14 with the App Router. This handles the heavy lifting for server-side rendering, complex state management, and integrating the Stripe SDK for subscription billing.&lt;/p&gt;

&lt;p&gt;Serverless is the right tool for the billing logic. Stripe webhooks require reliable, persistent execution environments that can handle complex cryptographic verification and database writes without timing out. I paired this with NextAuth.js for OAuth providers, keeping the authentication flows secure and centralized.&lt;/p&gt;

&lt;p&gt;But for lightweight, globally distributed tools, edge computing makes much more sense. I integrated Cloudflare Workers to handle edge-side API routes. A perfect example is the built-in SEO checker and landing page analyzer. Instead of routing a user's URL analysis request back to a central server, the edge worker intercepts the request at a node closest to the user.&lt;/p&gt;

&lt;p&gt;Here is a look at how the edge worker handles the SEO analysis without needing a heavy DOM parser:&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="c1"&gt;// Cloudflare Worker for edge SEO analysis&lt;/span&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="k"&gt;async&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;request&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="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/analyze&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;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Not found&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;404&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;targetUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;url&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;targetUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Missing url&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Fetch target page at the edge (low latency worldwide)&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;targetUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;cf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;cacheTtl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cacheEverything&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;html&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Run SEO checks (regex-based, no DOM needed)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;title&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;([^&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;title&amp;gt;/i&lt;/span&gt;&lt;span class="p"&gt;)?.[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;desc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;meta&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*name=&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;"'&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;description&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;"'&lt;/span&gt;&lt;span class="se"&gt;][^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*content=&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;"'&lt;/span&gt;&lt;span class="se"&gt;]([^&lt;/span&gt;&lt;span class="sr"&gt;"'&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;)[&lt;/span&gt;&lt;span class="sr"&gt;"'&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;)?.[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;h1Count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;h1&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt; &amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/gi&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="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h1Count&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="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;By using Cloudflare Workers, we can fetch the target HTML and run lightweight regex checks directly at the edge. The &lt;code&gt;cf&lt;/code&gt; object allows us to utilize Cloudflare's cache features, keeping execution fast. This avoids the overhead of spinning up a headless browser or loading a massive HTML parsing library, keeping the worker well within the strict execution time limits of edge environments.&lt;/p&gt;

&lt;p&gt;What worked really well with this hybrid setup was the separation of concerns. Keeping the Stripe checkout and NextAuth logic on standard serverless routes meant I didn't have to fight against edge environment limitations for complex database transactions. Meanwhile, pushing the SEO analyzer to the edge resulted in noticeably faster response times for users outside the primary deployment region.&lt;/p&gt;

&lt;p&gt;What surprised me was how easy it was to maintain type safety across both environments by keeping the project strictly TypeScript-first. Sharing types between the Next.js frontend and the Cloudflare Workers backend made the developer experience much smoother. If I were to change anything, I would probably create an even more abstracted routing layer in the boilerplate so developers don't have to manually configure the deployment boundaries between Vercel and Cloudflare Pages. The one-click deployment works well now, but abstracting the edge routing further would save even more time.&lt;/p&gt;

&lt;p&gt;If you want to see this edge architecture in action, I've deployed the SEO analyzer as a free tool. You can test your own landing pages and see how edge computing handles the analysis in real time, checking your titles, meta descriptions, and header counts instantly.&lt;/p&gt;

&lt;p&gt;You can try the SEO Checker here: &lt;a href="https://saaslaunch.dev/free-tools/seo-checker" rel="noopener noreferrer"&gt;https://saaslaunch.dev/free-tools/seo-checker&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Full disclosure: I'm the developer of SaaSLaunch. I built this because I needed it myself.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>stripe</category>
      <category>cloudflare</category>
      <category>typescript</category>
    </item>
    <item>
      <title>How I Built a Next.js SaaS Starter Kit with Edge SEO Tools</title>
      <dc:creator>seller-mind</dc:creator>
      <pubDate>Wed, 04 Nov 2026 02:15:53 +0000</pubDate>
      <link>https://dev.to/sellermind/how-i-built-a-nextjs-saas-starter-kit-with-edge-seo-tools-i1</link>
      <guid>https://dev.to/sellermind/how-i-built-a-nextjs-saas-starter-kit-with-edge-seo-tools-i1</guid>
      <description>&lt;p&gt;When I first started building SaaS products, I noticed a frustrating pattern. Every time I had a new idea, I would spend the first three to four days just wiring up the same foundational code. I’d configure NextAuth, set up Stripe checkout sessions, and build the webhook handlers. By the time I actually started writing code for my unique product logic, I was already burnt out. On top of that, whenever I wanted to include a simple SEO analysis tool for my users, I’d hit a wall. I didn't want to pay for expensive third-party APIs just to parse a few meta tags, but running heavy DOM parsing on a standard serverless function felt slow and resource-intensive. So I decided to build something.&lt;/p&gt;

&lt;p&gt;The cost of ignoring boilerplate efficiency is massive, especially for indie developers. If you lose three days to setup on every project, that’s weeks of lost development time over a year. But the bigger issue is the architecture trade-offs we make when we rush. When you need an SEO checker, you usually have two choices: use a heavy Node.js library on your main server (which increases cold starts and memory usage) or pay for an external API. Neither is ideal for a lightweight SaaS. Furthermore, if your server is hosted in a single region, users from across the globe experience high latency when running these checks. I needed a way to standardize the boring stuff while keeping the edge computing fast and cheap.&lt;/p&gt;

&lt;p&gt;I created SaaSLaunch, a production-ready SaaS boilerplate using Next.js 14+ with the App Router, Stripe billing, and NextAuth.js. But the core architectural decision was integrating Cloudflare Workers for edge-side API routes.&lt;/p&gt;

&lt;p&gt;For authentication, leveraging NextAuth.js with the App Router requires a slightly different setup than the old Pages router. We define the auth options in a dedicated configuration file and use the auth wrapper in our middleware to protect the dashboard routes. This keeps the session management secure and centralized.&lt;/p&gt;

&lt;p&gt;Let’s look at the Stripe integration first. Using the Next.js App Router, we can create clean, type-safe API routes. Here is how the checkout session is structured:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Next.js API route for Stripe checkout (App Router)&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;NextResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&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;stripe&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;stripe&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;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRIPE_SECRET_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;priceId&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="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;session&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;checkout&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;sessions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;subscription&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;payment_method_types&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;card&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;line_items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;priceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="na"&gt;success_url&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;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;APP_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/dashboard?success=true`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;cancel_url&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;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;APP_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/pricing?cancelled=true`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;client_reference_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;url&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;url&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;Handling Stripe webhooks was another area where this setup saved time. Verifying the webhook signatures and updating the database based on the checkout.session.completed event is fully typed, which gave me peace of mind when processing real payments.&lt;/p&gt;

&lt;p&gt;But for the SEO analysis tool, I wanted to push the compute to the edge. By using Cloudflare Workers, the SEO checker runs in data centers all over the world, drastically reducing latency for global users.&lt;/p&gt;

&lt;p&gt;Here is the Cloudflare Worker code that handles the edge SEO analysis:&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="c1"&gt;// Cloudflare Worker for edge SEO analysis&lt;/span&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="k"&gt;async&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;request&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="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/analyze&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;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Not found&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;404&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;targetUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;searchParams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;url&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;targetUrl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Missing url&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="c1"&gt;// Fetch target page at the edge (low latency worldwide)&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;targetUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;cf&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;cacheTtl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;cacheEverything&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;html&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;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="c1"&gt;// Run SEO checks (regex-based, no DOM needed)&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;title&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;([^&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;&amp;lt;&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;title&amp;gt;/i&lt;/span&gt;&lt;span class="p"&gt;)?.[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;desc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;meta&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*name=&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;"'&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;description&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;"'&lt;/span&gt;&lt;span class="se"&gt;][^&lt;/span&gt;&lt;span class="sr"&gt;&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*content=&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;"'&lt;/span&gt;&lt;span class="se"&gt;]([^&lt;/span&gt;&lt;span class="sr"&gt;"'&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;)[&lt;/span&gt;&lt;span class="sr"&gt;"'&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;)?.[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;h1Count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;html&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;h1&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt; &amp;gt;&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/gi&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="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;h1Count&lt;/span&gt; &lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="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 trade-off here is that we can't use heavy DOM parsers in this specific lightweight edge environment without increasing the bundle size significantly. Instead, we use regex-based parsing. For basic SEO checks like extracting the title, meta description, and counting H1 tags, regex is incredibly fast and keeps the worker execution time in the single-digit milliseconds.&lt;/p&gt;

&lt;p&gt;Building this architecture taught me a lot about the balance between developer experience and runtime performance. What worked exceptionally well was the one-click deployment to both Vercel for the Next.js frontend and Cloudflare Pages for the edge workers. It completely removed the deployment friction. The TypeScript-first approach also paid off; having full type safety across the Stripe webhooks and NextAuth callbacks prevented so many runtime errors before they ever reached production.&lt;/p&gt;

&lt;p&gt;What surprised me the most was the performance difference in the SEO tool. Running the regex-based edge worker was noticeably faster than the traditional serverless functions I used in my previous projects, especially for users outside the US East coast. If I were to change one thing, I would probably add a few more OAuth providers out of the box, though NextAuth makes it relatively painless to add them manually now.&lt;/p&gt;

&lt;p&gt;If you want to see how this edge SEO analysis works in practice without setting up the boilerplate yourself, I’ve made a live version available. You can test how the edge worker analyzes your site's basic SEO metrics right now.&lt;/p&gt;

&lt;p&gt;Try the free SEO Checker here: &lt;a href="https://saaslaunch.dev/free-tools/seo-checker" rel="noopener noreferrer"&gt;https://saaslaunch.dev/free-tools/seo-checker&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Full disclosure: I'm the developer of SaaSLaunch. I built this because I needed it myself.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>tutorial</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>Why I Switched My Shopify Tracking From Theme Edits to App Embeds</title>
      <dc:creator>seller-mind</dc:creator>
      <pubDate>Tue, 03 Nov 2026 02:29:15 +0000</pubDate>
      <link>https://dev.to/sellermind/why-i-switched-my-shopify-tracking-from-theme-edits-to-app-embeds-7nc</link>
      <guid>https://dev.to/sellermind/why-i-switched-my-shopify-tracking-from-theme-edits-to-app-embeds-7nc</guid>
      <description>&lt;p&gt;When I first launched my Shopify store, I genuinely thought the hardest part was getting that initial sale. I was wrong. The real headache started about two weeks after launch, when my support inbox suddenly flooded with "Where is my order?" (WISMO) emails.&lt;/p&gt;

&lt;p&gt;At first, I handled it manually. I’d log into the carrier’s site, copy the tracking link, and paste it into an email reply. It was a terrible use of time. So, I tried to be smart and hardcode a custom tracking display directly into my theme’s order status files. It worked for a bit, until I updated my theme and completely broke the tracking layout.&lt;/p&gt;

&lt;p&gt;After spending a weekend untangling Liquid logic and comparing a few different tracking approaches, I realized there had to be a cleaner way. So I decided to build something.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Problem with Post-Purchase Tracking
&lt;/h3&gt;

&lt;p&gt;The WISMO problem is a silent margin-killer for e-commerce stores. It’s not just about the annoyance of reading the same email ten times a day; it’s about the operational cost. If you are spending your own valuable development time answering logistics questions, you are losing money.&lt;/p&gt;

&lt;p&gt;When looking for a solution, I compared three main architectural approaches for handling post-purchase tracking on Shopify:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Manual Handling:&lt;/strong&gt; Obviously unsustainable. It doesn't scale and leads to slow response times, which frustrates customers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deep Theme Edits:&lt;/strong&gt; Hardcoding tracking logic into your theme files. The trade-off here is full control, but the cost is high maintainability. Every time you switch themes or update your current theme, you risk breaking the tracking page. Plus, querying carrier APIs directly from Liquid is a bad practice that slows down page rendering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Heavy Third-Party Iframes:&lt;/strong&gt; Many existing apps inject a massive, unbranded iframe into the order status page. While easy to install, they often look disjointed from your store's design and can negatively impact your Core Web Vitals due to heavy external scripts.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I needed an approach that offered the branding control of a theme edit, but the maintainability of a lightweight app.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Technical Approach: App Embed Blocks
&lt;/h3&gt;

&lt;p&gt;This led me to Shopify’s App Embed blocks. App Embeds allow developers to inject UI components into a store without modifying the core theme files. The merchant simply toggles the block on in the theme editor.&lt;/p&gt;

&lt;p&gt;The main trade-off with App Embeds is that they rely on the merchant remembering to enable the block in the theme editor. However, the architectural benefits far outweigh this minor friction. It completely decouples the tracking UI from the theme, meaning theme updates will never break the tracking widget.&lt;/p&gt;

&lt;p&gt;To make this work, I had to rethink how data flows from Shopify to the frontend. Instead of doing heavy lifting in Liquid, the theme passes basic order identifiers to a lightweight JavaScript widget, which then fetches the real-time tracking status.&lt;/p&gt;

&lt;p&gt;Here is the App Embed block implementation I use to render the tracking widget on the order status page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight liquid"&gt;&lt;code&gt;&lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;comment&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;&lt;span class="c"&gt;
  Renders shipment tracking widget on order status pages
&lt;/span&gt;&lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;endcomment&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;

&amp;lt;div id="parcelglance-widget" 
     data-order-id="&lt;span class="cp"&gt;{{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;}}&lt;/span&gt;"
     data-order-number="&lt;span class="cp"&gt;{{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;}}&lt;/span&gt;"
     data-store-name="&lt;span class="cp"&gt;{{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;shop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;}}&lt;/span&gt;"
     style="min-height: 200px;"&amp;gt;
&amp;lt;/div&amp;gt;

&amp;lt;script&amp;gt;
(function() {
  const script = document.createElement('script');
  script.src = 'https://cdn.parcelglance.com/widget.js';
  script.async = true;
  script.onload = function() {
    Parcelglance.init({
      orderId: document.getElementById('parcelglance-widget').dataset.orderId,
      orderNumber: document.getElementById('parcelglance-widget').dataset.orderNumber,
      storeName: document.getElementById('parcelglance-widget').dataset.storeName,
      container: 'parcelglance-widget',
    });
  };
  document.head.appendChild(script);
})();
&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let's break down the technical decisions here. First, notice the &lt;code&gt;min-height: 200px&lt;/code&gt; on the container. When I first built this, I didn't include a height constraint, which caused a massive Cumulative Layout Shift (CLS) when the script loaded and injected the tracking UI. Setting a minimum height reserves the space and keeps the page stable.&lt;/p&gt;

&lt;p&gt;Second, the script is loaded asynchronously. We don't want the tracking widget to block the main thread or delay the rendering of the primary order confirmation details. The &lt;code&gt;onload&lt;/code&gt; callback ensures we only initialize the widget once the external script is fully parsed and ready.&lt;/p&gt;

&lt;p&gt;On the backend, the architecture relies on Shopify webhooks rather than Liquid API calls. When an order is fulfilled, Shopify sends a webhook to our backend. We verify the HMAC signature to ensure the request is legitimate, parse the fulfillment data, and store the tracking number and carrier in our database. When the frontend widget initializes, it uses the order ID to fetch this pre-processed data, resulting in a near-instant load time for the customer.&lt;/p&gt;

&lt;h3&gt;
  
  
  Practical Results and Takeaways
&lt;/h3&gt;

&lt;p&gt;Switching to the App Embed architecture completely changed how I manage the post-purchase experience. What worked really well was the separation of concerns. I can now update the tracking widget's CSS, add new carrier integrations, or tweak the JavaScript logic without ever touching the merchant's theme files.&lt;/p&gt;

&lt;p&gt;What surprised me was the performance gain. By moving the tracking logic out of Liquid and into an async JavaScript widget, the order status page renders significantly faster. The initial paint happens immediately with the basic order details, and the tracking widget populates a fraction of a second later.&lt;/p&gt;

&lt;p&gt;If I were to do it again, I would have implemented a skeleton loader from day one. While the &lt;code&gt;min-height&lt;/code&gt; trick solved the layout shift, a subtle pulsing skeleton while the script loads would make the transition feel even more polished. It's a small UX detail, but in the post-purchase experience, every little bit of reassurance counts.&lt;/p&gt;

&lt;h3&gt;
  
  
  Wrapping Up
&lt;/h3&gt;

&lt;p&gt;Building this architecture solved my WISMO problem and gave me a lot of insight into how Shopify stores handle post-purchase logistics. I ended up packaging this exact App Embed approach into a free tool called Parcelglance.&lt;/p&gt;

&lt;p&gt;If you are a Shopify merchant tired of dealing with WISMO tickets, or a developer looking to see how App Embeds can be used for tracking, you can check out the free Shipment Tracking Widget at &lt;a href="https://parcelglance.com" rel="noopener noreferrer"&gt;parcelglance.com&lt;/a&gt;. It’s designed to be lightweight, fully branded to your store, and easy to install via the theme editor.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Full disclosure: I'm the developer of Parcelglance. I built this because I needed it myself.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
    <item>
      <title>How I Engineered a Shopify Tracking Widget to Reduce WISMO</title>
      <dc:creator>seller-mind</dc:creator>
      <pubDate>Tue, 03 Nov 2026 02:27:36 +0000</pubDate>
      <link>https://dev.to/sellermind/how-i-engineered-a-shopify-tracking-widget-to-reduce-wismo-268</link>
      <guid>https://dev.to/sellermind/how-i-engineered-a-shopify-tracking-widget-to-reduce-wismo-268</guid>
      <description>&lt;p&gt;When I first started helping a friend manage their Shopify store, I thought the hard part was getting sales. I was wrong. The hard part was what happened &lt;em&gt;after&lt;/em&gt; the sale.&lt;/p&gt;

&lt;p&gt;Within a week of launching, my friend’s inbox was completely flooded. "Where is my order?" "Did you ship my stuff?" "Why hasn't my tracking updated?" These WISMO (Where Is My Order) tickets were eating up hours of our day. The default Shopify order status page was functional, but it felt disconnected from the rest of the brand, and customers were still emailing us because they didn't know how to navigate to it.&lt;/p&gt;

&lt;p&gt;I looked at existing solutions, but many required heavy theme modifications that broke during updates, or they locked essential features behind high-tier pricing. So I decided to build something.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem with Post-Purchase
&lt;/h2&gt;

&lt;p&gt;WISMO tickets are the silent killer of e-commerce margins. They aren't just a nuisance; they represent a direct cost in support time. When a customer can't easily find their tracking information, their default action is to email your support team.&lt;/p&gt;

&lt;p&gt;Beyond the operational cost, there's a brand experience cost. You spend hours optimizing your checkout flow and branding, only to hand the customer off to a generic, unbranded tracking page the moment they pay. The post-purchase experience is often treated as an afterthought, but it's actually the longest part of the customer journey. If the tracking experience is clunky, it erodes the trust you just built.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Technical Approach
&lt;/h2&gt;

&lt;p&gt;To solve this, I needed to build a tracking widget that was deeply integrated into the store's theme, easy to install, and secure. I decided to use Shopify’s App Embed blocks for the frontend, combined with a Node.js backend listening to webhooks for the data.&lt;/p&gt;

&lt;p&gt;The biggest architectural decision was how to inject the widget. Modifying theme files directly is a nightmare because theme updates overwrite your changes. Shopify’s App Embed blocks solve this by injecting snippets via the theme editor, surviving theme upgrades.&lt;/p&gt;

&lt;p&gt;Here is the Liquid and JavaScript snippet we use for the App Embed block. It safely passes the order data to the frontend and initializes the widget asynchronously so it doesn't block the main thread or hurt page load speeds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight liquid"&gt;&lt;code&gt;&lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;comment&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;&lt;span class="c"&gt;
  Renders shipment tracking widget on order status pages
&lt;/span&gt;&lt;span class="cp"&gt;{%&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;endcomment&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;%}&lt;/span&gt;

&amp;lt;div id="parcelglance-widget" 
     data-order-id="&lt;span class="cp"&gt;{{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;}}&lt;/span&gt;"
     data-order-number="&lt;span class="cp"&gt;{{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;}}&lt;/span&gt;"
     data-store-name="&lt;span class="cp"&gt;{{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nv"&gt;shop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cp"&gt;}}&lt;/span&gt;"
     style="min-height: 200px;"&amp;gt;
&amp;lt;/div&amp;gt;

&amp;lt;script&amp;gt;
(function() {
  const script = document.createElement('script');
  script.src = 'https://cdn.parcelglance.com/widget.js';
  script.async = true;
  script.onload = function() {
    Parcelglance.init({
      orderId: document.getElementById('parcelglance-widget').dataset.orderId,
      orderNumber: document.getElementById('parcelglance-widget').dataset.orderNumber,
      storeName: document.getElementById('parcelglance-widget').dataset.storeName,
      container: 'parcelglance-widget',
    });
  };
  document.head.appendChild(script);
})();
&amp;lt;/script&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By using &lt;code&gt;script.async = true&lt;/code&gt;, we ensure the tracking widget loads in the background. The &lt;code&gt;onload&lt;/code&gt; callback guarantees we don't try to initialize the widget before the library is ready.&lt;/p&gt;

&lt;p&gt;On the backend, we need to know when an order is actually fulfilled. Instead of polling the Shopify API (which burns through rate limits), we use webhooks. Security is paramount here, so every request must be verified using the HMAC signature provided by Shopify.&lt;/p&gt;

&lt;p&gt;Here is the Express.js webhook handler we use to process fulfillment updates:&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;crypto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;crypto&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;verifyShopifyWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hmacHeader&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;crypto&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createHmac&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256&lt;/span&gt;&lt;span class="dl"&gt;'&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="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;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;hmacHeader&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Handle fulfillment update webhook&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhooks/orders/fulfilled&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&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;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;verified&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;verifyShopifyWebhook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-shopify-hmac-sha256&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SHOPIFY_WEBHOOK_SECRET&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;verified&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;401&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Invalid&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="c1"&gt;// Store fulfillment data for tracking lookup&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fulfillments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;trackingNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fulfillments&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;tracking_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;carrier&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fulfillments&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;tracking_company&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;shipped&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;

    &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OK&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;Notice that we use &lt;code&gt;express.raw()&lt;/code&gt; to capture the raw body. This is strictly required because the HMAC signature is calculated against the raw JSON string, not the parsed JavaScript object. If you parse it first, the hash won't match, and your webhook will fail.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical Results and Learnings
&lt;/h2&gt;

&lt;p&gt;Building this out taught me a lot about the quirks of the Shopify ecosystem. What worked really well was the App Embed approach. Merchants could install the app and toggle the tracking widget on or off directly from their theme editor without touching a single line of code.&lt;/p&gt;

&lt;p&gt;One thing that surprised me was how much the visual design mattered. Even though the tracking data was accurate, customers perceived the tracking as more reliable when it was wrapped in a clean, branded UI that matched their store's typography and colors. The post-purchase anxiety is largely psychological.&lt;/p&gt;

&lt;p&gt;If I were to change one thing from the initial build, it would be how I handled carrier API rate limits. Early on, I tried fetching tracking updates directly from the frontend widget. It was a security risk and a performance bottleneck. Moving to a backend webhook architecture and caching the carrier status locally made the widget significantly faster and more resilient to carrier API downtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;I originally built this to solve my friend's WISMO problem, but it turned out to be a solid solution for other store owners as well. I've packaged it into a free tool called Parcelglance. It adds a branded, real-time tracking widget to your store and helps keep those support tickets at bay.&lt;/p&gt;

&lt;p&gt;You can check out the free Shipment Tracking Widget at &lt;a href="https://parcelglance.com" rel="noopener noreferrer"&gt;parcelglance.com&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Full disclosure: I'm the developer of Parcelglance. I built this because I needed it myself.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>javascript</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Spreadsheet vs Code: Rethinking How We Calculate Etsy Fees</title>
      <dc:creator>seller-mind</dc:creator>
      <pubDate>Tue, 03 Nov 2026 02:26:07 +0000</pubDate>
      <link>https://dev.to/sellermind/spreadsheet-vs-code-rethinking-how-we-calculate-etsy-fees-1efn</link>
      <guid>https://dev.to/sellermind/spreadsheet-vs-code-rethinking-how-we-calculate-etsy-fees-1efn</guid>
      <description>&lt;p&gt;When I first started selling handcrafted leather goods on Etsy, I thought pricing would be the easy part. I built a trusty Google Sheet. I set up columns for materials, labor, and a messy block of formulas to estimate Etsy's fees. For a while, it worked fine. &lt;/p&gt;

&lt;p&gt;Then, I noticed my profit margins were mysteriously shrinking. After an hour of debugging my spreadsheet, I realized I had completely forgotten to account for the offsite ads fee on a few items, and my payment processing formula was slightly off. The cost of ignoring these micro-calculations was eating directly into my bottom line. Spreadsheets are great, but they are brittle. One broken cell reference or a missed conditional check, and your entire pricing strategy falls apart. So I decided to build something.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Problem with Marketplace Math
&lt;/h3&gt;

&lt;p&gt;The core problem with pricing on marketplaces like Etsy isn't just knowing your material costs; it's accurately predicting the platform's cut. Etsy's fee structure is notoriously layered: listing fees, transaction fees, payment processing, and conditional fees like offsite ads. &lt;/p&gt;

&lt;p&gt;Most sellers rely on one of three approaches to handle this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Mental Math:&lt;/strong&gt; Highly inaccurate and ignores fixed costs like the $0.20 listing fee.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spreadsheets:&lt;/strong&gt; Flexible but prone to formula errors, hard to share, and difficult to maintain when platform rates change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Third-Party SaaS Tools:&lt;/strong&gt; Comprehensive, but can sometimes be expensive, complex, or locked behind paywalls just to access basic math.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The cost of getting this wrong is silent but compounding. If you underprice by just a few percentage points because you missed a fee nuance, over a year of hundreds of sales, that's a significant loss. Conversely, overpricing to "play it safe" kills your conversion rate. We needed a way to calculate fees that was transparent, instant, and didn't require a monthly subscription just to access basic math.&lt;/p&gt;

&lt;h3&gt;
  
  
  A Technical Approach to Transparent Fees
&lt;/h3&gt;

&lt;p&gt;When I set out to build a calculator, I had to decide on the architecture. I could build a heavy backend that scrapes Etsy's pages or relies on external APIs for every calculation, or I could build a lightweight, client-side JavaScript tool. &lt;/p&gt;

&lt;p&gt;I chose the latter. The trade-off is that client-side logic requires hardcoding the current fee structures, meaning the code needs to be updated if Etsy changes its rates. However, the benefits far outweigh this for a simple calculator: instant load times, zero server costs, and complete transparency. Users can literally view the source code to see exactly how their fees are calculated.&lt;/p&gt;

&lt;p&gt;Let's look at the core logic. Instead of a massive, unreadable spreadsheet formula, we can break the fee structure down into a clean, predictable JavaScript function.&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="c1"&gt;// Etsy fee calculation logic&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;calculateEtsyFees&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quantity&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;hasOffsiteAds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;listingFee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.20&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;quantity&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;transactionFee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.065&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// 6.5%&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;paymentFee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.03&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// 3% + $0.25&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;offsiteAdFee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hasOffsiteAds&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.12&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;totalFees&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;listingFee&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;transactionFee&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;paymentFee&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;offsiteAdFee&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;netProfit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;totalFees&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;listingFee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;transactionFee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;paymentFee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;offsiteAdFee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;totalFees&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;netProfit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;netProfit&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;N/A&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Example: $25 handmade candle&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fees&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculateEtsyFees&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fees&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;By isolating each fee component, we make the logic explicit. The listing fee is a flat rate per quantity. The transaction fee is a straight percentage. The payment processing fee combines a percentage with a fixed per-order cost. Finally, the offsite ad fee is conditional.&lt;/p&gt;

&lt;p&gt;By returning an object with all these breakdowns, we not only get the net profit but also the exact margin percentage. This approach eliminates the "black box" feeling of complex spreadsheet formulas. If Etsy changes the transaction fee from 6.5% to 7%, I only have to update one line of code, rather than hunting through a web of nested spreadsheet cells.&lt;/p&gt;

&lt;h3&gt;
  
  
  Practical Results and Iterations
&lt;/h3&gt;

&lt;p&gt;Building this as a standalone, lightweight tool yielded some surprisingly good results. What worked best was the simplicity. By stripping away the bloat of full shop management suites and just focusing on accurate, transparent math, the tool became incredibly fast and accessible.&lt;/p&gt;

&lt;p&gt;What surprised me was how often sellers actually look at the breakdown. When you present fees as a single opaque number, people just accept it. When you show the exact listing, transaction, and payment fees side-by-side, it sparks a lot of "aha" moments for new sellers who didn't realize payment processing was a separate line item.&lt;/p&gt;

&lt;p&gt;If I were to change one thing early on, I would have integrated the Etsy API v3 from day one to automatically pull active listings. While the calculator is great for manual entry, sellers eventually want to analyze their existing shop in bulk. Recognizing this, I later expanded the toolkit to include a Chrome extension for on-page listing analysis, AI-powered listing generators for optimized titles, and holiday keyword suggestions. But building the core, transparent calculator first was the right MVP choice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Try It Yourself
&lt;/h3&gt;

&lt;p&gt;If you're tired of debugging spreadsheet formulas or guessing your margins, I've wrapped this core logic in a clean, easy-to-use interface. You can use the free Etsy Fee Calculator at SellerMind to get a full, transparent breakdown of your costs in seconds. It's completely free, requires no sign-up, and helps you price your items with confidence.&lt;/p&gt;

&lt;p&gt;You can check it out here: &lt;a href="https://thesellermind.com/tools/fee-calculator" rel="noopener noreferrer"&gt;Etsy Fee Calculator&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Full disclosure: I'm the developer of SellerMind. I built this because I needed it myself.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>etsy</category>
      <category>javascript</category>
      <category>ecommerce</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Building a JavaScript Tool to Calculate True Etsy Margins</title>
      <dc:creator>seller-mind</dc:creator>
      <pubDate>Tue, 03 Nov 2026 02:24:46 +0000</pubDate>
      <link>https://dev.to/sellermind/building-a-javascript-tool-to-calculate-true-etsy-margins-455f</link>
      <guid>https://dev.to/sellermind/building-a-javascript-tool-to-calculate-true-etsy-margins-455f</guid>
      <description>&lt;p&gt;When I first started selling my handcrafted leather goods on Etsy, I thought I had my pricing figured out. I calculated my materials, added a bit for my time, and slapped on a margin. But at the end of the month, my bank account didn't reflect the sales volume I was seeing. I was puzzled until I dug into my Etsy payment statement.&lt;/p&gt;

&lt;p&gt;The hidden costs—transaction fees, payment processing, and the dreaded offsite ads fee—were quietly eating into my profits. I was essentially subsidizing the platform's marketing budget out of my own pocket. I tried using a generic spreadsheet, but it was clunky, hard to update, and didn't account for edge cases like varying quantities or offsite ad opt-outs. So I decided to build something.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;The core problem with pricing on marketplaces like Etsy isn't just knowing your costs; it's understanding the compounding nature of platform fees. Most sellers just look at the 6.5% transaction fee and call it a day. But the reality is a multi-layered fee structure.&lt;/p&gt;

&lt;p&gt;You have the $0.20 listing fee per item. Then the 6.5% transaction fee on the total order amount (including shipping). On top of that, there's the payment processing fee (usually 3% + $0.25 in the US). And if you're enrolled in Offsite Ads, that's an additional 12% or 15% on orders generated through their ads.&lt;/p&gt;

&lt;p&gt;Why does this matter from a technical and business perspective? Because if your pricing logic doesn't account for all these variables, your net margin calculations are fundamentally flawed. The cost of ignoring this is severe: you might price a $15 item thinking you're making $5 in profit, when in reality, after all fees and materials, you're breaking even or losing money. Accurate fee calculation isn't just an accounting exercise; it's the foundation of a sustainable e-commerce business.&lt;/p&gt;

&lt;h2&gt;
  
  
  Technical Approach
&lt;/h2&gt;

&lt;p&gt;To solve this, I needed a reliable, programmatic way to calculate these fees. I started by mapping out the exact fee logic based on current seller policies. I chose JavaScript for this because I wanted to easily integrate it into a web-based tool later, and it handles the floating-point math well enough for currency if we round correctly.&lt;/p&gt;

&lt;p&gt;Let's look at the core calculation logic. I built a function that takes the item price, quantity, and a boolean for offsite ads.&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="c1"&gt;// Etsy fee calculation logic&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;calculateEtsyFees&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quantity&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;hasOffsiteAds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;listingFee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.20&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;quantity&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;transactionFee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.065&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;       &lt;span class="c1"&gt;// 6.5%&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;paymentFee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.03&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.25&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;     &lt;span class="c1"&gt;// 3% + $0.25&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;offsiteAdFee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hasOffsiteAds&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.12&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;totalFees&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;listingFee&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;transactionFee&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;paymentFee&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;offsiteAdFee&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;netProfit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;totalFees&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;listingFee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;transactionFee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;paymentFee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;offsiteAdFee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;totalFees&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;netProfit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;margin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;netProfit&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;price&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toFixed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;%&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;N/A&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Example: $25 handmade candle&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fees&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculateEtsyFees&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fees&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; 
&lt;span class="c1"&gt;// Output includes netProfit and margin&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this snippet, the key takeaway is the &lt;code&gt;paymentFee&lt;/code&gt;. Notice the flat &lt;code&gt;$0.25&lt;/code&gt; added to the 3%. This is where many sellers get tripped up. On a $5 item, that $0.25 is a massive 5% hit on its own. By isolating it in the code, we can clearly see its impact.&lt;/p&gt;

&lt;p&gt;But calculating fees is only half the battle. You also need to ensure your listings are actually found. This led me to build a simple SEO title analyzer. The search algorithm favors titles that are descriptive but not spammy. I wrote a quick utility to evaluate listing titles before publishing.&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="c1"&gt;// Analyze listing title for SEO quality&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;analyzeTitleSEO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;words&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&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;issues&lt;/span&gt; &lt;span class="o"&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;words&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;issues&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Title too short — add more keywords&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;words&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;issues&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Title too long — may get truncated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="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="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;issues&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;No numbers — consider adding quantity/size&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;title&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toUpperCase&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="nx"&gt;issues&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ALL CAPS — looks spammy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;wordCount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;words&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;score&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seoCheck&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;analyzeTitleSEO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Handmade Leather Wallet&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seoCheck&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This function uses basic regex and string splitting to flag common mistakes. It checks for optimal word count, ensures there are numbers (which buyers often search for, like '2 pack' or '12 inch'), and flags aggressive ALL CAPS formatting. It returns a simple 0-100 score based on the number of issues found.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical Results
&lt;/h2&gt;

&lt;p&gt;Building these tools changed how I approached my shop. The fee calculator immediately highlighted that my lower-priced items were actually unprofitable once the $0.25 payment processing fee was factored in. I adjusted my pricing strategy, bundling smaller items to increase the average order value and dilute that fixed fee.&lt;/p&gt;

&lt;p&gt;What surprised me most was how much time the SEO analyzer saved. I used to spend 20 minutes agonizing over a single title. Having a programmatic check gave me a baseline of confidence. I could iterate quickly without worrying if I was missing obvious red flags.&lt;/p&gt;

&lt;p&gt;If I were to rebuild this today, I would add more granular shipping fee calculations. Shipping is a complex beast, especially with calculated shipping profiles, and my current logic abstracts it away to keep the core fee math clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;I ended up expanding these scripts into a full suite of tools because I realized other sellers were facing the exact same margin and visibility issues. I packaged the fee calculator, along with an AI listing generator and shop profit tracker, into a platform called SellerMind.&lt;/p&gt;

&lt;p&gt;If you're building on Etsy and want to verify your margins, you can use the free Etsy Fee Calculator I built. It gives you a full breakdown of listing, transaction, payment, and offsite ad fees so you can price with confidence. You can check it out at thesellermind.com/tools/fee-calculator.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Full disclosure: I'm the developer of SellerMind. I built this because I needed it myself.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>tutorial</category>
      <category>etsy</category>
      <category>ecommerce</category>
    </item>
  </channel>
</rss>
