<?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: Akın Coşkun</title>
    <description>The latest articles on DEV Community by Akın Coşkun (@akincskn).</description>
    <link>https://dev.to/akincskn</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%2F3819862%2Fa28d11d8-e320-484d-9c50-b1d94016f2c0.jpeg</url>
      <title>DEV Community: Akın Coşkun</title>
      <link>https://dev.to/akincskn</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/akincskn"/>
    <language>en</language>
    <item>
      <title>Six Weeks of Writing About Solo-Built SaaS Tools: What Actually Got Read</title>
      <dc:creator>Akın Coşkun</dc:creator>
      <pubDate>Tue, 27 Oct 2026 06:00:00 +0000</pubDate>
      <link>https://dev.to/akincskn/six-weeks-of-writing-about-solo-built-saas-tools-what-actually-got-read-1gnm</link>
      <guid>https://dev.to/akincskn/six-weeks-of-writing-about-solo-built-saas-tools-what-actually-got-read-1gnm</guid>
      <description>&lt;p&gt;TL;DR: Over September and October I wrote about the architecture decisions behind five small, mostly-free tools I've built — OpsFlow, Araç Sağlık Karnesi, Cerezmatik, LeadPilot, and FormJet. Same writer, same rough format, pretty different results. Here's what I noticed about which posts actually landed and why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The format I stuck with
&lt;/h2&gt;

&lt;p&gt;Every post followed the same shape on purpose: a specific technical decision, the simpler alternative I considered and rejected, the trade-off I accepted, and a code snippet that's actually from the thing I built rather than a generic example. No "10 tips" posts, no top-of-funnel "what is a webhook" explainers — just the decision itself, argued honestly, including the part where the decision has a real cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do again
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Naming the alternative I didn't pick did more work than describing what I did pick.&lt;/strong&gt; The Kafka-vs-Redis post and the FormJet schema post both got more engagement in the "why not the other thing" section than in the description of the actual implementation. People reading dev-focused platforms already assume you can build the thing; what they're actually curious about is the judgment call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Admitting the trade-off explicitly, instead of only in a closing paragraph, changed how comments read.&lt;/strong&gt; Posts where I stated the limitation as its own section — "the honest trade-off" — got fewer comments pointing out the limitation as if it were a flaw I'd missed, and more comments engaging with whether they'd have made the same call. That's a better conversation to be having.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd change
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Cross-posting identically to Medium and dev.to undersells both.&lt;/strong&gt; Dev.to's audience wanted more code, faster; a couple of comments on Medium were clearly from a less technical reader who wanted more context on why the product exists before the architecture discussion started. Next round, I'd keep the technical spine of each post shared but write a different opening 2-3 paragraphs per platform.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A locally-specific post (KVKK, not GDPR) outperformed my expectations for a narrow topic.&lt;/strong&gt; I almost skipped writing about Cerezmatik because "cookie consent" felt like a boring, saturated topic. The angle that made it work wasn't the tool, it was the specific mismatch (GDPR-shaped tools misapplied to a different legal regime) — that's a lesson I should apply more deliberately: the interesting post is rarely "I built X," it's "here's a mismatch nobody else pointed out."&lt;/p&gt;

&lt;h2&gt;
  
  
  The one number worth sharing
&lt;/h2&gt;

&lt;p&gt;None of these five tools got meaningfully more signups from the writing directly — that was never really the mechanism I expected. What it did do consistently: gave me something concrete to link to instead of a bare GitHub repo when a potential user asked "how does this actually work," which is a much better answer than "trust me."&lt;/p&gt;

&lt;p&gt;If you're building something small and solo and deciding whether writing about it is worth the time, my answer after six weeks is: not for the traffic, but for having a real answer ready the next time someone asks how it works.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>saas</category>
      <category>writing</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>I Built a KVKK Cookie Consent Tool Because Every "GDPR Banner" Library Assumes You're in the EU</title>
      <dc:creator>Akın Coşkun</dc:creator>
      <pubDate>Tue, 20 Oct 2026 06:00:00 +0000</pubDate>
      <link>https://dev.to/akincskn/i-built-a-kvkk-cookie-consent-tool-because-every-gdpr-banner-library-assumes-youre-in-the-eu-21k6</link>
      <guid>https://dev.to/akincskn/i-built-a-kvkk-cookie-consent-tool-because-every-gdpr-banner-library-assumes-youre-in-the-eu-21k6</guid>
      <description>&lt;p&gt;TL;DR: Turkish data protection law (KVKK) isn't GDPR — the required disclosures, the retention-period language, and the regulator are all different — but almost every off-the-shelf cookie consent widget is written for GDPR and just translated into Turkish, which isn't the same as being compliant with KVKK. Cerezmatik is a small, self-hosted consent banner that's actually built around the Turkish requirements. Here's what's different.&lt;/p&gt;

&lt;h2&gt;
  
  
  What GDPR widgets get wrong for a Turkish site
&lt;/h2&gt;

&lt;p&gt;The usual pattern — a banner with "Accept All / Reject All / Manage Preferences" and a cookie category list — is a GDPR pattern. KVKK has its own structure: sites need an "Aydınlatma Metni" (a specific disclosure text about the data controller, purpose of processing, and legal basis) that's a distinct legal document from a generic privacy policy, and the consent mechanism needs to reference it specifically, not a generically translated "privacy policy" link.&lt;/p&gt;

&lt;p&gt;Most libraries I evaluated either hardcoded GDPR-style category names (translated but structurally GDPR) or expected you to write your own text in a config field with no guidance on what KVKK actually requires that text to say. Neither is wrong for a company operating under GDPR, but both leave a Turkish site with a banner that looks compliant without doing the specific thing KVKK asks for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core design decision
&lt;/h2&gt;

