<?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: Gadget</title>
    <description>The latest articles on DEV Community by Gadget (@gadget).</description>
    <link>https://dev.to/gadget</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F887060%2F39039e13-38ce-4404-8d2c-9aaa53dfab4a.jpg</url>
      <title>DEV Community: Gadget</title>
      <link>https://dev.to/gadget</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/gadget"/>
    <language>en</language>
    <item>
      <title>In Gadget framework v1.7.0, Shopify TOML becomes the source of truth for your app</title>
      <dc:creator>Gadget</dc:creator>
      <pubDate>Sun, 05 Apr 2026 18:54:41 +0000</pubDate>
      <link>https://dev.to/gadget/in-gadget-framework-v170-shopify-toml-becomes-the-source-of-truth-for-your-app-2g2o</link>
      <guid>https://dev.to/gadget/in-gadget-framework-v170-shopify-toml-becomes-the-source-of-truth-for-your-app-2g2o</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;With Gadget framework v1.7.0, the Shopify TOML file becomes your source of truth for any Shopify app configuration.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you are building a Shopify app with Gadget, upgrading to Gadget framework &lt;code&gt;v1.7.0&lt;/code&gt; moves your app onto a TOML-first configuration model. After the migration runs, Shopify’s TOML file becomes the source of truth for your app configuration.&lt;/p&gt;

&lt;p&gt;That might sound like a small change, but it has real consequences. Gadget apps already had Shopify TOML files before &lt;code&gt;v1.7.0&lt;/code&gt;, but those files did not fully describe how the app was configured. Parts of the connection still flowed through Gadget’s legacy install path, especially around webhook registration.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;v1.7.0&lt;/code&gt;, your Shopify app configuration now aligns with Shopify’s own app platform model: the TOML is canonical, Gadget writes to it, and the Shopify CLI deploys it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The TOML was already there, but it wasn’t the whole story
&lt;/h2&gt;

&lt;p&gt;Before &lt;code&gt;v1.7.0&lt;/code&gt;, a Gadget Shopify app could already have a TOML file that looked something like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="py"&gt;client_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"094c8f4584ca2d23a54c9565f648be88"&lt;/span&gt;
&lt;span class="py"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"my-product-quiz"&lt;/span&gt;
&lt;span class="py"&gt;application_url&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"https://my-product-quiz--development.gadget.app/api/shopify/install-or-render"&lt;/span&gt;
&lt;span class="py"&gt;embedded&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="nn"&gt;[auth]&lt;/span&gt;
&lt;span class="py"&gt;redirect_urls&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s"&gt;"https://my-product-quiz--development.gadget.app/api/connections/auth/shopify/callback"&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="nn"&gt;[webhooks]&lt;/span&gt;
&lt;span class="py"&gt;api_version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"2026-01"&lt;/span&gt;

&lt;span class="nn"&gt;[[webhooks.subscriptions]]&lt;/span&gt;
&lt;span class="py"&gt;compliance_topics&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt; &lt;span class="s"&gt;"customers/data_request"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"customers/redact"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"shop/redact"&lt;/span&gt; &lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="py"&gt;uri&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"https://my-product-quiz--development.gadget.app/api/webhooks/shopify"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That file was a real Shopify TOML. But it was not yet the full contract for the app.&lt;/p&gt;

&lt;p&gt;You can see that in a few places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GraphQL API scopes are not managed in the TOML.&lt;/li&gt;
&lt;li&gt;Webhook configuration was minimal, with compliance topics pointing to a Gadget-managed endpoint. Model and global action webhook subscriptions were still handled outside the TOML model.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The TOML existed, but it was not the file you would look at to fully understand how a Gadget Shopify app was wired up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changes in v1.7.0
&lt;/h2&gt;

&lt;p&gt;Once your app is upgraded to framework &lt;code&gt;v1.7.0&lt;/code&gt; and the migration runs, the TOML becomes the canonical definition of the connection.&lt;/p&gt;

&lt;p&gt;Scopes now live in TOML:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[access_scopes]&lt;/span&gt;
&lt;span class="py"&gt;scopes&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"read_products,write_products,read_orders"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Webhook subscriptions for enabled models now live there too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[webhooks]&lt;/span&gt;
&lt;span class="py"&gt;api_version&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"2024-10"&lt;/span&gt;

