<?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>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>
    <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:15:51 +0000</pubDate>
      <link>https://dev.to/akincskn/i-solved-double-booking-without-locks-using-one-postgresql-constraint-160</link>
      <guid>https://dev.to/akincskn/i-solved-double-booking-without-locks-using-one-postgresql-constraint-160</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>
    <item>
      <title>How I Built an AI SDR Agent That Finds Leads and Writes Personalized Cold Emails</title>
      <dc:creator>Akın Coşkun</dc:creator>
      <pubDate>Sat, 21 Mar 2026 14:42:59 +0000</pubDate>
      <link>https://dev.to/akincskn/how-i-built-an-ai-sdr-agent-that-finds-leads-and-writes-personalized-cold-emails-fb7</link>
      <guid>https://dev.to/akincskn/how-i-built-an-ai-sdr-agent-that-finds-leads-and-writes-personalized-cold-emails-fb7</guid>
      <description>&lt;p&gt;TL;DR&lt;br&gt;
I built LeadPilot — an AI-powered Sales Development Representative that automates lead discovery, company research, pain point analysis, and personalized cold email generation. A 4-agent AI pipeline does in 5 minutes what takes a human SDR 4+ hours. Here's how it works and what I learned building it.&lt;/p&gt;

&lt;p&gt;What is an AI SDR Agent?&lt;br&gt;
An SDR (Sales Development Representative) is the person who finds potential customers, researches them, and sends the first outreach message. It's the starting point of every sales pipeline.&lt;br&gt;
An AI SDR agent automates this entire process. Instead of manually searching LinkedIn, reading company websites, and writing individual emails, AI does it all — faster, more consistently, and at scale.&lt;br&gt;
LeadPilot handles 4 steps that would normally take hours:&lt;/p&gt;

&lt;p&gt;Find leads matching your target criteria&lt;br&gt;
Research each company by analyzing their website&lt;br&gt;
Identify pain points relevant to your offering&lt;br&gt;
Write personalized emails — not templates, genuinely personalized to each company&lt;/p&gt;

&lt;p&gt;Why I Built This&lt;br&gt;
I'm a freelancer. I need clients. The traditional approach — browsing job boards, sending generic proposals — is slow and competitive. Cold outreach works better, but it's time-consuming to do well.&lt;br&gt;
I wanted a tool where I could say "Find SaaS companies with 10-50 employees that might need automation" and get back a list of companies with ready-to-send emails. So I built one.&lt;/p&gt;

&lt;p&gt;The 4-Agent Architecture&lt;br&gt;
LeadPilot uses a sequential AI pipeline. Each agent has a specific job, and the output of one feeds into the next.&lt;br&gt;
Agent #1: Lead Finder&lt;br&gt;
The user provides: target sector, company size, location, and their product/service. The Lead Finder uses Serper.dev (Google Search API) to find matching companies. It runs multiple search queries in parallel to cast a wide net, then AI extracts structured company data from the search results.&lt;br&gt;
Input: "SaaS companies, 10-50 employees, US, N8N automation setup"&lt;br&gt;
Output: List of 10 companies with names, websites, and descriptions.&lt;br&gt;
Agent #2: Company Researcher&lt;br&gt;
For each lead, this agent fetches the company's website using Cheerio (server-side HTML parsing) and extracts: what they do, their products, about page content, team information, and any public contact details.&lt;br&gt;
This is where personalization starts. The researcher doesn't just grab the company name — it understands their business, their tech stack, their recent activity.&lt;br&gt;
Agent #3: Pain Point Analyzer&lt;br&gt;
This is the strategic brain. It takes the company profile and the user's product/service, then identifies specific pain points this company likely has. It also generates a match score (0-100) and the best "angle" for outreach.&lt;br&gt;
For example: "This company uses Shopify but handles order notifications manually. Your N8N automation could save them 15+ hours/week on order processing workflows. Match score: 82."&lt;br&gt;
Agent #4: Email Composer&lt;br&gt;
The final agent writes a 3-email sequence for each lead:&lt;/p&gt;

&lt;p&gt;Email 1 (Day 0): First touch. Opens with something specific about their company. States the problem. Offers the solution. Clear call-to-action.&lt;br&gt;
Email 2 (Day 3): Follow-up. Different angle, social proof or case study reference.&lt;br&gt;
Email 3 (Day 7): Last attempt. Short, direct, final compelling reason.&lt;/p&gt;

&lt;p&gt;Every email is under 150 words. No corporate jargon. No "I hope this email finds you well." Each one references specific details about the company — because the previous agents already did the research.&lt;/p&gt;

&lt;p&gt;The Tech Stack (Zero Cost)&lt;br&gt;
LayerTechnologyCostFrontend + BackendNext.js 14 + Tailwind + shadcn/ui$0AI (Primary)Groq Llama 3.3 70B$0AI (Fallback)Google Gemini 2.0 Flash$0SearchSerper.dev (2,500 free searches/month)$0HTML ParsingCheerio$0AuthNextAuth.js v5$0DatabaseNeon PostgreSQL + Prisma$0DeployVercel$0&lt;br&gt;
Total: $0/month.&lt;/p&gt;