&lt;p&gt;Cerezmatik treats the "Aydınlatma Metni" as a first-class, versioned document rather than a static link:&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;DisclosureVersion&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;content&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;publishedAt&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;dataController&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;processingPurposes&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;legalBasis&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;// KVKK madde 5/6 reference&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every time a visitor gives consent, the record stores which disclosure version they consented to, not just a boolean "consented: true". If the site updates its data processing purposes six months later, existing consents don't silently cover the new purposes — the banner re-prompts, because the version they agreed to no longer matches the current one.&lt;/p&gt;

&lt;p&gt;That versioning is the single feature that took the tool from "a banner" to "something that could actually hold up if the Personal Data Protection Authority (KVKK Kurumu) asked how consent was collected."&lt;/p&gt;

&lt;h2&gt;
  
  
  What it looks like on a site
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://cdn.cerezmatik.app/embed.js"&lt;/span&gt; &lt;span class="na"&gt;data-site-id=&lt;/span&gt;&lt;span class="s"&gt;"xxx"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One script tag, no cookie set until the visitor makes a choice, and the banner blocks non-essential scripts (analytics, ad pixels) from firing until consent is given — which is itself a KVKK requirement that a lot of "consent" banners get wrong by loading every tracker before the visitor has clicked anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-off
&lt;/h2&gt;

&lt;p&gt;I'm not a lawyer, and Cerezmatik doesn't make anyone KVKK-compliant by itself — that's a legal determination that depends on what a specific site actually does with data, not just how its banner looks. What it does is remove the most common structural mismatch I kept seeing: sites that copied a GDPR banner pattern and assumed "translated" meant "compliant."&lt;/p&gt;

&lt;p&gt;If your product serves any Turkish users and collects analytics or ad-tracking cookies, it's worth checking whether your current banner references an actual Aydınlatma Metni or just a generic privacy policy — that gap is easy to miss and common enough that I built a whole tool around closing it.&lt;/p&gt;

