<?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: Savage Solutions</title>
    <description>The latest articles on DEV Community by Savage Solutions (@savage_solutions).</description>
    <link>https://dev.to/savage_solutions</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3840051%2F1d4b543c-72fa-4bc5-a1a4-f8b76e9c4655.jpg</url>
      <title>DEV Community: Savage Solutions</title>
      <link>https://dev.to/savage_solutions</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/savage_solutions"/>
    <language>en</language>
    <item>
      <title>A Hanging Dedup Fetch Killed Our Daily Blog for a Week</title>
      <dc:creator>Savage Solutions</dc:creator>
      <pubDate>Wed, 02 Sep 2026 10:01:38 +0000</pubDate>
      <link>https://dev.to/savage_solutions/a-hanging-dedup-fetch-killed-our-daily-blog-for-a-week-1lnl</link>
      <guid>https://dev.to/savage_solutions/a-hanging-dedup-fetch-killed-our-daily-blog-for-a-week-1lnl</guid>
      <description>&lt;h2&gt;
  
  
  The Week Our Blog Went Silent
&lt;/h2&gt;

&lt;p&gt;For seven days, our automated blog pipeline produced nothing. No posts, no errors in Slack, no pages. The cron job showed as running. The logs showed it starting. And then: nothing. The process just sat there, alive but useless, until the next scheduled run kicked off another zombie alongside it.&lt;/p&gt;

&lt;p&gt;This is the story of how a single hanging HTTP fetch inside a &lt;code&gt;Promise.all&lt;/code&gt; took down our entire content automation system, why no alert fired, and exactly what we changed to make sure it never happens again.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Pipeline Actually Does
&lt;/h2&gt;

&lt;p&gt;Our blog automation is a TypeScript service running on a containerized Next.js backend. Each day, a cron triggers a content generation job that runs three gate checks before writing anything to MongoDB:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Cannibalization check&lt;/strong&gt;, queries existing posts to make sure the new topic doesn't overlap with something we already rank for&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Semantic dedup check&lt;/strong&gt;, hits an external embedding API to compare the candidate post against recent content&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Base keyword check&lt;/strong&gt;, validates the target keyword against our keyword strategy rules&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All three ran concurrently inside a single &lt;code&gt;Promise.all&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;cannibalizationResult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dedupResult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;baseKeyResult&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
  &lt;span class="nf"&gt;checkCannibalization&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nf"&gt;checkSemanticDedup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="nf"&gt;checkBaseKeyword&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is clean, fast, and completely correct under normal conditions. The problem was what happened when conditions were not normal.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Actual Failure: EAI_AGAIN on Flaky Container DNS
&lt;/h2&gt;

&lt;p&gt;Our container environment started throwing intermittent &lt;code&gt;EAI_AGAIN&lt;/code&gt; errors on DNS resolution. &lt;code&gt;EAI_AGAIN&lt;/code&gt; is a temporary DNS failure, the resolver saying "try again later." Node's &lt;code&gt;fetch&lt;/code&gt; (and most HTTP clients) will retry internally on these, but if the DNS never resolves, the fetch just hangs. No rejection, no timeout, no error. It waits.&lt;/p&gt;

&lt;p&gt;The semantic dedup check calls an external embedding API. That fetch hung indefinitely on a DNS failure. Because it was inside &lt;code&gt;Promise.all&lt;/code&gt; with no per-fetch timeout, the entire &lt;code&gt;Promise.all&lt;/code&gt; also hung. The outer async handler never returned. The cron job never reached its &lt;code&gt;COMPLETE&lt;/code&gt; state.&lt;/p&gt;

&lt;p&gt;Here is the part that made it invisible: our alerting was wired to the cron's success grep. The monitoring script looked for a &lt;code&gt;"status": "complete"&lt;/code&gt; string in the job's output. If the job never returned, that string never appeared. But the monitor didn't treat "no output" as a failure. It treated it as "still running." So no page fired.&lt;/p&gt;

&lt;p&gt;Seven days. Fourteen missed blog posts. Zero alerts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: Promise.race With a Named Timeout
&lt;/h2&gt;

&lt;p&gt;The fix has two parts. First, wrap every gate check in a &lt;code&gt;Promise.race&lt;/code&gt; against a named timeout. Second, make sure the outer handler returns a non-success JSON response when any check fails or times out, so the cron's success-grep has something to catch.&lt;/p&gt;

&lt;p&gt;Here is the timeout wrapper we added:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;withTimeout&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&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;promise&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&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;ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;T&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;timeout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;never&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;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;reject&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;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;label&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; timed out after &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;ms`&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="nx"&gt;ms&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;race&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;promise&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;timeout&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;And here is how the gate checks now run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;cannibalizationResult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;dedupResult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;baseKeyResult&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
    &lt;span class="nf"&gt;withTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;checkCannibalization&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;30000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cannibalization_check_timeout&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;withTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;checkSemanticDedup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;30000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;dedup_check_timeout&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nf"&gt;withTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;checkBaseKeyword&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;30000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base_key_check_timeout&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;]);&lt;/span&gt;

  &lt;span class="c1"&gt;// proceed with generation&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;error&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="nx"&gt;topic&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Gate check failed or timed out&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;reason&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="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth noting about this implementation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The timeout is &lt;strong&gt;30 seconds per check&lt;/strong&gt;, named &lt;code&gt;dedup_check_timeout&lt;/code&gt; in logs so we can grep for it specifically in Datadog.&lt;/li&gt;
&lt;li&gt;We return &lt;code&gt;status: 200&lt;/code&gt; with an error body rather than a 5xx, because our cron runner treats non-200 as a network failure and retries silently. The success-grep now looks for &lt;code&gt;"status": "complete"&lt;/code&gt; and pages on anything else.&lt;/li&gt;
&lt;li&gt;We &lt;strong&gt;fail closed&lt;/strong&gt;. If the dedup check times out, we do not generate the post. A missed post is recoverable. A duplicate or cannibalizing post is not.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why Fail-Closed Is the Right Call Here
&lt;/h2&gt;

&lt;p&gt;Some teams would argue for fail-open: if the dedup check is unavailable, skip it and generate anyway. That logic makes sense for non-critical gates. For content quality gates, it does not.&lt;/p&gt;

&lt;p&gt;The cannibalization check exists because publishing a post that competes with an existing ranking page actively hurts SEO. The semantic dedup check exists because publishing near-duplicate content is a quality signal Google penalizes. Skipping either check because of a transient DNS failure trades a short-term miss for a long-term ranking problem.&lt;/p&gt;

&lt;p&gt;Fail-closed means we miss a day of content. Fail-open means we potentially publish something that damages the site. The math is straightforward.&lt;/p&gt;

&lt;p&gt;The key rule we wrote into our runbook: &lt;strong&gt;a silent hang is never acceptable, even if the failure mode is conservative&lt;/strong&gt;. The system must always return, and it must always return something the monitoring layer can evaluate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Added to Monitoring
&lt;/h2&gt;

&lt;p&gt;Beyond the code change, we made three monitoring updates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Explicit timeout log lines&lt;/strong&gt;: every &lt;code&gt;dedup_check_timeout&lt;/code&gt; or &lt;code&gt;cannibalization_check_timeout&lt;/code&gt; error writes a structured log entry that triggers a Datadog monitor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cron heartbeat check&lt;/strong&gt;: the job now writes a &lt;code&gt;{"status": "heartbeat"}&lt;/code&gt; line every 60 seconds while running. If the monitor sees no heartbeat and no completion within 5 minutes, it pages.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dead-letter queue for skipped posts&lt;/strong&gt;: when a gate check fails, the topic goes into a MongoDB collection called &lt;code&gt;pending_retry&lt;/code&gt; so the next day's run picks it up automatically.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The retry collection is what actually recovered the seven missed posts. Once the DNS issue was resolved and the fix was deployed, the next cron run processed the backlog automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Broader Pattern
&lt;/h2&gt;

&lt;p&gt;This failure is not specific to blog automation. Any &lt;code&gt;Promise.all&lt;/code&gt; that calls external services without per-promise timeouts is vulnerable to this exact hang. The pattern shows up in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Parallel API enrichment pipelines&lt;/li&gt;
&lt;li&gt;Multi-source data aggregation jobs&lt;/li&gt;
&lt;li&gt;Webhook processors that fan out to third-party services&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix is always the same: &lt;code&gt;Promise.race&lt;/code&gt; with a named timeout, structured error logging, and a monitoring layer that treats "no response" as a failure, not as "still running."&lt;/p&gt;

&lt;p&gt;At &lt;a href="https://savagesolutions.io" rel="noopener noreferrer"&gt;Savage Digital Solutions&lt;/a&gt;, this incident became the basis for a standard we now apply to every async pipeline we build: no &lt;code&gt;Promise.all&lt;/code&gt; over external calls without an explicit per-promise timeout and a fail-closed error path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A single hanging &lt;code&gt;fetch&lt;/code&gt; inside &lt;code&gt;Promise.all&lt;/code&gt; with no timeout will hang the entire &lt;code&gt;Promise.all&lt;/code&gt; indefinitely.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;EAI_AGAIN&lt;/code&gt; DNS errors in containerized environments do not reject promises. They hang them.&lt;/li&gt;
&lt;li&gt;Wrap every external call in &lt;code&gt;Promise.race&lt;/code&gt; with a named timeout (we use 30 seconds, named per-check for log grep).&lt;/li&gt;
&lt;li&gt;Fail closed on content quality gates. A missed post is recoverable; a cannibalizing post is not.&lt;/li&gt;
&lt;li&gt;Your monitoring must treat "no response" as a failure. Success-grep on a process that never returns will never fire.&lt;/li&gt;
&lt;li&gt;Write a structured log line on every timeout so your observability layer has something to alert on.&lt;/li&gt;
&lt;li&gt;Use a &lt;code&gt;pending_retry&lt;/code&gt; collection or equivalent dead-letter queue so skipped jobs are not permanently lost.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>typescript</category>
      <category>devops</category>
      <category>node</category>
      <category>automation</category>
    </item>
    <item>
      <title>We Were IndexNow-Pinging GitHub PRs Instead of Blog URLs (Here's How We Fixed It)</title>
      <dc:creator>Savage Solutions</dc:creator>
      <pubDate>Wed, 26 Aug 2026 10:01:38 +0000</pubDate>
      <link>https://dev.to/savage_solutions/we-were-indexnow-pinging-github-prs-instead-of-blog-urls-heres-how-we-fixed-it-mno</link>
      <guid>https://dev.to/savage_solutions/we-were-indexnow-pinging-github-prs-instead-of-blog-urls-heres-how-we-fixed-it-mno</guid>
      <description>&lt;h2&gt;
  
  
  The Bug Nobody Notices Until Bing Returns a 403
&lt;/h2&gt;

&lt;p&gt;We build and maintain content pipelines for clients at Savage Digital Solutions, and last month we shipped a migration for Candid Studios that moved their blog authoring workflow into gitMdx. The idea was clean: writers commit MDX files, a GitHub Action merges the PR, Next.js rebuilds the static pages, and IndexNow fires off a ping to Bing and other search engines so the new post gets crawled within minutes instead of days.&lt;/p&gt;

&lt;p&gt;It did not work. Not even close.&lt;/p&gt;

&lt;h2&gt;
  
  
  What gitMdx's &lt;code&gt;publishResult.url&lt;/code&gt; Actually Returns
&lt;/h2&gt;

&lt;p&gt;Here is the part that burned us. After a successful publish call in gitMdx, the SDK returns a &lt;code&gt;publishResult&lt;/code&gt; object. We assumed &lt;code&gt;publishResult.url&lt;/code&gt; would be the live blog URL. It is not. It is the GitHub Pull Request URL.&lt;/p&gt;

&lt;p&gt;So our IndexNow submission code was doing 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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;publishResult&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;gitMdx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&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;submissionUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SITE_URL&lt;/span&gt;&lt;span class="p"&gt;}${&lt;/span&gt;&lt;span class="nx"&gt;publishResult&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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// submissionUrl = "https://candidstudios.nethttps://github.com/org/repo/pull/47"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That concatenation produces &lt;code&gt;https://candidstudios.nethttps://github.com/org/repo/pull/47&lt;/code&gt;. Bing's IndexNow endpoint accepted the POST without complaint, because the API does basic schema validation, not URL reachability validation at submission time. The crawler then tried to fetch that nonsense string, failed silently, and the posts never got indexed.&lt;/p&gt;

&lt;p&gt;We only caught it when a Candid Studios post that should have surfaced in Bing within the hour was still invisible three days later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Second Problem: The Key File Was Never Hosted
&lt;/h2&gt;

&lt;p&gt;IndexNow requires you to prove ownership of the domain by hosting a plain text file at &lt;code&gt;/{your-key}.txt&lt;/code&gt; on the same domain you are submitting URLs for. The filename is literally your API key.&lt;/p&gt;

&lt;p&gt;Our deployment script created the key file locally and committed it to the repo, but the Next.js &lt;code&gt;public/&lt;/code&gt; directory for Candid Studios was not being deployed to the root of &lt;code&gt;candidstudios.net&lt;/code&gt;. The file existed in the build artifact but was never reachable at &lt;code&gt;https://candidstudios.net/{INDEXNOW_KEY}.txt&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Bing returned a 403 on the first attempt and a 404 on subsequent ones depending on CDN cache state. Both mean the same thing operationally: Bing cannot verify you own the domain, so it discards the submission.&lt;/p&gt;

&lt;p&gt;The IndexNow spec is explicit about this. From the documentation:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"The key file must be accessible at https://{host}/{key}.txt and must return a 200 status code."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We had skipped that verification step entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix, Step by Step
&lt;/h2&gt;

&lt;p&gt;Here is exactly what we changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Stop using &lt;code&gt;publishResult.url&lt;/code&gt; for IndexNow submissions.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Build the submission URL from the post slug directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// e.g. "how-to-choose-a-brand-photographer"&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;submissionUrl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;SITE_URL&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;slug&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="c1"&gt;// submissionUrl = "https://candidstudios.net/blog/how-to-choose-a-brand-photographer"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the URL that actually exists on the public web. &lt;code&gt;publishResult.url&lt;/code&gt; is useful for linking to the PR in a Slack notification or a CMS audit log. It is not a public URL.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. POST &lt;code&gt;host&lt;/code&gt; and &lt;code&gt;urlList&lt;/code&gt; as separate fields.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The IndexNow POST body requires both fields. We had been sending only &lt;code&gt;urlList&lt;/code&gt;. The corrected request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://api.indexnow.org/indexnow&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;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json; charset=utf-8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;candidstudios.net&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;INDEXNOW_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;keyLocation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`https://candidstudios.net/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;INDEXNOW_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.txt`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;urlList&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;submissionUrl&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;Sending &lt;code&gt;host&lt;/code&gt; separately from the URLs in &lt;code&gt;urlList&lt;/code&gt; is required by the spec. Without it, some engines reject the batch silently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Host the key file at the correct path on every money site.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For a Next.js project, drop the key file into the &lt;code&gt;public/&lt;/code&gt; directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;public/
  {INDEXNOW_KEY}.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The file content is just the key string on a single line. After deployment, verify it manually before running any submissions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-I&lt;/span&gt; https://candidstudios.net/&lt;span class="o"&gt;{&lt;/span&gt;INDEXNOW_KEY&lt;span class="o"&gt;}&lt;/span&gt;.txt
