<?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>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>
    <item>
      <title>I Analyzed My Portfolio with AI and Scored 53/100 — Here's How I Fixed It to 85+</title>
      <dc:creator>Akın Coşkun</dc:creator>
      <pubDate>Sat, 21 Mar 2026 14:40:45 +0000</pubDate>
      <link>https://dev.to/akincskn/i-analyzed-my-portfolio-with-ai-and-scored-53100-heres-how-i-fixed-it-to-85-1ccj</link>
      <guid>https://dev.to/akincskn/i-analyzed-my-portfolio-with-ai-and-scored-53100-heres-how-i-fixed-it-to-85-1ccj</guid>
      <description>&lt;p&gt;TL;DR&lt;br&gt;
I built a GEO (Generative Engine Optimization) analyzer tool, tested it on my own portfolio site, and scored a disappointing 53/100. Then I systematically fixed every issue it found. Here's the exact process, what I changed, and why it matters for anyone who wants their content to appear in ChatGPT, Perplexity, and Google AI Overviews.&lt;/p&gt;

&lt;p&gt;What is GEO and Why Should You Care?&lt;br&gt;
Generative Engine Optimization is the practice of structuring your content so AI search engines can find, understand, and cite it. Traditional SEO gets you ranked on Google. GEO gets you mentioned in AI-generated answers.&lt;br&gt;
This matters because the way people search is changing fast. Instead of clicking through 10 blue links, users are asking ChatGPT "What's the best competitor analysis tool?" or asking Perplexity "How do I automate cold outreach?" If your content isn't optimized for these AI engines, you're invisible to a growing segment of your audience.&lt;/p&gt;

&lt;p&gt;The Tool I Built&lt;br&gt;
GEO Analyzer evaluates any web page across 5 categories, each scored out of 20 points for a total of 100:&lt;/p&gt;

&lt;p&gt;Content Structure — H1 tags, heading hierarchy, question-based H2s, internal links&lt;br&gt;
E-E-A-T Signals — Author info, dates, citations, statistics, trust indicators&lt;br&gt;
Technical AI Readiness — Meta tags, schema markup, Open Graph, canonical URLs, AI crawler access&lt;br&gt;
Content Quality — Value, clarity, flow, Q&amp;amp;A format, originality (AI-evaluated)&lt;br&gt;
AI Search Optimization — Snippable content, FAQ structure, definitions, lists, topic focus (AI-evaluated)&lt;/p&gt;

&lt;p&gt;The first three categories are analyzed programmatically using Cheerio for HTML parsing. The last two use Groq's Llama 3.3 model for AI-powered evaluation, with Gemini as a fallback.&lt;/p&gt;

&lt;p&gt;My Score: 53/100 (Grade: F)&lt;br&gt;
Here's what the analyzer found on my portfolio site:&lt;br&gt;
CategoryScoreIssuesContent Structure6/20No H1 tag, no question-based H2s, only 1 internal linkE-E-A-T Signals13/20No publication date, no statisticsTechnical AI Readiness13/20No schema markup, no canonical URLContent Quality13/20Low Q&amp;amp;A format, weak flowAI Search Optimization8/20No snippable content, no FAQ, no definitions&lt;br&gt;
The biggest problems were clear: no schema markup (AI crawlers couldn't understand my site's structure), no question-based headings (AI engines love Q&amp;amp;A format), and no "snippable" content (short, quotable sentences AI can cite directly).&lt;/p&gt;