&lt;p&gt;Cerezmatik is free to try — link's in my profile.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>typescript</category>
      <category>privacy</category>
    </item>
    <item>
      <title>What Actually Gets Cold Outreach Emails Marked as Spam (It's Not What I Thought)</title>
      <dc:creator>Akın Coşkun</dc:creator>
      <pubDate>Tue, 13 Oct 2026 06:00:00 +0000</pubDate>
      <link>https://dev.to/akincskn/what-actually-gets-cold-outreach-emails-marked-as-spam-its-not-what-i-thought-oa5</link>
      <guid>https://dev.to/akincskn/what-actually-gets-cold-outreach-emails-marked-as-spam-its-not-what-i-thought-oa5</guid>
      <description>&lt;p&gt;TL;DR: I run LeadPilot, a tool that finds leads and sends personalized cold emails on autopilot. The thing that actually tanked deliverability wasn't spammy-sounding copy — it was sending infrastructure decisions I made in week one and didn't revisit until open rates quietly collapsed. Here's what I got wrong and what fixed it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The assumption I started with
&lt;/h2&gt;

&lt;p&gt;Like most people building an outbound tool, I assumed spam filtering was mostly about content: avoid words like "free," "guarantee," "act now," keep the copy conversational, don't overuse links. I optimized for that. Open rates were fine for about three weeks, then dropped by more than half over a single week with no copy changes on my end.&lt;/p&gt;

&lt;p&gt;The content wasn't the problem. The sending pattern was.&lt;/p&gt;

&lt;h2&gt;
  
  
  What was actually happening
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Shared IP reputation, inherited from strangers.&lt;/strong&gt; I was sending through a generic transactional email provider's shared IP pool. Every other sender on that pool who got flagged for spam dragged my deliverability down with them, invisibly — I had no way to see it happening from my side, only the symptom (dropping opens).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No warm-up curve.&lt;/strong&gt; I went from zero sending volume to a few hundred emails a day immediately once a customer connected their account. Mailbox providers use sending-volume ramp as a trust signal; a brand-new sending identity that jumps straight to volume looks like exactly what a spammer does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One domain doing everything.&lt;/strong&gt; Marketing site, product emails, and cold outbound were all sending from the same domain. A spam complaint on a cold email hurts the reputation of transactional emails from that same domain too — password resets, notifications, all of it shares the blast radius.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually fixed it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// simplified sending-domain strategy&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;domains&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;marketing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;company.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;product&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;app.company.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;outbound&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;outreach.company-mail.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// separate root domain&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Isolating cold outbound onto its own domain, with its own SPF/DKIM/DMARC records, meant a deliverability problem there couldn't touch product emails. Then I built a warm-up scheduler: new sending identities start at 20 emails/day and increase by roughly 20% daily only if reply and bounce rates stay within healthy ranges, capping out after about three weeks.&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;getDailySendCap&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SendingIdentity&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="kr"&gt;number&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;daysSinceStart&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;daysBetween&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bounceRate&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;getBounceRate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;identity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;days&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;7&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;bounceRate&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.03&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;identity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentCap&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// freeze ramp, don't increase&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rampedCap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;daysSinceStart&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;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rampedCap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Freezing the ramp — not just slowing it — the moment bounce rate crosses 3% turned out to matter more than the ramp curve itself. A sending identity that keeps climbing while its bounce rate creeps up is the fastest way back into a shared blocklist.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-off
&lt;/h2&gt;

&lt;p&gt;None of this makes outreach fast. A new customer's sending identity spends its first three weeks capped well below what they'd like to send, and I've had to explain "why can't it just send more" more than once. But the alternative — no ramp — is what caused the collapse in the first place, so it's a trade I'm not willing to undo for anyone's first month.&lt;/p&gt;

&lt;p&gt;If your outbound tool's opens are dropping and the copy hasn't changed, check the sending infrastructure before you touch a single word of the email.&lt;/p&gt;

&lt;p&gt;LeadPilot is free while it's early — link's in my profile.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>ai</category>
      <category>email</category>
      <category>saas</category>
    </item>
    <item>
      <title>Why My Form Backend Doesn't Store Your Form Schema</title>
      <dc:creator>Akın Coşkun</dc:creator>
      <pubDate>Tue, 06 Oct 2026 06:00:00 +0000</pubDate>
      <link>https://dev.to/akincskn/why-my-form-backend-doesnt-store-your-form-schema-1ikm</link>
      <guid>https://dev.to/akincskn/why-my-form-backend-doesnt-store-your-form-schema-1ikm</guid>
      <description>&lt;p&gt;TL;DR: FormJet takes form submissions from any HTML form — no JS framework required, just a &lt;code&gt;&amp;lt;form action="https://formjet.app/f/xxx" method="POST"&amp;gt;&lt;/code&gt; — and turns them into webhooks, emails, or a spreadsheet row. The one decision that shaped everything else: I don't ask you to define your form's fields ahead of time. Here's why that's harder than it sounds and why I still think it's right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The typical form-backend model
&lt;/h2&gt;

&lt;p&gt;Most form-as-a-service products want you to define a schema first: field names, types, validation rules, all configured in a dashboard before your form can submit anything. That's a reasonable design if the product's job is validation. It's a bad fit if the product's job is "get out of the way."&lt;/p&gt;

&lt;p&gt;I wanted someone to be able to write a plain HTML form with whatever &lt;code&gt;name&lt;/code&gt; attributes make sense to them, point the &lt;code&gt;action&lt;/code&gt; at FormJet, and have it just work — including if they add a field next month without touching any dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "schema-less" actually means in the code
&lt;/h2&gt;

&lt;p&gt;Every submission is stored as-is, keyed by whatever field names the form used:&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;handleSubmission&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formId&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;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="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;submission&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;formId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;fields&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="c1"&gt;// no validation against a predefined schema&lt;/span&gt;
    &lt;span class="na"&gt;receivedAt&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;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;submissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;submission&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;dispatchWebhooks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;submission&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 webhook payload mirrors whatever was submitted, field-for-field. If someone's HTML form has &lt;code&gt;name="email"&lt;/code&gt; this week and they rename it to &lt;code&gt;name="contact_email"&lt;/code&gt; next month, FormJet doesn't reject the change or need reconfiguration — it just reflects whatever showed up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost of not having a schema
&lt;/h2&gt;

&lt;p&gt;This is where I have to be honest about the trade-off, because it's real: no schema means no server-side validation. If a form is missing its &lt;code&gt;email&lt;/code&gt; field entirely because of a typo in the HTML, FormJet stores a submission without an email field — silently, from FormJet's point of view. It doesn't know that field was supposed to exist.&lt;/p&gt;

&lt;p&gt;I handled this with an opt-in layer rather than making it mandatory: users can define expected fields after the fact, purely for their own alerting (get notified if 10 submissions in a row are missing a field you usually see), without that layer ever blocking a submission from being accepted. Validation became an observability feature, not a gatekeeper.&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;checkFieldDrift&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formId&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="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;recentSubmissions&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="nx"&gt;submissions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&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;expectedFields&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="nx"&gt;expectedFields&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="nx"&gt;formId&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;missingRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calculateMissingRate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;recentSubmissions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expectedFields&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;missingRate&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.5&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;notifyOwner&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;formId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expectedFields&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;h2&gt;
  
  
  Why I'd make the same call again
&lt;/h2&gt;

&lt;p&gt;The alternative — required schema, strict validation — optimizes for data quality at the cost of the thing that makes a form backend actually convenient: you can change your HTML without going anywhere else to update a config. For a solo developer or small team wiring up a contact form or a waitlist signup, that convenience is the entire pitch. The moment FormJet starts rejecting submissions because a field doesn't match a schema, it's stopped being "drop this in and forget about it" and started being one more system to configure.&lt;/p&gt;

&lt;p&gt;If you're building a developer tool and torn between strict-by-default and permissive-by-default, it's worth asking which failure mode your users would rather debug: a silently missing field, or a submission that got rejected and the person on the other end never knew.&lt;/p&gt;

&lt;p&gt;FormJet is free for low volume — link's in my profile.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>typescript</category>
      <category>api</category>
    </item>
    <item>
      <title>The 8-Digit Code That Replaced Login Forms in My Vehicle Service History App</title>
      <dc:creator>Akın Coşkun</dc:creator>
      <pubDate>Tue, 29 Sep 2026 06:00:00 +0000</pubDate>
      <link>https://dev.to/akincskn/the-8-digit-code-that-replaced-login-forms-in-my-vehicle-service-history-app-3dpk</link>
      <guid>https://dev.to/akincskn/the-8-digit-code-that-replaced-login-forms-in-my-vehicle-service-history-app-3dpk</guid>
      <description>&lt;p&gt;TL;DR: Araç Sağlık Karnesi (Vehicle Health Record) lets a repair shop log every service they do on a car, and lets the car's owner — or the next mechanic who touches it — pull up the full history with nothing but an 8-character code. No account, no password, no app install. Here's the access-control model behind that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with "just make an account"
&lt;/h2&gt;

&lt;p&gt;Every vehicle history tool I looked at before building this one starts with a sign-up flow. That's fine for the shop, which is a repeat user. It's a terrible match for the customer, who interacts with the system maybe four times a year and will not remember a password for an app they open twice.&lt;/p&gt;

&lt;p&gt;The actual requirement, once I wrote it down, wasn't "authenticate the user." It was "let the right person see the right car's history, and nothing else." Those aren't the same problem, and treating them as the same problem is why so many of these tools end up with a login screen nobody wanted.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the code actually is
&lt;/h2&gt;

&lt;p&gt;Every vehicle gets one row in a &lt;code&gt;vehicles&lt;/code&gt; table and one generated code — 8 characters, uppercase alphanumeric, excluding visually ambiguous characters like &lt;code&gt;0&lt;/code&gt;/&lt;code&gt;O&lt;/code&gt; and &lt;code&gt;1&lt;/code&gt;/&lt;code&gt;I&lt;/code&gt;. The code is the effective primary key for read access: anyone who has it can look up that vehicle's service timeline, and the shop can hand it to the customer printed on a receipt or texted after service.&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;generateVehicleCode&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;alphabet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ABCDEFGHJKMNPQRSTUVWXYZ23456789&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// no 0/O/1/I&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;i&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;alphabet&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;alphabet&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="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With a 32-character alphabet and 8 slots, that's roughly 1 trillion combinations — enough that brute-forcing a specific code isn't practical, especially with basic rate limiting on the lookup endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I drew the security line
&lt;/h2&gt;

&lt;p&gt;I want to be upfront about the trade-off here, because it's a real one: this is a bearer-token model, not authentication. If you know the code, you can see the history. That's an acceptable trade for service records that are, honestly, low-sensitivity — mileage, what was replaced, when. It would be the wrong model for anything financial or medical.&lt;/p&gt;

&lt;p&gt;Two decisions kept it from being naive:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read vs. write are separate permissions entirely.&lt;/strong&gt; The customer-facing code is read-only. Adding a new service record requires a shop-level login tied to that shop's account — the vehicle code never grants write access, so a customer sharing their code with a new mechanic can't accidentally (or maliciously) let that mechanic edit history from a different shop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Codes are per-vehicle, not per-customer.&lt;/strong&gt; If a car changes hands, the history — and the code — travels with it. That was actually a feature request from an early user: used-car buyers wanted a way to verify service history before purchase, and a code that outlives the original owner is exactly what that needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest limitation
&lt;/h2&gt;

&lt;p&gt;There's no revocation story yet. If a code leaks, the only fix today is regenerating it, which breaks any receipt or link that already has the old one printed on it. For the current scale — a handful of shops, low-stakes data — I've decided that's acceptable. It's the first thing I'd build if a shop asked for it.&lt;/p&gt;

&lt;p&gt;If you're building something similar and the users on one side of your product will never want an account, it's worth asking whether the "modern" answer (OAuth, magic links, passkeys) is actually solving their problem, or just the one your framework nudges you toward by default.&lt;/p&gt;

&lt;p&gt;Araç Sağlık Karnesi is free for repair shops — link's in my profile if you want to see it.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>saas</category>
      <category>typescript</category>
      <category>postgres</category>
    </item>
    <item>
      <title>I Built a RAG Chatbot Platform With Java Spring Boot and Next.js</title>
      <dc:creator>Akın Coşkun</dc:creator>
      <pubDate>Fri, 25 Sep 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/akincskn/i-built-a-rag-chatbot-platform-with-java-spring-boot-and-nextjs-10ll</link>
      <guid>https://dev.to/akincskn/i-built-a-rag-chatbot-platform-with-java-spring-boot-and-nextjs-10ll</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I built an AI Chatbot Platform: a SaaS where you upload PDFs, URLs, or plain text, and it turns that content into a chatbot that only answers from what you gave it. Under the hood it's a RAG (Retrieval Augmented Generation) pipeline: pgvector with an HNSW index for similarity search, and a two-provider AI setup that tries Groq's Llama 3.3 first and falls back to Google Gemini. The backend is Java Spring Boot, the frontend is Next.js, and the resulting chatbots embed into any website with one line of code.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually does
&lt;/h2&gt;

&lt;p&gt;A user uploads documents (PDF, URL, or raw text), the platform chunks and embeds them, and stores the vectors in Postgres via pgvector. When a visitor asks the chatbot a question, the platform retrieves the most relevant chunks by vector similarity and feeds them to an LLM as context, so the answer is grounded in the uploaded material instead of the model's general training data. The finished chatbot can be embedded into any website with a single script tag.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why pgvector with an HNSW index
&lt;/h2&gt;

&lt;p&gt;Storing embeddings in Postgres (via the pgvector extension) instead of standing up a dedicated vector database keeps the stack to one database for both relational data (users, chatbots, documents) and vector data. HNSW (Hierarchical Navigable Small World) is the index type that makes similarity search fast at scale: it builds a layered graph of embeddings so a query doesn't have to compare against every stored vector, trading a small amount of recall for a large speedup. For a platform where every chatbot response depends on a fast retrieval step, that trade-off is the right one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why two AI providers, not one
&lt;/h2&gt;

&lt;p&gt;The platform calls Groq's Llama 3.3 first because Groq's inference is fast and cheap, which matters when every chatbot reply requires a live generation call. But relying on a single provider means a rate limit or an outage on their end becomes an outage for every chatbot built on the platform. So there's a fallback path to Google Gemini: if the primary call fails or gets rate-limited, the request retries against Gemini instead of just failing. It's a small amount of extra complexity in exchange for not going down when one vendor has a bad day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Java Spring Boot for the API, Next.js for the frontend
&lt;/h2&gt;

&lt;p&gt;Most of my other projects are Node/Next.js end to end, so splitting to a Java Spring Boot backend here was a deliberate choice, not a default. Spring Security handles JWT-based auth, and Spring Boot's ecosystem made it straightforward to wire up the document ingestion pipeline (chunking, embedding calls, pgvector writes) as a set of clearly separated services rather than a pile of API route handlers. The frontend stays in Next.js 14 with TypeScript and shadcn/ui, talking to the Spring Boot API over REST. Keeping the boundary explicit (one API, one frontend, deployed separately on Render and Vercel) made it easy to reason about what each layer is responsible for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;p&gt;Java 21, Spring Boot 3.x, Spring Security (JWT), PostgreSQL with pgvector, Next.js 14, TypeScript, shadcn/ui, Groq (Llama 3.3), Google Gemini, HuggingFace, Render, Vercel, Neon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The AI Chatbot Platform is live at chatbot-web-peach.vercel.app, frontend source on GitHub: akincskn/chatbot-web, backend API: akincskn/chatbot-api.&lt;/p&gt;

&lt;p&gt;I'm Akin Coskun, a full-stack developer from Turkey building production SaaS tools with zero-cost infrastructure. More projects on my portfolio: akin-coskun.web.app.&lt;/p&gt;

</description>
      <category>rag</category>
      <category>java</category>
      <category>springboot</category>
      <category>ai</category>
    </item>
    <item>
      <title>Why I Chose Kafka Over a Simple Job Queue for a Solo-Built Incident Management Tool</title>
      <dc:creator>Akın Coşkun</dc:creator>
      <pubDate>Tue, 22 Sep 2026 14:08:32 +0000</pubDate>
      <link>https://dev.to/akincskn/why-i-chose-kafka-over-a-simple-job-queue-for-a-solo-built-incident-management-tool-4dpl</link>
      <guid>https://dev.to/akincskn/why-i-chose-kafka-over-a-simple-job-queue-for-a-solo-built-incident-management-tool-4dpl</guid>
      <description>&lt;p&gt;TL;DR: I built OpsFlow, a B2B incident management tool, and the hardest decision wasn't the UI or the on-call rotation logic — it was picking Kafka over a much simpler Redis-backed job queue for the alert pipeline. Here's why the "boring, simple" choice would have actually cost me more.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;OpsFlow needs to take an incoming alert (from a monitoring tool, a webhook, or a manual page) and fan it out to multiple things at once: notify the right on-call engineer, update a status page, log a timeline event, and potentially trigger an escalation policy if nobody acks in time. Each of these is a separate concern, they fail independently, and they need to happen reliably even if one consumer is temporarily down.&lt;/p&gt;

&lt;p&gt;My first instinct, since I was building this solo with zero infrastructure budget, was to skip Kafka entirely. A Redis list with BLPOP and a couple of worker processes would have gotten me 80% of the way there in an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I didn't stop at Redis
&lt;/h2&gt;

&lt;p&gt;Two things changed my mind once I actually mapped out the failure modes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Replay matters for incidents.&lt;/strong&gt; If the notification service crashes for three minutes, I don't want those alerts gone — I want every consumer to pick up exactly where it left off once it's back. A Redis list is destructive on pop; once a worker reads a message, it's gone if that worker dies mid-processing. Kafka's consumer groups with offset tracking meant I could restart a crashed consumer and it would resume from the last committed offset, no lost alerts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multiple independent consumers, one event.&lt;/strong&gt; Notification, status page, timeline, and escalation are four different services that all need to see the same alert. With Redis I'd have needed to either duplicate the message four times at publish time (fragile — what if I add a fifth consumer later?) or build a pub/sub fan-out myself. Kafka topics with multiple consumer groups gave me that for free — each service reads the same topic independently, at its own pace.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually looks like
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// producer: alert-ingest service&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;kafka&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;producer&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&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;incidents.alerts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;incident&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;serviceId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;value&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;incidentId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;incident&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;severity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;incident&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;receivedAt&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="nf"&gt;toISOString&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="c1"&gt;// consumer: notification-service, its own consumer group&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;kafka&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;consumer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;groupId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;notification-service&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;eachMessage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;message&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;alert&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;notifyOnCall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alert&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;Keying by &lt;code&gt;serviceId&lt;/code&gt; matters more than it looks — it guarantees alerts for the same service land on the same partition, so escalation logic that needs to see events in order for one service never gets them out of sequence, even though alerts for different services process fully in parallel.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest trade-off
&lt;/h2&gt;

&lt;p&gt;Running Kafka solo isn't free. I run it as a single-broker instance, which means I gave up the durability guarantees a real cluster gives you — if that one broker dies, I lose the ability to produce or consume until it's back. For a tool that's currently serving a handful of early customers, that's an acceptable trade for now. It's the kind of decision I'll revisit the day OpsFlow has enough traffic that "single broker" stops being funny.&lt;/p&gt;

&lt;p&gt;If you're building something similar and trying to decide between "the simple thing" and "the correct thing," my rule of thumb ended up being: if losing a message silently is something you'd have to explain to a customer during an incident, don't use a destructive queue.&lt;/p&gt;

&lt;p&gt;OpsFlow is free while it's early — if you're running on-call for a small team and curious, the link's in my profile.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>saas</category>
      <category>typescript</category>
      <category>kafka</category>
    </item>
    <item>
      <title>Why I Used Kafka for a Small Incident Management Tool</title>
      <dc:creator>Akın Coşkun</dc:creator>
      <pubDate>Fri, 18 Sep 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/akincskn/why-i-used-kafka-for-a-small-incident-management-tool-2683</link>
      <guid>https://dev.to/akincskn/why-i-used-kafka-for-a-small-incident-management-tool-2683</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I built OpsFlow, an incident management SaaS for small ops teams: log incidents, assign severity, track SLA deadlines, and notify the right people in real time. Under the hood it's a microservice setup (API, frontend, and a separate notification service) that talks over Kafka. For a tool with a modest number of daily incidents, that's arguably more infrastructure than the traffic justifies, and that trade-off is worth being honest about.&lt;/p&gt;

&lt;h2&gt;
  
  
  What OpsFlow actually does
&lt;/h2&gt;

&lt;p&gt;OpsFlow lets a team log an incident, assign a severity level, and track it against an SLA deadline. Every organization is multi-tenant isolated, every user has a role (RBAC) that controls what they can see and act on, and every state change (acknowledged, escalated, resolved) needs to reach the right people immediately, not on the next page refresh.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a separate notification service
&lt;/h2&gt;

&lt;p&gt;The part that pushed OpsFlow toward a microservice split wasn't the incident CRUD (that's a normal REST API), it was notifications. Every incident state change can fan out to multiple channels and multiple people, and that work is bursty and shouldn't block the request that created the incident. So the API publishes an event to Kafka when something changes, and a dedicated notification service consumes those events and handles delivery. The incident API stays fast and simple; the notification service can retry, batch, or slow down without the person filing the incident ever noticing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Was Kafka overkill?
&lt;/h2&gt;