&lt;span class="c"&gt;# Expect: HTTP/2 200&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you get anything other than 200, your submissions are being discarded. Fix the hosting first.&lt;/p&gt;

&lt;p&gt;We also had a second client site in the same pipeline. The key file was missing there too. Both sites needed the file deployed before IndexNow would work for either of them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Is Easy to Miss
&lt;/h2&gt;

&lt;p&gt;The IndexNow API returns a &lt;code&gt;200 OK&lt;/code&gt; even when the key file is unreachable at submission time. Bing validates the key asynchronously when it attempts to crawl the submitted URLs. So your POST succeeds, your logs look clean, and you have no idea the submissions are being silently dropped until you notice the pages are not appearing in search results.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;publishResult.url&lt;/code&gt; issue is similarly invisible. The URL string looks plausible in a log line if you are not reading carefully. &lt;code&gt;https://candidstudios.nethttps://github.com/...&lt;/code&gt; is obviously wrong when you stare at it, but in a JSON log payload scrolling past in a terminal, it reads as a long URL and your eye moves on.&lt;/p&gt;

&lt;p&gt;Both bugs required us to go back to first principles: what does this variable actually contain, and is the thing we are submitting publicly reachable right now?&lt;/p&gt;

&lt;h2&gt;
  
  
  The Rule That Prevents This
&lt;/h2&gt;

&lt;p&gt;Discovery only works if the URL is public and the key is public. That is the entire contract. Before any IndexNow integration ships, run two checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;curl -I https://{yourdomain}/{INDEXNOW_KEY}.txt&lt;/code&gt; returns 200&lt;/li&gt;
&lt;li&gt;The URL in &lt;code&gt;urlList&lt;/code&gt; returns 200 when fetched from outside your network&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If either check fails, the submission does nothing.&lt;/p&gt;

&lt;p&gt;We now run both checks as part of the deployment verification step in our GitHub Actions workflow, before the IndexNow POST fires. A failed curl exits the action with a non-zero code and pages the on-call engineer instead of silently wasting the submission.&lt;/p&gt;

&lt;p&gt;The team at Savage Digital Solutions (savagesolutions.io) has since applied this same verification pattern to every client site running an automated IndexNow pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;publishResult.url&lt;/code&gt; in gitMdx returns a GitHub PR URL, not the live page URL. Build your IndexNow submission URL from the post slug: &lt;code&gt;{website}/blog/{slug}&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;String-concatenating &lt;code&gt;SITE_URL&lt;/code&gt; with a GitHub URL produces a malformed string that Bing will attempt and fail to crawl.&lt;/li&gt;
&lt;li&gt;The IndexNow POST body requires both &lt;code&gt;host&lt;/code&gt; and &lt;code&gt;urlList&lt;/code&gt; as separate fields. Omitting &lt;code&gt;host&lt;/code&gt; causes silent failures on some engines.&lt;/li&gt;
&lt;li&gt;The key file must be hosted at &lt;code&gt;/{key}.txt&lt;/code&gt; on every domain you submit URLs for, and it must return HTTP 200. Bing validates this asynchronously, so a successful POST does not confirm the key is reachable.&lt;/li&gt;
&lt;li&gt;IndexNow returns &lt;code&gt;200 OK&lt;/code&gt; at submission time even when the key file is missing. You will not see the failure in your POST logs.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;curl -I https://{domain}/{key}.txt&lt;/code&gt; as a deployment gate before any IndexNow submission fires.&lt;/li&gt;
&lt;li&gt;If the URL is not publicly reachable and the key file is not publicly reachable, the submission is discarded.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>indexnow</category>
      <category>nextjs</category>
      <category>seo</category>
      <category>typescript</category>
    </item>
    <item>
      <title>One Enum Mismatch Hid Every Blog Video for Weeks</title>
      <dc:creator>Savage Solutions</dc:creator>
      <pubDate>Wed, 19 Aug 2026 10:01:41 +0000</pubDate>
      <link>https://dev.to/savage_solutions/one-enum-mismatch-hid-every-blog-video-for-weeks-528i</link>
      <guid>https://dev.to/savage_solutions/one-enum-mismatch-hid-every-blog-video-for-weeks-528i</guid>
      <description>&lt;h2&gt;
  
  
  The Bug That Looked Like a Design Decision
&lt;/h2&gt;

&lt;p&gt;For weeks, dozens of blog posts on a HeyGen-powered video platform showed a blank space where the video player should have been. No error. No broken image icon. Just nothing. The CDN MP4 existed. The HeyGen render had completed. The database had a valid URL. And yet: blank page.&lt;/p&gt;

&lt;p&gt;This is the story of how a single mismatched status string caused every video to silently disappear, and exactly how we fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What HeyGen Sends vs. What We Expected
&lt;/h2&gt;

&lt;p&gt;HeyGen's webhook payload includes a &lt;code&gt;status&lt;/code&gt; field. When a video finishes rendering, that field arrives as &lt;code&gt;'completed'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Our Next.js frontend was written to render the video player only when &lt;code&gt;status === 'ready'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Those two strings never matched. Not once.&lt;/p&gt;

&lt;p&gt;Every time HeyGen fired a webhook, our handler wrote &lt;code&gt;videoStatus: 'completed'&lt;/code&gt; into MongoDB. The Next.js page checked for &lt;code&gt;'ready'&lt;/code&gt;, found something else, and rendered nothing. The player component never mounted. No console error, because the code path was technically correct. It just never reached the render branch.&lt;/p&gt;

&lt;p&gt;The result: dozens of posts with a valid &lt;code&gt;files2.heygen.ai&lt;/code&gt; MP4 URL sitting in the database and a blank &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; where the player should have been.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mapping the Broken State Machine
&lt;/h2&gt;

&lt;p&gt;The first step was writing down the actual states the system was moving through versus the states we intended.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Intended finite state machine:&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;none → pending → ready | failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Actual states in MongoDB after weeks of webhook writes:&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;none → pending → completed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;'ready'&lt;/code&gt; never appeared in the database. Not in a single document. The &lt;code&gt;finalize&lt;/code&gt; function, the retry handler, and the webhook writer were all using different strings with no shared enum.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: One Source of Truth for Status
&lt;/h2&gt;

&lt;p&gt;We defined a TypeScript enum that every writer had to import:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kr"&gt;enum&lt;/span&gt; &lt;span class="nx"&gt;VideoStatus&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;Pending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pending&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;Ready&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ready&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;Failed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;failed&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then we updated three places that were writing status to MongoDB:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The HeyGen webhook handler&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Before&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;updateOne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;heygenJobId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;video_id&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$set&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;videoStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="c1"&gt;// wrote 'completed'&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// After&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;statusMap&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="nx"&gt;VideoStatus&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;completed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;VideoStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Ready&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;VideoStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Failed&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;normalized&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;statusMap&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;VideoStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Pending&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;updateOne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;heygenJobId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;video_id&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$set&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;videoStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;normalized&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. The finalize function&lt;/strong&gt; (called after manual review) was updated to write &lt;code&gt;VideoStatus.Ready&lt;/code&gt; instead of the string &lt;code&gt;'ready'&lt;/code&gt; it had been writing inconsistently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The retry handler&lt;/strong&gt; was writing &lt;code&gt;'pending'&lt;/code&gt; as a raw string. Updated to &lt;code&gt;VideoStatus.Pending&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bulk-Updating the Existing Documents
&lt;/h2&gt;

&lt;p&gt;Fixing the writers only helped future webhooks. The existing documents in MongoDB still had &lt;code&gt;videoStatus: 'completed'&lt;/code&gt; on dozens of posts. We ran a targeted update:&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="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;updateMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;videoStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;completed&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;$set&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;videoStatus&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ready&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that query ran, every post with a valid CDN URL immediately became visible. The Next.js pages re-rendered on the next request and the players mounted correctly. No redeployment needed. No content changes. Just the status string corrected in the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preventing Dead Players from Expired URLs
&lt;/h2&gt;

&lt;p&gt;While we were in the webhook handler, we found a second problem. HeyGen's &lt;code&gt;files2.heygen.ai&lt;/code&gt; URLs expire. If a post sat in &lt;code&gt;'completed'&lt;/code&gt; limbo long enough, the URL in the database was no longer valid. Rendering the player with a dead URL would show a broken player, which is worse than showing nothing because it signals to the reader that something went wrong.&lt;/p&gt;

&lt;p&gt;We added a URL validation step before the player component mounts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;isVideoUrlAlive&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;HEAD&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;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;If the check returns &lt;code&gt;false&lt;/code&gt;, the component renders a static thumbnail with a "Video processing" label instead of a broken player. The post still reads correctly. The reader is not confused.&lt;/p&gt;

&lt;p&gt;This also means the system now rejects expired &lt;code&gt;files2.heygen.ai&lt;/code&gt; URLs at render time rather than silently displaying a broken embed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Stayed Hidden So Long
&lt;/h2&gt;

&lt;p&gt;A few things made this bug hard to catch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No error was thrown.&lt;/strong&gt; The conditional &lt;code&gt;if (status === 'ready')&lt;/code&gt; evaluated to &lt;code&gt;false&lt;/code&gt; and the component returned &lt;code&gt;null&lt;/code&gt;. Perfectly valid React.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The database looked healthy.&lt;/strong&gt; Documents had a &lt;code&gt;videoStatus&lt;/code&gt; field with a value. Nothing was &lt;code&gt;null&lt;/code&gt; or missing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HeyGen's dashboard showed successful renders.&lt;/strong&gt; The videos existed. The problem was entirely in how we stored and read the status.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The blank space looked intentional.&lt;/strong&gt; Without a prior working state to compare against, it was easy to assume the player was just not yet wired up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The only way we caught it was by querying MongoDB directly and noticing that &lt;code&gt;'ready'&lt;/code&gt; appeared zero times while &lt;code&gt;'completed'&lt;/code&gt; appeared dozens of times.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Broader Pattern
&lt;/h2&gt;

&lt;p&gt;This class of bug appears whenever two systems use the same concept with different vocabulary and there is no translation layer between them. HeyGen owns its status strings. We own ours. The webhook handler is the boundary, and it should have been doing the translation from day one.&lt;/p&gt;

&lt;p&gt;The fix is not complicated: define your internal states explicitly, map external values to them at the entry point, and never let a third-party string propagate into your own database. A TypeScript enum enforced at compile time would have caught this before it shipped.&lt;/p&gt;