&lt;p&gt;Technical Challenges I Solved&lt;br&gt;
Challenge 1: Long-Running Pipeline&lt;br&gt;
A 10-lead campaign takes 3-5 minutes. You can't keep an HTTP connection open that long. My solution: fire-and-forget pattern. The API starts the pipeline in the background, immediately returns a campaign ID. The frontend polls /api/campaign/[id]/status every 2 seconds to get progress updates.&lt;br&gt;
The user sees: "Finding leads... (3/10 found)" → "Researching companies... (5/10)" → "Composing emails... (9/10)" → "Complete!"&lt;br&gt;
Challenge 2: AI Returning Invalid JSON&lt;br&gt;
AI models sometimes return markdown-wrapped JSON, add explanations, or produce malformed structures. My solution: strict prompt instructions ("Respond ONLY with valid JSON"), JSON parse with try/catch, one retry with the same model, then fallback to the secondary model. If both fail, the lead is marked with an error but the pipeline continues.&lt;br&gt;
Challenge 3: Website Scraping Reliability&lt;br&gt;
Not every website plays nice. Some block scrapers (403), some redirect infinitely, some are entirely JavaScript-rendered (invisible to Cheerio). My solution: 10-second timeout on all fetches, User-Agent header to avoid blocks, graceful degradation (if scraping fails, the AI works with whatever the search result provided).&lt;br&gt;
Challenge 4: Prompt Injection&lt;br&gt;
Users enter free text (sector, product description, value proposition) that gets injected into AI prompts. A malicious user could try to manipulate the AI's behavior. My solution: input sanitization that strips control characters and potential injection patterns before they reach the prompt.&lt;br&gt;
Challenge 5: SSRF Protection&lt;br&gt;
The scraper fetches URLs from search results. Without protection, an attacker could trick it into fetching internal network resources. My solution: URL validation that blocks localhost, private IP ranges, and non-HTTP protocols before any fetch request.&lt;/p&gt;

&lt;p&gt;What Makes Good Cold Emails&lt;br&gt;
Building the Email Composer agent taught me a lot about cold outreach:&lt;br&gt;
Personalization is everything. "I noticed your company uses Shopify" beats "Dear business owner" by 10x. The research agents exist specifically to feed the email agent with personalization material.&lt;br&gt;
Shorter is better. Every email is capped at 150 words. Busy people don't read essays from strangers.&lt;br&gt;
One CTA per email. "Would you be open to a 15-minute call?" — not "check our website, read our blog, follow us on LinkedIn, and also here's a PDF."&lt;br&gt;
The 3-email sequence matters. Most responses come from email 2 or 3, not email 1. Persistence (without being annoying) is key. Day 0, Day 3, Day 7 is the sweet spot.&lt;/p&gt;

&lt;p&gt;Real Output Example&lt;br&gt;
Campaign: "Find marketing agencies in the US that need AI automation"&lt;br&gt;
Lead: Bright Spark Digital (fictional example)&lt;br&gt;
Email 1 — Day 0:&lt;br&gt;
Subject: "Quick question about your client reporting"&lt;br&gt;
"Hi Sarah, I noticed Bright Spark Digital manages campaigns across Google Ads and Meta for multiple clients. Reporting across platforms usually eats up 5-10 hours per week for agencies your size. I build custom AI automation workflows that pull data from all ad platforms into unified dashboards — automatically. Would you be open to a quick 15-minute chat about how this could work for your team?"&lt;br&gt;
Email 2 — Day 3:&lt;br&gt;
Subject: "Re: your client reporting"&lt;br&gt;
"Hi Sarah, following up on my previous note. I recently helped a similar-sized agency reduce their weekly reporting time from 8 hours to 20 minutes using N8N automation. Happy to share exactly what we built. Worth a quick conversation?"&lt;br&gt;
Email 3 — Day 7:&lt;br&gt;
Subject: "Last note"&lt;br&gt;
"Hi Sarah, I know you're busy. One quick question: if you could automate one repetitive task in your agency, what would it be? I might already have a solution built. Either way, no hard feelings — just wanted to make sure this didn't slip through the cracks."&lt;/p&gt;

&lt;p&gt;Try It&lt;/p&gt;

&lt;p&gt;Live app: leadpilot-ashy.vercel.app&lt;br&gt;
Source code: github.com/akincskn/leadpilot&lt;/p&gt;

&lt;p&gt;Create a campaign, define your target market, and get personalized leads with email sequences in minutes.&lt;/p&gt;

&lt;p&gt;What's Next&lt;br&gt;
LeadPilot is part of a larger ecosystem of AI tools I'm building. If you're interested in AI automation, SDR tooling, or building SaaS with zero-cost infrastructure, follow me for more.&lt;br&gt;
Other tools in the ecosystem:&lt;/p&gt;

&lt;p&gt;RivalRadar — AI competitor analysis&lt;br&gt;
GEO Analyzer — AI search optimization scoring&lt;br&gt;
Portfolio MCP Server — Query my portfolio via AI assistants&lt;/p&gt;

&lt;p&gt;I'm Akın Coşkun, a full-stack developer and AI automation specialist from Turkey. I build production SaaS applications with zero-cost infrastructure. Find me on GitHub or check my portfolio.&lt;/p&gt;

</description>
      <category>sass</category>
      <category>webdev</category>
      <category>ai</category>
      <category>programming</category>
    </item>
  </channel>
</rss>