&lt;p&gt;Honestly, for OpsFlow's actual traffic, a Redis-backed job queue would have handled the load with a fraction of the operational surface. Kafka's real value here isn't throughput, it's the durable log: notification delivery failures don't lose the event, consumers can replay from an offset, and adding a second consumer later (an audit log service, for example) doesn't mean touching the producer at all. That's a real architectural benefit for a product built around "did the right person get told," even if the queue depth on any given day is small.&lt;/p&gt;

&lt;h2&gt;
  
  
  RBAC and SLA tracking, on the same event stream
&lt;/h2&gt;

&lt;p&gt;Roles determine both visibility and action: who can see an incident, who can change its severity, who can close it. SLA tracking runs off the same event stream. An incident's assigned severity determines its deadline, and the notification service watches for approaching and breached deadlines the same way it watches for state changes, as events, not as a separate polling job.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;p&gt;Express.js, React, React Native, TypeScript, PostgreSQL, Kafka, Redis, MUI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;OpsFlow is live at opsflowweb.vercel.app, source on GitHub: akincskn/opsflow-api.&lt;/p&gt;

&lt;p&gt;I'm Akin Coskun, a full-stack developer from Turkey building production SaaS tools with zero-cost infrastructure. More projects on my portfolio: akin-coskun.web.app.&lt;/p&gt;