&lt;p&gt;This is the kind of integration work the team at Savage Digital Solutions (savagesolutions.io) runs into regularly when connecting AI video tools to production content pipelines. The tools are capable. The gaps are almost always in the plumbing between them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;HeyGen webhooks write &lt;code&gt;status: 'completed'&lt;/code&gt;. If your app expects &lt;code&gt;'ready'&lt;/code&gt;, every video silently disappears.&lt;/li&gt;
&lt;li&gt;Define a TypeScript enum for your internal video states (&lt;code&gt;none&lt;/code&gt;, &lt;code&gt;pending&lt;/code&gt;, &lt;code&gt;ready&lt;/code&gt;, &lt;code&gt;failed&lt;/code&gt;) and import it everywhere status is written to MongoDB.&lt;/li&gt;
&lt;li&gt;The webhook handler is the translation layer. Map third-party status strings to your internal enum at that boundary, not downstream.&lt;/li&gt;
&lt;li&gt;After fixing the writers, run a &lt;code&gt;updateMany&lt;/code&gt; to correct existing documents. Future webhooks will not fix historical data.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;files2.heygen.ai&lt;/code&gt; URLs expire. Validate the URL with a &lt;code&gt;HEAD&lt;/code&gt; request before mounting the player. Render a fallback rather than a broken embed.&lt;/li&gt;
&lt;li&gt;A blank page with no error is often a conditional that evaluates to &lt;code&gt;false&lt;/code&gt;, not a missing component. Query the database directly when the UI gives you nothing to debug.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>typescript</category>
      <category>mongodb</category>
      <category>nextjs</category>
      <category>heygen</category>
    </item>
    <item>
      <title>Never Ship a Blog Post Without a Featured Image Again</title>
      <dc:creator>Savage Solutions</dc:creator>
      <pubDate>Wed, 12 Aug 2026 10:01:50 +0000</pubDate>
      <link>https://dev.to/savage_solutions/never-ship-a-blog-post-without-a-featured-image-again-3ed3</link>
      <guid>https://dev.to/savage_solutions/never-ship-a-blog-post-without-a-featured-image-again-3ed3</guid>
      <description>&lt;h2&gt;
  
  
  The Bug Nobody Noticed for Weeks
&lt;/h2&gt;

&lt;p&gt;Our blog listing page was silently broken for weeks. Cards rendered with blank image slots, gray rectangles where featured images should have been. No error, no alert, no automated test caught it. A human finally looked at &lt;code&gt;/blog&lt;/code&gt; and flagged it.&lt;/p&gt;

&lt;p&gt;The root cause was embarrassingly simple: our publishing pipeline allowed posts to go live without a featured image, with the assumption that someone would backfill the image later through an async queue. That assumption was wrong. Posts shipped to production, listing cards went blank, and the queue sat unprocessed.&lt;/p&gt;

&lt;p&gt;This is the story of how we fixed it with a hard publish gate, and why the fix is permanent.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the Pipeline Worked Before
&lt;/h2&gt;

&lt;p&gt;We run a content pipeline that publishes through two paths: Payload CMS and a &lt;code&gt;gitMdx&lt;/code&gt; flow for markdown-based posts. Both paths fed into the same listing page, and both had the same gap: neither required a featured image URL before allowing a post to move from draft to published.&lt;/p&gt;

&lt;p&gt;The async image queue was designed as a convenience. If a post was ready to go but the image was still being generated (we use HeyGen for some video thumbnails and other tooling for static images), the post could publish and the image would follow. In practice, the image rarely followed on time. The queue backed up, editors forgot, and the listing page accumulated blank cards.&lt;/p&gt;

&lt;p&gt;We also had a secondary problem: internal tooling and some post metadata were using the short form &lt;code&gt;Savage Digital Solutions&lt;/code&gt; instead of the full name &lt;code&gt;Savage Digital Solutions&lt;/code&gt;. That inconsistency was slipping into published content.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fix: &lt;code&gt;evaluatePublishGate&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;We wrote a single gate function called &lt;code&gt;evaluatePublishGate&lt;/code&gt; that runs before either publish path can execute. The rule is binary: if the post does not have a valid &lt;code&gt;https&lt;/code&gt; featured image URL, it does not ship. It stays in draft.&lt;/p&gt;

&lt;p&gt;Here is the core logic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;evaluatePublishGate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PostPayload&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;GateResult&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;featuredImage&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;brandMentions&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;post&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;featuredImage&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;featuredImage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;approved&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="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Missing or invalid featured image URL. Post forced to draft.&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;forbiddenBrandForm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;Savage Solutions&lt;/span&gt;&lt;span class="se"&gt;\b&lt;/span&gt;&lt;span class="sr"&gt;/i&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;brandMentions&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;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;forbiddenBrandForm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;m&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;approved&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="na"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Short brand form detected. Use full name: Savage Digital Solutions.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;approved&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This runs synchronously before the Payload publish hook and before the &lt;code&gt;gitMdx&lt;/code&gt; pipeline writes to the production branch. If &lt;code&gt;approved&lt;/code&gt; is &lt;code&gt;false&lt;/code&gt;, the post status is set to &lt;code&gt;draft&lt;/code&gt; and the publish is aborted. No exceptions, no overrides.&lt;/p&gt;

&lt;p&gt;The brand name check was added at the same time. The gate now rejects any post where the string &lt;code&gt;Savage Digital Solutions&lt;/code&gt; appears without the full &lt;code&gt;Savage Digital Solutions&lt;/code&gt; form. This catches copy-paste errors and shorthand that crept in from internal tooling.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Changed in the Payload Hook
&lt;/h2&gt;

&lt;p&gt;In Payload CMS, collection hooks run at specific lifecycle points. We attached &lt;code&gt;evaluatePublishGate&lt;/code&gt; to the &lt;code&gt;beforeChange&lt;/code&gt; hook on the Posts collection:&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;beforeChange&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;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;operation&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;operation&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;update&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;published&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;evaluatePublishGate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;approved&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;draft&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`[PublishGate] Blocked: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hook intercepts any attempt to set &lt;code&gt;status: 'published'&lt;/code&gt;, runs the gate, and silently downgrades to &lt;code&gt;draft&lt;/code&gt; if the gate fails. The &lt;code&gt;console.warn&lt;/code&gt; feeds into our logging pipeline so we can track how often posts are blocked and why.&lt;/p&gt;

&lt;p&gt;For the &lt;code&gt;gitMdx&lt;/code&gt; path, we added the same check as a pre-commit validation step. The script reads the frontmatter of any markdown file staged for the production branch and calls &lt;code&gt;evaluatePublishGate&lt;/code&gt; before allowing the commit to proceed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Async Queue Still Exists, But It Cannot Bypass the Gate
&lt;/h2&gt;

&lt;p&gt;We did not remove the async image queue. It still exists for repair work: if a published post needs its image updated or replaced, the queue handles that. But the queue no longer serves as a workaround for missing images on new posts.&lt;/p&gt;

&lt;p&gt;The distinction matters. Repair and creation are different operations. A post that already has a valid featured image can have that image updated asynchronously without breaking the listing card. A post that has no image at all will render a blank card the moment it goes live. The gate only blocks the second case.&lt;/p&gt;

&lt;p&gt;This also means the queue backlog is now smaller and more predictable. It handles genuine updates, not a pile of posts waiting for images that should have been required upfront.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Rule We Wrote Down
&lt;/h2&gt;

&lt;p&gt;After shipping the fix, we wrote one sentence in our internal docs:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If the card would look broken on &lt;code&gt;/blog&lt;/code&gt;, it does not ship.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That sentence covers the featured image requirement, the brand name check, and any future gate conditions we add. It is a product standard, not a technical constraint. The technical constraint (the gate function) exists to enforce the product standard.&lt;/p&gt;

&lt;p&gt;This framing helped when we discussed adding more gate conditions later. Every proposed condition gets evaluated against the same question: would a missing or malformed version of this field cause a visible defect on the listing page or in a post? If yes, it belongs in the gate.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Would Do Differently
&lt;/h2&gt;

&lt;p&gt;The async backfill pattern was a mistake from the start. It optimized for publishing speed at the cost of listing page integrity. The right default is always to require complete data before publishing, not to publish incomplete data and fix it later.&lt;/p&gt;

&lt;p&gt;If you are running a Next.js blog with Payload or any headless CMS, the place to enforce this is in the CMS hook layer, not in the frontend. The frontend should be able to assume that any published post has a valid featured image. Defensive rendering in the listing component (fallback images, skeleton states) is fine for edge cases, but it should not be the primary defense against missing data.&lt;/p&gt;

&lt;p&gt;The team at Savage Digital Solutions (savagesolutions.io) now treats &lt;code&gt;evaluatePublishGate&lt;/code&gt; as a template for other content quality checks. The pattern is reusable: define the condition that would cause a visible defect, write a synchronous check, attach it to the publish lifecycle, and force draft on failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A missing featured image causes blank listing cards in production. This is a silent failure with no automatic alerts unless you have explicit gate logic.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;evaluatePublishGate&lt;/code&gt; runs synchronously before both Payload CMS publish hooks and &lt;code&gt;gitMdx&lt;/code&gt; pipeline commits. It requires a valid &lt;code&gt;https&lt;/code&gt; featured image URL or forces the post to &lt;code&gt;draft&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The same gate enforces full brand name usage. The short form &lt;code&gt;Savage Digital Solutions&lt;/code&gt; is blocked at the gate level, not caught in post-publish review.&lt;/li&gt;
&lt;li&gt;Async image queues are valid for repair operations on already-published posts. They are not a substitute for requiring complete data before initial publish.&lt;/li&gt;
&lt;li&gt;Attach quality gates to the CMS &lt;code&gt;beforeChange&lt;/code&gt; hook, not to frontend rendering logic. The frontend should receive clean data, not compensate for missing data.&lt;/li&gt;
&lt;li&gt;One sentence captures the standard: if the card would look broken on &lt;code&gt;/blog&lt;/code&gt;, it does not ship.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>typescript</category>
      <category>cms</category>
      <category>blogging</category>
      <category>devops</category>
    </item>
    <item>
      <title>How We Killed Alert Fatigue Without Going Blind</title>
      <dc:creator>Savage Solutions</dc:creator>
      <pubDate>Wed, 12 Aug 2026 02:20:46 +0000</pubDate>
      <link>https://dev.to/savage_solutions/how-we-killed-alert-fatigue-without-going-blind-38j2</link>
      <guid>https://dev.to/savage_solutions/how-we-killed-alert-fatigue-without-going-blind-38j2</guid>
      <description>&lt;h2&gt;
  
  
  The Problem Was Noise, Not Negligence
&lt;/h2&gt;

&lt;p&gt;We had dozens of cron jobs. Each one was configured to email on every run, success or failure. On a quiet night that meant a wall of green. On a bad night it meant that same wall of green, except somewhere buried in it was a red that nobody caught until a client noticed.&lt;/p&gt;

&lt;p&gt;That is not a discipline problem. That is a systems design problem. Alert fatigue is a reliability bug, not a personality flaw, and treating it like the former means you keep blaming people instead of fixing the architecture.&lt;/p&gt;

&lt;p&gt;Here is exactly what we changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Old Setup and Why It Failed
&lt;/h2&gt;

&lt;p&gt;Every cron job had its own &lt;code&gt;mail()&lt;/code&gt; call or &lt;code&gt;nodemailer&lt;/code&gt; transport wired directly to an ops inbox. A job that ran three times a day sent three emails. Multiply that by a few dozen jobs and you get hundreds of messages per day, most of them saying nothing actionable.&lt;/p&gt;

&lt;p&gt;The inbox became a place people skimmed and archived. When a real failure arrived, it looked identical to the routine noise. The signal-to-noise ratio was so bad that the inbox itself became untrustworthy.&lt;/p&gt;

&lt;p&gt;The fix required two things: a single spool that owns all outbound alerts, and a routing layer that decides what gets delivered, when, and at what priority.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Spool Architecture
&lt;/h2&gt;

&lt;p&gt;We built a central alert spool as a MongoDB collection. Every cron job, background worker, and scheduled task writes to it instead of sending email directly. A record looks like 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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;AlertRecord&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ObjectId&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;source&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="c1"&gt;// e.g. 'invoice-sync-cron'&lt;/span&gt;
  &lt;span class="nl"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;info&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;warn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;critical&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;dispatched&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;dispatchedAt&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing sends email on its own anymore. The spool owns that responsibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Routing Layer: Batch vs. Immediate
&lt;/h2&gt;

&lt;p&gt;A separate dispatcher process runs on a schedule and applies two rules:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 1: Criticals go immediately.&lt;/strong&gt; Any record with &lt;code&gt;level: 'critical'&lt;/code&gt; triggers an immediate send to the ops channel. No batching, no delay.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rule 2: Everything else batches at 3x/day.&lt;/strong&gt; Info and warn records accumulate and go out as a single digest at fixed times. One email in the morning, one midday, one at end of day. The digest groups records by source so the reader can scan by job name rather than by timestamp.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;dispatchBatch&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;pending&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;AlertRecord&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;dispatched&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="na"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;info&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;warn&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="nf"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&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;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;grouped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;groupBy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;source&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;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;formatDigest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;grouped&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendEmail&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;OPS_EMAIL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Alert Digest (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; items)`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pending&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;r&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;AlertRecord&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateMany&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$in&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ids&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;dispatched&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;dispatchedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The dispatcher runs via its own cron at 07:00, 12:00, and 17:00. Criticals run through a separate process that polls every 60 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Kill and Demote Lists
&lt;/h2&gt;

&lt;p&gt;Batching alone was not enough. Some jobs produce lines that are technically non-zero exit codes but are completely expected. A sync job that reports "0 new records found" should not even appear in the digest.&lt;/p&gt;

&lt;p&gt;We added two regex lists that run at write time, before a record ever enters the spool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;KILL_PATTERNS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;RegExp&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="sr"&gt;/0 new records found/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="sr"&gt;/heartbeat ok/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="sr"&gt;/cache warmed successfully/i&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;DEMOTE_PATTERNS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;RegExp&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="sr"&gt;/rate limit warning/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="sr"&gt;/retry attempt &lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="sr"&gt; of 3/i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;classifyAlert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;level&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;AlertLevel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;AlertLevel&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="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;pattern&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;KILL_PATTERNS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// drop entirely&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;pattern&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;DEMOTE_PATTERNS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pattern&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;info&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// downgrade to info&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;level&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;classifyAlert&lt;/code&gt; returns &lt;code&gt;null&lt;/code&gt;, the record is never written. If it returns a lower level than the caller passed in, the record is written at the demoted level. The lists live in a config file that any developer can edit without touching the dispatcher logic.&lt;/p&gt;

