<?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: Serhii Lyzun</title>
    <description>The latest articles on DEV Community by Serhii Lyzun (@serhii_lyzun).</description>
    <link>https://dev.to/serhii_lyzun</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%2F4191639%2F7beb8f6b-9d93-4e67-bd8d-5dc179a08872.jpeg</url>
      <title>DEV Community: Serhii Lyzun</title>
      <link>https://dev.to/serhii_lyzun</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/serhii_lyzun"/>
    <language>en</language>
    <item>
      <title>I built a $1 face search with Next.js 16, and my free demo mode was quietly costing me money</title>
      <dc:creator>Serhii Lyzun</dc:creator>
      <pubDate>Wed, 21 Oct 2026 07:03:55 +0000</pubDate>
      <link>https://dev.to/serhii_lyzun/i-built-a-1-face-search-with-nextjs-16-and-my-free-demo-mode-was-quietly-costing-me-money-n75</link>
      <guid>https://dev.to/serhii_lyzun/i-built-a-1-face-search-with-nextjs-16-and-my-free-demo-mode-was-quietly-costing-me-money-n75</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwsqw7efum9njmw176495.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwsqw7efum9njmw176495.png" alt=" " width="800" height="442"&gt;&lt;/a&gt;&lt;br&gt;
Hi DEV 👋 I'm Serhii, and this is my first post here.&lt;/p&gt;

&lt;p&gt;For the last six months I've been building &lt;strong&gt;FaceSift&lt;/strong&gt;, a small web app that does reverse face search: you upload a photo of a face and it shows where that face appears online. People mostly use it to check a dating match, to find out whether someone is using their photos, or to look up a profile that seems fake.&lt;/p&gt;

&lt;p&gt;I don't run my own face recognition. FaceSift wraps the &lt;a href="https://facecheck.id" rel="noopener noreferrer"&gt;FaceCheck.ID&lt;/a&gt; API. What I built is everything around it: upload, consent, payment, result masking, and a lot of edge cases I didn't expect.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js 16&lt;/strong&gt; (App Router, Turbopack), TypeScript, Tailwind v4&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zod v4&lt;/strong&gt; to validate the vendor's API responses&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MySQL&lt;/strong&gt; on Aiven for orders and search state&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NOWPayments&lt;/strong&gt; for a $1 crypto payment per search&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;next-intl&lt;/code&gt; for 5 languages, &lt;code&gt;heic2any&lt;/code&gt; for iPhone photos&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I want to share three lessons from it. None of them is about face recognition. They're all about wrapping a paid third-party API, which a lot of us end up doing.&lt;/p&gt;
&lt;h2&gt;
  
  
  1. My "free" demo mode was spending real credits
&lt;/h2&gt;

&lt;p&gt;FaceSift has a &lt;strong&gt;Demo / Live&lt;/strong&gt; toggle. Demo is free and lets people see the flow before paying. The FaceCheck API has a &lt;code&gt;demo: true&lt;/code&gt; flag, and the docs present it as a testing mode, so I used it for the free tier.&lt;/p&gt;

&lt;p&gt;Then I checked the credit balance against my logs. Demo searches were spending the same 3 credits as live ones. Every visitor who tried the free mode was costing me money.&lt;/p&gt;

&lt;p&gt;The fix was to stop calling the vendor in demo mode at all. The route returns a canned response after an artificial delay, so the UX still feels like a real search:&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;// app/api/search/route.ts&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DEMO_SEARCH_DELAY_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3000&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;requestedDemo&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="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&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="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;DEMO_SEARCH_DELAY_MS&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;demoSearchResult&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// static JSON mock&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The upload route does the same thing. Demo mode returns a random UUID as the search id and never uploads the photo anywhere. A nice side effect is that demo users' photos now never leave my server, which is better for privacy too.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; don't trust a "test mode" flag on a paid API until you've compared your logs with the actual bill. I now log the remaining credit balance next to every live search result, so any drift shows up right away.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Fail closed on anything that costs money
&lt;/h2&gt;