</description>
      <category>kafka</category>
      <category>microservices</category>
      <category>webdev</category>
      <category>saas</category>
    </item>
    <item>
      <title>Why I Chose Next.js Server Components for a Small Apartment Dues Tracker</title>
      <dc:creator>Akın Coşkun</dc:creator>
      <pubDate>Fri, 11 Sep 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/akincskn/why-i-chose-nextjs-server-components-for-a-small-apartment-dues-tracker-n7</link>
      <guid>https://dev.to/akincskn/why-i-chose-nextjs-server-components-for-a-small-apartment-dues-tracker-n7</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I built KolayAidat, a free apartment dues (aidat) tracker for Turkish apartment managers. Residents upload payment receipts, the manager approves or rejects them, and everyone can see the building's payment status without a spreadsheet. The whole app runs on Next.js Server Components, which turned out to be the right call for an app that is mostly "look at data, take one action" screens.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with a shared spreadsheet
&lt;/h2&gt;

&lt;p&gt;Most Turkish apartment buildings track monthly dues (aidat) in a shared Excel file or a WhatsApp group, updated by whoever the building manager happens to be that year. It's manual, error prone, and gives residents no way to check their own payment history without asking. KolayAidat replaces that with a small web app: residents log in, upload a receipt when they pay, and see their own payment record. The manager gets a single dashboard showing who has paid, who hasn't, and a queue of receipts waiting for approval.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two roles, one dashboard
&lt;/h2&gt;