&lt;p&gt;This is the "tunable" part. Over the first two weeks we added about a dozen kill patterns and four demote patterns based on what kept showing up in the digest that nobody needed to act on.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Changed in Practice
&lt;/h2&gt;

&lt;p&gt;Before: hundreds of emails per day, most of them ignored, real failures buried.&lt;/p&gt;

&lt;p&gt;After: three digest emails per day for routine items, immediate delivery for anything critical. The digest emails are skimmable in under two minutes because they are grouped by source and stripped of known-benign lines.&lt;/p&gt;

&lt;p&gt;The ops inbox went from a place people avoided to a place where every message carries weight. When a critical arrives at 2am, it is not competing with 40 info-level emails from the same hour.&lt;/p&gt;

&lt;p&gt;The key insight is that the goal was never fewer alerts. The goal was alerts that matter. Dropping volume was the mechanism, not the objective.&lt;/p&gt;

&lt;h2&gt;
  
  
  Applying This to Your Stack
&lt;/h2&gt;

&lt;p&gt;The pattern is not specific to our TypeScript and MongoDB setup. The same architecture works if your jobs are Python scripts writing to PostgreSQL, or Go workers writing to Redis. The three components are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A persistent spool that decouples job execution from notification delivery&lt;/li&gt;
&lt;li&gt;A dispatcher that applies time-based routing (immediate vs. batched)&lt;/li&gt;
&lt;li&gt;An edge filter (kill/demote lists) that drops or downgrades known-benign messages before they enter the spool&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are running Next.js API routes that trigger background work, you can write to the spool from a server action and let the dispatcher handle delivery. If you are using a queue like BullMQ, the dispatcher can be a separate worker that consumes from a dedicated alerts queue instead of polling MongoDB.&lt;/p&gt;

&lt;p&gt;The team at Savage Digital Solutions (savagesolutions.io) uses this pattern across client projects where cron-heavy backends were generating so much noise that real incidents were going undetected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Alert fatigue is a reliability bug. Fix the system, not the people.&lt;/li&gt;
&lt;li&gt;Funnel every alert through a single spool. Never let individual jobs send email directly.&lt;/li&gt;
&lt;li&gt;Route by severity: immediate delivery for criticals, 3x/day batched digest for everything else.&lt;/li&gt;
&lt;li&gt;Add kill and demote regex lists at the edge so known-benign lines never enter the spool.&lt;/li&gt;
&lt;li&gt;Tune the kill/demote lists continuously for the first two weeks. Most of the noise reduction comes from a small number of high-frequency patterns.&lt;/li&gt;
&lt;li&gt;Fewer messages with consistent signal value is the goal. Volume reduction is just how you get there.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>typescript</category>
      <category>monitoring</category>
      <category>backend</category>
    </item>
    <item>
      <title>How to Optimize Content So ChatGPT and Perplexity Cite You (Not Just Rank You)</title>
      <dc:creator>Savage Solutions</dc:creator>
      <pubDate>Wed, 05 Aug 2026 10:01:38 +0000</pubDate>
      <link>https://dev.to/savage_solutions/how-to-optimize-content-so-chatgpt-and-perplexity-cite-you-not-just-rank-you-4141</link>
      <guid>https://dev.to/savage_solutions/how-to-optimize-content-so-chatgpt-and-perplexity-cite-you-not-just-rank-you-4141</guid>
      <description>&lt;p&gt;Most SEO advice still optimizes for the ten blue links. That model is losing ground fast. When a buyer asks ChatGPT "what agency should I use for AI-powered web development" or asks Perplexity "how do I optimize a Next.js site for Core Web Vitals," the answer engine doesn't return a ranked list. It quotes a source. One source, maybe two. If your content isn't structured to be that source, you're invisible to a growing slice of purchase-intent queries.&lt;/p&gt;

&lt;p&gt;This is the problem I've been working on directly. Here's exactly what we changed and why it works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why AI Engines Cite What They Cite
&lt;/h2&gt;

&lt;p&gt;Perplexity and ChatGPT's browsing mode don't reward domain authority the way Google does. They reward &lt;strong&gt;entity density, factual specificity, and quotability&lt;/strong&gt;. An answer engine is looking for a sentence it can lift verbatim and attribute. That means your content needs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Named tools with version context ("Next.js 14 App Router," not "a modern framework")&lt;/li&gt;
&lt;li&gt;Specific metrics ("reduced Time to First Byte from 780ms to 210ms")&lt;/li&gt;
&lt;li&gt;Declarative claims in the first 100 words, not buried in paragraph six&lt;/li&gt;
&lt;li&gt;A canonical URL pointing back to your primary domain&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last point is underappreciated. If you publish on Dev.to without setting &lt;code&gt;canonical_url&lt;/code&gt; to your money site, the syndicated copy competes with your origin. Worse, if the Dev.to version gets cited, the authority signal goes to Dev.to, not you. Set the canonical. Every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Four-Part Structure That Gets Cited
&lt;/h2&gt;

&lt;p&gt;I tested this structure across a set of technical posts published on Dev.to and Hashnode over a 90-day period. Posts structured this way appeared in Perplexity answer boxes within 48 hours of indexing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Lead with a concrete, attributable claim.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not "AI is changing content marketing." Instead: "Perplexity indexes Dev.to posts within hours of publication, making developer platforms faster citation targets than traditional blogs for AI answer engines."&lt;/p&gt;

&lt;p&gt;That sentence is quotable. It names a specific tool (Perplexity), a specific platform (Dev.to), and a specific behavior (indexes within hours). An answer engine can lift it and attribute it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Name every tool, metric, and step explicitly.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Vague writing is uncitable. Compare:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Vague: "We improved the site's performance using caching."&lt;/li&gt;
&lt;li&gt;Citable: "We added Redis caching in front of a MongoDB Atlas cluster, which dropped average API response time from 1,200ms to 340ms on a Next.js 14 API route."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second version contains five named entities. An answer engine processing a query about Next.js performance optimization has something real to quote.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Include a code block or numbered procedure.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Code blocks signal technical authority to both human readers and AI indexers. Here's the canonical URL pattern I use for every Dev.to post:&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="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Your&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Post&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Title"&lt;/span&gt;
&lt;span class="na"&gt;published&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;canonical_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://yourdomain.com/blog/your-post-slug"&lt;/span&gt;
&lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;nextjs&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;typescript&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;performance&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;webdev&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This single front-matter field routes citation authority back to the origin domain. Without it, you're building someone else's entity graph.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. End with a Key Takeaways section.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Answer engines frequently pull from summary sections. A bulleted list of declarative facts at the end of a post gives the engine a clean extraction target. Write each bullet as a standalone factual claim, not a teaser.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Canonical URL Is Not Optional
&lt;/h2&gt;

&lt;p&gt;I want to be direct about this because I see it skipped constantly. When you publish technical content on a high-authority developer platform without a canonical URL, you are donating your research to that platform's domain. Perplexity will cite "dev.to/yourhandle" instead of "yourdomain.com/blog/your-post." The buyer who reads that citation never visits your site.&lt;/p&gt;

&lt;p&gt;The fix takes 10 seconds:&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="na"&gt;canonical_url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://savagesolutions.io/blog/ai-citation-optimization"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dev.to, Hashnode, and Medium all support this field. Use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Platform Selection Matters for Indexing Speed
&lt;/h2&gt;

&lt;p&gt;Not all platforms get crawled by AI answer engines at the same rate. From direct observation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dev.to&lt;/strong&gt; posts appear in Perplexity results within 2 to 6 hours of publication&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hashnode&lt;/strong&gt; custom domain posts index within 12 to 24 hours&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Medium&lt;/strong&gt; posts behind the paywall are not indexed by Perplexity at all&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitHub README files&lt;/strong&gt; in public repos are indexed and cited, particularly for TypeScript and Python tooling queries&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're writing about HeyGen API integration, MongoDB schema design, or TypeScript utility types, a GitHub repo with a detailed README is a citation target, not just documentation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Entity Density: A Practical Checklist
&lt;/h2&gt;

&lt;p&gt;Before publishing any technical post, I run through this list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Does the first paragraph name at least two specific tools or technologies?&lt;/li&gt;
&lt;li&gt;[ ] Is there at least one metric with a before/after comparison?&lt;/li&gt;
&lt;li&gt;[ ] Are all tool names spelled exactly as they appear in official documentation ("Next.js" not "NextJS," "TypeScript" not "typescript")&lt;/li&gt;
&lt;li&gt;[ ] Does the post include a code block or numbered procedure?&lt;/li&gt;
&lt;li&gt;[ ] Is the canonical URL set to the origin domain?&lt;/li&gt;
&lt;li&gt;[ ] Does the Key Takeaways section contain standalone factual claims?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This isn't a checklist for Google. It's a checklist for making your content extractable by a language model that needs to answer a specific question with a specific citation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Looks Like in Practice
&lt;/h2&gt;

&lt;p&gt;At Savage Digital Solutions (savagesolutions.io), this is the content workflow we apply to every technical post we produce for clients. A post about integrating HeyGen's video generation API with a Next.js 14 frontend will name the specific API endpoint (&lt;code&gt;POST /v2/video/generate&lt;/code&gt;), the average render time we observed (roughly 90 seconds for a 60-second clip at 1080p), and the TypeScript interface we used to type the response. That specificity is what gets cited.&lt;/p&gt;

&lt;p&gt;The goal is not to rank for "HeyGen tutorial." The goal is to be the source ChatGPT quotes when a developer asks it how to integrate HeyGen into a Next.js project.&lt;/p&gt;

&lt;p&gt;Those are different targets, and they require different writing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;AI answer engines cite entity-rich, factual, quotable content, not high-domain-authority pages with vague prose.&lt;/li&gt;
&lt;li&gt;Setting &lt;code&gt;canonical_url&lt;/code&gt; in Dev.to and Hashnode front matter routes citation authority back to your origin domain instead of the syndication platform.&lt;/li&gt;
&lt;li&gt;Dev.to posts are indexed by Perplexity within 2 to 6 hours, making developer platforms faster citation targets than most traditional blogs.&lt;/li&gt;
&lt;li&gt;Every named tool (Next.js 14, MongoDB Atlas, HeyGen, TypeScript, Redis) increases the entity density of a post and improves its extractability by answer engines.&lt;/li&gt;
&lt;li&gt;A Key Takeaways section written as standalone declarative facts gives answer engines a clean extraction target at the end of a post.&lt;/li&gt;
&lt;li&gt;The metric that matters is not keyword ranking. It is whether your content appears as the cited source when a buyer asks an AI for a recommendation.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>seo</category>
      <category>nextjs</category>
      <category>typescript</category>
    </item>
    <item>
      <title>A Weekly Cron Died Silently for 10 Weeks. Here Is What We Changed.</title>
      <dc:creator>Savage Solutions</dc:creator>
      <pubDate>Wed, 15 Jul 2026 10:02:49 +0000</pubDate>
      <link>https://dev.to/savage_solutions/a-weekly-cron-died-silently-for-10-weeks-here-is-what-we-changed-33m5</link>
      <guid>https://dev.to/savage_solutions/a-weekly-cron-died-silently-for-10-weeks-here-is-what-we-changed-33m5</guid>
      <description>&lt;h2&gt;
  
  
  The Bug Nobody Saw
&lt;/h2&gt;

&lt;p&gt;For roughly 10 weeks, a scheduled publishing job produced zero output. No error email. No Slack ping. No dashboard turning red. The job ran on schedule every week, hit a failure condition early in the pipeline, logged a message to the console, and returned. That was it.&lt;/p&gt;

&lt;p&gt;The failure path looked something like 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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runPublishingJob&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;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchScheduledContent&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;items&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;No items to publish. Exiting.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// silent exit, no alert&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;publishAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;console.log&lt;/code&gt; and early return felt reasonable when someone wrote it. If there is nothing to publish, just exit cleanly. The problem is that &lt;code&gt;fetchScheduledContent()&lt;/code&gt; was supposed to return items every single week. When it started returning nothing, because of an upstream MongoDB query that had quietly broken, the job treated a critical failure as a normal no-op.&lt;/p&gt;

&lt;p&gt;Ten weeks of content never went out. Nobody noticed until a stakeholder asked why the publishing cadence had gone quiet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Happens
&lt;/h2&gt;

&lt;p&gt;Scheduled jobs are easy to forget about once they are running. Unlike an HTTP endpoint that a user hits and immediately reports as broken, a cron job runs in the background. If it does not produce visible output, the only way to know it is working is to go looking. Most teams do not go looking.&lt;/p&gt;

&lt;p&gt;The deeper issue is that silence gets interpreted as success. The job ran. No exception was thrown. No process crashed. From the outside, everything looked fine.&lt;/p&gt;

&lt;p&gt;This is a monitoring design flaw, not a code quality flaw. The developer who wrote that early return was not being careless. The system just had no concept of "this job ran but did nothing, and that is suspicious."&lt;/p&gt;

&lt;h2&gt;
  
  
  The Two Things Every Scheduled Job Needs
&lt;/h2&gt;