&lt;p&gt;The Fixes (53 → 85+)&lt;br&gt;
Fix 1: Schema Markup (0 → 5 points)&lt;br&gt;
I added JSON-LD structured data to my site. This tells AI crawlers exactly who I am and what I do:&lt;br&gt;
A Person schema with my name, job title, skills, and social links. Plus a FAQPage schema for common questions. AI engines parse this structured data directly — it's like handing them a summary card instead of making them read your entire page.&lt;br&gt;
Fix 2: Single H1 + Question-Based H2s (0 → 10 points)&lt;br&gt;
Changed my heading structure. One clear H1 at the top. H2s reformatted as questions: "What Technologies Do I Work With?", "What Problems Do I Solve?", "How Can I Help Your Business?"&lt;br&gt;
AI engines are trained on billions of question-answer pairs. When your heading is a question and the content below answers it, you're speaking their language.&lt;br&gt;
Fix 3: Snippable Content (0 → 5 points)&lt;br&gt;
Added short, definitive sentences that AI can quote directly. For example: "Akın Coşkun is a full-stack developer specializing in AI-powered automation, N8N workflows, and zero-cost SaaS development."&lt;br&gt;
This sentence is designed to be the answer when someone asks an AI "Who is Akın Coşkun?" or "Who builds N8N automations?"&lt;br&gt;
Fix 4: FAQ Section with Schema (0 → 4 points)&lt;br&gt;
Added a dedicated FAQ section with 5 common questions, each with a concise answer. Backed by FAQPage schema markup so AI engines can extract Q&amp;amp;A pairs directly.&lt;br&gt;
Fix 5: Statistics and Data (0 → 4 points)&lt;br&gt;
Added concrete numbers: "10+ production projects", "$0/month infrastructure cost", "1 published npm package", "4 professional certifications." AI engines love citing specific statistics.&lt;br&gt;
Fix 6: Internal Linking (1 → 4 links)&lt;br&gt;
Connected everything: project cards link to blog posts, blog section links to projects, FAQ links to relevant pages. AI crawlers follow internal links to build a complete picture of your site.&lt;br&gt;
Fix 7: Canonical URL + robots.txt&lt;br&gt;
Added canonical URL to prevent duplicate content issues. Updated robots.txt to explicitly allow AI crawlers (GPTBot, ClaudeBot, PerplexityBot).&lt;/p&gt;

&lt;p&gt;What I Learned About GEO&lt;br&gt;
After going through this process, here are the key takeaways:&lt;br&gt;
Schema markup is non-negotiable. It's the single most impactful thing you can add for AI visibility. Person, FAQPage, SoftwareApplication — these schemas give AI engines structured data they can parse instantly.&lt;br&gt;
Question-based headings work. AI engines are fundamentally Q&amp;amp;A machines. Format your content as questions and answers, and you're aligning with how they process information.&lt;br&gt;
Snippable sentences are your AI elevator pitch. Write 1-2 sentences per section that could stand alone as an AI-generated answer. Clear, definitive, factual.&lt;br&gt;
Traditional SEO still matters. AI engines often use Google search results as their source. If you rank well on Google, you're more likely to be cited by ChatGPT and Perplexity.&lt;br&gt;
E-E-A-T is universal. Whether it's Google's algorithm or an LLM deciding which source to cite, expertise, experience, authoritativeness, and trust are what get you chosen.&lt;/p&gt;

&lt;p&gt;Try GEO Analyzer Yourself&lt;br&gt;
Enter any URL and get your GEO score with specific recommendations:&lt;/p&gt;

&lt;p&gt;Live app: geo-analyzer-sepia.vercel.app&lt;br&gt;
Source code: github.com/akincskn/geo-analyzer&lt;/p&gt;

&lt;p&gt;What's Next&lt;br&gt;
GEO is still a new field. Most websites score below 50/100 because nobody's optimizing for AI search yet. The developers and marketers who start now will have a massive advantage as AI search becomes the default.&lt;br&gt;
I'm continuing to build tools in this space. If you're interested in GEO/AEO, AI automation, or zero-cost SaaS development, follow me for more.&lt;/p&gt;

&lt;p&gt;I'm Akın Coşkun, a full-stack developer and AI automation specialist. I build tools like RivalRadar (AI competitor analysis), GEO Analyzer (AI search optimization), and LeadPilot (AI SDR agent). Find me on GitHub or check my portfolio.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>programming</category>
      <category>productivity</category>
    </item>
    <item>
      <title>N8N for AI Automation: How I Built Multi-Agent Workflows That Actually Work</title>
      <dc:creator>Akın Coşkun</dc:creator>
      <pubDate>Thu, 19 Mar 2026 19:25:41 +0000</pubDate>
      <link>https://dev.to/akincskn/n8n-for-ai-automation-how-i-built-multi-agent-workflows-that-actually-work-3l5e</link>
      <guid>https://dev.to/akincskn/n8n-for-ai-automation-how-i-built-multi-agent-workflows-that-actually-work-3l5e</guid>
      <description>&lt;p&gt;TL;DR&lt;br&gt;
N8N isn't just a "Zapier alternative." I use it as the backbone for AI agent orchestration — multi-step workflows where AI models analyze data, make decisions, and produce structured outputs. Here's my practical guide based on building two production applications with N8N.&lt;/p&gt;