&lt;p&gt;There are exactly two roles: resident and manager. A resident can only see and act on their own apartment's dues; a manager can see every apartment in the building and approve or reject any receipt. Rather than building two separate apps, the same dashboard route renders different data and different actions depending on the signed-in user's role, checked server-side on every request through NextAuth.js sessions, so a resident can't page-hack their way into another apartment's records by guessing a URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  The receipt approval flow
&lt;/h2&gt;

&lt;p&gt;When a resident marks a month as paid, they upload a receipt image or PDF. That payment sits in a "pending" state until the manager reviews it. The manager can approve it, which marks the month paid for that apartment, or reject it with a short note explaining why (wrong amount, blurry receipt, wrong month), which reopens the month for the resident to fix and resubmit. Nothing gets marked paid without a human on the manager side actually looking at the receipt, which matters more than automation here since these are real cash payments between neighbors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Server Components, not a client-heavy SPA
&lt;/h2&gt;

&lt;p&gt;KolayAidat's screens are almost entirely "load a list, show its status, take one action on a row." That's a pattern Server Components are good at: the dashboard's data-heavy list of apartments and payment statuses renders on the server with a direct database read, no client-side data-fetching waterfall, no loading spinners for what is fundamentally a boring table. The only client components are the small interactive pieces, uploading a receipt, clicking approve or reject, which keeps the JavaScript shipped to the browser small for an app whose users are, realistically, checking it once a month on a phone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Email notifications without a queue
&lt;/h2&gt;

&lt;p&gt;Residents get an email when their receipt is approved or rejected, and managers get one when a new receipt is waiting for review. At this scale (dozens of residents per building, not thousands), a full message queue would be overkill, so notifications fire directly from the relevant server action right after the database write succeeds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;p&gt;Next.js 14, Prisma, PostgreSQL, Tailwind CSS, shadcn/ui, NextAuth.js.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;KolayAidat is free for apartment managers. Live at kolayaidat.vercel.app, source on GitHub: akincskn/kolayaidat.&lt;/p&gt;

&lt;p&gt;I'm Akin Coskun, a full-stack developer from Turkey building production SaaS tools with zero-cost infrastructure. More projects on my portfolio: akin-coskun.web.app.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>webdev</category>
      <category>saas</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I Built a Turkish Wordle for Football Fans: The Deterministic Puzzle Algorithm</title>
      <dc:creator>Akın Coşkun</dc:creator>
      <pubDate>Fri, 04 Sep 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/akincskn/i-built-a-turkish-wordle-for-football-fans-the-deterministic-puzzle-algorithm-2dj</link>
      <guid>https://dev.to/akincskn/i-built-a-turkish-wordle-for-football-fans-the-deterministic-puzzle-algorithm-2dj</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I built Santra, a Wordle-style daily puzzle game for Turkish football fans. Every day, everyone gets the same Super Lig player to guess, drawn from a static dataset of 198 players across 19 clubs. Picking "today's player" never touches a database: the puzzle is generated deterministically from the date itself, so the core game logic stays completely stateless.&lt;/p&gt;

&lt;h2&gt;
  
  
  A puzzle that doesn't need a database
&lt;/h2&gt;