&lt;p&gt;After this incident, we established a rule that applies to every scheduled job we build or maintain:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A positive heartbeat.&lt;/strong&gt; The job must emit a signal when it completes successfully, not just when it fails. We use a lightweight ping to a health-check service (we use Better Uptime for this, though Cronitor and Healthchecks.io work the same way). If the ping does not arrive within the expected window, the service fires an alert. The job does not get credit for running unless it explicitly checks in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A failure alert routed to a human.&lt;/strong&gt; A &lt;code&gt;console.log&lt;/code&gt; is not an alert. An alert is a message that lands somewhere a person actually reads, within a timeframe that matters. For us, that means a dedicated ops channel in Slack.&lt;/p&gt;

&lt;p&gt;Here is the revised pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runPublishingJob&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;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchScheduledContent&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;items&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendAlert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;critical&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Publishing job ran but fetched zero items. Expected at least 1.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;job&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;weekly-publisher&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="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;published&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;publishAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&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;published&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;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;await&lt;/span&gt; &lt;span class="nf"&gt;sendAlert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;critical&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Publishing job completed but zero items were published. Items fetched: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;job&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;weekly-publisher&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;heartbeat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ping&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;weekly-publisher-success&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Published &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;published&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; items.`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sendAlert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;critical&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Publishing job threw an unhandled error: &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="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;job&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;weekly-publisher&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key addition is the "zero published" alert. Even if the job runs without throwing, if it publishes nothing, that is loud now, not invisible.&lt;/p&gt;

&lt;h2&gt;
  
  
  How We Route Alerts
&lt;/h2&gt;

&lt;p&gt;We consolidate all scheduled job alerts into one ops Slack channel. The routing rules are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Immediate criticals&lt;/strong&gt; post to the channel the moment they fire. Zero published, unhandled exceptions, missed heartbeats. These cannot wait.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-critical warnings&lt;/strong&gt; (slower-than-expected run times, retry counts above threshold) go into a digest that posts three times per day: 8am, 1pm, and 6pm.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This structure matters. If every alert is immediate, engineers start ignoring the channel. If every alert is batched, a critical failure sits unread for hours. The two-tier system keeps the channel signal-to-noise ratio high enough that people actually look at it.&lt;/p&gt;

&lt;p&gt;The digest is a simple Lambda function (we run most of our backend infrastructure on AWS) that queries a DynamoDB table of alert events, formats them, and posts to Slack via webhook. Nothing exotic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "Zero Published" Alert Is the Specific Fix
&lt;/h2&gt;

&lt;p&gt;Most monitoring guides tell you to alert on errors. That is necessary but not sufficient. You also need to alert on the absence of expected output.&lt;/p&gt;

&lt;p&gt;For a weekly publishing job, the expected output is at least one published item per run. If that does not happen, something is wrong, whether or not an exception was thrown. The &lt;code&gt;fetchScheduledContent&lt;/code&gt; function returning an empty array was not an error in the JavaScript sense. It was a logic failure that only became visible when you asked "did this job do what it was supposed to do?"&lt;/p&gt;

&lt;p&gt;This is sometimes called an outcome-based alert, as opposed to an error-based alert. Both are necessary. Error-based alerts catch crashes. Outcome-based alerts catch silent failures.&lt;/p&gt;

&lt;p&gt;For any scheduled job, ask: what is the minimum acceptable output of a successful run? Then alert if the job produces less than that, even if it exits cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Audit Now
&lt;/h2&gt;

&lt;p&gt;After this incident, we audited every scheduled job across our systems. The checklist we used:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does this job have a heartbeat ping configured?&lt;/li&gt;
&lt;li&gt;Is the heartbeat monitored with an expected-interval check?&lt;/li&gt;
&lt;li&gt;Does the job alert on zero or below-threshold output, not just on exceptions?&lt;/li&gt;
&lt;li&gt;Does the alert route to the ops channel, not just to logs?&lt;/li&gt;
&lt;li&gt;Has someone tested the failure path in the last 90 days?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Several jobs failed this checklist. None of them were broken, but they would have been invisible if they had broken.&lt;/p&gt;

&lt;p&gt;At Savage Digital Solutions (savagesolutions.io), this audit is now part of our standard deployment review for any background job, whether it is a Next.js API route triggered by Vercel Cron, a standalone Node.js process, or a Python script running on a schedule in AWS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Silence Is Not Success
&lt;/h2&gt;

&lt;p&gt;The phrase we use internally now is: silence is not success. A job that runs and produces no output should be treated as suspicious until proven otherwise. The burden of proof is on the job to demonstrate it worked, not on the engineer to go check.&lt;/p&gt;

&lt;p&gt;This is a small cultural shift, but it changes how you write monitoring code. Instead of "alert when something goes wrong," the mindset becomes "confirm that something went right, and alert if you cannot confirm it."&lt;/p&gt;

&lt;p&gt;Ten weeks is a long time for a publishing pipeline to be down. The fix took about two hours to implement. The audit took a day. The policy change took one team conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;console.log&lt;/code&gt; followed by a &lt;code&gt;return&lt;/code&gt; is not an alert. It is invisible in production.&lt;/li&gt;
&lt;li&gt;Every scheduled job needs a positive heartbeat, a ping that fires on success, monitored for expected arrival.&lt;/li&gt;
&lt;li&gt;Alert on zero or below-threshold output, not only on thrown exceptions. Silent no-ops are often the most dangerous failures.&lt;/li&gt;
&lt;li&gt;Route all job alerts to one ops channel. Use immediate delivery for criticals and a digest (we use 3x/day) for warnings.&lt;/li&gt;
&lt;li&gt;Audit existing scheduled jobs against a checklist: heartbeat configured, failure path tested, output threshold alert in place.&lt;/li&gt;
&lt;li&gt;Silence is not success. A job that does nothing should be loud, not invisible.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>typescript</category>
      <category>monitoring</category>
      <category>backend</category>
    </item>
    <item>
      <title>How We Make Every Scheduled Job Safe to Re-Run</title>
      <dc:creator>Savage Solutions</dc:creator>
      <pubDate>Wed, 15 Jul 2026 06:02:23 +0000</pubDate>
      <link>https://dev.to/savage_solutions/how-we-make-every-scheduled-job-safe-to-re-run-48o8</link>
      <guid>https://dev.to/savage_solutions/how-we-make-every-scheduled-job-safe-to-re-run-48o8</guid>
      <description>&lt;h2&gt;
  
  
  The Problem With Cron Jobs Nobody Talks About
&lt;/h2&gt;

&lt;p&gt;Cron jobs lie to you. They look deterministic on paper: run at 9 AM, do the thing, done. In practice, they restart mid-flight when a deploy happens, they overlap when the previous run takes longer than the interval, and they re-fire when a server bounces. If your job cannot tell what it already did, it will eventually do it twice.&lt;/p&gt;

&lt;p&gt;I've seen this cause real damage: duplicate posts published to social feeds, duplicate records written to MongoDB, duplicate video generation requests sent to HeyGen. None of these are catastrophic in isolation, but they compound. A weekly content publisher that fires twice produces two identical posts. A billing job that overlaps charges a customer twice. The fix is not better infrastructure. The fix is writing jobs that are safe to re-run by design.&lt;/p&gt;

&lt;p&gt;We call this property idempotency, and at this point it's a hard requirement for every scheduled job we ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Idempotency Actually Means in Practice
&lt;/h2&gt;

&lt;p&gt;An idempotent job produces the same outcome whether it runs once or ten times. That sounds abstract, so here's the concrete version: before any write, the job checks whether that write already happened. If it did, it skips. If it didn't, it proceeds.&lt;/p&gt;

&lt;p&gt;There are two mechanisms we use to enforce this.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Find-or-Create on a Stable Dedup Key
&lt;/h3&gt;

&lt;p&gt;Every record that a job creates needs a stable, deterministic identifier that exists before the write happens. We call this the dedup key. For email processing jobs, it's the &lt;code&gt;Message-ID&lt;/code&gt; header, which is set by the sending server and never changes. For content jobs, it's a hash of the source content.&lt;/p&gt;

&lt;p&gt;The pattern in TypeScript with MongoDB looks like 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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;published_posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;updateOne&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;dedupKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;contentHash&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;$setOnInsert&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;dedupKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;contentHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;postContent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;createdAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;$setOnInsert&lt;/code&gt; combined with &lt;code&gt;upsert: true&lt;/code&gt; is the key detail here. If a document with that &lt;code&gt;dedupKey&lt;/code&gt; already exists, MongoDB does nothing. If it doesn't exist, it creates it. The operation is atomic. You can run this line a hundred times with the same &lt;code&gt;contentHash&lt;/code&gt; and end up with exactly one document.&lt;/p&gt;

&lt;p&gt;This is not a try/catch around a duplicate key error. That approach has a race condition. The &lt;code&gt;upsert&lt;/code&gt; with &lt;code&gt;$setOnInsert&lt;/code&gt; is atomic at the database level.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. A 'Used' Ledger Collection
&lt;/h3&gt;

&lt;p&gt;The find-or-create pattern handles writes, but some jobs also pull from a generator: a queue of items to process, a list of topics to publish, a pool of assets to use. If the generator doesn't track what it's already handed out, a re-run pulls the same item again.&lt;/p&gt;

&lt;p&gt;We solve this with a ledger collection. Before a generator returns an item, it writes that item's ID to a &lt;code&gt;used_items&lt;/code&gt; collection. On every subsequent call, the generator queries against that collection and excludes anything already present.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getNextUnusedTopic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Topic&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;&amp;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;usedIds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;used_items&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;jobId&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;doc&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;doc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;itemId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toArray&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;topics&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findOne&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;$nin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;usedIds&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ready&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;used_items&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;insertOne&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;jobId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;itemId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;usedAt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ledger write happens before the item is processed, not after. If the job crashes mid-flight, the item is marked used and will be skipped on re-run. That's intentional. A skipped item is recoverable. A duplicate action, like a second HeyGen video generation request for the same script, costs money and creates noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Weekly Publisher: A Real Example
&lt;/h2&gt;

&lt;p&gt;We run a weekly content publishing job that generates social posts from a content queue and publishes them via a Next.js API route. The job is scheduled with a cron expression and runs on a Node.js server.&lt;/p&gt;

&lt;p&gt;Before this pattern was in place, a deploy that happened to coincide with the job's fire time would cause two runs in the same minute. The result was two identical posts going out within seconds of each other.&lt;/p&gt;