&lt;p&gt;Why N8N Over Code?&lt;br&gt;
Let me be direct: I can write Node.js. I can build Express APIs. I could orchestrate AI agents entirely in code using LangChain or CrewAI.&lt;br&gt;
But I choose N8N for certain workflows because:&lt;br&gt;
Visual debugging is unmatched. Click any node, see exactly what data went in and came out. When an AI agent produces garbage, you can inspect the prompt, the input data, and the response in seconds. In code, you'd be adding console.logs and redeploying.&lt;br&gt;
Fallback logic is trivial. My primary AI model (Groq) sometimes hits rate limits. In N8N, I add an IF node after the AI call — if error, route to Gemini. Done. In code, this is a try/catch with retry logic, environment variables, and error classification.&lt;br&gt;
Non-linear workflows are natural. Split a list of competitors into parallel analysis paths, merge the results, then feed everything into a report generator. N8N's Split In Batches → parallel nodes → Merge pattern handles this visually.&lt;br&gt;
Changes don't require redeployment. I can update an AI agent's prompt directly in N8N's editor. No git commit, no CI/CD, no Vercel rebuild. For iterating on AI prompts, this speed matters.&lt;/p&gt;

&lt;p&gt;My N8N Architecture Pattern&lt;br&gt;
After two production projects (FormJet and RivalRadar), I've settled on this pattern:&lt;br&gt;
Next.js Frontend&lt;br&gt;
      ↓ (POST request)&lt;br&gt;
Next.js API Route&lt;br&gt;
      ↓ (auth check, validation, credit check)&lt;br&gt;
N8N Webhook&lt;br&gt;
      ↓ (workflow execution)&lt;br&gt;
AI Agent Pipeline&lt;br&gt;
      ↓ (structured JSON output)&lt;br&gt;
N8N HTTP Response&lt;br&gt;
      ↓&lt;br&gt;
Next.js API Route&lt;br&gt;
      ↓ (save to database, return to frontend)&lt;br&gt;
Frontend renders result&lt;br&gt;
The frontend never talks to N8N directly. The API route acts as a gateway — handling auth, input validation, and database operations. N8N only handles the AI orchestration part.&lt;br&gt;
This separation matters because:&lt;/p&gt;