&lt;p&gt;Most daily puzzle games store "today's answer" in a database row that a cron job writes at midnight. Santra skips that step entirely. The player index for any given date comes from a seed built out of the date itself (year, month, day), run through a small deterministic hashing function, then taken modulo 198. Feed in the same date and you get the same number, every time, on every server, with no shared state. That means the puzzle can be computed on any request, cached aggressively, and it never drifts out of sync even if I redeploy at 11:59 PM.&lt;/p&gt;

&lt;h2&gt;
  
  
  Guessing in Turkish is harder than it looks
&lt;/h2&gt;

&lt;p&gt;Turkish football surnames are full of i, s, g, o, u, c with dots, cedillas, and umlauts (Cakir, Muslera, Calhanoglu). A naive string comparison punishes a fan for a missing cedilla the same way it punishes them for guessing the wrong player entirely. Every guess and every player name in Santra gets run through a normalization pass before comparison: Turkish-specific case folding (the dotted and dotless "i" problem breaks the standard lowercase function in most languages) plus diacritic stripping, so "calhanoglu" and "Calhanoglu" match cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two modes, one dataset
&lt;/h2&gt;

&lt;p&gt;The core game runs once a day, same puzzle for everyone, which is the whole point of a shared daily ritual. But some players want to grind. A separate /antrenman (practice) route reuses the same 198-player, 19-club dataset but picks a random index on every visit instead of the date-derived one, so it stays unlimited without ever touching the daily puzzle's determinism.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing a puzzle you can't screenshot
&lt;/h2&gt;

&lt;p&gt;Wordle-style games are deceptively easy to break: off-by-one errors in attribute matching, case-sensitivity bugs, normalization edge cases that only show up on one specific surname. Santra's guess-comparison logic is covered by Vitest unit tests that pin down exact matches, partial matches (right club, wrong player, for example), and misses, so a refactor of the normalization function can't silently break the game for someone mid-streak.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;p&gt;Next.js 14, TypeScript, Prisma, Tailwind CSS, Vitest for testing, deployed on Vercel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Santra is free, no account needed. Live at santra-theta.vercel.app, source on GitHub: akincskn/Santra.&lt;/p&gt;

&lt;p&gt;I'm Akin Coskun, a full-stack developer from Turkey building production SaaS tools with zero-cost infrastructure. More projects on my portfolio: akin-coskun.web.app.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>typescript</category>
      <category>webdev</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I Built a Multi-Tenant SaaS Where Customers Never Need an Account</title>
      <dc:creator>Akın Coşkun</dc:creator>
      <pubDate>Fri, 28 Aug 2026 07:00:00 +0000</pubDate>
      <link>https://dev.to/akincskn/i-built-a-multi-tenant-saas-where-customers-never-need-an-account-3m81</link>
      <guid>https://dev.to/akincskn/i-built-a-multi-tenant-saas-where-customers-never-need-an-account-3m81</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I built Arac Saglik Karnesi (Vehicle Health Card), a free digital service-history tool for auto repair shops in Turkey. Every vehicle gets an 8-character code. Customers type that code into a page and see the full maintenance history: no signup, no app, no password. The interesting engineering problem wasn't the lookup. It was making one shared Next.js app safely serve dozens of independent auto shops without any of them ever seeing another shop's data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two audiences, two access models
&lt;/h2&gt;

&lt;p&gt;A shop owner needs a normal authenticated dashboard: add vehicles, log service visits, upload photos, manage staff. The shop's customers need zero-friction access. They got a code printed on a receipt, they type it in, they see their car's history. No account, no login screen, no "create a password to view your own car" moment that makes people abandon the page.&lt;/p&gt;

&lt;p&gt;Those are genuinely different security models living in the same app, and mixing them up is exactly how multi-tenant systems leak data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-tenancy without accidental leaks
&lt;/h2&gt;

&lt;p&gt;Every table that holds shop-owned data carries a tenant identifier, and every query is scoped by that identifier at the query layer, not just filtered in the UI afterward. That distinction matters: a bug in a screen's rendering logic can't accidentally show one shop's customers on another shop's dashboard if the database query itself was never allowed to fetch cross-tenant rows in the first place.&lt;/p&gt;

&lt;p&gt;Auth.js v5 handles the owner-side session and ties every authenticated request to exactly one shop. The customer-facing lookup page is intentionally unauthenticated, and instead scoped entirely by the 8-character code, which functions as a capability token: whoever holds the code can view that one vehicle's record, and nobody can browse their way into someone else's car by guessing, since the code space and rate limiting make brute force impractical.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a code and not a login
&lt;/h2&gt;

&lt;p&gt;Requiring the customer to create an account just to see their own service history kills the point of the product. The entire value proposition is a shop being able to say "we did the work, here's the record," and any friction between the receipt and that proof cancels out the trust it was supposed to build. An 8-character alphanumeric code is short enough to print on a receipt or text to a customer, and long enough that guessing your way to a specific vehicle isn't practical.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compressing photos before they leave the browser
&lt;/h2&gt;

&lt;p&gt;Shops attach before and after photos of the work, which is a strong trust signal for customers. Uploading full-resolution phone photos straight to Vercel Blob would be slow on shop wifi and adds up in storage and egress at scale. Photos are compressed client-side, in the browser, before the upload even starts, so what actually crosses the network is already the size it needs to be.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Next.js 16, React 19, TypeScript&lt;/li&gt;
&lt;li&gt;Prisma 6 and PostgreSQL on Neon, tenant-scoped at the query layer&lt;/li&gt;
&lt;li&gt;Auth.js v5 for shop-owner authentication&lt;/li&gt;
&lt;li&gt;Upstash Redis&lt;/li&gt;
&lt;li&gt;Vercel Blob for photos, compressed client-side before upload&lt;/li&gt;
&lt;li&gt;Tailwind CSS&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Arac Saglik Karnesi is free for auto repair shops, no monthly fee. Live at arac-saglik-karnesi.vercel.app, source on GitHub: akincskn/arac-saglik-karnesi.&lt;/p&gt;