&lt;p&gt;My first version decided "demo or live" like this: if the client sends &lt;code&gt;mode: "demo"&lt;/code&gt;, use the demo path; otherwise use the live one. That's backwards. A missing field, a typo, or a hand-crafted request would end up on the path that spends money.&lt;/p&gt;

&lt;p&gt;Now only an explicit &lt;code&gt;"live"&lt;/code&gt; counts as live:&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;// Only an explicit "live" is treated as a credit-consuming request.&lt;/span&gt;
&lt;span class="c1"&gt;// Anything missing or unrecognized falls through to the free demo path.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;requestedDemo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;mode&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;live&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second half of the same mistake: I originally checked payment only when deciding whether to &lt;strong&gt;mask the result URLs&lt;/strong&gt;. By that point the vendor call had already happened. Anyone who replayed a request with &lt;code&gt;mode: "live"&lt;/code&gt; could spend my credits and only get masked results back. The attacker gets nothing useful, but I still pay.&lt;/p&gt;

&lt;p&gt;The payment check now runs before the vendor is ever called:&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;row&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;orderId&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;getSearchByOrderId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getSearchByIdSearch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idSearch&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="nf"&gt;getIsPaidStatus&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;payment_status&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Payment not confirmed&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;402&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;data&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;searchOnce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;idSearch&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// only reached when paid&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Yes, HTTP &lt;strong&gt;402 Payment Required&lt;/strong&gt; is a real status code, and I finally had a reason to use it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. "One payment = one search" with a single UPDATE
&lt;/h2&gt;

&lt;p&gt;Every $1 payment buys exactly one search. Users refresh, open two tabs, hit back after paying, and the payment webhook can arrive before or after they return from the checkout page. I didn't want a queue or a distributed lock for a $1 product.&lt;/p&gt;

&lt;p&gt;It turned out one conditional &lt;code&gt;UPDATE&lt;/code&gt; covers it, because MySQL applies it atomically to the row:&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;result&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;mySqlPool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;execute&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;ResultSetHeader&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="s2"&gt;`UPDATE searches
     SET id_search = ?, payment_status = 'finished'
   WHERE order_id = ?
     AND id_search IS NULL
     AND payment_status IN (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;PAID&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;?&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&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;idSearch&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;PAID&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;affectedRows&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;linked&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;alreadyLinked&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If two requests race, only one of them gets &lt;code&gt;affectedRows === 1&lt;/code&gt;. The other sees &lt;code&gt;0&lt;/code&gt; and is told the credit was already used. The &lt;code&gt;payment_status IN (...)&lt;/code&gt; condition also means a partially paid or expired order can never be linked by mistake, whichever code path gets there.&lt;/p&gt;

&lt;p&gt;I use the same pattern to mark a search as &lt;code&gt;in_progress&lt;/code&gt;. That update also reclaims searches stuck &lt;code&gt;in_progress&lt;/code&gt; for more than a few minutes, so a crashed request doesn't burn someone's dollar forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  A note on ethics
&lt;/h2&gt;

&lt;p&gt;A face search tool can be misused, and I think about that a lot. FaceSift shows a consent screen before any upload. Users have to confirm they're 18+, that they won't use the results to harass, stalk or harm anyone, and that face search is legal where they live. Demo mode never sends the photo to the vendor at all. I'd really like to hear how others building in sensitive areas approach this, because I don't think I've got it all figured out.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Next I want to write about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;sending GA4 events from a &lt;strong&gt;payment webhook&lt;/strong&gt; without losing the buyer's session, using the Measurement Protocol and &lt;code&gt;after()&lt;/code&gt; from &lt;code&gt;next/server&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;fixing Core Web Vitals when a &lt;strong&gt;third-party web component&lt;/strong&gt; was my LCP element&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're curious, the app is at &lt;a href="https://facesift.com" rel="noopener noreferrer"&gt;facesift.com&lt;/a&gt;. Demo mode is free and, as you now know, really doesn't cost anything 😄&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Question for you:&lt;/strong&gt; have you ever been surprised by how a third-party API billed you? I'd like to hear your stories, and any tips for catching it earlier than I did.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>nextjs</category>
      <category>typescript</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