&lt;p&gt;Auth stays in your application (not in N8N)&lt;br&gt;
Database operations use Prisma (type-safe, not N8N's generic DB nodes)&lt;br&gt;
N8N focuses on what it's best at: workflow orchestration&lt;/p&gt;

&lt;p&gt;Building an AI Agent Pipeline in N8N&lt;br&gt;
Here's the actual workflow structure from RivalRadar:&lt;br&gt;
Step 1: Webhook Trigger&lt;br&gt;
Receives company name + industry from the frontend.&lt;br&gt;
Step 2: AI Agent — Competitor Finder&lt;/p&gt;

&lt;p&gt;System prompt: "You are a competitive intelligence analyst. Given a company and industry, identify the top 5 direct competitors."&lt;br&gt;
Tools: HTTP Request node configured for web search&lt;br&gt;
Output: JSON array of competitor names&lt;/p&gt;

&lt;p&gt;Step 3: Split In Batches&lt;br&gt;
Takes the competitor array and processes each one individually. This is N8N's loop mechanism.&lt;br&gt;
Step 4: AI Agent — Company Analyzer (runs per competitor)&lt;/p&gt;

&lt;p&gt;System prompt: "Analyze this company. Return: overview, key products, pricing model, target audience."&lt;br&gt;
Tools: HTTP Request for web research&lt;br&gt;
Output: Structured company profile&lt;/p&gt;

&lt;p&gt;Step 5: Merge&lt;br&gt;
Collects all individual analyses back into a single array.&lt;br&gt;
Step 6: AI Agent — Report Generator&lt;/p&gt;

&lt;p&gt;Input: All competitor analyses + original company&lt;br&gt;
System prompt: "Generate a competitive analysis report with: SWOT analysis, pricing comparison table, market positioning, and 3-5 actionable recommendations."&lt;br&gt;
Output: Complete report JSON&lt;/p&gt;

&lt;p&gt;Step 7: HTTP Response&lt;br&gt;
Returns the report to the calling API route.&lt;/p&gt;

&lt;p&gt;Practical Tips from Production&lt;br&gt;
Tip 1: Always Force JSON Output&lt;br&gt;
In your AI agent's system prompt, add:&lt;br&gt;
IMPORTANT: Respond ONLY with valid JSON. No markdown, no explanations, no code blocks. Just pure JSON.&lt;br&gt;
Then add a JSON Parse node right after. If it fails to parse, your error handler catches it and retries.&lt;br&gt;
Tip 2: Implement Fallback Models&lt;br&gt;
[AI Agent (Groq)] → [IF: error?]&lt;br&gt;
                         ↓ Yes&lt;br&gt;
                    [AI Agent (Gemini)]&lt;br&gt;
                         ↓ No&lt;br&gt;
                    [Continue workflow]&lt;br&gt;
Rate limits are real on free tiers. Always have a backup model.&lt;br&gt;
Tip 3: Use Sub-Workflows for Reusable Agents&lt;br&gt;
If you have the same "analyze a company" agent used in multiple places, make it a sub-workflow. Call it from your main workflow with parameters. This is N8N's version of function extraction.&lt;br&gt;
Tip 4: Log Everything to Stderr&lt;br&gt;
N8N's stdout is reserved for the protocol. Use console.error() for debugging logs. This caught me off guard initially.&lt;br&gt;
Tip 5: The Webhook Path Bug&lt;br&gt;
If you're self-hosting N8N and your webhook paths aren't working, check the SQLite database. When webhookId is missing from the node data, getNodeWebhookPath() generates a compound path instead of your clean path. The fix: insert webhookId directly into the DB. The isFullPath: true flag only works when webhookId exists.&lt;/p&gt;

&lt;p&gt;N8N vs Code-Based Alternatives&lt;br&gt;
CriteriaN8NLangChain/CrewAIVisual debuggingExcellentNone (logs only)Prompt iteration speedInstant (edit in UI)Requires redeployComplex branchingVisual and intuitiveCode-basedVersion controlExport JSONGit nativeType safetyNoneFull TypeScriptTestingManual in UIUnit testableProduction monitoringBuilt-in execution logsCustom implementation&lt;br&gt;
My take: Use N8N for AI orchestration workflows where you need rapid iteration. Use code-based frameworks when you need type safety, unit testing, and tight integration with your application logic.&lt;/p&gt;

&lt;p&gt;Self-Hosting N8N for Free&lt;br&gt;
Here's my setup:&lt;/p&gt;

&lt;p&gt;Render.com — Free tier web service running N8N's Docker image&lt;br&gt;
UptimeRobot — Pings the instance every 5 minutes to prevent sleep&lt;br&gt;
SQLite — N8N's default database (sufficient for low-volume workflows)&lt;/p&gt;

&lt;p&gt;Total cost: $0/month.&lt;br&gt;
The trade-off: Render's free tier sleeps after 15 minutes of inactivity. UptimeRobot keeps it alive, but the first request after a sleep cycle takes ~30 seconds. For my use case (on-demand analysis), this is acceptable.&lt;/p&gt;

&lt;p&gt;What I'm Building Next&lt;br&gt;
I'm expanding into:&lt;/p&gt;

&lt;p&gt;CrewAI/LangChain for code-based multi-agent systems&lt;br&gt;
MCP servers for connecting AI to custom data (already published one on npm)&lt;br&gt;
GEO/AEO optimization tools for AI search visibility&lt;br&gt;
SDR automation agents for sales outreach&lt;/p&gt;

&lt;p&gt;N8N will remain my go-to for rapid prototyping and visual workflow design. But as I tackle more complex agent architectures, I'll complement it with code-based frameworks.&lt;/p&gt;

&lt;p&gt;Resources&lt;/p&gt;

&lt;p&gt;N8N Documentation&lt;br&gt;
N8N AI Agent Templates&lt;br&gt;
My project using N8N — RivalRadar&lt;br&gt;
My project using N8N — FormJet&lt;/p&gt;

&lt;p&gt;I'm Akın Coşkun, a full-stack developer specializing in AI automation and N8N workflows. Find me on GitHub or check my portfolio.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>programming</category>
      <category>devops</category>
    </item>
    <item>
      <title>I Published My Own MCP Server on npm — Here's Why Every Developer Should</title>
      <dc:creator>Akın Coşkun</dc:creator>
      <pubDate>Thu, 19 Mar 2026 19:25:00 +0000</pubDate>
      <link>https://dev.to/akincskn/i-published-my-own-mcp-server-on-npm-heres-why-every-developer-should-1ek5</link>
      <guid>https://dev.to/akincskn/i-published-my-own-mcp-server-on-npm-heres-why-every-developer-should-1ek5</guid>
      <description>&lt;p&gt;TL;DR&lt;br&gt;
MCP (Model Context Protocol) is becoming the standard for connecting AI models to external data. I built a personal portfolio MCP server and published it on npm. Now anyone can add my server to Claude Desktop and ask AI about my projects, skills, and experience. Here's how I built it and why this matters for your career.&lt;/p&gt;

&lt;p&gt;What is MCP?&lt;br&gt;
If you've used Claude Desktop, Cursor, or Claude Code recently, you've probably seen MCP in action. MCP is an open protocol developed by Anthropic that lets AI models talk to external tools and data sources.&lt;br&gt;
Think of it this way:&lt;/p&gt;

&lt;p&gt;REST API: Human (browser) → your server → data → human reads it&lt;br&gt;
MCP: AI model (Claude) → your server → data → AI uses it to answer questions&lt;/p&gt;

&lt;p&gt;If you know how to build an Express API, you already know 90% of what you need to build an MCP server. The concepts are identical — you define endpoints (called "tools" in MCP), handle inputs, and return responses. The only difference is the protocol.&lt;/p&gt;

&lt;p&gt;Why I Built a Portfolio MCP Server&lt;br&gt;
The idea is simple: instead of copy-pasting my project details into every AI conversation, I built a server that AI can query directly.&lt;br&gt;
When someone adds my MCP server to Claude Desktop and asks "What are Akın's projects?", Claude calls my server, gets the data, and presents it naturally.&lt;br&gt;
This is powerful for three reasons:&lt;/p&gt;

&lt;p&gt;Personal branding. Almost nobody has a personal MCP server. It immediately signals that you understand cutting-edge AI infrastructure.&lt;br&gt;
Practical demonstration. Instead of saying "I know MCP" on your resume, you can say "Here's my MCP server — install it and try it."&lt;br&gt;
It's the future. MCP is becoming the de facto standard. OpenAI is sunsetting their Assistants API in favor of MCP. Over 1,000 community-built MCP servers already exist. This ecosystem is only growing.&lt;/p&gt;

&lt;p&gt;How I Built It&lt;br&gt;
The Stack&lt;/p&gt;

&lt;p&gt;TypeScript (strict mode)&lt;br&gt;
@modelcontextprotocol/sdk (official MCP SDK)&lt;br&gt;
Zod (input validation)&lt;br&gt;
stdio transport (runs locally, no server needed)&lt;/p&gt;

&lt;p&gt;Project Structure&lt;br&gt;
akin-portfolio-mcp/&lt;br&gt;
├── src/&lt;br&gt;
│   ├── index.ts          # MCP server entry point&lt;br&gt;
│   ├── data/&lt;br&gt;
│   │   ├── about.ts      # Personal info&lt;br&gt;
│   │   ├── skills.ts     # Tech stack&lt;br&gt;
│   │   └── projects.ts   # All 8 projects&lt;br&gt;
│   ├── tools/&lt;br&gt;
│   │   ├── about.ts      # get_about, get_contact tools&lt;br&gt;
│   │   ├── skills.ts     # get_skills tool&lt;br&gt;
│   │   └── projects.ts   # get_projects, search_projects tools&lt;br&gt;
│   └── types/&lt;br&gt;
│       └── index.ts      # TypeScript interfaces&lt;br&gt;
├── package.json&lt;br&gt;
├── tsconfig.json&lt;br&gt;
└── README.md&lt;br&gt;
Defining a Tool&lt;br&gt;
Here's the core pattern. If you've ever written an Express route handler, this will look familiar:&lt;br&gt;
typescriptserver.tool(&lt;br&gt;
  "get_projects",&lt;br&gt;
  "Lists all of Akın's projects with descriptions and tech stacks",&lt;br&gt;
  async () =&amp;gt; {&lt;br&gt;
    return {&lt;br&gt;
      content: [{&lt;br&gt;
        type: "text",&lt;br&gt;
        text: JSON.stringify(projects, null, 2)&lt;br&gt;
      }]&lt;br&gt;
    };&lt;br&gt;
  }&lt;br&gt;
);&lt;br&gt;
That's it. Define a name, a description (this is what the AI reads to decide when to call your tool), and a handler function.&lt;br&gt;
For tools with parameters, you add a Zod schema:&lt;br&gt;
typescriptserver.tool(&lt;br&gt;
  "search_projects",&lt;br&gt;
  "Search projects by technology or keyword",&lt;br&gt;
  { keyword: z.string().describe("Technology or keyword to search for") },&lt;br&gt;
  async ({ keyword }) =&amp;gt; {&lt;br&gt;
    const results = projects.filter(p =&amp;gt;&lt;br&gt;
      p.stack.some(s =&amp;gt; s.toLowerCase().includes(keyword.toLowerCase())) ||&lt;br&gt;
      p.description.toLowerCase().includes(keyword.toLowerCase())&lt;br&gt;
    );&lt;br&gt;
    return {&lt;br&gt;
      content: [{&lt;br&gt;
        type: "text",&lt;br&gt;
        text: results.length &amp;gt; 0&lt;br&gt;
          ? JSON.stringify(results, null, 2)&lt;br&gt;
          : &lt;code&gt;No projects found matching "${keyword}"&lt;/code&gt;&lt;br&gt;
      }]&lt;br&gt;
    };&lt;br&gt;
  }&lt;br&gt;
);&lt;br&gt;
Publishing to npm&lt;br&gt;
The key insight: MCP servers distributed via npm can be run with npx — no installation required. Users just add this to their Claude Desktop config:&lt;br&gt;
json{&lt;br&gt;
  "mcpServers": {&lt;br&gt;
    "akin-portfolio": {&lt;br&gt;
      "command": "npx",&lt;br&gt;
      "args": ["-y", "akin-portfolio-mcp"]&lt;br&gt;
    }&lt;br&gt;
  }&lt;br&gt;
}&lt;br&gt;
To make this work, your package.json needs a bin field pointing to your compiled entry point, and you need .npmignore to exclude source files from the published package.&lt;/p&gt;

&lt;p&gt;The 7 Tools My Server Provides&lt;/p&gt;

&lt;p&gt;get_about — Personal information and bio&lt;br&gt;
get_skills — Full tech stack by category&lt;br&gt;
get_projects — All 8 projects with details&lt;br&gt;
get_project_detail — Deep dive into a specific project&lt;br&gt;
search_projects — Find projects by technology keyword&lt;br&gt;
get_contact — Contact information&lt;br&gt;
get_experience_summary — Career overview&lt;/p&gt;

&lt;p&gt;What I Learned&lt;br&gt;
MCP is easier than you think. If you can write a TypeScript function, you can build an MCP server. The SDK handles all the protocol complexity.&lt;br&gt;
Descriptions matter more than code. The tool descriptions are what the AI reads to decide when to call your tools. Poor descriptions = AI never uses your tools. Good descriptions = seamless integration.&lt;br&gt;
stdio is the simplest transport. No server deployment needed. The MCP server runs as a local process on the user's machine. npm distribution makes it effortless.&lt;br&gt;
Error handling is critical. When a user asks about a project that doesn't exist, your server should return a helpful error, not crash. I use isError: true in responses so Claude can communicate failures gracefully.&lt;/p&gt;

&lt;p&gt;Try It Yourself&lt;br&gt;
Install my server:&lt;br&gt;
bashnpx akin-portfolio-mcp&lt;br&gt;
Or add it to Claude Desktop / Claude Code:&lt;br&gt;
bashclaude mcp add akin-portfolio npx -y akin-portfolio-mcp&lt;br&gt;
Then ask: "What are Akın's projects?" or "Does Akın know N8N?"&lt;br&gt;
Source code: github.com/akincskn/akin-portfolio-mcp&lt;br&gt;
npm: npmjs.com/package/akin-portfolio-mcp&lt;/p&gt;

&lt;p&gt;You Should Build One Too&lt;br&gt;
Seriously. Here's why:&lt;/p&gt;

&lt;p&gt;It takes a weekend (mine took a day)&lt;br&gt;
It demonstrates real MCP knowledge&lt;br&gt;
It's a conversation starter in interviews and on LinkedIn&lt;br&gt;
Almost nobody has one yet — you'll stand out&lt;/p&gt;

&lt;p&gt;Start with your portfolio data. Add your projects, skills, and experience. Publish it on npm. Link it in your GitHub README. That's it — you now have an MCP server that AI assistants can use to learn about you.&lt;br&gt;
The developers who understand MCP early will have a significant advantage as this ecosystem grows. Don't wait.&lt;/p&gt;

&lt;p&gt;I'm Akın Coşkun, a full-stack developer building AI-powered tools and MCP servers. Find me on GitHub or try my MCP server: npx akin-portfolio-mcp&lt;/p&gt;

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