&lt;p&gt;I'm Akın Coşkun, a full-stack developer from Turkey building production SaaS tools with zero-cost infrastructure. More projects on my portfolio: akin-coskun.web.app.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>saas</category>
      <category>typescript</category>
      <category>prisma</category>
    </item>
    <item>
      <title>I Solved Double-Booking Without Locks — Using One PostgreSQL Constraint</title>
      <dc:creator>Akın Coşkun</dc:creator>
      <pubDate>Fri, 21 Aug 2026 15:23:17 +0000</pubDate>
      <link>https://dev.to/akincskn/i-solved-double-booking-without-locks-using-one-postgresql-constraint-209m</link>
      <guid>https://dev.to/akincskn/i-solved-double-booking-without-locks-using-one-postgresql-constraint-209m</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I built Randevu, a free appointment booking system for Turkish barbershops and hair salons. The UI was the easy part. The hard part was making sure two customers can never grab the same time slot, even when they both hit "confirm" in the same millisecond. Instead of application-level locks, a job queue, or "check-then-insert and hope," I pushed the guarantee down into the database itself with a single PostgreSQL EXCLUDE constraint. Here's why that works and everything else I had to build around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem every naive booking app has
&lt;/h2&gt;

&lt;p&gt;Most booking flows look like this on the backend:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check if the slot is free (SELECT ... WHERE time = X)&lt;/li&gt;
&lt;li&gt;If free, insert the new appointment&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That works fine in a demo. It breaks the moment two requests hit that code path close together. Both read "free" before either has written anything, both insert, and now you have two customers standing in the same chair at 2:00 PM. This is a textbook race condition, and "just add a loading spinner" does not fix it.&lt;/p&gt;

&lt;p&gt;The usual fixes are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Row-level locks, meaning SELECT with FOR UPDATE: works, but you have to remember to use it correctly on every code path that touches appointments, forever.&lt;/li&gt;
&lt;li&gt;A job queue that serializes all writes: solves it, but now you've added an entire piece of infrastructure just to stop double-booking.&lt;/li&gt;
&lt;li&gt;A unique constraint on the exact timestamp: only works if every appointment has an identical, fixed duration. The moment services have different lengths, this falls apart, because two ranges can overlap without their start times ever matching.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What actually works: EXCLUDE with a range type
&lt;/h2&gt;

&lt;p&gt;Postgres has a constraint type built almost exactly for this: EXCLUDE USING gist. Instead of saying "these two rows can't have the same value," it says "these two rows can't overlap," using a range type (tstzrange) and the btree_gist extension.&lt;/p&gt;

&lt;p&gt;Conceptually, the constraint on the appointments table says: for a given barber, no two rows may have overlapping start_time to end_time ranges. It doesn't matter if the request comes from the API, a background job, or a manual database edit, Postgres itself refuses the insert if it overlaps. No application code has to remember to check anything, because it is physically impossible to violate.&lt;/p&gt;

&lt;p&gt;The practical effect: when two customers submit a booking for overlapping times at the same instant, one INSERT succeeds and the other fails with a constraint violation, which the API just turns into a normal "sorry, that slot was just taken" response. No locks to hold, no queue to babysit, no race condition, ever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Appointment timeouts had to be dynamic, not fixed
&lt;/h2&gt;

&lt;p&gt;A "held" appointment (customer started checkout but hasn't confirmed) needs to expire so it doesn't block the slot forever. A flat "expires in 10 minutes" rule sounds simple until you notice it breaks at closing time: if a shop closes at 8 PM and someone starts booking at 7:55, a fixed timeout can quietly hold a slot past close, or expire mid-conversation. So the hold duration is calculated relative to the barber's actual working hours for that day, not a constant, short enough near closing time, normal otherwise.&lt;/p&gt;

&lt;h2&gt;
  
  
  WhatsApp notifications without the WhatsApp Business API
&lt;/h2&gt;

&lt;p&gt;The official WhatsApp Business API is built for companies with support teams and approval budgets, not a single barber. Randevu sends confirmations and reminders as automated messages over regular WhatsApp instead of requiring the shop to apply for, pay for, and integrate the Business API. The customer gets a normal WhatsApp message; the barber never has to touch a Meta developer console.&lt;/p&gt;

&lt;h2&gt;
  
  
  Push notifications without Firebase
&lt;/h2&gt;

&lt;p&gt;Web push almost always means "add Firebase Cloud Messaging." Randevu skips it and talks to the browser's Push API directly with VAPID keys: no Firebase project, no extra vendor, one less thing to configure per environment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping the public booking link bot-free
&lt;/h2&gt;

&lt;p&gt;A booking page has no login wall by design, that's the whole point, a customer shouldn't need an account. But an open, unauthenticated form is exactly what booking-spam bots look for. Cloudflare Turnstile sits in front of the confirm step as an invisible, non-annoying check, so real customers never see a CAPTCHA, but scripted submissions get filtered out before they ever reach the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Next.js 16 and TypeScript, full-stack, no separate backend&lt;/li&gt;
&lt;li&gt;Prisma and PostgreSQL on Neon, with the EXCLUDE constraint enforced at the database level&lt;/li&gt;
&lt;li&gt;Upstash Redis and QStash for scheduling reminders and expiring holds&lt;/li&gt;
&lt;li&gt;Web Push (VAPID) for notifications&lt;/li&gt;
&lt;li&gt;Cloudflare Turnstile for bot protection on the public booking form&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;Randevu is free for barbershops and hair salons, no app to install, no monthly fee. Live at randevu-five.vercel.app, source on GitHub: akincskn/randevu.&lt;/p&gt;

&lt;p&gt;I'm Akın Coşkun, a full-stack developer from Turkey building production SaaS tools with zero-cost infrastructure. More projects on my portfolio: akin-coskun.web.app.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>webdev</category>
      <category>nextjs</category>
      <category>saas</category>
    </item>
  </channel>
</rss>