&lt;span class="nn"&gt;[[webhooks.subscriptions]]&lt;/span&gt;
&lt;span class="py"&gt;topics&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"products/create"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"products/update"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"products/delete"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="py"&gt;uri&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="py"&gt;"https://webhooks.gadget-services.net/shopify/v1?domain&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="err"&gt;my-product-quiz--development.gadget.app&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When you want a global action to respond to a Shopify webhook, the action code links to a TOML-managed subscription through a &lt;code&gt;triggerKey&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;triggers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;shopify&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;triggerKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;productUpdates&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;api&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With the corresponding TOML entry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[[webhooks.subscriptions]]&lt;/span&gt;
&lt;span class="py"&gt;topics&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"products/update"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="py"&gt;uri&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"""
https://webhooks.gadget-services.net/shopify/v1/&lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;&lt;span class="s"&gt;productUpdates?domain=&lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;&lt;span class="s"&gt;my-product-quiz--development.gadget.app&lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;&lt;span class="s"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Filtering and payload shaping move into TOML too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[[webhooks.subscriptions]]&lt;/span&gt;
&lt;span class="py"&gt;topics&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"products/update"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="py"&gt;filter&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"vendor:Nike"&lt;/span&gt;
&lt;span class="py"&gt;include_fields&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"vendor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"updated_at"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="py"&gt;uri&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"""
https://webhooks.gadget-services.net/shopify/v1/&lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;&lt;span class="s"&gt;shopifyProduct-update?domain=&lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;&lt;span class="s"&gt;my-product-quiz--development.gadget.app&lt;/span&gt;&lt;span class="se"&gt;\
&lt;/span&gt;&lt;span class="s"&gt;"""&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the core shift in &lt;code&gt;v1.7.0&lt;/code&gt;: configuration that used to be partly implicit or managed through Gadget’s legacy path is now expressed directly in Shopify’s app manifest.&lt;/p&gt;

&lt;p&gt;One exception: metafield webhooks are not managed in your TOML. This is because they are set as part of the &lt;code&gt;webhookSubscriptionsCreate&lt;/code&gt; &lt;a href="https://shopify.dev/docs/api/admin-graphql/latest/mutations/webhookSubscriptionCreate" rel="noopener noreferrer"&gt;GraphQL request&lt;/a&gt; that Gadget does for you to enable webhooks.&lt;/p&gt;

&lt;h2&gt;
  
  
  You can still use Gadget’s connection flow
&lt;/h2&gt;

&lt;p&gt;This does not mean every change has to become a manual TOML edit.&lt;/p&gt;

&lt;p&gt;You can still use Gadget’s connection flow in the editor. The difference is that those changes now write through to the TOML instead of being registered via API.&lt;/p&gt;

&lt;p&gt;So if you prefer to work in the editor, you still can. If you want to edit the TOML directly, you can do that too. Either way, there is now one canonical config file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is a better model
&lt;/h2&gt;

&lt;p&gt;For teams shipping Shopify apps, this mostly comes down to clarity.&lt;/p&gt;

&lt;p&gt;Gadget and Shopify are working from the same definition. Changes made in the editor and changes made in code converge in one place. And when Shopify exposes something through TOML, Gadget apps can take advantage of it without needing a parallel Gadget-specific surface first.&lt;/p&gt;

&lt;p&gt;That is a better fit for the way modern Shopify apps are built: explicit, CLI-friendly, and easy to reason about in source control.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migration is automatic
&lt;/h2&gt;

&lt;p&gt;If you already have a Shopify app on Gadget, you don’t need to migrate your TOML files manually.&lt;/p&gt;

&lt;p&gt;The migration happens automatically when you upgrade your app to Gadget framework &lt;code&gt;v1.7.0&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The one thing you should do before upgrading is make sure your app is in source control. Commit your current state so you have a clean diff of the TOML and related changes that come with the migration. After that, upgrade and let the migration run. No other steps should be required.&lt;/p&gt;

&lt;p&gt;There is one important operational detail: development stores will need to manually re-register webhooks after the upgrade. This isn’t needed in production; production stores are automatically re-registered when you deploy &lt;code&gt;v1.7.0&lt;/code&gt; to prod.&lt;/p&gt;

&lt;h2&gt;
  
  
  Learn more
&lt;/h2&gt;

&lt;p&gt;For more details on the upgrade, environment-specific TOML behavior, and the underlying connection model, &lt;a href="https://docs.gadget.dev/guides/gadget-framework/v1-7-migration?_gl=1*10zalr3*_gcl_au*MzU3MDE1OTgxLjE3NzU0MTQyNTk." rel="noopener noreferrer"&gt;see the docs&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;If you run into migration issues, get in touch with us on &lt;a href="https://ggt.link/discord" rel="noopener noreferrer"&gt;Discord&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>news</category>
      <category>softwaredevelopment</category>
      <category>tooling</category>
      <category>webdev</category>
    </item>
    <item>
      <title>We set out to save money on observability. Instead, we (accidentally) rebuilt our incident response workflow.</title>
      <dc:creator>Gadget</dc:creator>
      <pubDate>Tue, 24 Mar 2026 16:24:18 +0000</pubDate>
      <link>https://dev.to/gadget/we-set-out-to-save-money-on-observability-instead-we-accidentally-rebuilt-our-incident-response-3k5i</link>
      <guid>https://dev.to/gadget/we-set-out-to-save-money-on-observability-instead-we-accidentally-rebuilt-our-incident-response-3k5i</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;The Gadget infrastructure team shares how using agents to migrate our observability stack to Grafana and ClickHouse ended up transforming how we handle production debugging and incident response.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This post was supposed to be about how we migrated Gadget’s observability stack from Axiom to ClickHouse and Grafana, and saved a bunch of money. We did that, but the big story is actually how the collection of agent skills we built to help us with this migration ended up fundamentally transforming how we approach incident response and production debugging at Gadget.&lt;/p&gt;

&lt;p&gt;So we will still talk a bit about the migration, what the process looked like and some of the tools we used, but what we really want to share is how agents have become our primary mechanism for incident response.&lt;/p&gt;

&lt;p&gt;Over the course of a couple of weeks, we went from manually searching through logs and traces and dashboards, to delegating these investigatory tasks to Claude. And it’s all thanks to the agent skills and MCP server we built to help us with our migration.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I use the dashboards way less and do zero log searching myself; it's all Claude. We learned how to use ClickHouse well, wrote the skill, and then stopped thinking about it.&lt;br&gt;
‍&lt;br&gt;
In a way, learning how to use ClickHouse for infra/observability/operations wasn't useful because it enables data visualization with dashboards. It was useful because it let us set guardrails for our MCP server and ClickHouse skill.&lt;/p&gt;

&lt;p&gt;-Kirin Rastogi, Infrastructure Engineer @ Gadget&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;(And to be clear, we did save money. The entire migration took about a week and was finished 1.5 weeks &lt;em&gt;ahead of schedule&lt;/em&gt;. Our monthly observability bill dropped from ~$20k to ~$8k.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Observe our observability
&lt;/h2&gt;

&lt;p&gt;Gadget is a platform for full-stack web apps, providing developers with hosted development and production environments and APIs to “summon” infrastructure as needed. Our infrastructure and platform powers thousands of ecommerce and web apps, which means we, Gadget’s infra team, need our observability dial turned up all the way to 11.&lt;/p&gt;

&lt;p&gt;We are heavily biased towards log-based alerting, which means we need to search and aggregate log entries and trace spans for our dashboards. We use Prometheus-style metrics for our infrastructure in GCP, but we are log-centric when it comes to application monitoring, which might be a bit different from metrics-based observability commonly used by other infra teams.&lt;/p&gt;

&lt;p&gt;(We are not log-based because we are a team of beavers based in Ottawa, Canada, a common misconception. This setup is largely an artifact of early decisions and technical debt. Log-based observability is dead simple to set up, and we’ve built alerts based on detecting “needle in the haystack” events across our platform. When Kirin joined the Gadget team, he had one question: “&lt;em&gt;How do you live like this?&lt;/em&gt;”&lt;/p&gt;

&lt;p&gt;(We’re working on it.)&lt;/p&gt;

&lt;p&gt;Traces are handled by our OTel collector. k8s pod logs flow through &lt;a href="https://vector.dev/" rel="noopener noreferrer"&gt;Vector&lt;/a&gt; and make use of custom VRL (Vector Remap Language) scripts that transform our raw container output into OTel-formatted ClickHouse rows.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa8sr34qnwckh3ssb95ob.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fa8sr34qnwckh3ssb95ob.png" alt=" " width="800" height="332"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Once more, with agents
&lt;/h2&gt;

&lt;p&gt;Our observability stack now includes agent skills and rule files. These skills were initially built to assist with the migration from Axoim to Clickhouse and Grafana, and now they power incident response. Claude loads the ClickHouse skill, queries logs and traces via MCP, and runs through an RCA playbook. All the same tooling that was built to migrate dashboards is now the first thing engineers reach for when something breaks.&lt;/p&gt;

&lt;p&gt;Instead of manually digging through dashboards, agents are our first entry point to incident investigation. Dashboards are primarily used for secondary validation and to power our alerts.&lt;/p&gt;

&lt;p&gt;We didn’t set out to do this, but our agent skills have massively reduced incident response times. RCA investigations have gone from taking days to hours, and from weeks to days for more complex issues. Plus any member of the team can investigate any issue and actually come up with useful and actionable ideas and results; there is way less “Who built this?” and way more “Here’s how we can fix this.” Now Andy can dig into skipper controller assignments even though skipper is Scott’s baby.&lt;/p&gt;

&lt;p&gt;(skipper is Gadget’s Kubernetes-based sandbox orchestrator: it assigns tenant-specific app workloads to sandbox pods, routes requests to the right instance, and scales pods up or down as demand changes. In our infra stack, it sits between the platform API and the user’s sandbox runtime, giving us per-tenant isolation with efficient pod reuse.) &lt;/p&gt;

&lt;p&gt;We’ll talk more about how and why we built these skills later. Let’s quickly chat about the migration from Axoim.&lt;/p&gt;

&lt;h2&gt;
  
  
  It’s not you, it’s me
&lt;/h2&gt;

&lt;p&gt;We had a flippin’ great deal on Axoim for the past 3 years. We liked using it: APL, their query language, made exploration easy compared to raw SQL, and the Axoim team worked with us to optimize our queries over large time windows.&lt;/p&gt;

&lt;p&gt;But our monthly bill was about to increase from ~$20K to $25-30K a month. We also needed to build an observability dashboard for our users which would further increase our costs if we stuck with Axoim.&lt;/p&gt;

&lt;p&gt;So we decided to migrate to ClickHouse, which could also power our in-platform observability dashboard for users, and Grafana.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Grafana and ClickHouse?
&lt;/h2&gt;

&lt;p&gt;Because everyone uses Grafana, and everyone can’t be wrong. (But actually. Also, it does make it easier for new Gadget employees to onboard.)&lt;/p&gt;

&lt;p&gt;We already use Grafana to monitor Kubernetes test failures in CI, and we use it via &lt;code&gt;pgwatch&lt;/code&gt; for PostgreSQL monitoring. Most of the Gadget team is ex-Shopify or ex-Stripe, and both companies use Grafana. It is our cozy, familiar observability blanket. Our “observabili-blanket”. We know how to use it, agents know how to configure it, and there is plenty of material available to figure out what we don’t already know.&lt;/p&gt;

&lt;p&gt;There is a small penalty for choosing Grafana: its customizability comes at the expense of setting it up yourself. Thankfully, Claude was a massive help there. More on that later.&lt;/p&gt;

&lt;p&gt;We had no prior experience with ClickHouse, but we knew that column storage made it well-suited for logs and traces, plus ClickHouse is extremely compressable so we could optimize our storage costs.&lt;/p&gt;

&lt;p&gt;We ultimately chose ClickHouse’s hosted solution: &lt;a href="https://clickhouse.com/cloud" rel="noopener noreferrer"&gt;clickhouse.cloud&lt;/a&gt;. They manage the infrastructure; we control the schema. This PaaS approach gave us the operational benefits we needed without managing storage clusters ourselves. We’re a small team and don’t need even more infra to manage at this time.&lt;/p&gt;

&lt;p&gt;And because we use ClickHouse, we didn’t need to fight with PromQL, an added bonus.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing the OTel collector
&lt;/h2&gt;

&lt;p&gt;Step one of the actual migration was transforming our OTel (Open Telemetry) data. We were, perhaps, a bit messy when initially defining attributes for our data sources. In Axoim, we could use a single virtual field to check all 4 variants of &lt;code&gt;environment_id&lt;/code&gt;. That wouldn’t fly in ClickHouse’s typed JSON columns. We needed one canonical path per concept. (Claude initially struggled to migrate the queries because the data was a mess.)&lt;/p&gt;

&lt;p&gt;So we tackled two big pieces of work: we migrated our attributes to follow OTel semantic conventions and normalized our attributes so we have a single source of truth for a given value.&lt;/p&gt;

&lt;h2&gt;
  
  
  OTel convention migration
&lt;/h2&gt;

&lt;p&gt;We started by remapping our legacy OTel attribute names to current semantic conventions across 13 different categories such as network, HTTP, database, deployment, and exception.&lt;/p&gt;

&lt;p&gt;It looked a little something like this:&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Favp2qg7hu4cophu6d576.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Favp2qg7hu4cophu6d576.png" alt=" " width="1998" height="1095"&gt;&lt;/a&gt;&lt;br&gt;
Standardized naming made it easier for agents and humans to understand our data streams during incidents.&lt;/p&gt;
&lt;h2&gt;
  
  
  Normalizing the attributes
&lt;/h2&gt;

&lt;p&gt;Axoim allowed us to gracefully handle duplicate attributes. (Or maybe just ignore the duplicate attributes, gracefully.) It would be more difficult to filter and group at runtime on ClickHouse, so we normalized our attributes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;environment_id&lt;/code&gt;, &lt;code&gt;environmentId&lt;/code&gt;, &lt;code&gt;billing.str.environment_id&lt;/code&gt;, &lt;code&gt;function.tenant&lt;/code&gt; were all consolidated into &lt;code&gt;gadget.environment_id&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;application_id&lt;/code&gt;, &lt;code&gt;applicationId&lt;/code&gt;, &lt;code&gt;billing.str.application_id&lt;/code&gt; were all consolidated into &lt;code&gt;gadget.application_id&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We also split attributes into smaller segments using the OTTL (Open Telemetry Transformation Language) UserAgent function:&lt;/p&gt;

&lt;p&gt;Example, &lt;code&gt;url.full&lt;/code&gt; decomposed into &lt;code&gt;url.domain&lt;/code&gt;, &lt;code&gt;url.path&lt;/code&gt;, &lt;code&gt;url.query&lt;/code&gt;, &lt;code&gt;url.scheme&lt;/code&gt;, &lt;code&gt;url.port&lt;/code&gt;, &lt;code&gt;url.fragment&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;Claude wrote all of the transform logic, and we thoroughly reviewed and tested all changes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# explode the user agent attribute&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;span&lt;/span&gt;
  &lt;span class="na"&gt;conditions&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;attributes["user_agent.original"] != nil&lt;/span&gt;
  &lt;span class="na"&gt;statements&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;merge_maps(&lt;/span&gt;
        &lt;span class="s"&gt;cache,&lt;/span&gt;
        &lt;span class="s"&gt;UserAgent(attributes["user_agent.original"]),&lt;/span&gt;
        &lt;span class="s"&gt;"upsert"&lt;/span&gt;
      &lt;span class="s"&gt;)&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;set(&lt;/span&gt;
        &lt;span class="s"&gt;attributes["user_agent.name"],&lt;/span&gt;
        &lt;span class="s"&gt;cache["user_agent.name"]&lt;/span&gt;
      &lt;span class="s"&gt;) where&lt;/span&gt;
        &lt;span class="s"&gt;attributes["user_agent.name"] == nil and&lt;/span&gt;
        &lt;span class="s"&gt;cache["user_agent.name"] != nil&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;set(&lt;/span&gt;
        &lt;span class="s"&gt;attributes["user_agent.version"],&lt;/span&gt;
        &lt;span class="s"&gt;cache["user_agent.version"]&lt;/span&gt;
      &lt;span class="s"&gt;) where&lt;/span&gt;
        &lt;span class="s"&gt;attributes["user_agent.version"] == nil and&lt;/span&gt;
        &lt;span class="s"&gt;cache["user_agent.version"] != nil&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;set(&lt;/span&gt;
        &lt;span class="s"&gt;attributes["user_agent.os.name"],&lt;/span&gt;
        &lt;span class="s"&gt;cache["user_agent.os.name"]&lt;/span&gt;
      &lt;span class="s"&gt;) where&lt;/span&gt;
        &lt;span class="s"&gt;attributes["user_agent.os.name"] == nil and&lt;/span&gt;
        &lt;span class="s"&gt;cache["user_agent.os.name"] != nil&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;set(&lt;/span&gt;
        &lt;span class="s"&gt;attributes["user_agent.os.version"],&lt;/span&gt;
        &lt;span class="s"&gt;cache["user_agent.os.version"]&lt;/span&gt;
      &lt;span class="s"&gt;) where&lt;/span&gt;
        &lt;span class="s"&gt;attributes["user_agent.os.version"] == nil and&lt;/span&gt;
        &lt;span class="s"&gt;cache["user_agent.os.version"] != nil&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That covered the work to clean up our tracing data, but we still had a couple more hurdles before we tackled the dashboards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Another semi-related hurdle: forking Grafana’s ClickHouse plugin
&lt;/h2&gt;

&lt;p&gt;But first, a final small detour.&lt;/p&gt;

&lt;p&gt;ClickHouse provides &lt;a href="https://clickhouse.com/docs/use-cases/observability/clickstack/ingesting-data/schemas" rel="noopener noreferrer"&gt;these schemas&lt;/a&gt; for OTel tables. We decided to use JSON types for &lt;code&gt;LogAttributes&lt;/code&gt;, &lt;code&gt;SpanAttributes&lt;/code&gt;, and &lt;code&gt;ResourceAttributes&lt;/code&gt; instead of the suggested &lt;code&gt;Map(String, String)&lt;/code&gt; type, because of the feature-richness of the JSON type, including the ability to type-hint paths upfront and read JSON subpaths as separate columns.&lt;/p&gt;

&lt;p&gt;But because the suggested format is &lt;code&gt;Map(String, String)&lt;/code&gt;, Grafana’s ClickHouse plugin expects that format. So Yandu forked the Grafana plugin and updated it to support our JSON columns.&lt;/p&gt;

&lt;h2&gt;
  
  
  But what about the log data?
&lt;/h2&gt;

&lt;p&gt;These same transformations had to be done on log data as well. We use a VRL script to do things like normalize log severity to consistent &lt;code&gt;SeverityNumber&lt;/code&gt; and &lt;code&gt;SeverityText&lt;/code&gt; pairs from a collection of integer codes, strings, and, as we like to so elegantly put it, “unknown” formats.&lt;/p&gt;

&lt;p&gt;We also extract and parse service names, removing k8s suffixes, and do the same attribute normalization performed on the OTel collector. Deduplication was also required and we dropped 11 specific log messages also found in traces so the pipelines output totally unique data.&lt;/p&gt;

&lt;p&gt;With the data attributes standardized and normalized (and more readable), we were ready to start building our dashboards in Grafana. The data transformation enabled us to use Claude to hammer through the next stages of the migration: migrating queries from Axiom’s APL to SQL, and building our dashboards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kirin: master of agent skills and rebuilding dashboards
&lt;/h2&gt;

&lt;p&gt;The migrations could finally begin!&lt;/p&gt;

&lt;p&gt;We didn’t just throw Claude at the problem right away. Scott manually ported the first dashboards: a subset of our operator central board and an inspector dashboard for individual k8s pods. Manually doing a first pass was useful. We learned how we want to structure our Grafana queries and the best query patterns for our use cases. (Kirin &lt;em&gt;was&lt;/em&gt; able to port over our alert rules with the help of Claude and careful manual reviews and testing, while Scott worked on the first dashboard.)&lt;/p&gt;

&lt;p&gt;And now that we had an initial pattern, we could unleash Claude. These Grafana dashboard definitions were pulled down and committed to source control. We created agent skills and rules that then referenced the examples.&lt;/p&gt;

&lt;p&gt;Kirin set out to build an agent skill to help with the migrations. The original goal was to build a skill that speeds up migrations by running through the following high-level steps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Read existing Axoim dashboards&lt;/li&gt;
&lt;li&gt;Translate APL queries to SQL&lt;/li&gt;
&lt;li&gt;Execute queries against ClickHouse to verify correctness&lt;/li&gt;
&lt;li&gt;Update YAML files locally&lt;/li&gt;
&lt;li&gt;Push to Grafana and compare results&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;He used Claude and our manually built example (along with intimate knowledge about the shape of our data and pipeline) to build a collection of reference docs to assist us in the migration. This skill transformed into the foundation of our incident response procedure.&lt;/p&gt;

&lt;p&gt;It also supercharged our dashboard migrations. Migration time dropped from 4 hours to 20 minutes per dashboard.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The biggest change to my workflow has been thinking of complex prompts that handle the whole task with all information up front. Including details like what to do, how to test it, and guidance on how to do it (although usually it is better than me at this part). That way, I can work on something else after. Going back and forth with an agent because you missed details earlier means that you are wasting time, and possibly bloating the context. If you can describe what to do succinctly, it will act more efficiently and correctly.&lt;/p&gt;

&lt;p&gt;-Kirin Rastogi, Infrastructure Engineer @ Gadget&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Claude yeets (carefully migrates) queries from APL to SQL
&lt;/h2&gt;

&lt;p&gt;Translating APL to SQL would have been a horrible manual experience.&lt;/p&gt;

&lt;p&gt;Once again, we were making use of Axoim’s virtual fields and stored query fragments to smooth over our disorganized data attributes, and now we need to translate those queries to read from ClickHouse.&lt;/p&gt;

&lt;p&gt;(During the migration process, we ran the two observability stacks in parallel, then did a simple in-code switchover once we were confident that ClickHouse and Grafana were working properly.)&lt;/p&gt;

&lt;p&gt;There were a couple of non-obvious translations that needed to happen, including migrating from APL’s &lt;code&gt;extend&lt;/code&gt; and chained &lt;code&gt;summarize&lt;/code&gt; map to nested SQL expressions. Here’s an example of this translation on one of our APL queries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'core-tracing'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s1"&gt;'RunActivity:createAttemptAndRunBackgroundActionWithVisibility'&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;extend&lt;/span&gt;
    &lt;span class="n"&gt;scheduled_start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;todatetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'attributes.custom'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'background-action.next-attempt-starts-after'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;extend&lt;/span&gt;
    &lt;span class="n"&gt;actual_start&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;todatetime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'attributes.custom'&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="s1"&gt;'background-action.start-date'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt;
    &lt;span class="n"&gt;datetime_diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"second"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actual_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scheduled_start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;summarize&lt;/span&gt;
    &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;percentile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="n"&gt;datetime_diff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;"second"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actual_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scheduled_start&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="mi"&gt;50&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;bin_auto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_time&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;environment_id&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;summarize&lt;/span&gt;
    &lt;span class="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;percentile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;bin_auto&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_time&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Translated to SQL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;WITH&lt;/span&gt;
  &lt;span class="n"&gt;extended&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt;
      &lt;span class="nb"&gt;Timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;parseDateTimeBestEffort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;SpanAttributes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'background-action.next-attempt-starts-after'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;scheduled_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;parseDateTimeBestEffort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;SpanAttributes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'background-action.start-date'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;actual_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SpanAttributes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'gadget.environment_id'&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;environment_id&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;otel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;otel_traces_v2&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;__timeFilter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Timestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;SpanName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'RunActivity:createAttemptAndRunBackgroundActionWithVisibility'&lt;/span&gt;
  &lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;per_env&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt;
      &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="n"&gt;__timeInterval&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Timestamp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;environment_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="n"&gt;quantile&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;5&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;
        &lt;span class="n"&gt;dateDiff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'second'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scheduled_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actual_start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;extended&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;dateDiff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'second'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scheduled_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actual_start&lt;/span&gt;&lt;span class="p"&gt;)&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="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;environment_id&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="nb"&gt;time&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;quantile&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;9&lt;/span&gt;&lt;span class="p"&gt;)(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;per_env&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="nb"&gt;time&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A game of spot-the-differences is pretty much just one big circle.&lt;/p&gt;

&lt;p&gt;This is where agents were helpful. We could capture these transformation patterns repeated throughout our APL queries and use Claude to correctly and uniformly translate them to SQL queries. &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Scott's pro tip: Grafana silently treats non-string &lt;code&gt;GROUP BY&lt;/code&gt; columns as metric values instead of labels, which breaks visualizations. You'll stare at a graph wondering why all your series collapsed into one line, and the fix is wrapping the column in &lt;code&gt;toString()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;-Scott Côté, Infrastructure Engineer @ Gadget&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;You can take a look at our since-removed-from-the-codebase-because-the-migration-is-complete skill in &lt;a href="https://gist.github.com/rdraward/7c3d980cb3e4a741c8d461d5b0f34fab" rel="noopener noreferrer"&gt;this gist&lt;/a&gt;. It contains common operator migrations from APL to SQL, and was incredibly useful for us (and for Claude).&lt;/p&gt;

&lt;h2&gt;
  
  
  Where we are now: AI-powered incident response
&lt;/h2&gt;

&lt;p&gt;Claude handled the migration so well, so we leaned in. We invested time in building skills and testing existing MCP servers so agents can help us with production debugging and incident response.&lt;/p&gt;

&lt;p&gt;In its current form, our &lt;code&gt;/clickhouse&lt;/code&gt; skill now has:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;28 best-practice rule files&lt;/strong&gt; documenting topics like: primary key design, data types, partitioning, JSON usage, JOIN patterns, skipping indices, materialized views (incremental + refreshable), batch sizing, async inserts, mutation avoidance, and OPTIMIZE/FINAL avoidance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Namespace-specific schemas&lt;/strong&gt; for our various services: gadget, sandbox, skipper, dateilager, and ingress-nginx, each with their own attribute reference and query examples. Each namespace schema documents which &lt;code&gt;SpanName&lt;/code&gt; values exist, what &lt;code&gt;LogAttributes&lt;/code&gt;/&lt;code&gt;SpanAttributes&lt;/code&gt; paths are populated, and ready-to-run example queries.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;System diagnostics&lt;/strong&gt; for OOM, cascade failures, and &lt;code&gt;connection&lt;/code&gt; pileups. What are our current memory and query counts? Who in the system is traditionally a memory hog? All this, and much more, answerable by an agent.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;core schema reference&lt;/strong&gt;, documenting &lt;code&gt;otel_logs_v1&lt;/code&gt; and &lt;code&gt;otel_traces_v2&lt;/code&gt; column layouts and other key operational knowledge around query shape (&lt;code&gt;toDateTime(Timestamp)&lt;/code&gt; is the PRIMARY KEY, putting it anywhere except first is 18-24x slower) and other restrictions (there is a 30-minute window limit on traces. It’s a big table, and wider windows reliably timeout).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instructions for running ClickHouse queries&lt;/strong&gt; using our &lt;code&gt;gadget-admin&lt;/code&gt; MCP server. The MCP server uses a read-only ClickHouse user granted a small quota of resources so that MCP requests don’t saturate the ClickHouse cluster.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And then there’s the &lt;strong&gt;8-phase RCA investigation playbook&lt;/strong&gt;. The playbook automates root-cause analysis for environment crashes and has become the entry point for incident response for unknown issues in production. It works through the following steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Error timeline&lt;/strong&gt;: Dig into the shape of the incident: when did errors start/peak/resolve? &lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Traffic pattern&lt;/strong&gt;: What's generating load: webhook flood, BGA backlog, or organic traffic?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limit analysis&lt;/strong&gt;: Which rate limits were hit, which consumer classes?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database budget&lt;/strong&gt;: Budget consumption at 10-second resolution, by model and operation type.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concurrency controller&lt;/strong&gt;: PID controller behavior: crashing, oscillating, or stuck at 1?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Traffic composition&lt;/strong&gt;: Deep-dive into what's generating traffic, webhook amplification ratios, and pre/post-crash comparison.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error deep-dive&lt;/strong&gt;: Timeout counts with span-layer deduplication (each timeout propagates through ~4 span layers, so divide by ~4 for unique count).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recovery Verification&lt;/strong&gt;: Confirm intervention worked, non-background action traffic continuity, recon rate normalization, and background action traffic resumption.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;An agent can use the skill to automate deep dives into incidents, saving us from manually traversing dashboards and walking through the same steps.&lt;/p&gt;

&lt;p&gt;Here are some real-life examples of prompts we used to debug and resolve issues:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;/clickhouse&lt;/strong&gt; why did the number of assignments by skipper controllers get so skewed such that 1 controller is assigning more functions than others -- this looks to have started around Mar 5, 2026, 9:00:00 PM EST&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;/clickhouse&lt;/strong&gt; why is this temporal workflow failing? Destructively-delete-app-313876&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;/clickhouse&lt;/strong&gt; figure out why these errors have gone off in the past hour: Remote procedure call failed: Timeout opening websocket connection to Gadget API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;/clickhouse&lt;/strong&gt; go over all our ClickHouse tables in production and figure out which ones haven't received reads and/or writes in the past 7 days so we can clean them up.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We use the skill for debugging, incident response, to clean up unused infra... it’s a core piece of our daily workflow that just didn’t exist a couple of months ago.&lt;/p&gt;

&lt;p&gt;Want to talk observability and incident response, or have questions about how we use agents? Gadget’s ops team is always hanging out in our &lt;a href="https://ggt.link/discord" rel="noopener noreferrer"&gt;developer Discord&lt;/a&gt;!&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Thanks to Scott, Kirin, Yandu Oppacher, and the rest of the infrastructure team for contributing!&lt;/em&gt;&lt;/p&gt;

</description>
      <category>grafana</category>
      <category>devops</category>
      <category>infrastructure</category>
      <category>observability</category>
    </item>
    <item>
      <title>Building HubSpot app Home and Settings pages</title>
      <dc:creator>Gadget</dc:creator>
      <pubDate>Fri, 13 Mar 2026 18:58:57 +0000</pubDate>
      <link>https://dev.to/gadget/building-hubspot-app-home-and-settings-pages-463d</link>
      <guid>https://dev.to/gadget/building-hubspot-app-home-and-settings-pages-463d</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;This post guides you through creating a comprehensive App Home and Settings experience for a HubSpot integration using Gadget. Building on the previous custom object template, it unveils HubSpot's new App Home Page, offering a centralized view of your custom object.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Building the HubSpot App Home and Settings Page on Gadget
&lt;/h2&gt;

&lt;p&gt;HubSpot's much-awaited and overdue feature: The App Home Page! (And also, a Settings page). &lt;/p&gt;

&lt;p&gt;When creating ad hoc HubSpot apps for internal use, one thing never felt right to me was the lack of a centralized overview page for the integration. Having only an app card to view data feels restrictive. If you wanted to shoehorn in a method to have an overview of all data handled by the app with just the app card, you would need to create a clunky solution or compromise the clarity of the app card. &lt;/p&gt;

&lt;p&gt;That's why, for this app template, we built up from the &lt;a href="https://gadget.dev/blog/building-hubspot-custom-objects-in-gadget" rel="noopener noreferrer"&gt;previous HubSpot Private app template&lt;/a&gt; as it's a great use case for having an app homepage to display all of the teams in an organization, grouped by company.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where we left off
&lt;/h2&gt;

&lt;p&gt;In the last post, we built a HubSpot app card on Gadget that lets users group contacts from a company into named teams that were then stored as a custom object (customObjectTeam) in Gadget. The app card is scoped to a single company record: you pick contacts, name the team, and save.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv6adjcrz2e76ikpsfbvq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv6adjcrz2e76ikpsfbvq.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That works well for creation. But it leaves an obvious gap: there's nowhere to get a bird's-eye view of every team across the entire portal, no easy way to clean up stale data, and no easy way to organize any WebUI modification tools inside HubSpot. That's what this post is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The App Home Page
&lt;/h2&gt;

&lt;p&gt;HubSpot's App Home Page (currently in public beta, sign up at &lt;a href="https://app.hubspot.com/l/product-updates/new-to-you?rollout=237984" rel="noopener noreferrer"&gt;https://app.hubspot.com/l/product-updates/new-to-you?rollout=237984&lt;/a&gt;) gives your app a proper landing page, which you can quickly access from the Marketplace icon under Your recently visited apps, or directly at &lt;code&gt;https://app.hubspot.com/app/{HubID}/{appId}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The app Home Page is built identically to a card extension, a React component using &lt;code&gt;hubspot/ui-extensions&lt;/code&gt;, registered with &lt;code&gt;hubspot.extend&amp;lt;"home"&amp;gt;&lt;/code&gt; and configured via an &lt;code&gt;*-hsmeta.json&lt;/code&gt; file with &lt;code&gt;"location": "home"&lt;/code&gt;. HubSpot recommends using certain hubspot ui-extension components and props to fit the UI expectations of a Home page, same idea with the Settings page. &lt;/p&gt;

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

&lt;p&gt;The homepage gives a full organizational view: every company, every team under it, and every contact in each team, all in one scrollable page.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxyix9ntut7662vd217wu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxyix9ntut7662vd217wu.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Company logos are pulled from HubSpot and displayed inline. Every company name and contact name is a deep link directly to their HubSpot record so this page doubles as a quick-navigation tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  The data architecture
&lt;/h2&gt;

&lt;p&gt;The key design choice here is that Gadget only stores IDs. The &lt;code&gt;customObjectTeam&lt;/code&gt; model holds &lt;code&gt;teamName&lt;/code&gt;, &lt;code&gt;portalId&lt;/code&gt;, a &lt;code&gt;parentCompany&lt;/code&gt; number, and &lt;code&gt;teamContacts&lt;/code&gt; (a JSON array of HubSpot contact IDs). No personal identifiers, no logo image files.&lt;/p&gt;

&lt;p&gt;When the homepage loads, the &lt;code&gt;GET /hubspot/all-teams&lt;/code&gt; route:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Queries Gadget for all &lt;code&gt;customObjectTeam&lt;/code&gt; records belonging to the portal.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Collects all unique parentCompany IDs and fires a single batch request to the HubSpot CRM companies API for names and logos.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Collects all unique contact IDs across every team and fires a single batch request to the HubSpot CRM contacts API for names, titles, and emails.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Assembles and returns a response grouped by company, with ID arrays replaced by fully hydrated objects.&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Fetch all teams for this portal&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;teams&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;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;customObjectTeam&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;portalId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;equals&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;portalIdNumber&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;select&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;teamName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;teamContacts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;parentCompany&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;portalId&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="p"&gt;});&lt;/span&gt;

    &lt;span class="c1"&gt;// Get unique company IDs&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;uniqueCompanyIds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;teams&lt;/span&gt;
          &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;parentCompany&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
          &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&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;// Batch-fetch company names and logos from HubSpot&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;companyNames&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;companyLogos&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;uniqueCompanyIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;batchResponse&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;hubspotClient&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;crm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;companies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;batchApi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
          &lt;span class="na"&gt;inputs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;uniqueCompanyIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;})),&lt;/span&gt;
          &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&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;hs_logo_url&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
          &lt;span class="na"&gt;propertiesWithHistory&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;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;company&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;batchResponse&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="nx"&gt;companyNames&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;company&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="o"&gt;=&lt;/span&gt;
            &lt;span class="nx"&gt;company&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s2"&gt;`Company &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;company&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
          &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;company&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hs_logo_url&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="nx"&gt;companyLogos&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;company&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="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;company&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hs_logo_url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Failed to fetch company names from HubSpot; using IDs as fallback&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;// Collect all unique contact IDs across all teams&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;uniqueContactIds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;teams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flatMap&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
          &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;teamContacts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;teamContacts&lt;/span&gt; &lt;span class="k"&gt;as &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;)[]).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;String&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two API calls to HubSpot, regardless of how many companies or teams exist. This same route is reused by both the Homepage and the Settings page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Auth
&lt;/h2&gt;

&lt;p&gt;Like the App card, the Homepage authenticates by first calling &lt;code&gt;POST /hubspot/auth&lt;/code&gt; to get a short-lived JWT, then passing it as a Bearer token on subsequent requests. The token is validated server-side by &lt;code&gt;requireHubspotJwt&lt;/code&gt;, which checks the signature, confirms the session isn't expired, and extracts the portalId to scope all database queries.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo5vhv325pacbne7j92p0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fo5vhv325pacbne7j92p0.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Settings Page
&lt;/h2&gt;

&lt;p&gt;The settings page lives at &lt;code&gt;Marketplace → My Apps → [your app] → Settings&lt;/code&gt; and is configured with &lt;code&gt;"type": "settings"&lt;/code&gt; in its &lt;code&gt;*-hsmeta.json&lt;/code&gt;. It shares the same React + &lt;code&gt;hubspot/ui-extensions&lt;/code&gt; toolkit as the homepage and card, with one important constraint: no hubspot/ui-extensions/crm components, since settings isn't tied to any CRM object.&lt;/p&gt;

&lt;p&gt;Where the homepage is read-only, the settings page is where you manage your teams. It loads the same &lt;code&gt;GET /hubspot/all-teams&lt;/code&gt; data, but adds two actions per team:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Removing contacts&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Clicking Manage Contacts on a team opens a modal panel. Inside is a checkbox list of every contact on that team. Selecting contacts and hitting Remove&lt;/p&gt;

&lt;p&gt;Selected calls &lt;code&gt;POST /hubspot/remove-contacts&lt;/code&gt;, which:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Verifies the team belongs to the current portal (no cross-portal writes).&lt;/li&gt;
&lt;li&gt;Filters the teamContacts array to exclude the selected IDs.&lt;/li&gt;
&lt;li&gt;Saves the updated array back to the Gadget record.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp1njswuee8qf0cua3x1n.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fp1njswuee8qf0cua3x1n.png" alt=" "&gt;&lt;/a&gt;&lt;br&gt;
The UI updates immediately in state, no reload needed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deleting teams&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Delete Team uses a two-step confirm pattern: the first click swaps the button for Confirm Delete and Cancel, requiring a second deliberate action before anything is actually deleted. This calls &lt;code&gt;POST /hubspot/delete-team&lt;/code&gt;, which performs the same portal ownership check before calling &lt;code&gt;api.customObjectTeam.delete&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Deleted teams disappear from the list immediately. Companies with no remaining teams are removed too.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this unlocks
&lt;/h2&gt;

&lt;p&gt;Adding the homepage and settings page to the custom objects template completes a proper app loop:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Create teams from the company app card&lt;/li&gt;
&lt;li&gt;View all teams org-wide on the app homepage&lt;/li&gt;
&lt;li&gt;Manage (remove contacts, delete teams) from the settings page&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The separation of concerns is clean: the card is scoped to one company record, the homepage is a read-only portal-wide view, and the settings page is where administrative actions live. None of them needed new models, just new routes and UI extensions on top of the same &lt;code&gt;customObjectTeam&lt;/code&gt; model and &lt;code&gt;GET-all-teams&lt;/code&gt; endpoint.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://app.gadget.dev/auth/fork?domain=hubspot-private-app-full.gadget.app&amp;amp;_gl=1*ufxudl*_gcl_au*MTA4Nzk3MzgwOC4xNzY2NTk2NjEzLjE1MzE3MTcxOC4xNzcwMTM1NDQwLjE3NzAxMzU3MTc." rel="noopener noreferrer"&gt;The full template is available here!&lt;br&gt;
&lt;/a&gt;&lt;/p&gt;

</description>
      <category>api</category>
      <category>softwaredevelopment</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>We ran vinext (Next.js on Vite) inside a Gadget app</title>
      <dc:creator>Gadget</dc:creator>
      <pubDate>Mon, 09 Mar 2026 21:07:00 +0000</pubDate>
      <link>https://dev.to/gadgetdev/we-ran-vinext-nextjs-on-vite-inside-a-gadget-app-1nld</link>
      <guid>https://dev.to/gadgetdev/we-ran-vinext-nextjs-on-vite-inside-a-gadget-app-1nld</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Use vinext in Gadget to get a drop-in replacement for your Next apps on Gadget's infrastructure.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Cloudflare dropped something pretty fun the other week: &lt;a href="https://blog.cloudflare.com/vinext/" rel="noopener noreferrer"&gt;they rebuilt a drop-in replacement for Next.js on top of Vite&lt;/a&gt;. It took them about a week and only cost $1100 in tokens.&lt;/p&gt;

&lt;p&gt;The project is called &lt;a href="https://github.com/cloudflare/vinext" rel="noopener noreferrer"&gt;vinext&lt;/a&gt;. It keeps the Next.js API but swaps the underlying toolchain for Vite, which means dramatically faster builds and smaller client bundles. The announcement blog post is worth a read if you care about those numbers.&lt;/p&gt;

&lt;p&gt;My immediate reaction was: &lt;em&gt;can we use this to power a Gadget frontend?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Gadget already uses Vite for its frontend tooling. If vinext also runs on Vite, we should be able to get it running. So I spent some time experimenting to get a Next-style frontend running inside a Gadget project.&lt;/p&gt;

&lt;p&gt;As it turns out you can get something working pretty quickly!&lt;/p&gt;

&lt;p&gt;So I figured I’d share the steps I took to set things up. This isn’t meant to be a strict tutorial, it’s more of a “here are the steps I took and how I used agents to do this ” guide.&lt;/p&gt;

&lt;p&gt;Also, this is very experimental on both the vinext and Gadget side of things.&lt;/p&gt;

&lt;p&gt;You can also check out the video:&lt;/p&gt;

&lt;p&gt;

  &lt;iframe src="https://www.youtube.com/embed/aTpD46H77ak"&gt;
  &lt;/iframe&gt;


&lt;/p&gt;

&lt;h2&gt;
  
  
  The idea
&lt;/h2&gt;

&lt;p&gt;A normal Gadget app contains everything you need to power a modern full-stack web app.&lt;/p&gt;

&lt;p&gt;You get a managed Postgres database, a Node backend, authentication and multi-tenancy, and a generated API client. The frontend is a Vite + React Router project that is already wired up to that backend, and has built-in auth and session management.&lt;/p&gt;

&lt;p&gt;The experiment was simple: keep all of that exactly the same, but replace the default frontend runtime with vinext.&lt;/p&gt;

&lt;p&gt;Conceptually, the architecture becomes:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fieuttm13m1u368m9d9hl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fieuttm13m1u368m9d9hl.png" alt=" "&gt;&lt;/a&gt;&lt;br&gt;
The backend still behaves like a normal Gadget app. The frontend just adopts the Next programming model running on Vite.&lt;/p&gt;
&lt;h2&gt;
  
  
  Starting with a normal Gadget app
&lt;/h2&gt;

&lt;p&gt;I began by creating a new Gadget web app and pulled it down locally using the &lt;code&gt;ggt&lt;/code&gt; &lt;a href="https://docs.gadget.dev/reference/ggt?_gl=1*1kgkltm*_gcl_au*MTA4Nzk3MzgwOC4xNzY2NTk2NjEzLjE1MzE3MTcxOC4xNzcwMTM1NDQwLjE3NzAxMzU3MTc." rel="noopener noreferrer"&gt;CLI&lt;/a&gt;. Relevant for the vinext migration, all new apps have a &lt;code&gt;package.json&lt;/code&gt; file, a &lt;code&gt;vite.config.mts&lt;/code&gt;, and a &lt;code&gt;web&lt;/code&gt; directory containing the frontend (in addition to everything needed to power and configure the database and backend).&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;web&lt;/code&gt; directory is where everything interesting happens.&lt;/p&gt;

&lt;p&gt;Normally, Gadget frontends use React Router. In this experiment that router disappears and the frontend becomes a vinext app instead.&lt;/p&gt;

&lt;p&gt;Once that swap happens, the project suddenly feels a lot like a Next app. Instead of client-side routing handled by React Router, the project now uses vinext’s Next-style page routing. Page components, layouts, and routing conventions look exactly like what you’d expect from a new NextJS app.&lt;/p&gt;

&lt;p&gt;What’s nice is that all the Gadget primitives still work exactly the same way. For example, grabbing the authenticated user in a component still looks like this:&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;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useUser&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That hook comes from Gadget and continues to work normally as long as the &lt;code&gt;GadgetProvider&lt;/code&gt; is setup properly. vinext is just responsible for routing and rendering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Making Vite happy
&lt;/h2&gt;

&lt;p&gt;The only part of the integration that required a bit of experimentation was the Vite configuration.&lt;/p&gt;

&lt;p&gt;The project needs both the Gadget Vite plugin and the vinext plugin so the frontend can run the vinext runtime while still talking to the Gadget backend. (The Tailwind plugin was used too. It didn’t need to be touched.)&lt;/p&gt;

&lt;p&gt;It seems like authentication needed a small passthrough configuration so auth-related requests resolve correctly during development. This was the major hangup I ran into during the migration experiment, and Codex helped me resolve it. It’s definitely possible (probable, even) that there’s a more “Next-native” way to handle this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;vite&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mts&lt;/span&gt;

&lt;span class="cm"&gt;/**
 * Vite plugin that prevents vinext/RSC from handling Gadget backend routes.
 *
 * The @vitejs/plugin-rsc registers a catch-all middleware that writes a
 * response for ALL requests (returning 404 for unmatched App Router routes).
 * This prevents Gadget's platform from handling backend paths like /auth/*
 * (OAuth flows).
 *
 * This plugin patches the middleware stack after all plugins have registered,
 * wrapping every async handler so that backend paths call next() instead of
 * being handled by the RSC renderer.
 */&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;gadgetBackendPassthrough&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="nx"&gt;Plugin&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;backendPrefixes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/auth/&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;isBackendPath&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;backendPrefixes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;some&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prefix&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;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gadget-backend-passthrough&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nf"&gt;configureServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="c1"&gt;// Return a post-middleware setup function. This runs after all&lt;/span&gt;
      &lt;span class="c1"&gt;// plugins' configureServer hooks, including vinext and RSC.&lt;/span&gt;
      &lt;span class="c1"&gt;// By listing this plugin AFTER vinext, our setup function runs&lt;/span&gt;
      &lt;span class="c1"&gt;// after the RSC middleware is already in the stack.&lt;/span&gt;
      &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stack&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;middlewares&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;layer&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;stack&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;fn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;handle&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;fn&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fn&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;__gadgetWrapped&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

          &lt;span class="c1"&gt;// Wrap all 3-param middleware handlers (req, res, next)&lt;/span&gt;
          &lt;span class="c1"&gt;// to skip backend paths. This catches the RSC handler&lt;/span&gt;
          &lt;span class="c1"&gt;// regardless of its name.&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;fn&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;3&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;fn&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;2&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;original&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fn&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;wrapped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;function &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;res&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;next&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
              &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;originalUrl&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
              &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isBackendPath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
              &lt;span class="p"&gt;}&lt;/span&gt;
              &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;original&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="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
            &lt;span class="p"&gt;};&lt;/span&gt;
            &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;wrapped&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;__gadgetWrapped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="nx"&gt;layer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;handle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;wrapped&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
          &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once those pieces are in place, vinext spins up and I can build like I would any other NextJS app, but using my Gadget API client to call my app APIs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Letting an agent do the migration
&lt;/h2&gt;

&lt;p&gt;Rather than doing everything by hand, I let an agent (Codex) handle most of the conversion.&lt;/p&gt;

&lt;p&gt;I started with a fresh NextJS app and used the &lt;code&gt;cloudflare/vinext&lt;/code&gt; agent skill to convert it to vinext. If you have an existing app, you could convert it instead. The skill one-shot the transformation. I tested locally and used the converted project as a reference for migrating my Gadget app. &lt;/p&gt;

&lt;p&gt;From there, it was mostly a matter of copying the frontend structure into the Gadget project and adapting it so it used the Gadget API client and authentication hooks. The agent took care of all of it for me. There was a bit of back and forth around using the Vite Tailwind plugin (my reference app used PostCSS instead). And the only other hangup was the auth passthrough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Look ma, no Next!
&lt;/h2&gt;

&lt;p&gt;Once everything is wired together, you can build a NextJS-style app on Gadget. But, you know, without actually using NextJS.&lt;/p&gt;

&lt;p&gt;You still get access to Gadget’s infra-less setup: define data models, Gadget generates the API client, and call those APIs directly from vinext pages. Authentication flows through Gadget. Multi-tenancy still works. The hosted development environment’s backend and database are unchanged.&lt;/p&gt;

&lt;p&gt;The frontend just happens to speak the Next API.&lt;/p&gt;

&lt;p&gt;vinext is still very early, but the idea behind it is compelling. If the Next programming model can run comfortably on top of Vite, it opens up a lot of flexibility in how those applications can be built and hosted.&lt;/p&gt;

&lt;p&gt;Gadget happens to fit that model well. Because the platform already exposes backend services through a generated client, the frontend runtime becomes relatively interchangeable (so long as it uses Vite!). vinext just restructures how you build the UI layer.&lt;/p&gt;

&lt;p&gt;This experiment worked better than expected. But let's be real: I migrated a fresh NextJS app and a fresh Gadget app. I didn’t have a bunch of existing, complex logic. I wasn’t exactly pushing the boundaries of vinext here.&lt;/p&gt;

&lt;p&gt;But as a proof of concept, running vinext inside a Gadget project is very possible. &lt;/p&gt;

&lt;p&gt;Want to chat more or have questions? I’m usually hanging out in our &lt;a href="https://ggt.link/discord" rel="noopener noreferrer"&gt;dev Discord&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>vite</category>
      <category>react</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Building Hubspot custom objects in Gadget</title>
      <dc:creator>Gadget</dc:creator>
      <pubDate>Tue, 17 Feb 2026 19:07:03 +0000</pubDate>
      <link>https://dev.to/gadget/building-hubspot-custom-objects-in-gadget-5dbn</link>
      <guid>https://dev.to/gadget/building-hubspot-custom-objects-in-gadget-5dbn</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Extend HubSpot with custom objects powered by Gadget’s managed infrastructure, no Hubspot plan upgrade required!&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;While working on internal automations in HubSpot, I hit a wall.&lt;/p&gt;

&lt;p&gt;At Gadget, we have a partner program, and we wanted to optimize and automate this process as "&lt;em&gt;we&lt;/em&gt;" - (Franco, Head of Partnerships) handled this process manually. As you can imagine, having to manage a massive spreadsheet of partners to ensure every referral is tracked and associated with a partner account is not the preferred way to start each and every morning.&lt;/p&gt;

&lt;p&gt;I was tasked with finding a solution in HubSpot because tracking relationships between partners makes sense to do in a CRM (Customer ‘&lt;em&gt;Relationship&lt;/em&gt;’ Management) tool.&lt;/p&gt;

&lt;p&gt;But I hit a wall. I was unable to create more than 2 objects in Hubspot, and by default, HubSpot already contains 2 objects, Customers and Companies. &lt;/p&gt;

&lt;p&gt;So I checked what plan we would need to get one more object, and the price increase was the definition of “sticker shock”.&lt;/p&gt;

&lt;p&gt;My next thought was “why not build the Hubspot extension in Gadget?”, which then expanded to “This would be an amazing template for users to plug-and-play for internal apps.”&lt;/p&gt;

&lt;p&gt;That's how we got here with the &lt;a href="https://app.gadget.dev/auth/fork?domain=marks-static-obj.gadget.app&amp;amp;_gl=1*15qoz8x*_gcl_au*MTA4Nzk3MzgwOC4xNzY2NTk2NjEzLjE1MzE3MTcxOC4xNzcwMTM1NDQwLjE3NzAxMzU3MTc." rel="noopener noreferrer"&gt;HubSpot Static Object template&lt;/a&gt;. The goal here is to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Save you a hefty annual subscription&lt;/li&gt;
&lt;li&gt;Build your own custom automations&lt;/li&gt;
&lt;li&gt;And keep your data if you end your subscription for that extra custom object&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Watch a full walkthrough on deploying the app template here:&lt;/p&gt;

&lt;p&gt;

  &lt;iframe src="https://www.youtube.com/embed/SkRw3C6wNqg"&gt;
  &lt;/iframe&gt;


&lt;/p&gt;

&lt;p&gt;This template is built from the internal issues we had at Gadget and refined for other HubSpot users facing steep price increases to unlock additional functionality. The app template uses Gadget to rapidly provision Postgres-backed data models, automatically generate CRUD APIs, and manage authentication and environment configuration out of the box.&lt;/p&gt;

&lt;p&gt;On top of this foundation, Node is used as an orchestration layer to handle HubSpot-specific workflows such as JWT validation, conditional business logic, and coordinated read/write operations between HubSpot and the Gadget database, making it well suited for managing team-level data within a larger organization as a custom object.&lt;/p&gt;

&lt;p&gt;For example, you may have 2 separate customers under the same domain or company and in HubSpot, that is not easy to distinguish, nor is it practical to make and manage a separate variant Company object of that Company account.&lt;/p&gt;

&lt;p&gt;This template creates a custom object to manage groups within a company. Let's walk through it step by step!&lt;/p&gt;

&lt;p&gt;The first step to a custom object is determining the fields and data required. This will be seen in the templates &lt;code&gt;api/models/customObjectTeam&lt;/code&gt; model that contains the fields:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;teamName&lt;/code&gt; - Name of the team&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;teamContacts&lt;/code&gt; - Array of HubSpot contact IDs&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;parentCompany&lt;/code&gt; - HubSpot company ID&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;portalId&lt;/code&gt; - HubSpot portal/account ID&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These fields allow us to define a teamName and track associated contacts within the array.&lt;/p&gt;

&lt;p&gt;When you create a model in Gadget it automatically creates the Postgres table and CRUD actions to handle Create, Update, and Delete, which are also included in the customObjectTeam model. &lt;/p&gt;

&lt;p&gt;The template exposes this custom object to HubSpot through a small set of Node-based API routes that act as an orchestration layer between HubSpot and Gadget’s data models.&lt;/p&gt;

&lt;p&gt;The primary routes are &lt;code&gt;api/routes/hubspot/GET-teams.ts&lt;/code&gt; and &lt;code&gt;api/routes/hubspot/POST-action.ts&lt;/code&gt;, which handle reading, creating, and updating team records stored in Gadget’s Postgres database. These routes are responsible for coordinating HubSpot requests, applying conditional business logic, and translating HubSpot-specific identifiers into the internal data model.&lt;/p&gt;

&lt;p&gt;By handling this logic in Node, the template keeps complex workflow and integration behavior outside of HubSpot while still allowing HubSpot to act as the primary interface for users. All route access is secured using JWT-based authentication, which is enforced consistently across these endpoints.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvdg6mb79epysd9zxy2cz.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvdg6mb79epysd9zxy2cz.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The app card uses JWT auth to securely query the Gadget backend from Hubspot using HubSpot’s v3 authentication spec, which is handled in &lt;code&gt;api/routes/hubspot/POST-auth.ts&lt;/code&gt; and keeps the connection secure. &lt;/p&gt;

&lt;p&gt;Breaking down api/routes/hubspot/POST-auth.ts, it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Extracts the Bearer token from the Authorization header&lt;/li&gt;
&lt;li&gt;Verifies the JWT signature using &lt;code&gt;GADGET_ENVIRONMENT_JWT_SIGNING_KEY&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Checks that the session exists and hasn't expired (10-minute window)&lt;/li&gt;
&lt;li&gt;Throws an error if any validation fails&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By externalizing custom objects into Gadget, you regain control over your data model, your automations, and your long-term costs without giving up HubSpot as the system of record for relationships. If you’ve ever hit the same wall we did, this approach offers a practical, extensible way forward.&lt;/p&gt;

</description>
      <category>automation</category>
      <category>productivity</category>
      <category>saas</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Diagnose issues in production and development, with the operations dashboard</title>
      <dc:creator>Gadget</dc:creator>
      <pubDate>Tue, 20 Jan 2026 18:22:17 +0000</pubDate>
      <link>https://dev.to/gadget/diagnose-issues-in-production-and-development-with-the-operations-dashboard-1im</link>
      <guid>https://dev.to/gadget/diagnose-issues-in-production-and-development-with-the-operations-dashboard-1im</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Gadget's new operations dashboard provides charts and tables that help devs investigate errors and inefficient application code.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The operations dashboard helps you understand how your app behaves, providing up-to-date information about your app’s performance, errors, and resource usage, while you build and after you deploy.&lt;/p&gt;

&lt;p&gt;Because Gadget runs every development environment on real cloud infrastructure, operational signals appear early. Slow queries, inefficient actions, background action retries, and excess resource usage (especially excessive Shopify syncs) don’t wait for production. The operations dashboard surfaces those signals in a single interface, so you can understand performance and cost as you build &lt;em&gt;and&lt;/em&gt; after you have deployed to production.&lt;/p&gt;

&lt;p&gt;The database view in the ops dashboard:&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fomzv450s5s441b73pc3v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fomzv450s5s441b73pc3v.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  A clear picture of how your application runs
&lt;/h2&gt;

&lt;p&gt;The ops dashboard provides visibility into your application’s performance, errors, and resource usage through a collection of pre-built charts and tables. Together, they show how real requests move through your system.&lt;/p&gt;

&lt;p&gt;Instead of interpreting isolated metrics, you can see how traffic, execution time, failures, and infrastructure usage change over time, and how they relate to one another.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnqvc4b0frk4h617xqapn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fnqvc4b0frk4h617xqapn.png" alt="How hard are the workers working? Find out in the worker health view"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Because charts share a selected time range, you might notice a spike in database activity driven by a specific query pattern, then look at the same time window to see how that change affected background processing, search indexing, or overall compute usage. Keeping these signals aligned makes it easier to reason about cause and effect across the hosted infrastructure.&lt;/p&gt;
&lt;h2&gt;
  
  
  So, what does the dashboard show me?
&lt;/h2&gt;

&lt;p&gt;The operations dashboard includes pre-built charts that span the full lifecycle of a running application:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;App overview&lt;/strong&gt;: HTTP status codes, errors, actions run, CPU time, and number of platform operations performed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Worker health&lt;/strong&gt;: Worker CPU usage, memory consumption, event loop utilization, and how they all scale. (Read the docs to learn more about workers and the Gadget runtime environment.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database usage&lt;/strong&gt;: Query volume, storage, read and write volume, and how they correlate to rate limits, index usage, and file storage size.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search usage&lt;/strong&gt;: Request volume, read/write activity, storage and index usage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Background actions&lt;/strong&gt;: Number of action attempts, and enqueued, failed, cancelled, and running action counts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Traffic behaviour&lt;/strong&gt;: Route requests, bandwidth, and average page load times.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shopify activity (Shopify apps only)&lt;/strong&gt;: Number of Shopify API calls, rate limit errors, webhooks, syncs, and active installs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These charts are displayed on a shared timeline, making it easy to move between application behavior, infrastructure usage, and cost without losing context.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1kq8mq2q990bc7738h18.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F1kq8mq2q990bc7738h18.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you want to learn more about individual tables, we have &lt;a href="https://docs.gadget.dev/guides/development-tools/operations-dashboard?_gl=1*myastx*_gcl_au*MTA4Nzk3MzgwOC4xNzY2NTk2NjEz" rel="noopener noreferrer"&gt;descriptions of each table in our docs&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;
  
  
  How engineers actually use the operations dashboard
&lt;/h2&gt;

&lt;p&gt;Engineers don’t usually open the ops dashboard to look at a single metric. They use it to understand why the system is behaving a certain way and what changed. The dashboard is designed to help engineers reason about performance, errors, and resource usage together, instead of in isolation.&lt;/p&gt;

&lt;p&gt;A few common workflows:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tracing a performance regression&lt;/strong&gt;
After a deploy, request times start creeping up. By looking at traffic, execution time, and database activity on the same timeline, you can quickly see whether the slowdown is coming from increased user activity or long-running logic in your actions or routes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Understanding background work under load&lt;/strong&gt;
As traffic increases, background actions may start to queue or take longer to complete. The dashboard makes it easy to see when workers are spending more time under load, whether jobs are piling up, how often background actions need to be retried, and how that correlates with CPU and memory usage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Catching inefficiencies early in development&lt;/strong&gt;
Because the same dashboard is available in development environments, you might notice an unexpected spike in database reads, retries, or search indexing while building a feature. Seeing that behavior early makes it easier to fix inefficient queries or actions before they become production problems.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Investigating errors with context&lt;/strong&gt;
When errors increase, the dashboard shows what else was happening at the same time: traffic changes, worker saturation, rate limits, or resource pressure. That context helps distinguish between transient issues and problems rooted in application behavior.‍&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Making informed performance and cost tradeoffs&lt;/strong&gt;
By connecting resource usage to real application activity, engineers can see whether increased compute or database usage is driven by real demand or avoidable work. That makes it easier to decide where optimization is worth the effort and where additional resources are the right tradeoff.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwowu7hqkgpiugz3k8a7i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwowu7hqkgpiugz3k8a7i.png" alt=" "&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Instead of jumping between logs, metrics, and dashboards, the operations dashboard gives engineers a single, shared view of how their application behaves as it’s built and as it scales.&lt;/p&gt;
&lt;h2&gt;
  
  
  Resource usage you can reason about
&lt;/h2&gt;

&lt;p&gt;Because Gadget runs every environment on hosted cloud infrastructure, resource usage matters before you hit production. The ops dashboard surfaces how your application uses compute, database, storage, and network resources while you build and test your app, so cost isn’t something you only discover after deploying.&lt;/p&gt;

&lt;p&gt;By connecting resource usage to application behavior, the dashboard helps explain changes in your monthly spend. You can see whether increases are driven by real demand, background work, inefficient queries, or long-running actions, and make informed tradeoffs as you iterate. This makes it easier to understand how changes in traffic or code affect your monthly bill before they become a surprise.&lt;/p&gt;

&lt;p&gt;The same dashboard is available in development and production environments. Because all Gadget environments run on the same managed runtime, the signals you see while building are the same ones that matter in production.&lt;/p&gt;

&lt;p&gt;The result is fewer surprises and an application that’s easier to operate as it scales.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Under the hood, the dashboard is powered by ClickHouse and backed by precomputed materialized views, so you can explore high-volume operational data interactively without slow queries or long load times. The result is a fast, always-on view of how your application behaves across every development and production environment.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Have questions or want to dig deeper? Ask us in our developer &lt;a href="https://ggt.link/discord" rel="noopener noreferrer"&gt;Discord&lt;/a&gt; or check out our &lt;a href="https://docs.gadget.dev/guides/development-tools/operations-dashboard?_gl=1*ee72ve*_gcl_au*MTA4Nzk3MzgwOC4xNzY2NTk2NjEz" rel="noopener noreferrer"&gt;documentation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Want to learn more about how to use the operations dashboard? See Gadget CTO, Harry Brundage, explain each chart and how to use them:&lt;/p&gt;

&lt;p&gt;

  &lt;iframe src="https://www.youtube.com/embed/6taG0TdfCnI"&gt;
  &lt;/iframe&gt;


&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>backend</category>
      <category>saas</category>
      <category>devtools</category>
    </item>
    <item>
      <title>Building HubSpot apps in 2025: What’s new, what’s changing, and how to get started.</title>
      <dc:creator>Gadget</dc:creator>
      <pubDate>Tue, 06 Jan 2026 15:53:33 +0000</pubDate>
      <link>https://dev.to/gadgetdev/building-hubspot-apps-in-2025-whats-new-whats-changing-and-how-to-get-started-4mne</link>
      <guid>https://dev.to/gadgetdev/building-hubspot-apps-in-2025-whats-new-whats-changing-and-how-to-get-started-4mne</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;HubSpots developer ecosystem is changing a lot, so let's break down what is changing for developers like yourself.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;HubSpot’s developer ecosystem has expanded rapidly over the last two years. With the release of &lt;strong&gt;HubSpot’s Developer Platform&lt;/strong&gt;, they are transitioning away from legacy CRM extensions, which were all crammed in the side-bar, toward a much more capable environment by taking apps out of the side panel with &lt;strong&gt;App Cards&lt;/strong&gt;, alongside a modernized &lt;strong&gt;HubSpot CLI&lt;/strong&gt;, and a long-awaited &lt;strong&gt;local development workflow&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Whether you’re building a lightweight internal integration or a full commercial app for the marketplace, understanding these changes is essential. This post summarizes the key improvements, how the new platform differs from the legacy model, and what new tooling options are available to developers in 2025.&lt;/p&gt;

&lt;h2&gt;
  
  
  The new HubSpot app environment
&lt;/h2&gt;

&lt;p&gt;HubSpot’s new development environment is designed to be significantly more extensible, structured, and consistent than the previous generation of “sidebar-only” CRM extensions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Extensible App Cards
&lt;/h2&gt;

&lt;p&gt;Extensible UI options like the App Card are the centrepiece of the modern HubSpot app framework, compared to legacy CRM Cards (deprecated in 2025 and sunset in October 2026).&lt;/p&gt;

&lt;p&gt;At a high level, the key differences are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Placement is flexible:&lt;/strong&gt; cards can appear in the main record pages, not just the sidebar.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rendering is reactive:&lt;/strong&gt; extensions use React UI components within HubSpot’s native UX.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data access is easier:&lt;/strong&gt; apps fetch data through &lt;code&gt;hubspot.fetch()&lt;/code&gt;, which handles the data handling process for developers out of the box&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;More ways to view data:&lt;/strong&gt; App Objects, Events, and eventually Home Pages provide a path to more deeply integrated experiences.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Account executives and BDRs can rejoice now as information can be more digestible at a glance, rather than a slow, clunky sidebar. Rather than sit, wait, and scramble to review the Legacy apps data 5 minutes into the customer call, sales teams can be right on time with the center page app card, giving them what they need at a glance, between back-to-back calls.&lt;/p&gt;

&lt;h2&gt;
  
  
  App settings &amp;amp; app home
&lt;/h2&gt;

&lt;p&gt;The new &lt;strong&gt;App Settings&lt;/strong&gt; and &lt;strong&gt;App Home&lt;/strong&gt; experiences allow developers to build configuration UIs directly inside HubSpot. You no longer need an external settings page or a separate admin dashboard. Apps can now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Store and render their own configuration screens&lt;/li&gt;
&lt;li&gt;Allow merchants to manage connections and preferences&lt;/li&gt;
&lt;li&gt;Use the same UI Extensions framework as CRM-facing cards&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This closes a long-standing pain point for app developers and users where the app existed on an object page side-panel.&lt;/p&gt;

&lt;p&gt;There is a large list of beneficial changes for developers, all in the right direction, with more development environment customization, logging, and promised consistency for future HubSpot API changes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fphufz2x33g0h8aj45fno.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fphufz2x33g0h8aj45fno.png" alt=" " width="800" height="308"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;These changes include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Developers can now configure which combination of hub and tier their test accounts are, whereas before it was always enterprise&lt;/li&gt;
&lt;li&gt;Developers can configure &amp;amp; deploy single projects across multiple environments, as well as define and set reference environment variables&lt;/li&gt;
&lt;li&gt;Easily integrate Sentry and Honeycomb for logging and debugging&lt;/li&gt;
&lt;li&gt;Regularly cadenced releases for API updates in March and September, as well as selectable versioning. &lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A notable feature in the HubSpot CLI is the &lt;code&gt;hs get-started&lt;/code&gt; command, which scaffolds a working starter app, authenticates the CLI, and walks developers through deployment and installation. During this process, templated app cards, AGENTS.md, and CLAUDE.md are generated so your app is AI-ready as well.&lt;/p&gt;

&lt;p&gt;HubSpot has also promoted several upcoming Breeze AI features designed to help developers build faster by searching documentation, educating, exploring, debugging, and scaffolding. Breeze is also exposed through an MCP server, enabling the same AI capabilities to be used in any MCP-compatible environment, such as IDEs or chat tools.&lt;/p&gt;

&lt;p&gt;Of the mentioned AI features, the only ones currently available are the “Fast and Intelligent” documentation search, and debugging. The accompanying material with these statements is just the AI chat found in the developer documentation page, and not embedded in the app logs page. &lt;/p&gt;

&lt;p&gt;So far, anecdotal experience with these features feels like another AI assistant straight from Anthropic, lightly wrapped in whatever content it’s claimed to be an “expert” on. Key highlights from my experience of using Breeze AI are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;This was a miss for me as a result of the expectations. I found it quite slow, strangely clunky with page navigation, and the Assistant did not seem properly educated or integrated on the HubSpot docs knowledge base for about half of my questions.&lt;/li&gt;
&lt;li&gt;There were instances where I could tell it was not searching the HubSpot docs for questions I had and used external information that did not pertain to HubSpot's components.&lt;/li&gt;
&lt;li&gt;Heavily gravitates to assuming all questions pertain to Legacy apps &lt;/li&gt;
&lt;li&gt;There was a specific instance where I got lost in the HubSpot menus (as we all do) and asked the Breeze AI “Where can I find my App’s logs”. The response was that “Legacy Apps do not have logs”, and when I clarified more that this is a “Non-legacy app” it told me HubSpot does not have logging capabilities, and I will need to use an external platform. I knew it existed as I do use the logs in HubSpot, and eventually found it myself in /Development/Monitoring/Logs. Then selected my app from the dropdown menu at the top of the screen.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Authentication
&lt;/h2&gt;

&lt;p&gt;With the expansion of the HubSpot app store comes a push for a standard web application experience, meaning OAuth for multi-tenant apps. You will still have the option of static auth for private app distributions. &lt;/p&gt;

&lt;p&gt;If you are looking to have your app be publicly listed on the HubSpot app marketplace, you will need to use OAuth. &lt;/p&gt;

&lt;p&gt;If you are building a private app for a single client, an internal tool, or just want to start building without worrying about OAuth, Hubspot's static authentication is a fast way to get started.&lt;/p&gt;

&lt;p&gt;The primary difference between OAuth and static is how your application backend will obtain its access token. With OAuth, your backend must perform the full authorization flow. This includes generating an authorization URL using your HubSpot app’s Client ID, Client Secret, redirect URI, and required scopes.&lt;/p&gt;

&lt;p&gt;Users are redirected to HubSpot to grant access, and HubSpot sends your backend a temporary authorization code at the redirect URI. Your backend must exchange this code for an access token and a refresh token using HubSpot’s token endpoint, then store and refresh these tokens as needed. In contrast, static auth allows you to manually supply the Client ID, Client Secret, and permanent access token without performing redirects or token exchanges.&lt;/p&gt;

&lt;p&gt;For more information on working with OAuth you can check out &lt;a href="https://developers.hubspot.com/docs/apps/developer-platform/build-apps/authentication/oauth/working-with-oauth?__hstc=211494346.4c4c6f1e11a2c6a3287eb6cea937be82.1758813430354.1767471470255.1767711836267.58&amp;amp;__hssc=211494346.3.1767711836267&amp;amp;__hsfp=1750239613" rel="noopener noreferrer"&gt;HubSpot’s developer docs&lt;/a&gt;, OR stay tuned to our Gadget blog for our upcoming &lt;strong&gt;Gadget app template for HubSpot Oauth&lt;/strong&gt;, which will have a working implementation for HubSpot Oauth.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj64w2wwoi8cpb15fdqqd.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fj64w2wwoi8cpb15fdqqd.png" alt=" " width="800" height="388"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Start building Hubspot apps on Gadget
&lt;/h2&gt;

&lt;p&gt;HubSpot’s 2025 platform is a great step forward for a new era of extensibility in the HubSpot ecosystem. With a fully modern app framework, richer UI options, more predictable APIs, and a growing set of developer tools, builders can finally create polished, reliable, highly integrated experiences inside HubSpot.&lt;/p&gt;

&lt;p&gt;As the platform evolves through 2025 and beyond, we look forward to seeing the support HubSpot is putting forward in developers to nurture its platform and add highly requested and bespoke capabilities for a wide range of businesses. &lt;/p&gt;

&lt;p&gt;Want to start building private Hubspot apps for single clients? Try out the &lt;a href="https://app.gadget.dev/auth/fork?domain=hubspot-static-auth-template.gadget.app&amp;amp;_gl=1*g8sex1*_gcl_au*MTA4Nzk3MzgwOC4xNzY2NTk2NjEz" rel="noopener noreferrer"&gt;Gadget Hubspot template&lt;/a&gt;. It includes static auth, user and session token management, and gives you a hosted and scaled Postgres db, serverless Node backend, and Vite frontend for your app admin.&lt;/p&gt;

</description>
      <category>hubspot</category>
      <category>webdev</category>
      <category>saas</category>
      <category>api</category>
    </item>
    <item>
      <title>Getting started with Shop Minis (and other mini apps!)</title>
      <dc:creator>Gadget</dc:creator>
      <pubDate>Thu, 04 Dec 2025 21:54:12 +0000</pubDate>
      <link>https://dev.to/gadget/getting-started-with-shop-minis-and-other-mini-apps-17hb</link>
      <guid>https://dev.to/gadget/getting-started-with-shop-minis-and-other-mini-apps-17hb</guid>
      <description>&lt;h2&gt;
  
  
  Learn about the Shopify Shop Mini ecosystem and how to get started building Shop Minis with custom backends.
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;TLDR:&lt;/strong&gt; Shop Minis are custom, cross-merchant apps that enhance the buyer experience in Shopify’s Shop app. Gadget now has a template that handles authentication, session management, and data multitenancy for Shop Mini apps.&lt;/p&gt;

&lt;p&gt;Shopify recently announced Shop Mini apps, providing developers a way to provide shoppers custom experiences in Shopify’s Shop mobile app. I’m going to talk a bit about what mini apps are, what is unique about Shop Mini apps (compared to “traditional” admin-embedded Shopify apps), and how you can start building and submitting your Shop Minis.&lt;/p&gt;

&lt;p&gt;If you need a custom backend or database for your Shop Mini, check out the &lt;a href="https://app.gadget.dev/auth/fork?domain=shop-mini-template.gadget.app&amp;amp;_gl=1*z0127b*_gcl_au*MTQyNzc3MzA4Ny4xNzU4ODEzNDI4" rel="noopener noreferrer"&gt;Gadget template&lt;/a&gt; and follow the README to start making authenticated requests from your mini to Gadget.&lt;/p&gt;

&lt;p&gt;Prefer a video?&lt;/p&gt;

&lt;p&gt;

  &lt;iframe src="https://www.youtube.com/embed/gAJzSVnuivo?start=2"&gt;
  &lt;/iframe&gt;


&lt;/p&gt;

&lt;h2&gt;
  
  
  What are mini apps?
&lt;/h2&gt;

&lt;p&gt;Mini apps aren’t unique to the Shop app. In fact, on November 13, Apple announced their own &lt;a href="https://developer.apple.com/news/?id=xcz1s7cz" rel="noopener noreferrer"&gt;Mini Apps Partner Program&lt;/a&gt;, accompanied by mini app billing APIs (which are notably absent for Shop Mini apps!).&lt;/p&gt;

&lt;p&gt;Mini apps are small applications built with web technologies like HTML, JavaScript, and CSS, that run inside a host application rather than inside a web browser. A host app provides some combination of an outer shell, navigation, identity, APIs, and native UI elements, and the mini app delivers focused functionality inside that wrapper.&lt;/p&gt;

&lt;p&gt;It’s like if you were eating a sandwich, and inside that sandwich was another, smaller sandwich. The outer sandwich provides structure and a foundation, something you can hold. The inner sandwich: a delightful twist of new flavors. There is probably a food truck in Portland that sells these already.&lt;/p&gt;

&lt;p&gt;A better example: building games for the Discord mobile app:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fblfvdnignqqsk2bm4ib6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fblfvdnignqqsk2bm4ib6.png" alt="Mini apps can be built inside the Discord mobile app!"&gt;&lt;/a&gt;&lt;br&gt;
Mini apps can be built inside the Discord mobile app!&lt;/p&gt;

&lt;p&gt;You can think of it like embedding a web-powered widget inside a native container. The host controls high-level concerns; the mini concentrates on a specific user interaction. With mini apps for mobile, you can bypass building, deploying, or managing a full mobile app and instead deliver reach through the existing host app’s user base.&lt;/p&gt;

&lt;p&gt;The recently announced ChatGPT App SDK is another example of a mini app. The Apps SDK enables devs to build custom functionality inside ChatGPT (for the 800 million monthly users) and provides an API and design system you can use to extend ChatGPT’s base functionality.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are Shop Minis?
&lt;/h2&gt;

&lt;p&gt;Shop Minis are just mini apps for Shopify’s Shop mobile app and its user base. (The Shop app is a central hub for all merchants selling on Shopify. Instead of a shopper having to find and visit individual shops, they can discover ALL Shopify products in a single place.)&lt;/p&gt;

&lt;p&gt;Instead of building tools for merchants or storefronts like you would when building traditional embedded Shopify apps, a Shop Mini delivers enhancements to the buyer experience across any merchant using Shopify.&lt;/p&gt;

&lt;p&gt;Use cases for Shop Minis include things like: custom style pickers, color or complement-color finders, enhanced product discovery and recommendations, or other buyer-facing UI features. In some ways, you can think of a Shop mini as analogous to a theme extension, but instead of modifying a storefront, you’re extending the Shop app itself for shoppers.&lt;/p&gt;

&lt;p&gt;Shop Mini app examples:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhqkeq00zvigrr2yvpyxn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhqkeq00zvigrr2yvpyxn.png" alt="Shop Mini app examples"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is powerful because it gives you reach across all Shopify merchants, without requiring each merchant to install or configure the mini individually. Once your mini is approved, any buyer using Shop can use your mini to find product recommendations.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to build Shop Minis
&lt;/h2&gt;

&lt;p&gt;At the moment, the Shop Mini functionality is part of a pilot program, not yet broadly available. However, Shopify has established a framework for early partners to develop, submit, and ship minis.&lt;/p&gt;

&lt;p&gt;If you are already familiar with traditional web tools and React, building a functioning Mini won’t be difficult (once you download XCode and/or Android Studio.) Shopify provides a well documented API for fetching cross-merchant products and, for apps with custom backends, has provided an &lt;a href="https://github.com/Shopify/shop-minis/blob/main/supabase/README.md" rel="noopener noreferrer"&gt;auth example using Supabase Edge Functions&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A sample Shop Mini app - tutorial coming soon:&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fshaxxktepxhuiorf80la.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fshaxxktepxhuiorf80la.png" alt="A sample Shop Mini app - tutorial coming soon!"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here is what you need to know if you decide to build a Shop Mini app:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Minis are built in React using a project generated by a command-line interface, via a &lt;code&gt;@shopify/shop-minis&lt;/code&gt; CLI.&lt;/li&gt;
&lt;li&gt;Mobile-first: Even though you’re building in React, you need to build and test your mini using Android Studio or XCode. If you try to build on a web browser you will only ever get mock data. &lt;em&gt;This is a trap!&lt;/em&gt; Just use the mobile simulators from the start!&lt;/li&gt;
&lt;li&gt;Styling &amp;amp; UI ecosystem: The mini runs with a provided component library + styling support (Tailwind CSS, a set of SDK components, icons from Lucide), to ensure a consistent look and feel inside the Shop app. Using emojis instead of Lucide icons? &lt;em&gt;Instant rejection.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Limited dependencies: Shopify enforces strict dependency constraints. Using an npm package not on the list?  &lt;em&gt;Instant rejection.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Data fetching &amp;amp; hooks: Shopify provides hooks to fetch relevant data like user info, product data, and other context. These hooks are the intended way to access Shopify data, especially merchant and product data. Reading product data from a custom endpoint? &lt;em&gt;Instant rejection.&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Backend + auth architecture:

&lt;ul&gt;
&lt;li&gt;You can build a custom backend to support your mini, which requires you to set up JWT authentication on your server.&lt;/li&gt;
&lt;li&gt;Before signing a JWT, you need to call the Mini Admin API with the provided Mini API key to verify that the auth request is legitimate.&lt;/li&gt;
&lt;li&gt;Session and user management, and data multi-tenancy must be handled manually.&lt;/li&gt;
&lt;li&gt;The mini must request the openid scope and trusted domains need to be registered in &lt;code&gt;manifest.json&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;


&lt;/li&gt;

&lt;/ul&gt;

&lt;p&gt;The auth flow for custom Shop Mini backends:&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F07aqqr44oqhwsk1fo3hi.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F07aqqr44oqhwsk1fo3hi.png" alt="The auth flow for custom Shop Mini backends"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Analytics &amp;amp; metrics: The mini framework includes analytics support so you can monitor how often your mini is used and measure conversions, such as how often buyers use your mini to go from browsing to purchase. This helps you understand impact and performance.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;a href="https://shopify.dev/docs/api/shop-minis" rel="noopener noreferrer"&gt;Shop Mini docs&lt;/a&gt; are great and will help you get started. There are &lt;a href="https://shopify.dev/docs/api/shop-minis/guidelines" rel="noopener noreferrer"&gt;strict guidelines&lt;/a&gt; that you need to follow when building Shop Minis, including a hefty list of prohibited practices. Familiarize yourself with these items before you start building!&lt;/p&gt;

&lt;h2&gt;
  
  
  Publishing your Shop Mini
&lt;/h2&gt;

&lt;p&gt;The Shop Mini CLI has a &lt;code&gt;submit&lt;/code&gt; command you can use to send your mini to Shopify for review. &lt;/p&gt;

&lt;p&gt;As part of the submission, you must supply your source code and dependencies, which will be reviewed by the Shopify team. You also need to record a video demonstrating the full end-to-end flow: what happens from the buyer opening the Shop app, to launching/using the mini, to the end result (and benefit!) for the buyer.&lt;/p&gt;

&lt;p&gt;I expect these reviews to be incredibly thorough. Mini apps reflect on Shopify and their Shop app, and poor-quality minis will discourage buyers from using Shop in the first place. The review will check for compliance: security, performance, bundle size (Shopify is targeting &amp;lt;3 seconds for load and &amp;lt;5MB bundle size), correct behavior, and user experience, and the team will be making sure you don’t violate any of the Shop Mini guidelines.&lt;/p&gt;

&lt;p&gt;There’s a financial incentive for early builders: Shopify is offering a bounty of $5,000–$10,000 if the mini is submitted, approved, and launched by December 20. And if your mini makes use of AI, there is also reimbursement available for AI usage up to $500 per partner per month.&lt;/p&gt;

&lt;p&gt;Because the Shop Mini ecosystem is still relatively new, documentation and tooling are evolving. Treat this as an opportunity to influence the ecosystem. If you build now, you’ll learn early and be able to provide feedback to Shopify that shapes how Shop Minis work going forward.&lt;/p&gt;

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

&lt;p&gt;Shop Minis are a new mechanism for embedding buyer-facing functionality inside the Shop mobile app. Built in React and bound by strict constraints (styling, dependencies, performance, security), they allow developers to ship cross-merchant experiences at scale. Devs get access to the massive user base already using the Shop app. And with a custom backend and database, you have flexibility to deliver custom features, data, and behaviors to these users.&lt;/p&gt;

&lt;p&gt;If you want to build features that serve buyers directly without asking merchants to install or configure anything, minis are worth exploring.&lt;/p&gt;

&lt;p&gt;Start building Shop Minis today, with the &lt;a href="https://app.gadget.dev/auth/fork?domain=shop-mini-template.gadget.app&amp;amp;_gl=1*fosmn8*_gcl_au*MTQyNzc3MzA4Ny4xNzU4ODEzNDI4" rel="noopener noreferrer"&gt;Gadget template&lt;/a&gt;. Setup instructions are in the README.&lt;/p&gt;

</description>
      <category>shopify</category>
      <category>webdev</category>
      <category>tutorial</category>
      <category>saas</category>
    </item>
    <item>
      <title>Building scalable backends for Swift mobile apps</title>
      <dc:creator>Gadget</dc:creator>
      <pubDate>Wed, 03 Dec 2025 20:07:00 +0000</pubDate>
      <link>https://dev.to/gadget/building-scalable-backends-for-swift-mobile-apps-15c4</link>
      <guid>https://dev.to/gadget/building-scalable-backends-for-swift-mobile-apps-15c4</guid>
      <description>&lt;h2&gt;
  
  
  Learn how you can use Gadget as an auto-scaling backend and database for mobile apps built in Swift.
&lt;/h2&gt;

&lt;p&gt;In the past, only web developers used Gadget's infra and benefited from the smooth developer experience. Swift developers are getting wise to the productivity gains. The Swifties are beginning to join the revolution and do double the work in half the time with the Gadget platform.&lt;/p&gt;

&lt;p&gt;In this little walk-through, we'll use Gadget to spin up a database and API in minutes. Then we'll get a Swift app to talk to the Gadget backend using auto-generated code from the &lt;a href="https://github.com/apollographql/apollo-ios" rel="noopener noreferrer"&gt;Apollo iOS package&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The Swift app we'll be building is a pushup tracking app. Users can log pushups, and see a graph of how many pushups they've done in a week.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7nvmod92c5lxnufx6rei.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F7nvmod92c5lxnufx6rei.png" alt=" " width="736" height="1600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Spin up the DB and API
&lt;/h2&gt;

&lt;p&gt;Gadget has a web-based editor like Replit, &lt;a href="https://www.youtube.com/watch?v=FgXtF1cngVI" rel="noopener noreferrer"&gt;but you can also use the framework in your local dev environment&lt;/a&gt;. Either way, &lt;a href="https://gadget.dev/?utm_source=devto&amp;amp;utm_medium=community&amp;amp;utm_campaign=swift" rel="noopener noreferrer"&gt;you need to sign up for a Gadget account&lt;/a&gt;, then create a new app via the browser.&lt;/p&gt;

&lt;p&gt;When prompted, choose &lt;strong&gt;Web app&lt;/strong&gt; and enable auth.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftk5ktih48wn58yv0zt47.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftk5ktih48wn58yv0zt47.png" alt=" " width="800" height="572"&gt;&lt;/a&gt;&lt;br&gt;
Name your app, then let Gadget do the heavy lifting spinning up the database and API.&lt;/p&gt;
&lt;h2&gt;
  
  
  The pushup table
&lt;/h2&gt;

&lt;p&gt;Once the Gadget app is set upsetup, we need to add the table that will store the user's pushups. In the left navbar, go to the &lt;code&gt;api/models&lt;/code&gt; folder. Notice Gadget already created a user table that securely stores each user's account info.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fc1sdnvc7hh8n4qoe8n30.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fc1sdnvc7hh8n4qoe8n30.png" alt=" " width="800" height="309"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Press the "+" icon beside the models folder to create a new table in your app. Let's name our new table pushup. This will create the schema, database and API endpoints to CRUD the database. That's about an hour of work saved right there. Huzzah!&lt;/p&gt;

&lt;p&gt;Now let's add two columns to the database. The first records the number of pushups the user logged. The second is the foreign key to the user table. The foreign key says which pushups belong to which users.&lt;/p&gt;
&lt;h2&gt;
  
  
  Number of pushups field
&lt;/h2&gt;

&lt;p&gt;Select &lt;code&gt;schema&lt;/code&gt;, then press the "+" to add a new column to the database.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz62aiu07fus6m8ye6m6f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fz62aiu07fus6m8ye6m6f.png" alt=" " width="800" height="551"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Name the field &lt;code&gt;numberOfPushups&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;Number&lt;/strong&gt; as the field type&lt;/li&gt;
&lt;li&gt;In the validation section, make the &lt;code&gt;name&lt;/code&gt; field required&lt;/li&gt;
&lt;/ol&gt;
&lt;h2&gt;
  
  
  User foreign key
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Click the &lt;strong&gt;+&lt;/strong&gt; button to add another field&lt;/li&gt;
&lt;li&gt;Choose &lt;strong&gt;Belongs to&lt;/strong&gt; as the field type&lt;/li&gt;
&lt;li&gt;Set the API identifier to &lt;code&gt;user&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;In the &lt;strong&gt;Related model&lt;/strong&gt; dropdown, select &lt;code&gt;user&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;This creates a many-to-one relationship (many pushups can belong to one user)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;Auth&lt;/strong&gt;&lt;br&gt;
Now let's update permissions so that users can only read the pushup records associated with their account. &lt;/p&gt;

&lt;p&gt;Navigate to &lt;code&gt;accessControl/filters/pushup&lt;/code&gt;. This is where your &lt;a href="https://docs.gadget.dev/guides/data-access/gelly?_gl=1*k92zol*_gcl_au*MTQyNzc3MzA4Ny4xNzU4ODEzNDI4" rel="noopener noreferrer"&gt;Gelly&lt;/a&gt; filters should live for the pushup model. Press the &lt;strong&gt;+&lt;/strong&gt; icon to add a new filter.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0j8ouc78maf84uma9suw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0j8ouc78maf84uma9suw.png" alt=" " width="800" height="653"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Name the filter something like &lt;code&gt;tenancy.gelly&lt;/code&gt;. We want the filter to make sure the user ID associated with the request equals the user ID associated with the pushup. Here’s the gelly code that does that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;accessControl/filters/pushup/tenancy.gelly

filter ($user: User) on PushupLog [
  where userId == $user.id
]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy &amp;amp; paste that into your new gelly filter.&lt;/p&gt;

&lt;p&gt;Now we need to add this filter to the read action. Gadget has a handy GUI for that. You can find it by navigating to &lt;code&gt;accessControl/permissions&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Hover on the pushup/read row, and add a filter:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftgu498ipcqe08bs8dald.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ftgu498ipcqe08bs8dald.png" alt=" " width="800" height="575"&gt;&lt;/a&gt;&lt;br&gt;
Select the name of the filter you just created.&lt;/p&gt;

&lt;p&gt;Now the filter is associated with the read action, users can only view pushup records they created. We have thwarted the throng of nosy hackers once again — what a thrill.&lt;/p&gt;
&lt;h2&gt;
  
  
  Create the Swift app
&lt;/h2&gt;

&lt;p&gt;Believe it or not, we're done with the backend! Now let's create a simple Swift app that allows users to sign in, then manage their pushups.&lt;/p&gt;

&lt;p&gt;The Swift code is &lt;a href="https://github.com/gabeb03/repcount" rel="noopener noreferrer"&gt;available on GitHub&lt;/a&gt;. Let's set up and run the app:&lt;/p&gt;

&lt;p&gt;Open Xcode, and select &lt;strong&gt;Clone Git Repository&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3ygmhml35tt6yoo9hxym.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3ygmhml35tt6yoo9hxym.png" alt=" " width="786" height="506"&gt;&lt;/a&gt;&lt;br&gt;
Paste &lt;a href="https://github.com/gabeb03/repcount" rel="noopener noreferrer"&gt;https://github.com/gabeb03/repcount&lt;/a&gt; into the search box and press &lt;strong&gt;Clone&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In the &lt;strong&gt;Menu Bar&lt;/strong&gt;, select &lt;strong&gt;Product &amp;gt; Scheme &amp;gt; Edit Scheme&lt;/strong&gt;. Select the run section, then add this environment variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Product &amp;gt; Scheme &amp;gt; Edit Scheme

PUSHUP_GRAPHQL_ENDPOINT = https://&amp;lt;your app name&amp;gt;--&amp;lt;your environment&amp;gt;.gadget.app/api/graphql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgyakf80amkhhbr1mn6j2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgyakf80amkhhbr1mn6j2.png" alt=" " width="800" height="469"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now you can press the run button, and play around with the app. When you create new pushups, you should see them come up in the Gadget database. Go to &lt;code&gt;api/models/pushup/data&lt;/code&gt; to see the data that has been added to the &lt;code&gt;pushup&lt;/code&gt; table.&lt;/p&gt;

&lt;p&gt;Shazam!! You have a beautiful app that saves pushups to a scalable, secure backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Swift codebase deep dive&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;This section is for the folks who want to know exactly how I got our pushup app to talk to the Gadget API.&lt;/p&gt;

&lt;p&gt;Let's dive into the key parts of the codebase. By the end of this section, you should be able to drop Gadget into any native Swift app. Buckle up: we're going to cover a lot here.&lt;/p&gt;

&lt;h2&gt;
  
  
  The auto-generated GraphQL API
&lt;/h2&gt;

&lt;p&gt;Our goal in life is to write Swift code that will make API requests to Gadget. We want to use the API to create and read pushup entries from the database.&lt;/p&gt;

&lt;p&gt;Usually, we'd have to write all the code to do that by hand. Fortunately, Gadget knows how lazy we are, and has written all that code for us.&lt;/p&gt;

&lt;p&gt;Gadget automatically spins up a &lt;a href="https://www.youtube.com/watch?v=eIQh02xuVw4" rel="noopener noreferrer"&gt;GraphQL&lt;/a&gt; API that allows us to read/write to the database.&lt;/p&gt;

&lt;p&gt;Now all we have to do is write the Swift code to make calls to the GraphQL API.&lt;/p&gt;

&lt;p&gt;There are two ways to make calls to the Gadget API: write all the GraphQL API queries by hand, or generate those queries based on the Gadget's GraphQL schema. The second option is way easier. Let's go with that one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Install Apollo
&lt;/h2&gt;

&lt;p&gt;The Apollo library makes it super easy to generate the Swift code to hit the Gadget API. Go to File &amp;gt; Add Package Dependencies, then paste &lt;code&gt;https://github.com/apollographql/apollo-ios.git&lt;/code&gt; into the search box.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvnvjbxthkjlnytyg7739.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvnvjbxthkjlnytyg7739.png" alt=" " width="800" height="469"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Add the package, then add &lt;code&gt;Apollo&lt;/code&gt; and &lt;code&gt;ApolloAPI&lt;/code&gt; to the target&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F759eqz9tluvdnig1c8id.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F759eqz9tluvdnig1c8id.png" alt=" " width="800" height="469"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Finally, we need to install the Apollo CLI. Right click on the project name, then select "Install CLI"&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffu3slj9zifknj63qtar3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ffu3slj9zifknj63qtar3.png" alt=" " width="800" height="469"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Get schema
&lt;/h2&gt;

&lt;p&gt;Apollo needs the GraphQL API schema to be able to generate code. We can fetch it using npx:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Terminal

npx -p @apollo/rover rover graph introspect https://repcount--development.gadget.app/api/graphql
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Copy the schema into your project. I put my schema in &lt;code&gt;./graphql/schema/graphqls&lt;/code&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Set up codegen config
&lt;/h2&gt;

&lt;p&gt;Next, we need to tell Apollo how to generate the Swift files. All the settings are specified in a file called &lt;code&gt;apollo-codegen-config.json&lt;/code&gt;. There's the JSON file that worked for me. You'll need to tweak it for your project.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;apollo-codegen-config.json

{
   "schemaNamespace": "RepcountAPI",
   "input": {
     "operationSearchPaths": [
       "GraphQL/**/*.graphql"
     ],
     "schemaSearchPaths": [
       "./graphql/schema.graphqls" // &amp;lt;- put the path to your schema here
     ]
   },
   "output": {
     "testMocks": { "none": {} },
     "schemaTypes": {
       "path": "./RepcountAPI",
       "moduleType": {
         "embeddedInTarget": {
           "name": "Repcount"
         }
       }
     },
     "operations": { "inSchemaModule": {} }
   }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here comes the annoying part: you need to write the GraphQL mutations you want to convert to Swift by hand. Make a folder with all the GraphQL mutations. My mutations are in &lt;code&gt;/graphql&lt;/code&gt;. It doesn't matter where you put the queries. Apollo will look everywhere in your project and turn &lt;code&gt;&amp;lt;any file name&amp;gt;.graphql&lt;/code&gt; into Swift code.&lt;/p&gt;

&lt;p&gt;All the GraphQL mutations I wrote for the pushup app are &lt;a href="https://github.com/gabeb03/repcount/tree/main/repcount/graphql" rel="noopener noreferrer"&gt;here&lt;/a&gt;. Take a peek at &lt;a href="https://docs.gadget.dev/api/example-app/development/external-api-calls/graphql?_gl=1*1kff6nx*_gcl_au*MTQyNzc3MzA4Ny4xNzU4ODEzNDI4" rel="noopener noreferrer"&gt;Gadget's GraphQL docs&lt;/a&gt; if you're unfamiliar with Gadget's auto-generated GraphQL API&lt;/p&gt;

&lt;h2&gt;
  
  
  Generate Swift code
&lt;/h2&gt;

&lt;p&gt;Now all your GraphQL queries are written out, you can use Apollo to turn those queries into Swift code.&lt;/p&gt;

&lt;p&gt;Go to the terminal, and run the following from the root folder of your Swift project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Terminal

../apollo-ios-cli generate

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Swift files should magically appear in the folder you specified in &lt;code&gt;apollo-codegen-config.json&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deal with Xcode being dumb
&lt;/h2&gt;

&lt;p&gt;You are probably going to see a bunch of errors in the generated code that contain "cannot satisfy conformance requirement for a 'Sendable'"&lt;/p&gt;

&lt;p&gt;You can fix that by double-clicking on the project name:&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv3lup2k74dk3hbaio02k.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fv3lup2k74dk3hbaio02k.png" alt=" " width="800" height="396"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Go to &lt;strong&gt;Build Settings&lt;/strong&gt;, then &lt;strong&gt;Swift Compiler - Concurrency&lt;/strong&gt;. Set &lt;strong&gt;Default Actor Isolation&lt;/strong&gt; to &lt;strong&gt;nonisolated&lt;/strong&gt;. You should be able to build your project, and call the Gadget API from any Swift file!&lt;/p&gt;

&lt;p&gt;As far as I can tell, &lt;a href="https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/" rel="noopener noreferrer"&gt;Swift Concurrency&lt;/a&gt; is to blame for this quirk. Older Swift code was written assuming that it would all run on the main thread. In this brave, concurrent world, that may not be the case. Old, non-current Swift code was breaking, and developers were angry. Apple patched this problem by treating non-annotated code as if it were intentionally main-thread-bound. You make code main-thread-bound by adding the &lt;code&gt;@MainActor&lt;/code&gt; annotation. The “Default Actor Isolation = MainActor” means “treat every unannotated file as if it has the &lt;code&gt;@MainActor&lt;/code&gt; annotation”. The data models Apollo generates are meant to be passed between threads, so when they are given the &lt;code&gt;@MainActor&lt;/code&gt; annotation, the compiler gets very angry. &lt;/p&gt;

&lt;p&gt;Setting “Default Actor Isolation = nonisolated” doesn’t add the MainActor annotation to every file, and the compiler gets happy again.&lt;/p&gt;
&lt;h2&gt;
  
  
  Session token authentication
&lt;/h2&gt;

&lt;p&gt;Thanks to Apollo, we can talk to the Gadget API, but all requests we make from the Swift app will be unauthenticated. Unauthenticated users can't even read pushup records, so we need to set up &lt;a href="https://www.youtube.com/watch?v=UBUNrFtufWo" rel="noopener noreferrer"&gt;session authentication&lt;/a&gt; so the Gadget API knows the user making these API requests has signed in. Here’s a little diagram showing how we’ll set up auth:&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqn5wjhr4akbv4pfya7mb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqn5wjhr4akbv4pfya7mb.png" alt=" " width="800" height="183"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;When our beautiful app wants to make an authenticated request, it includes the session ID that was stored locally in the Authentication header of the request: &lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5nlua3wv4snccbvbck1h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5nlua3wv4snccbvbck1h.png" alt=" " width="800" height="163"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;All the code I’m going to show you is stolen from the &lt;a href="https://docs.gadget.dev/api/example-app/development/external-api-calls/authentication?_gl=1*1xqofjk*_gcl_au*MTQyNzc3MzA4Ny4xNzU4ODEzNDI4#session-token-authentication" rel="noopener noreferrer"&gt;Gadget auth docs&lt;/a&gt;. Check 'em out if you get stuck.&lt;/p&gt;

&lt;p&gt;When the user signs in, we need to store the session token in the Keychain. The session token is what authenticates requests, so it needs to be kept in a secure spot. The &lt;a href="https://developer.apple.com/documentation/security/storing-keys-in-the-keychain" rel="noopener noreferrer"&gt;Keychain&lt;/a&gt; is the perfect place.&lt;/p&gt;

&lt;p&gt;Here's a quick tour of the services you need to get session token authentication working:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/gabeb03/repcount/blob/main/repcount/APINetwork.swift" rel="noopener noreferrer"&gt;APINetwork.swift&lt;/a&gt;. The APINetwork spins up a GraphQL client that can make authenticated calls.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/gabeb03/repcount/blob/main/repcount/ApolloClient%2BAsync.swift" rel="noopener noreferrer"&gt;ApolloClient+Async.swift&lt;/a&gt;. This extends the Apollo client in APINetwork.swift so that it handles responses asynchronously.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/gabeb03/repcount/blob/main/repcount/AuthService.swift" rel="noopener noreferrer"&gt;AuthService.swift&lt;/a&gt;. This file exposes the sign in, sign out methods. The UI uses these methods to allow the user to authenticate.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/gabeb03/repcount/blob/main/repcount/AuthSessionManager.swift" rel="noopener noreferrer"&gt;AuthSessionManager.swift&lt;/a&gt;. AuthService.swift uses the methods in this file to store the session token in the Keychain.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://github.com/gabeb03/repcount/blob/main/repcount/AuthInterceptors.swift" rel="noopener noreferrer"&gt;AuthInterceptor.swift&lt;/a&gt;. I'm too lazy to fetch the session token from the Keychain every time and include it in every request I send to Gadget. I wrote this interceptor to add the session token to the Authentication header for every request.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wish there was a cleaner way to do authentication, but this verbose solution is what we are stuck with at the moment. Thankfully all the files above are general enough to work in any Swift project, not just my pushup app. Feel free to copy and paste.&lt;/p&gt;
&lt;h2&gt;
  
  
  Call the API from the Swift app
&lt;/h2&gt;

&lt;p&gt;FINALLY, we can start reading and writing to the database. Here's how I did it in my pushup app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Call Gadget API in Swift

import Apollo

final class PushupRepository: PushupRepositoryProtocol {
    private let sessionManager: AuthSessionManager

    init(sessionManager: AuthSessionManager = .shared) {
        self.sessionManager = sessionManager
    }

    private func client() -&amp;gt; ApolloClient {
        return APINetwork.shared.client(sessionToken: sessionManager.session?.token)
    }

    func fetchEntries(for userId: String) async throws -&amp;gt; [PushupEntry] {
        // Instead of passing in userId, you can get it from sessionManager.session?.userId
        let query = RepcountAPI.GetUserPushupsQuery(userId: userId)
        let result = try await client().fetchAsync(query: query)
        let entries = result.pushups.edges.map { $0.node.toEntry() }
        return entries.sorted { $0.displayDate &amp;gt; $1.displayDate }
    }
}

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Party time
&lt;/h2&gt;

&lt;p&gt;That's pretty much it! Once you have the GraphQL and authentication setup, you can start working on making the app beautiful and functional.&lt;/p&gt;

&lt;p&gt;I'll leave all that fun stuff to you. Bon voyage!&lt;/p&gt;

</description>
      <category>swift</category>
      <category>ios</category>
      <category>graphql</category>
      <category>backend</category>
    </item>
    <item>
      <title>Gadget’s BFCM 2025 (in numbers)</title>
      <dc:creator>Gadget</dc:creator>
      <pubDate>Tue, 02 Dec 2025 21:17:21 +0000</pubDate>
      <link>https://dev.to/gadget/gadgets-bfcm-2025-in-numbers-p45</link>
      <guid>https://dev.to/gadget/gadgets-bfcm-2025-in-numbers-p45</guid>
      <description>&lt;p&gt;Another year, another successful BFCM for merchants and Gadget developers! Let's dive into the numbers.&lt;/p&gt;

&lt;p&gt;Black Friday Cyber Monday (BFCM) remains the annual stress test for Shopify and BigCommerce apps, and this year pushed ecommerce traffic higher than anything we (&lt;a href="https://x.com/tobi/status/1995857920219820406" rel="noopener noreferrer"&gt;or Shopify&lt;/a&gt;) have seen before!&lt;/p&gt;

&lt;p&gt;Below is a recap of how Gadget apps performed, how traffic compared to a normal weekend, and how total load stacked up against BFCM 2024.&lt;/p&gt;

&lt;h2&gt;
  
  
  Total webhooks processed
&lt;/h2&gt;

&lt;p&gt;Gadget processed &lt;strong&gt;173,078,683 webhooks&lt;/strong&gt; over the BFCM 2025 weekend, which works out to an average rate of &lt;strong&gt;30,048.382 webhooks/minute&lt;/strong&gt;!&lt;/p&gt;

&lt;p&gt;Total webhooks over BFCM 2025:&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkg4bsfpiutnwclv931qb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkg4bsfpiutnwclv931qb.png" alt=" " width="800" height="412"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Webhooks/minute during BFCM 2025:&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmwqrx24wvg4sfp5fdtnw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmwqrx24wvg4sfp5fdtnw.png" alt=" " width="800" height="412"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;For context:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Last weekend (Nov 21-24), we processed 83,557,668 webhooks, or 19,342.053 webhooks/minute.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total webhooks the weekend before BFCM 2025:&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgl0e4xpskz06mw5ajbex.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgl0e4xpskz06mw5ajbex.png" alt=" " width="800" height="391"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Webhooks/minute the weekend before BFCM 2025:&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdwspqhymisb6yxcc24z8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fdwspqhymisb6yxcc24z8.png" alt=" " width="800" height="391"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Picking a random weekend earlier in the year, July 18-21, we processed 46,525,567 webhooks, or 10,769.807 webhooks/minute.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Total webhooks July 18-21, 2025:&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F87wvzpsgxq1uwmr8c0v5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F87wvzpsgxq1uwmr8c0v5.png" alt=" " width="800" height="390"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Webhooks/minute July 18-21, 2025:&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8abo8qjwff0sa16640dn.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F8abo8qjwff0sa16640dn.png" alt=" " width="800" height="390"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;During BFCM 2024, we processed a total of 89,406,843 webhooks, equivalent to ~15,524 webhooks/minute. See &lt;a href="https://gadget.dev/blog/gadget-apps-bfcm-2024-in-numbers" rel="noopener noreferrer"&gt;last year’s post&lt;/a&gt; for more info on these numbers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;We almost surpassed last year’s BFCM traffic on the weekend before BFCM 2025!&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This means that Gadget’s total webhook volume almost doubled from last year’s BFCM, &lt;strong&gt;growing ~94%&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The growth year over year reflects both the increasing scale of the apps built on Gadget and the expanding footprint of event-driven features across the Shopify ecosystem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Orders, orders, orders
&lt;/h2&gt;

&lt;p&gt;Shopify’s &lt;code&gt;orders/create&lt;/code&gt; webhook gives us a rough idea of how many new orders were modified or customized using Gadget apps.&lt;/p&gt;

&lt;p&gt;Over BFCM, the average rate of &lt;code&gt;orders/create&lt;/code&gt; webhooks processed by Gadget per minute was &lt;strong&gt;970.358&lt;/strong&gt;!&lt;/p&gt;

&lt;p&gt;Shopify orders/create webhooks/minute BFCM 2025:&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F305shozr3hgzva451cny.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F305shozr3hgzva451cny.png" alt=" " width="800" height="392"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That’s compared to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;~530 &lt;code&gt;orders/create&lt;/code&gt; webhooks/minute the previous weekend&lt;/li&gt;
&lt;li&gt;~345 &lt;code&gt;orders/create&lt;/code&gt; webhooks/minute during BFCM 2024&lt;/li&gt;
&lt;li&gt;~150 &lt;code&gt;orders/create&lt;/code&gt; webhooks/minute the weekend prior to BFCM 2024&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That’s almost a &lt;strong&gt;3x increase&lt;/strong&gt; (actual number: ~2.8x) in &lt;code&gt;orders/create&lt;/code&gt; webhooks processed compared to BFCM 2024!&lt;/p&gt;

&lt;h2&gt;
  
  
  Peak webhook rate
&lt;/h2&gt;

&lt;p&gt;This year’s highest sustained traffic reached &lt;strong&gt;52,659.547&lt;/strong&gt; webhooks/minute around 15:00 UTC (or 10:00 am ET) on Cyber Monday. The load curve showed a clean ramp, a stable peak throughout the day, and a slow taper as Cyber Monday deals wound down.&lt;/p&gt;

&lt;p&gt;Peak webhooks/minute during BFCM 2025:&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F66jcx4zarli1onfi5bwg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F66jcx4zarli1onfi5bwg.png" alt=" " width="800" height="459"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Weekend spike compared to pre-BFCM traffic
&lt;/h2&gt;

&lt;p&gt;To get an idea of how much of an increase in webhook traffic we see over BFCM, we can take a look at the biggest difference in webhooks/minute rates across BFCM and the weekend prior.&lt;/p&gt;

&lt;p&gt;The largest rate difference was &lt;strong&gt;31,619.900&lt;/strong&gt; additional webhooks/minute compared to the same minute the prior weekend (50,106.888 webhooks/minute during BFCM vs 18,486.988 webhooks/minute the weekend before BFCM), which is a spot increase of 171%!&lt;/p&gt;

&lt;p&gt;Biggest diff in webhook/minute rate vs weekend before BFCM:&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn67wzsxy60bpr4vwd6sb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn67wzsxy60bpr4vwd6sb.png" alt=" " width="800" height="462"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Congratulations again to all ecommerce developers for surviving yet another BFCM weekend!&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A special shoutout to the Gadget infrastructure team, who spend weeks (and months) prior to BFCM scaling up resources and redefining architecture to handle the increase in traffic over this important retail weekend!&lt;/p&gt;

&lt;p&gt;We look forward to scaling up again next year!&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>webhooks</category>
      <category>ai</category>
      <category>saas</category>
      <category>shopify</category>
    </item>
    <item>
      <title>Use Gadget's Preact hooks to build Shopify UI extensions</title>
      <dc:creator>Gadget</dc:creator>
      <pubDate>Wed, 12 Nov 2025 23:16:04 +0000</pubDate>
      <link>https://dev.to/gadget/use-gadgets-preact-hooks-to-build-shopify-ui-extensions-43o</link>
      <guid>https://dev.to/gadget/use-gadgets-preact-hooks-to-build-shopify-ui-extensions-43o</guid>
      <description>&lt;h2&gt;
  
  
  @gadgetinc/preact contains hooks and a Provider to manage your 2025-10 UI extension sessions and make custom network requests.
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmwr0sq18eco4i21gv595.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fmwr0sq18eco4i21gv595.png" alt=" " width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;TLDR: Gadget has released Preact hooks to help you build Shopify extensions on API version 2025-10 (and later).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Shopify’s API 2025-10 release marks a major shift for Shopify app developers. Extensions have officially moved to &lt;strong&gt;Preact&lt;/strong&gt; and &lt;strong&gt;Polaris web components&lt;/strong&gt; are now stable. These changes also enable Shopify to enforce a new &lt;strong&gt;64 KB bundle size limit&lt;/strong&gt; for extensions.&lt;/p&gt;

&lt;p&gt;These updates make extensions faster, smaller, and more consistent across Shopify surfaces. They also mean developers may have to replace existing tools and packages with Preact equivalents and (eventually) migrate existing extensions to Preact and web components.&lt;/p&gt;

&lt;p&gt;To support Preact extensions, we’re releasing &lt;a href="https://github.com/gadget-inc/js-clients/tree/main/packages/preact" rel="noopener noreferrer"&gt;Preact hooks&lt;/a&gt; designed to make it easy to work with your Gadget app backend while staying under Shopify’s new bundle size constraints.&lt;/p&gt;

&lt;h2&gt;
  
  
  The new UI extension stack: Preact and Polaris web components
&lt;/h2&gt;

&lt;p&gt;For years, Shopify extensions and apps have been built with React and Polaris React, or vanilla JS. With &lt;code&gt;2025-10&lt;/code&gt;, Shopify is taking a decisive step forward:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Preact replaces React or vanilla JS&lt;/strong&gt; in all new extensions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Polaris web components&lt;/strong&gt; are used to build extensions (instead of Polaris React) and are loaded directly from Shopify’s CDN.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extensions must stay under 64kb&lt;/strong&gt;. This limit enforces fewer dependencies, smaller runtimes, and faster load times for merchants and buyers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this means for developers
&lt;/h2&gt;

&lt;p&gt;Preact’s syntax is nearly identical to React. You’ll still use familiar hooks like &lt;code&gt;useState&lt;/code&gt; and &lt;code&gt;useEffect&lt;/code&gt;, and you will build with JSX. You may need to adapt your tooling and build pipeline. Gadget built a separate package for Preact hooks. Other packages you use in extensions may also need to be migrated. &lt;/p&gt;

&lt;p&gt;You’ll also need to switch from Polaris React to &lt;strong&gt;Polaris web components&lt;/strong&gt;. &lt;a href="https://shopify.dev/docs/api/checkout-ui-extensions/latest/upgrading-to-2025-10#mapping-legacy-components-to-polaris-web-components" rel="noopener noreferrer"&gt;Shopify’s migration guide has a useful mapping of “Legacy” Polaris components to web components&lt;/a&gt;. &lt;/p&gt;

&lt;p&gt;An important note: &lt;a href="https://polaris-react.shopify.com/" rel="noopener noreferrer"&gt;Polaris React&lt;/a&gt; is officially deprecated. However, as of writing, &lt;a href="https://community.shopify.dev/t/migration-deadline-for-ui-extensions-to-preact-web-components/23532" rel="noopener noreferrer"&gt;Shopify has not officially announced a migration deadline for existing extensions&lt;/a&gt;. A minimum of 1 year of support is guaranteed for the last React-focused API version: &lt;code&gt;2025-07&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using Gadget’s Preact hooks
&lt;/h2&gt;

&lt;p&gt;Gadget’s existing React tooling, the hooks and &lt;code&gt;Provider&lt;/code&gt; from &lt;code&gt;@gadgetinc/react&lt;/code&gt; and &lt;code&gt;@gadgetinc/shopify-extensions&lt;/code&gt;, don’t work with Preact.&lt;/p&gt;

&lt;p&gt;That’s why we built &lt;code&gt;@gadgetinc/preact&lt;/code&gt;: a lightweight set of utilities that make it easy to call your Gadget backend directly from your Preact-based extension. They handle session token registration, authenticated requests, and data fetching, allowing you to make custom network requests in a “Preact-ful” way.&lt;/p&gt;

&lt;p&gt;The hooks included in &lt;code&gt;@gadgetinc/preact&lt;/code&gt; match the hooks from &lt;code&gt;@gadgetinc/react&lt;/code&gt;, so you can build extensions with familiar APIs. There is one exception: &lt;code&gt;@gadgetinc/preact&lt;/code&gt; does not include support for the &lt;code&gt;useActionForm()&lt;/code&gt; hook.&lt;/p&gt;

&lt;p&gt;Here’s an example of how you might use them in a customer account UI extension to read data and render UI:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;extensions/note-goat/src/MenuActionItemButtonExtension.jsx&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import "@shopify/ui-extensions/preact";
import { render } from "preact";
import { Provider, useGadget } from "@gadgetinc/shopify-extensions/preact";
import { useMaybeFindFirst } from "@gadgetinc/preact";

// 1. Import (or init) your app's API client
import { apiClient } from "./api";

// 2. Export the extension
export default async () =&amp;gt; {
 render(&amp;lt;GadgetUIExtension /&amp;gt;, document.body);
};

function GadgetUIExtension() {
 const { sessionToken } = shopify;

 // 3. Use Gadget Provider to init session management
 return (
   &amp;lt;Provider api={apiClient} sessionToken={sessionToken}&amp;gt;
     &amp;lt;MenuActionItemButtonExtension /&amp;gt;
   &amp;lt;/Provider&amp;gt;
 );
}

function MenuActionItemButtonExtension() {
 // 4. Use 'ready' to ensure session is initialized before making API calls
 /** @type {{ ready: boolean, api: typeof apiClient }} */
 const { api, ready } = useGadget();

 // 5. Use hooks to call your Gadget API
 const [{ data, fetching, error }] = useMaybeFindFirst(api.message, {
   pause: !ready,
 });

 return !fetching &amp;amp;&amp;amp; data &amp;amp;&amp;amp; &amp;lt;s-button&amp;gt;{data.body}&amp;lt;/s-button&amp;gt;;
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And this pattern can be used for any UI extension built with Preact. (The least favourite child of the UI extension ecosystem, post-purchase UI extensions, still use React.) &lt;/p&gt;

&lt;p&gt;Preact hooks are only available for Gadget apps on framework version 1.5 or later. &lt;a href="https://docs.gadget.dev/guides/gadget-framework?_gl=1*sfrn8s*_gcl_au*MTQyNzc3MzA4Ny4xNzU4ODEzNDI4#how-to-change-your-application-s-framework-version" rel="noopener noreferrer"&gt;Read more about upgrading your app’s framework version&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting started
&lt;/h2&gt;

&lt;p&gt;To start building with Gadget’s Preact hooks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Set up your new extension.

&lt;ol&gt;
&lt;li&gt;Pull down your Gadget app locally using the Gadget CLI’s &lt;code&gt;ggt dev&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add &lt;code&gt;workspaces: [“extensions/*”]&lt;/code&gt; to your Gadget app’s &lt;code&gt;package.json&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Generate your new UI extension using the Shopify CLI.&lt;/li&gt;
&lt;/ol&gt;


&lt;/li&gt;

&lt;li&gt;Set &lt;code&gt;network_access = true&lt;/code&gt; in &lt;code&gt;shopify.extension.toml&lt;/code&gt;.&lt;/li&gt;

&lt;li&gt;Install the &lt;code&gt;@gadgetinc/preact&lt;/code&gt; and &lt;code&gt;@gadgetinc/shopify-extensions&lt;/code&gt; packages.&lt;/li&gt;

&lt;li&gt;Init your app’s API client and set up the &lt;code&gt;Provider&lt;/code&gt; in your extension.&lt;/li&gt;

&lt;li&gt;Start building!&lt;/li&gt;

&lt;/ol&gt;

&lt;p&gt;Keep an eye on your bundle size when building! If you exceed the 64kb limit, you will be notified when you run &lt;code&gt;shopify app deploy&lt;/code&gt; to deploy your extension.&lt;/p&gt;

&lt;p&gt;You can find full documentation and setup steps in the &lt;a href="https://docs.gadget.dev/guides/plugins/shopify/advanced-topics/extensions?_gl=1*1dpi4uj*_gcl_au*MTQyNzc3MzA4Ny4xNzU4ODEzNDI4" rel="noopener noreferrer"&gt;Gadget docs&lt;/a&gt;. If you want to walk through a short app build, you can follow our &lt;a href="https://docs.gadget.dev/guides/tutorials/shopify/ui-extension?_gl=1*1yp7o18*_gcl_au*MTQyNzc3MzA4Ny4xNzU4ODEzNDI4" rel="noopener noreferrer"&gt;Shopify UI extension tutorial&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The Shopify ecosystem is evolving fast. With Preact, Polaris web components, and Gadget’s Preact hooks, you can keep your extensions modern and performant without giving up the productivity of React-style development.&lt;/p&gt;

&lt;p&gt;If you have feedback or questions, chat with us on &lt;a href="https://ggt.link/discord" rel="noopener noreferrer"&gt;Discord&lt;/a&gt;. We’d love to hear what you’re building!&lt;/p&gt;

</description>
      <category>gadget</category>
      <category>shopify</category>
      <category>preact</category>
      <category>webdev</category>
    </item>
    <item>
      <title>[Boost]</title>
      <dc:creator>Gadget</dc:creator>
      <pubDate>Wed, 12 Nov 2025 22:52:41 +0000</pubDate>
      <link>https://dev.to/gadget/-3fjb</link>
      <guid>https://dev.to/gadget/-3fjb</guid>
      <description>&lt;div class="ltag__link"&gt;
  &lt;a href="/gabeb03" class="ltag__link__link"&gt;
    &lt;div class="ltag__link__pic"&gt;
      &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1501534%2F5320fbf3-9350-400e-9524-aa254bbe691f.jpeg" alt="gabeb03"&gt;
    &lt;/div&gt;
  &lt;/a&gt;
  &lt;a href="https://dev.to/gabeb03/stop-throwing-your-life-in-the-garbage-automate-1db" class="ltag__link__link"&gt;
    &lt;div class="ltag__link__content"&gt;
      &lt;h2&gt;Stop throwing your life in the garbage. Automate.&lt;/h2&gt;
      &lt;h3&gt;gabeb03 ・ Oct 17&lt;/h3&gt;
      &lt;div class="ltag__link__taglist"&gt;
        &lt;span class="ltag__link__tag"&gt;#gadget&lt;/span&gt;
        &lt;span class="ltag__link__tag"&gt;#programming&lt;/span&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/a&gt;
&lt;/div&gt;


</description>
      <category>gadget</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