&lt;p&gt;The fix was a two-layer check:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;At the start of the job, query the &lt;code&gt;published_posts&lt;/code&gt; collection for any post with a &lt;code&gt;weekKey&lt;/code&gt; matching the current ISO week number. If one exists, exit immediately.&lt;/li&gt;
&lt;li&gt;Before each individual post write, run the &lt;code&gt;$setOnInsert&lt;/code&gt; upsert on the content hash.
&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;weekKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;year&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;-W&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;weekNumber&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;alreadyPublished&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;collection&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;published_posts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findOne&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;weekKey&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;alreadyPublished&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Week &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;weekKey&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; already published. Exiting.`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A double-fire now produces zero duplicate posts. The second run hits the &lt;code&gt;alreadyPublished&lt;/code&gt; check within milliseconds and exits cleanly. The log line is there so we can confirm the guard fired if we ever need to audit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Rule We Follow
&lt;/h2&gt;

&lt;p&gt;A job that cannot tell what it already did will eventually do it twice.&lt;/p&gt;

&lt;p&gt;That sentence is the entire mental model. When we review a new scheduled job, the first question is: what does this job write, and how does it know if that write already happened? If the answer is "it doesn't," the job is not ready to ship.&lt;/p&gt;

&lt;p&gt;The dedup key and the ledger collection are not the only ways to implement this. Some teams use distributed locks (Redis &lt;code&gt;SET NX&lt;/code&gt; with a TTL is common). Some use a status field on the record itself, transitioning from &lt;code&gt;pending&lt;/code&gt; to &lt;code&gt;processing&lt;/code&gt; to &lt;code&gt;done&lt;/code&gt; with atomic updates. The specific mechanism matters less than the discipline of always having one.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Looks Like in a Real Stack
&lt;/h2&gt;

&lt;p&gt;For reference, the stack where we've applied this most heavily:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Runtime:&lt;/strong&gt; Node.js with TypeScript&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database:&lt;/strong&gt; MongoDB (Atlas)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduling:&lt;/strong&gt; cron expressions on a VPS, with some jobs triggered via Next.js API routes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;External services:&lt;/strong&gt; HeyGen for video generation, various publishing APIs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dedup key sources:&lt;/strong&gt; &lt;code&gt;Message-ID&lt;/code&gt; headers for email, SHA-256 content hashes for generated content, ISO week keys for time-bounded jobs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the pattern we've standardized on at Savage Digital Solutions (savagesolutions.io) after running into the overlap problem enough times to stop treating it as an edge case.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Every scheduled job should answer the question: "How do I know if I already did this?" before it does anything.&lt;/li&gt;
&lt;li&gt;Use a stable, deterministic dedup key (a &lt;code&gt;Message-ID&lt;/code&gt;, a content hash, a week key) as the basis for find-or-create writes.&lt;/li&gt;
&lt;li&gt;MongoDB's &lt;code&gt;upsert&lt;/code&gt; with &lt;code&gt;$setOnInsert&lt;/code&gt; is atomic and handles concurrent re-runs without race conditions.&lt;/li&gt;
&lt;li&gt;A 'used' ledger collection prevents generators from handing out the same item twice across re-runs.&lt;/li&gt;
&lt;li&gt;Write the ledger entry before processing the item, not after. A skipped item is recoverable. A duplicate action often isn't.&lt;/li&gt;
&lt;li&gt;A job-level guard (check if this job's output already exists, exit if so) is the cheapest protection against double-fires.&lt;/li&gt;
&lt;li&gt;Distributed locks (Redis &lt;code&gt;SET NX&lt;/code&gt;) are a valid alternative when you need to prevent concurrent execution rather than just duplicate writes.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>devops</category>
      <category>startup</category>
    </item>
    <item>
      <title>The True Cost of a Custom Website vs Templates in 2026</title>
      <dc:creator>Savage Solutions</dc:creator>
      <pubDate>Wed, 13 May 2026 10:00:43 +0000</pubDate>
      <link>https://dev.to/savage_solutions/the-true-cost-of-a-custom-website-vs-templates-in-2026-5252</link>
      <guid>https://dev.to/savage_solutions/the-true-cost-of-a-custom-website-vs-templates-in-2026-5252</guid>
      <description>&lt;h1&gt;
  
  
  The True Cost of a Custom Website vs Templates in 2026: A Developer's Honest Breakdown
&lt;/h1&gt;

&lt;p&gt;I've been building websites professionally for over a decade, and I can tell you that the "custom vs. template" debate is more nuanced in 2026 than it was five years ago. Back then, the answer was simpler: custom for brands that needed it, templates for everyone else. Today? The lines are blurred, and the real cost isn't just about the initial price tag.&lt;/p&gt;

&lt;p&gt;Let me walk you through what I've learned by building both custom solutions and maintaining template-based sites for clients.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Hidden Costs Nobody Talks About
&lt;/h2&gt;

&lt;p&gt;When a prospect asks me "How much for a website?", the answer always depends on their actual needs—not their budget. The difference between a template and custom build isn't just the development time. It's the entire lifecycle cost.&lt;/p&gt;

&lt;h3&gt;
  
  
  Template-Based Site Costs
&lt;/h3&gt;

&lt;p&gt;Let's be honest: platforms like Webflow, Framer, and even WordPress with premium themes are incredibly capable in 2026. The upfront cost is minimal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Direct costs:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Webflow Pro plan: $12-36/month&lt;/li&gt;
&lt;li&gt;Premium theme: $50-500&lt;/li&gt;
&lt;li&gt;Premium plugins: $20-200/month&lt;/li&gt;
&lt;li&gt;Analytics tools: $50-300/month&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Indirect costs (where it gets expensive):&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Learning curve for non-developers: 20-40 hours&lt;/li&gt;
&lt;li&gt;Limited customization = workarounds on workarounds&lt;/li&gt;
&lt;li&gt;Lock-in risk: Switching platforms costs thousands in migration&lt;/li&gt;
&lt;li&gt;Performance optimization: More plugins = slower sites&lt;/li&gt;
&lt;li&gt;Security updates: Dependent on third-party vendors&lt;/li&gt;
&lt;li&gt;Scaling limitations: What works for 10K monthly visitors may choke at 100K&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's a real example from a client. They used a popular page builder for their e-commerce site. After six months of growth, they hit a wall:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Initial setup: $0
Monthly hosting + plugins: $150
Performance optimization attempts: $2,000 (developer time)
Migration to custom solution: $8,000 (because the template couldn't handle their traffic)

Total hidden cost: $10,150 over 18 months
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The frustrating part? They could have built a custom solution from scratch in the same timeframe for less.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Custom Build Reality Check
&lt;/h2&gt;

&lt;p&gt;Here's where I need to be transparent: custom development is expensive upfront, but the math changes significantly over time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Direct costs:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Initial development: $8,000-50,000+ depending on complexity&lt;/li&gt;
&lt;li&gt;Hosting (optimized): $50-500/month&lt;/li&gt;
&lt;li&gt;Maintenance &amp;amp; updates: $500-2,000/month&lt;/li&gt;
&lt;li&gt;Security audits: $1,000-5,000 annually&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What you actually get:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Full control over performance&lt;/li&gt;
&lt;li&gt;Scalability that grows with your business&lt;/li&gt;
&lt;li&gt;No vendor lock-in&lt;/li&gt;
&lt;li&gt;Custom integrations with your existing tools&lt;/li&gt;
&lt;li&gt;Data ownership and portability&lt;/li&gt;
&lt;li&gt;Optimized for your specific use case&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Let me show you the performance difference with a real technical comparison.&lt;/p&gt;

&lt;h3&gt;
  
  
  Performance: Template vs. Custom (2026 Benchmarks)
&lt;/h3&gt;

&lt;p&gt;I recently rebuilt a client's site from Webflow to a custom Next.js + Vercel solution. Here's what changed:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Webflow site metrics:&lt;/strong&gt;&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="na"&gt;Lighthouse Score&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;62&lt;/span&gt;
&lt;span class="na"&gt;Core Web Vitals&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;LCP&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3.8s&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;FID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;180ms&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;CLS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.15&lt;/span&gt;

&lt;span class="na"&gt;Initial page load&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;2.4MB&lt;/span&gt;
&lt;span class="na"&gt;Time to interactive&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;4.2s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Custom Next.js site:&lt;/strong&gt;&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="na"&gt;Lighthouse Score&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;98&lt;/span&gt;
&lt;span class="na"&gt;Core Web Vitals&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;LCP&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;0.9s&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;FID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;45ms&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;CLS&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.01&lt;/span&gt;

&lt;span class="na"&gt;Initial page load&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;145KB&lt;/span&gt;
&lt;span class="na"&gt;Time to interactive&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1.1s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The performance difference directly impacts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SEO rankings&lt;/strong&gt;: Google prioritizes Core Web Vitals&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conversion rates&lt;/strong&gt;: Every 100ms improvement = ~1% conversion lift&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User experience&lt;/strong&gt;: Faster sites = higher engagement&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server costs&lt;/strong&gt;: Efficient code = lower infrastructure bills&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Break-Even Point
&lt;/h2&gt;

&lt;p&gt;This is where I usually lose non-technical founders, but it matters financially.&lt;/p&gt;

&lt;p&gt;For most businesses, the break-even point is &lt;strong&gt;12-24 months&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Let's do the math with a realistic scenario:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Template-based site (3-year projection):&lt;/strong&gt;&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="na"&gt;Year 1&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$2,000 (setup + plugins)&lt;/span&gt;
&lt;span class="na"&gt;Year 2&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$1,800 (recurring costs + optimization attempts)&lt;/span&gt;
&lt;span class="na"&gt;Year 3&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$8,000 (hit scaling limits, need rebuilds/workarounds)&lt;/span&gt;
&lt;span class="na"&gt;Total&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$11,800&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Custom site (3-year projection):&lt;/strong&gt;&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="na"&gt;Year 1&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$18,000 (initial build) + $12,000 (maintenance)&lt;/span&gt;
&lt;span class="na"&gt;Year 2&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$12,000 (maintenance + iterations)&lt;/span&gt;
&lt;span class="na"&gt;Year 3&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$12,000 (maintenance + features)&lt;/span&gt;
&lt;span class="na"&gt;Total&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;$54,000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait—custom is more expensive in absolute terms. But here's the critical question: &lt;strong&gt;What are you making with it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If the custom site generates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;30% better conversion rates&lt;/li&gt;
&lt;li&gt;40% faster load times&lt;/li&gt;
&lt;li&gt;Direct integrations that save 10 hours/week of manual work&lt;/li&gt;
&lt;li&gt;The ability to scale to 10x traffic without rebuilding&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;...then that custom site pays for itself through efficiency and revenue growth.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Should You Actually Use Each?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Use a template if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You're testing a new business idea (MVP phase)&lt;/li&gt;
&lt;li&gt;You have minimal technical needs (simple portfolio, brochure)&lt;/li&gt;
&lt;li&gt;Your budget is genuinely under $5,000 total&lt;/li&gt;
&lt;li&gt;You don't need integrations with existing systems&lt;/li&gt;
&lt;li&gt;You can live with vendor lock-in&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Use custom if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need specific integrations (CRM, ERP, analytics pipelines)&lt;/li&gt;
&lt;li&gt;Performance at scale matters (you expect growth)&lt;/li&gt;
&lt;li&gt;You own a brand people recognize (custom = competitive advantage)&lt;/li&gt;
&lt;li&gt;You need data ownership and compliance (healthcare, fintech)&lt;/li&gt;
&lt;li&gt;You plan to keep this business for 3+ years&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What Changed in 2026
&lt;/h2&gt;

&lt;p&gt;The template landscape has matured significantly. Modern page builders can now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Integrate with headless CMS platforms&lt;/li&gt;
&lt;li&gt;Deploy to custom domains with subresource integrity&lt;/li&gt;
&lt;li&gt;Handle complex conditional logic via visual builders&lt;/li&gt;
&lt;li&gt;Export to static HTML (reducing vendor lock-in)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But they still can't match custom solutions on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Optimization&lt;/strong&gt;: You can't tell Webflow "render only this component server-side"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integration depth&lt;/strong&gt;: Third-party APIs have limits in template builders&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost at scale&lt;/strong&gt;: Each user/transaction costs more on platform plans&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Customization&lt;/strong&gt;: The 20% of features you actually need take 80% of development time to hack around&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The Hybrid Approach (My Current Recommendation)
&lt;/h2&gt;

&lt;p&gt;The smartest move I've seen work is the &lt;strong&gt;hybrid approach&lt;/strong&gt;: start with a template, plan for custom.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;Phase 1 (Months 1-3): Webflow/Framer MVP
&lt;span class="p"&gt;-&lt;/span&gt; Validate product-market fit
&lt;span class="p"&gt;-&lt;/span&gt; Cost: $2,000-5,000

Phase 2 (Months 4-12): Custom build begins
&lt;span class="p"&gt;-&lt;/span&gt; Migrate content and learnings
&lt;span class="p"&gt;-&lt;/span&gt; Build custom based on what actually works
&lt;span class="p"&gt;-&lt;/span&gt; Cost: $15,000-25,000

Phase 3 (Month 12+): Full ownership
&lt;span class="p"&gt;-&lt;/span&gt; Template completely replaced
&lt;span class="p"&gt;-&lt;/span&gt; Maintain custom solution
&lt;span class="p"&gt;-&lt;/span&gt; Cost: $1,000-2,000/month
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives you the best of both worlds: speed to market plus long-term scalability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Templates aren't cheap—they're &lt;em&gt;initially&lt;/em&gt; cheap.&lt;/strong&gt; Hidden costs add up quickly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom development breaks even in 18-24 months&lt;/strong&gt; for most serious businesses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performance directly impacts revenue.&lt;/strong&gt; A 1-second faster site can mean 7-10% more conversions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan for growth.&lt;/strong&gt; If you're hiring employees or scaling to 6-7 figures in revenue, custom makes financial sense.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hybrid approaches work.&lt;/strong&gt; Start small, rebuild smart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vendor lock-in is real and expensive.&lt;/strong&gt; That "free" template might cost you thousands when you need to leave.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The question isn't "custom or template?" anymore. It's "what's the total cost of ownership over the lifetime of my business?" Once you ask that, the answer usually becomes clear.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;I'm the founder of Savage Digital Solutions (savagesolutions.io), where we build custom web solutions and help founders navigate these exact decisions. These insights come from real projects, real costs, and real clients learning these lessons the hard way.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>seo</category>
      <category>startup</category>
    </item>
    <item>
      <title>How AI Is Transforming the Photography Industry</title>
      <dc:creator>Savage Solutions</dc:creator>
      <pubDate>Wed, 13 May 2026 10:00:22 +0000</pubDate>
      <link>https://dev.to/savage_solutions/how-ai-is-transforming-the-photography-industry-8d2</link>
      <guid>https://dev.to/savage_solutions/how-ai-is-transforming-the-photography-industry-8d2</guid>
      <description>&lt;h1&gt;
  
  
  How AI Is Transforming the Photography Industry: A Developer's Perspective
&lt;/h1&gt;

&lt;p&gt;Over the past two years, I've watched artificial intelligence reshape the photography landscape in ways that go far beyond Instagram filters. As someone who's built tools and platforms in this space, I've seen firsthand how AI isn't replacing photographers—it's amplifying them, automating grunt work, and creating entirely new revenue streams. Let me walk you through what's actually happening under the hood.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Current AI Photography Stack
&lt;/h2&gt;

&lt;p&gt;When people hear "AI in photography," they think of image generation models like Midjourney or DALL-E. But that's just the surface. The real transformation is happening in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Image processing and enhancement&lt;/strong&gt; (super-resolution, noise reduction, color grading)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automated culling and tagging&lt;/strong&gt; (the workflow nightmare photographers actually face)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Background removal and object detection&lt;/strong&gt; (powered by segmentation models)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Style transfer and batch editing&lt;/strong&gt; (neural networks learning your editing style)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Smart cropping and composition optimization&lt;/strong&gt; (understanding rule-of-thirds programmatically)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most of these technologies aren't new, but they've become &lt;em&gt;accessible and affordable&lt;/em&gt; in the last 18-24 months.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Culling Problem: Where AI Adds Real Value
&lt;/h2&gt;

&lt;p&gt;Let me talk about something unglamorous but critical: &lt;strong&gt;photo culling&lt;/strong&gt;. A photographer shoots 2,000 images at a wedding. A human has to review every single one, delete blurry shots, duplicates, and unusable takes. This takes hours.&lt;/p&gt;

&lt;p&gt;Here's a practical example of how we're automating this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;PIL&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Image&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;torchvision&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;transforms&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;torchvision.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;resnet50&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;PhotoCuller&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resnet50&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pretrained&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eval&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transform&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;transforms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Compose&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
            &lt;span class="n"&gt;transforms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Resize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;transforms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CenterCrop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;224&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;transforms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ToTensor&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
            &lt;span class="n"&gt;transforms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;mean&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;0.485&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.456&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.406&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="n"&gt;std&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mf"&gt;0.229&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.224&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.225&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;def&lt;/span&gt; &lt;span class="nf"&gt;detect_blur&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;image_path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Detects blur using Laplacian variance&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="n"&gt;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;imread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;gray&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cvtColor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;COLOR_BGR2GRAY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;variance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Laplacian&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gray&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CV_64F&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;var&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;variance&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;  &lt;span class="c1"&gt;# threshold
&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;detect_faces&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;image_path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Returns number of detected faces&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="n"&gt;face_cascade&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;CascadeClassifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;haarcascades&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;haarcascade_frontalface_default.xml&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;img&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cv2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;imread&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;faces&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;face_cascade&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;detectMultiScale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.3&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="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;faces&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;score_image&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;image_path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Returns a quality score 0-100&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="n"&gt;blur_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;detect_blur&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
        &lt;span class="n"&gt;face_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;detect_faces&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="c1"&gt;# Add more heuristics: exposure, composition, etc.
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;blur_score&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;face_count&lt;/span&gt;

&lt;span class="c1"&gt;# Usage
&lt;/span&gt;&lt;span class="n"&gt;culler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;PhotoCuller&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;images&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;photo1.jpg&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;photo2.jpg&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;photo3.jpg&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;ranked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;images&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;culler&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;score_image&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This isn't magic—it's basic computer vision. But it cuts culling time by &lt;strong&gt;70-80%&lt;/strong&gt;. Photographers then fine-tune the AI's suggestions in seconds rather than hours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Batch Editing and Style Transfer
&lt;/h2&gt;

&lt;p&gt;Another pain point: consistency. A photographer's style is their brand, but applying it to 500 images takes forever. Modern AI can learn your editing style and apply it automatically.&lt;/p&gt;

&lt;p&gt;Tools like &lt;strong&gt;Adobe's Firefly&lt;/strong&gt; and open-source models like &lt;strong&gt;StyleGAN&lt;/strong&gt; enable this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;torchvision.models&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;vgg19&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;StyleTransferEngine&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;style_image_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;cuda&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;device&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;vgg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;vgg19&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pretrained&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;eval&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;style_image&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load_image&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;style_image_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;transfer_style&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content_image_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;num_iterations&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
        Neural style transfer using VGG features
        Transforms content_image to match style_image
        &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
        &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load_image&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content_image_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;output&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clone&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;requires_grad_&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;optimizer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;optim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;LBFGS&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;num_iterations&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;closure&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
                &lt;span class="n"&gt;optimizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;zero_grad&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="n"&gt;content_loss&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;content_loss&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;style_loss&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;style_loss&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;style_image&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;total_loss&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;content_loss&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;style_loss&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000000&lt;/span&gt;
                &lt;span class="n"&gt;total_loss&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;backward&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;total_loss&lt;/span&gt;

            &lt;span class="n"&gt;optimizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;step&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;closure&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

            &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Iteration &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tensor_to_image&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The beauty here: you train this once on a photographer's portfolio, then apply it to 500 images in batch. &lt;strong&gt;Cost per image: pennies. Time: seconds.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Object Detection and Selective Editing
&lt;/h2&gt;

&lt;p&gt;Imagine automating skin tone adjustments, background blur adjustments, or object-specific color grading. This is possible with &lt;strong&gt;YOLO (You Only Look Once)&lt;/strong&gt; or &lt;strong&gt;Mask R-CNN&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;ultralytics&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;YOLO&lt;/span&gt;

&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;YOLO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;yolov8n.pt&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# pretrained nano model
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;selective_enhance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image_path&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    Detect faces and apply selective enhancement
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;image_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;box&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;boxes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;class_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cls&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;if&lt;/span&gt; &lt;span class="n"&gt;class_id&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# Person detected
&lt;/span&gt;                &lt;span class="c1"&gt;# Apply skin tone enhancement
&lt;/span&gt;                &lt;span class="n"&gt;x1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;x2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y2&lt;/span&gt; &lt;span class="o"&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;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;box&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;xyxy&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="c1"&gt;# Selective editing logic here
&lt;/span&gt;                &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Face detected at (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;x1&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;y1&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;x2&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;y2&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real-world application: a wedding photographer can automatically enhance skin tones on all detected faces, adjust eye brightness, or blur backgrounds—all programmatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generative Fill and Inpainting
&lt;/h2&gt;

&lt;p&gt;One of the coolest recent developments is &lt;strong&gt;generative inpainting&lt;/strong&gt;—removing unwanted objects and letting AI fill in the background naturally. This leverages diffusion models like &lt;strong&gt;Stable Diffusion Inpainting&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Using Replicate API for Stable Diffusion Inpainting&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://api.replicate.com/v1/predictions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Token &lt;/span&gt;&lt;span class="nv"&gt;$REPLICATE_API_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "version": "your_model_version",
    "input": {
      "image": "https://example.com/photo.jpg",
      "mask": "https://example.com/mask.jpg",
      "prompt": "natural background, consistent lighting"
    }
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A photographer shoots a perfect portrait—but a photobomber's in the background. Mask it, let AI regenerate that area. Done in 10 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Business Impact
&lt;/h2&gt;

&lt;p&gt;Here's what I've observed at Candid Studios (candidstudios.net) and in discussions with other agencies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Turnaround time&lt;/strong&gt; reduced by 40-60%&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Operational costs&lt;/strong&gt; down 30% (fewer editing hours)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quality consistency&lt;/strong&gt; improved significantly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scalability&lt;/strong&gt; for small teams (one photographer can deliver 10x the volume)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But there's a catch: &lt;strong&gt;AI requires initial investment&lt;/strong&gt;. Training models on your specific style, integrating into workflows, and handling edge cases takes engineering time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Challenges and Realistic Expectations
&lt;/h2&gt;

&lt;p&gt;Let's be honest about limitations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;AI still fails on complex compositions&lt;/strong&gt; - multiple subjects, unusual lighting, artistic intent&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Legal/ethical concerns&lt;/strong&gt; - copyright, consent, deepfakes. These aren't technical problems yet but they're coming&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Requires human oversight&lt;/strong&gt; - you can't fully automate photography. You can automate 70% of the tedious work&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model hallucination&lt;/strong&gt; - generative models sometimes create "plausible but false" details&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The photographers winning right now aren't replacing themselves with AI. They're using AI to eliminate grunt work so they can focus on what matters: &lt;strong&gt;composition, lighting, and client relationships&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AI solves real workflow problems&lt;/strong&gt;: Culling, batch editing, object detection save hours per project&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's not about image generation&lt;/strong&gt;: Practical AI in photography is about automation and enhancement, not replacement&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start with your bottlenecks&lt;/strong&gt;: Identify where you lose time, then apply AI there&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expect to build custom tools&lt;/strong&gt;: Generic software helps, but custom models trained on your style/portfolio create competitive advantage&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Human oversight is mandatory&lt;/strong&gt;: AI as a tool, not a replacement&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The photography industry isn't being disrupted by AI—it's being liberated from tedium.&lt;/p&gt;




&lt;p&gt;I'm the founder of &lt;strong&gt;Candid Studios&lt;/strong&gt; (candidstudios.net), where we've been experimenting with these exact tools to help photographers and agencies work smarter.&lt;/p&gt;

</description>
      <category>photography</category>
      <category>business</category>
      <category>startup</category>
      <category>marketing</category>
    </item>
    <item>
      <title>Why We Chose Next.js Over Everything Else for Client Projects</title>
      <dc:creator>Savage Solutions</dc:creator>
      <pubDate>Wed, 06 May 2026 10:00:46 +0000</pubDate>
      <link>https://dev.to/savage_solutions/why-we-chose-nextjs-over-everything-else-for-client-projects-2d69</link>
      <guid>https://dev.to/savage_solutions/why-we-chose-nextjs-over-everything-else-for-client-projects-2d69</guid>
      <description>&lt;h1&gt;
  
  
  Why We Chose Next.js Over Everything Else for Client Projects
&lt;/h1&gt;

&lt;p&gt;When we started working with clients at scale, I realized we needed to make a decisive choice about our frontend framework. We'd dabbled with Create React App, experimented with Gatsby, and even considered Vue.js with Nuxt. But after shipping dozens of projects across various industries—from e-commerce platforms to SaaS dashboards—&lt;strong&gt;Next.js consistently delivered the best developer experience, fastest time-to-market, and most maintainable codebases&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This wasn't a theoretical decision. It came from real production battles, client demands, and the hard truth that framework choices compound over time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem We Were Solving
&lt;/h2&gt;

&lt;p&gt;Before committing to Next.js, we had three persistent pain points:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Build complexity&lt;/strong&gt;: Setting up webpack, babel, and code splitting across projects wasted hours&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API friction&lt;/strong&gt;: Our frontend and backend teams worked in separate repos, creating deployment coordination nightmares&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performance regression&lt;/strong&gt;: Client projects often shipped with poor Core Web Vitals, requiring expensive optimization sprints&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We needed something that handled these out-of-the-box rather than requiring custom configuration for every project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Next.js Won (The Real Reasons)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. &lt;strong&gt;Full-Stack Development in One Framework&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;This is the game-changer. With Next.js API routes, we can build the entire application—frontend and API—in a single repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// pages/api/users/[id].ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextApiRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;NextApiResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;prisma&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/lib/prisma&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;handler&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;NextApiRequest&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;NextApiResponse&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;GET&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&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;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUnique&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;where&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="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;PUT&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;updated&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;prisma&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;where&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="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;
    &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;updated&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;405&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;end&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;For clients, this means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Single deployment pipeline&lt;/li&gt;
&lt;li&gt;Unified error handling and logging&lt;/li&gt;
&lt;li&gt;Shared TypeScript types between client and server&lt;/li&gt;
&lt;li&gt;No network latency between frontend and "backend"&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. &lt;strong&gt;File-Based Routing That Actually Makes Sense&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Coming from manual route configuration, Next.js's file-based router is refreshingly intuitive. Your folder structure &lt;em&gt;is&lt;/em&gt; your routing structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pages/
├── index.ts                 → /
├── products.ts              → /products
├── products/[id].ts         → /products/:id
├── api/
│   ├── products.ts          → /api/products
│   └── products/[id].ts     → /api/products/:id
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No more maintaining separate routing configs. When you add a page, the route exists. This speeds up development by 30-40% in our experience, especially for CRUD-heavy dashboards.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. &lt;strong&gt;Image Optimization Built-In&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Before Next.js Image component, optimizing images across clients meant dealing with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Manual compression scripts&lt;/li&gt;
&lt;li&gt;Responsive image markup&lt;/li&gt;
&lt;li&gt;Format conversion to WebP&lt;/li&gt;
&lt;li&gt;Lazy loading implementation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now? One component:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Image&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/image&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;ProductCard&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;product&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Image&lt;/span&gt;
      &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;imageUrl&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;alt&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;height&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;sizes&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"&lt;/span&gt;
      &lt;span class="na"&gt;onLoadingComplete&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;naturalWidth&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
          &lt;span class="c1"&gt;// Broken image, handle it&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This single change improved Core Web Vitals across all our client projects by an average of 25 points. Clients love seeing LCP improvements without extra work.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. &lt;strong&gt;Static Site Generation + ISR = Performance Win&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;For content-heavy sites, Next.js's Static Site Generation (SSG) with Incremental Static Regeneration (ISR) is game-changing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// pages/blog/[slug].tsx&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;BlogPost&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;post&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="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;article&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;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/article&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getStaticProps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchPostFromCMS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&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;props&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;revalidate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt; &lt;span class="c1"&gt;// ISR: regenerate every hour&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;getStaticPaths&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;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchAllPosts&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;paths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;posts&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;p&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;params&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})),&lt;/span&gt;
    &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;blocking&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="c1"&gt;// Generate new posts on-demand&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A client's blog went from 2-second page loads to &lt;strong&gt;200ms&lt;/strong&gt; after switching to ISR. The hosting bill dropped by 60%.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. &lt;strong&gt;Middleware for Complex Logic&lt;/strong&gt;
&lt;/h3&gt;

&lt;p&gt;Next.js 12+ middleware lets us handle authentication, redirects, and request modification at the edge:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// middleware.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;middleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;NextRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cookies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;auth_token&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextUrl&lt;/span&gt;&lt;span class="p"&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/dashboard&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;redirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/login&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="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;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nextUrl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requestHeaders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Headers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;requestHeaders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-locale&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;fr&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;request&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;requestHeaders&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;matcher&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/((?!_next|static|favicon.ico).*)&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This eliminates the need for separate auth services or middleware layers for many projects.&lt;/p&gt;

&lt;h2&gt;
  
  
  What About the Drawbacks?
&lt;/h2&gt;

&lt;p&gt;I'd be dishonest if I didn't mention where Next.js requires workarounds:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cold starts on serverless&lt;/strong&gt;: If you're deploying to AWS Lambda, initial requests can be slow. We solved this by using Vercel (which has optimized Next.js cold starts) or keeping functions warm.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Large bundle sizes&lt;/strong&gt;: Next.js apps can bloat quickly without discipline. We enforce code splitting and dynamic imports:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;dynamic&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;next/dynamic&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HeavyChart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dynamic&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@/components/Chart&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;loading&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nx"&gt;Loading&lt;/span&gt; &lt;span class="nx"&gt;chart&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/p&amp;gt;&lt;/span&gt;&lt;span class="err"&gt;,
&lt;/span&gt;  &lt;span class="na"&gt;ssr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="c1"&gt;// Only load on client&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Learning curve for teams&lt;/strong&gt;: Developers from non-React backgrounds struggle initially. We mitigate this with good documentation and pairing new hires with experienced team members.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Business Impact
&lt;/h2&gt;

&lt;p&gt;Since standardizing on Next.js, we've seen measurable improvements:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;30% faster project delivery&lt;/strong&gt;: Boilerplate is minimal; developers jump into business logic immediately&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;25% fewer production bugs&lt;/strong&gt;: Shared types between frontend/API catch issues early&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lower hosting costs&lt;/strong&gt;: Static generation and efficient server usage reduces infrastructure expenses&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Easier client handoffs&lt;/strong&gt;: Monorepo structure makes maintenance straightforward for clients taking projects in-house&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Our Standard Stack
&lt;/h2&gt;

&lt;p&gt;For context on how this fits together, here's what we typically ship:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js&lt;/strong&gt;: Framework and API layer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TypeScript&lt;/strong&gt;: Type safety across the stack&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tailwind CSS&lt;/strong&gt;: Styling without context switching&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prisma&lt;/strong&gt;: Type-safe database ORM&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vercel&lt;/strong&gt;: Hosting and deployment (optimized for Next.js)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testing&lt;/strong&gt;: Jest + React Testing Library&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This combination covers 95% of client requirements without over-engineering.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Next.js Isn't the Answer
&lt;/h2&gt;

&lt;p&gt;To be clear: &lt;strong&gt;Next.js isn't universal&lt;/strong&gt;. Use something else if you need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Static-only sites (consider plain Next.js export or Hugo)&lt;/li&gt;
&lt;li&gt;Real-time collaborative features (consider Remix with WebSockets)&lt;/li&gt;
&lt;li&gt;Heavy desktop app-like UX (consider Electron + React)&lt;/li&gt;
&lt;li&gt;Extreme performance constraints (consider plain HTML/Vanilla JS)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But for the majority of modern web applications—especially business-focused software—Next.js delivers the best value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Full-stack capability&lt;/strong&gt; eliminates coordination overhead and deployment complexity&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File-based routing&lt;/strong&gt; and built-in optimizations accelerate development without sacrificing performance&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image optimization and ISR&lt;/strong&gt; deliver Core Web Vitals improvements automatically&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mature ecosystem&lt;/strong&gt; means community answers exist for nearly every problem&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Framework choice matters&lt;/strong&gt; more than technology zealots admit—pick what scales with your team and clients&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;At Savage Digital Solutions (savagesolutions.io), we've built dozens of applications with this stack, and it consistently delivers the best balance of developer experience, client satisfaction, and long-term maintainability.&lt;/p&gt;

&lt;p&gt;The framework wars are overblown. Pick one that handles your 95% case cleanly, then move on to solving real problems for your users.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;I'm the founder of Savage Digital Solutions (savagesolutions.io), where we build full-stack web applications for growing companies. We've shipped everything from marketplace platforms to analytics dashboards—all powered by Next.js.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>seo</category>
      <category>startup</category>
    </item>
    <item>
      <title>Building a Photography Business: Lessons from 3,000+ Events</title>
      <dc:creator>Savage Solutions</dc:creator>
      <pubDate>Wed, 06 May 2026 10:00:23 +0000</pubDate>
      <link>https://dev.to/savage_solutions/building-a-photography-business-lessons-from-3000-events-19k3</link>
      <guid>https://dev.to/savage_solutions/building-a-photography-business-lessons-from-3000-events-19k3</guid>
      <description>&lt;h1&gt;
  
  
  Building a Photography Business: Lessons from 3,000+ Events
&lt;/h1&gt;

&lt;p&gt;When I started my career, I was a full-stack developer. I could ship features, optimize databases, and scale infrastructure. But when I decided to build a photography business alongside my tech work, I quickly realized that creative businesses operate on entirely different principles than software development.&lt;/p&gt;

&lt;p&gt;Over the past five years, I've personally photographed and managed over 3,000 events—everything from corporate conferences to weddings to tech meetups. What surprised me most wasn't the photography itself. It was how much of this business is fundamentally about &lt;em&gt;systems&lt;/em&gt;, &lt;em&gt;data&lt;/em&gt;, and &lt;em&gt;automation&lt;/em&gt;—the exact skills I'd spent a decade honing as an engineer.&lt;/p&gt;

&lt;p&gt;If you're a developer considering a creative side business, or you're simply curious about applying engineering principles to non-technical domains, this is for you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Problem: It's Not About the Camera
&lt;/h2&gt;

&lt;p&gt;When most people think about photography businesses, they imagine talent. Better lenses. Better light. Better eye for composition.&lt;/p&gt;

&lt;p&gt;After 3,000 events, I can tell you: that's maybe 30% of what determines success.&lt;/p&gt;

&lt;p&gt;The other 70%? &lt;strong&gt;Operations.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The bottleneck isn't capturing great photos—it's everything else. It's managing inquiries, scheduling shoots, organizing files, delivering galleries, collecting payments, and maintaining client relationships. A friend once told me, "You don't have a photography business. You have a data management business that happens to produce photos."&lt;/p&gt;

&lt;p&gt;He was right.&lt;/p&gt;

&lt;h2&gt;
  
  
  From Spreadsheets to Systems
&lt;/h2&gt;

&lt;p&gt;Early on, like many creatives, I managed everything in Google Sheets and Gmail. This lasted approximately four events before I realized I was:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Manually renaming thousands of files&lt;/li&gt;
&lt;li&gt;Re-answering the same questions in every inquiry&lt;/li&gt;
&lt;li&gt;Losing track of which clients had paid&lt;/li&gt;
&lt;li&gt;Searching through emails to remember shoot details&lt;/li&gt;
&lt;li&gt;Re-editing the same presets on every batch of 500+ photos&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is where my developer brain kicked in. I needed to automate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Client Management &amp;amp; Automation
&lt;/h3&gt;

&lt;p&gt;I built a simple intake form that feeds directly into a database. Here's the architecture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Typeform (client inquiry) 
  → Zapier/Make webhook 
    → Custom Node.js service 
      → PostgreSQL 
        → Automated email response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The system:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Validates&lt;/strong&gt; incoming data (date conflicts, venue requirements)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto-generates&lt;/strong&gt; a proposal with pricing based on event type and length&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Creates&lt;/strong&gt; a calendar entry synced to the client&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Triggers&lt;/strong&gt; payment collection 60 days before the event&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sends&lt;/strong&gt; a pre-shoot checklist 2 weeks prior&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This simple automation reduced my email load by approximately 40 hours per month. I could focus on actual photography instead of being a customer service robot.&lt;/p&gt;

&lt;h3&gt;
  
  
  File Organization &amp;amp; Delivery
&lt;/h3&gt;

&lt;p&gt;Early on, I'd manually organize thousands of RAW files, edit them, export them to various formats, and then manually upload to galleries.&lt;/p&gt;

&lt;p&gt;Now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Automated ingestion on shoot day&lt;/span&gt;
./ingest.sh /volumes/external-drive &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--client-id&lt;/span&gt; &lt;span class="nv"&gt;$CLIENT_ID&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--event-date&lt;/span&gt; &lt;span class="nv"&gt;$EVENT_DATE&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--organize-by-camera&lt;/span&gt;

&lt;span class="c"&gt;# Triggers:&lt;/span&gt;
&lt;span class="c"&gt;# 1. Automatic culling (Python ML model, &amp;gt;95% accuracy)&lt;/span&gt;
&lt;span class="c"&gt;# 2. Batch editing (custom Lightroom API scripting)&lt;/span&gt;
&lt;span class="c"&gt;# 3. Gallery generation (Next.js static export)&lt;/span&gt;
&lt;span class="c"&gt;# 4. CDN upload (Cloudflare Workers)&lt;/span&gt;
&lt;span class="c"&gt;# 5. Client notification&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pipeline went from 2-3 hours of manual work per 500-photo event to about 15 minutes of supervision.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scheduling &amp;amp; Conflicts
&lt;/h3&gt;

&lt;p&gt;With 3,000+ events, double-booking is a catastrophic failure mode. I built a conflict detection system:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Simplified example&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;checkAvailability&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;eventDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;venueLocation&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;existingEvents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s2"&gt;`SELECT * FROM bookings 
     WHERE date = $1 
     AND (
       (travel_time_needed($2, existing_venue) &amp;gt; 0) OR 
       (time_overlap($3, duration) = true)
     )`&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;eventDate&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;venueLocation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;startTime&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;existingEvents&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;available&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;conflict&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Combined with a calendar view showing travel time between venues, this eliminated scheduling conflicts almost entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pricing as a Feature, Not an Afterthought
&lt;/h2&gt;

&lt;p&gt;As engineers, we often underprice our work because we underestimate the value of reliability and quality. Photography businesses are the same.&lt;/p&gt;

&lt;p&gt;I spent months analyzing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost per event&lt;/strong&gt; (equipment wear, travel, time)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time per deliverable&lt;/strong&gt; (editing, delivery, revisions)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Market rate&lt;/strong&gt; (what competitors actually charge)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client lifetime value&lt;/strong&gt; (referrals, repeat bookings)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I discovered that my most profitable clients weren't the highest-paying ones—they were the ones with clear requirements, reasonable revision limits, and upfront contracts.&lt;/p&gt;

&lt;p&gt;I built a pricing calculator:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;calculatePrice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;guestCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;location&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;baseRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;eventTypeRates&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;eventType&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="c1"&gt;// e.g., wedding = $3500&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;durationMultiplier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// +15% per hour&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;locationMultiplier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;location&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;remote&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// travel cost&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bulkDiscount&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;guestCount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mf"&gt;0.95&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// slight discount for large events&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;baseRate&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;durationMultiplier&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;locationMultiplier&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;bulkDiscount&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This simple formula ensured pricing consistency and prevented undervaluation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Metrics That Matter
&lt;/h2&gt;

&lt;p&gt;If there's one thing developers are good at, it's measurement. I applied the same rigor to the photography business:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Inquiry-to-booking rate&lt;/strong&gt;: Should be &amp;gt;30%&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revision cycles&lt;/strong&gt;: More than 2 = process failure&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Days to delivery&lt;/strong&gt;: Target &amp;lt;14 days post-event&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client satisfaction&lt;/strong&gt;: NPS score (net promoter score)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Revenue per hour of active work&lt;/strong&gt;: Hourly rate is meaningless; what matters is revenue divided by &lt;em&gt;actual&lt;/em&gt; billable hours&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When I started tracking these, I realized I was spending 60% of my time on non-billable work. After optimizing my systems, that dropped to 20%.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Technical Stack (If You Want It)
&lt;/h2&gt;

&lt;p&gt;In case you're curious, here's a minimal stack for running a creative services business:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Client management&lt;/strong&gt;: Custom Node.js backend (or Airtable if you want no-code)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scheduling&lt;/strong&gt;: Google Calendar API with custom conflict detection&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;File management&lt;/strong&gt;: S3 with automated tagging and organization&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gallery delivery&lt;/strong&gt;: Static Next.js site generated per event&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payments&lt;/strong&gt;: Stripe (with automation for deposits and final payments)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Analytics&lt;/strong&gt;: Posthog for client behavior, custom dashboards for business metrics&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'm the founder of &lt;strong&gt;Candid Studios&lt;/strong&gt; (candidstudios.net), where we apply these same principles to manage photography operations at scale. The platform handles everything I just described, purpose-built for creative service businesses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;p&gt;Here's what I'd tell any developer considering a creative business:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Systems matter more than talent.&lt;/strong&gt; Two photographers of equal skill will differ wildly in profitability based on operational efficiency.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Automate the repetitive.&lt;/strong&gt; If you're doing manual data entry, file organization, or email templating, you're leaving money on the table. Build or buy tools to handle it.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Measure everything.&lt;/strong&gt; You can't improve what you don't measure. Track inquiry-to-booking rates, delivery times, and profitability per client.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Pricing is a feature.&lt;/strong&gt; Take time to understand your costs and value. Don't guess.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Your technical skills are a competitive advantage.&lt;/strong&gt; Most creatives can't build their own systems. Use that.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Process scales, talent doesn't.&lt;/strong&gt; A single photographer (you) has a ceiling. Systems allow you to eventually hire other photographers and scale without losing quality.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The best part? Building a creative business taught me more about product thinking, customer psychology, and operations than any tech job ever did. And my ability to code made the whole thing possible.&lt;/p&gt;

&lt;p&gt;If you're a developer with creative interests, stop thinking of them as separate. They're complementary. Build systems around your craft, and you'll be surprised how far you can go.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;I'm the founder of Candid Studios (candidstudios.net), where we've applied these lessons to help creative service businesses automate and scale.&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>photography</category>
      <category>business</category>
      <category>startup</category>
      <category>marketing</category>
    </item>
  </channel>
</rss>
