<?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: mhk sameera</title>
    <description>The latest articles on DEV Community by mhk sameera (@mhk_sameera).</description>
    <link>https://dev.to/mhk_sameera</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%2F4115344%2F94fe80dc-4503-4cb0-9f0e-273eb0e453fb.png</url>
      <title>DEV Community: mhk sameera</title>
      <link>https://dev.to/mhk_sameera</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mhk_sameera"/>
    <language>en</language>
    <item>
      <title>Cutting Through the LinkedIn Noise: Show Your Real, Deployed App — Not Your Title</title>
      <dc:creator>mhk sameera</dc:creator>
      <pubDate>Wed, 23 Sep 2026 07:53:43 +0000</pubDate>
      <link>https://dev.to/mhk_sameera/cutting-through-the-linkedin-noise-show-your-real-deployed-app-not-your-title-14gd</link>
      <guid>https://dev.to/mhk_sameera/cutting-through-the-linkedin-noise-show-your-real-deployed-app-not-your-title-14gd</guid>
      <description>&lt;p&gt;LinkedIn right now is wall-to-wall "Software Engineer | Full Stack Developer | AI Enthusiast | Open to Work" — and almost none of it tells you anything. A title is not proof you can build something. It's not proof you can ship something. It's definitely not proof you can be trusted with someone else's product.&lt;/p&gt;

&lt;p&gt;So here's a simple idea instead of another opinion post: &lt;strong&gt;show the app, not the title.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;Drop a comment with:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A live link&lt;/strong&gt; — it has to actually work when someone clicks it right now.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One line on what it does&lt;/strong&gt; and who it's for.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Your stack.&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's it. No "I'm a passionate developer" preamble, no CV, no "DM me." Just the proof.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you don't have a deployed project yet, you still belong here.&lt;/strong&gt; Drop what you're currently building, even unfinished, even with zero users — a personal project, something from a course that you extended yourself, whatever you're actually working on right now. The bar isn't "impressive," it's "real and yours." This is meant to surface actual work at every stage, not just finished products with traction.&lt;/p&gt;

&lt;h2&gt;
  
  
  What counts
&lt;/h2&gt;

&lt;p&gt;This is for work you actually built — not a tutorial clone with your name swapped in, not a Figma mockup with no backend. It can be small. It can be in progress. It can have zero users. It just has to be something you're genuinely building or shipped yourself, not a copy-paste.&lt;/p&gt;

&lt;p&gt;If it's not live anymore, say so and link a video walkthrough instead — that's fine, dead links aren't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I think this is worth doing
&lt;/h2&gt;

&lt;p&gt;I'm a solo full-stack developer myself, and I'm tired of scrolling past the same recycled "10 tips to become a 10x developer" carousel to find literally anyone showing actual work. The signal-to-noise ratio on tech LinkedIn right now is bad, and it's bad in both directions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;For developers at every stage&lt;/strong&gt; — from someone learning their first framework to someone who's shipped ten products — your work is buried under personal branding and titles that say nothing. This isn't about ranking who's "more real." It's about making it possible to find actual work at all, whatever stage it's at.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For recruiters and founders&lt;/strong&gt;, there's no fast way to see what someone can actually do, versus how well they write about themselves.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A thread where the currency is a real link — finished or not, big or small — fixes both problems at once. You can't fake something that actually exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  For recruiters and anyone hiring
&lt;/h2&gt;

&lt;p&gt;If you're looking for a solo full-stack developer — someone who can own a project front to back — this is a faster filter than a hundred LinkedIn profiles. You'll see everything from early-career developers actively building, to people with years of deployed, real-world products. Judge the actual work directly instead of a résumé describing it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keeping it going
&lt;/h2&gt;

&lt;p&gt;I'll post this as a LinkedIn thread and keep it pinned. Reply with your project any time — this isn't a one-day thing, it's meant to keep collecting real, live work so it's still useful a month from now, not just for the first 48 hours.&lt;/p&gt;




&lt;p&gt;If you've got something real — shipped or still in progress — drop it below. Link, what it does, stack. Let's see what people are actually building, at every stage.&lt;/p&gt;

</description>
      <category>career</category>
      <category>discuss</category>
      <category>webdev</category>
      <category>hiring</category>
    </item>
    <item>
      <title>You Expect Corolla Pricing but Ordered Lamborghini Options — What a Bad WordPress Job Taught Me About Discounts</title>
      <dc:creator>mhk sameera</dc:creator>
      <pubDate>Mon, 21 Sep 2026 10:12:53 +0000</pubDate>
      <link>https://dev.to/mhk_sameera/you-expect-corolla-pricing-but-ordered-lamborghini-options-what-a-bad-wordpress-job-taught-me-2o6n</link>
      <guid>https://dev.to/mhk_sameera/you-expect-corolla-pricing-but-ordered-lamborghini-options-what-a-bad-wordpress-job-taught-me-2o6n</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frbqe1npijkxychwnbi1i.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Frbqe1npijkxychwnbi1i.jpeg" alt=" " width="799" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The conversation goes the same way almost every time a new client comes in.&lt;/p&gt;

&lt;p&gt;They ask the cost. I ask what they actually need. I send a quote. "That's expensive." I walk through exactly what's in it, line by line. Still expensive. I offer a small discount to keep the relationship. They come back asking for more.&lt;/p&gt;

&lt;p&gt;HI used to keep giving. Then a Canadian client taught me why that habit was going to cost me a lot more than money.&lt;/p&gt;

&lt;h2&gt;
  
  
  The job that changed how I quote
&lt;/h2&gt;

&lt;p&gt;This client bargained hard, I dropped my price to close the deal, and the scope was small: a few updates to their existing WordPress site. &lt;strong&gt;I'm not a big fan of WordPress&lt;/strong&gt; — I'll say that upfront, it colors this story — but it was a small job at a reduced rate, so I said yes.&lt;/p&gt;

&lt;p&gt;"A few updates" did not stay a few updates. One small ask led to another, which needed a plugin that conflicted with another plugin, which needed a theme change, which broke something else. Each individual request sounded reasonable on its own. Together, they added up to rebuilding the entire site from scratch — at the price I'd agreed to for a handful of tweaks.&lt;/p&gt;

&lt;p&gt;I did it. I finished the job. And I lost money and time on a client I'd already discounted to win.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I actually did wrong
&lt;/h2&gt;

&lt;p&gt;It wasn't agreeing to a discount. It was agreeing to a discount &lt;em&gt;without re-scoping the moment the ask changed&lt;/em&gt;. Every "can you also just..." should have been a new estimate, not a favor squeezed into the old one. I said yes to the relationship instead of saying yes to a number, and by the time I noticed how far the scope had moved, I'd already done half the extra work for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I say now
&lt;/h2&gt;

&lt;p&gt;When a client pushes for a lower price after I've already explained the quote, I tell them straight:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"You're expecting Corolla pricing, but what you've asked for is Lamborghini options. You'll get exactly what you pay for."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It sounds blunt written down. In conversation it isn't — it's said plainly, not angrily, and it does something a longer explanation doesn't: it makes the mismatch obvious in one sentence. A client who wants custom logic, clean code, and something that won't break in six months is not shopping in the same category as a client who wants the cheapest possible build. Both are valid. They're not the same job.&lt;/p&gt;

&lt;h2&gt;
  
  
  What changed in how I run projects since then
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The quote has a defined scope, in writing, before anything starts.&lt;/strong&gt; Not "a few updates" — an actual list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A discount is a number, not a mood.&lt;/strong&gt; If I reduce the price, the scope shrinks to match it. I say that out loud when I offer the discount, not after the client tries to expand the work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Can you also just—" gets a real answer: "Sure, that's outside the current scope, here's what it adds."&lt;/strong&gt; Every time, no exceptions, said the same calm way whether it's the first ask or the fifth.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I stopped treating discount requests as a negotiation I have to win by conceding.&lt;/strong&gt; Explaining the quote once is fair. Explaining it three times while lowering the number each time teaches the client that pushback is how you get a cheaper Claude — sorry, cheaper &lt;em&gt;developer&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;I'm honest about not loving certain platforms going in.&lt;/strong&gt; If I'm not a WordPress person and the job is a WordPress site, the client should hear that before I quote, not discover it when something breaks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If the number doesn't work, I walk.&lt;/strong&gt; Not every negotiation ends in a deal, and that's fine. I'd rather sleep well or watch a movie than take a job at a price that means I'm working for free by week two. Saying no to a bad price is cheaper than saying yes and resenting the project for the next month.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why I think this is worth sharing
&lt;/h2&gt;

&lt;p&gt;Every freelancer or agency dev has some version of this story. The details change — the platform, the client, the country — but the shape is identical: discount granted, scope quietly expands, developer eats the difference because walking away mid-project feels worse than finishing at a loss.&lt;/p&gt;

&lt;p&gt;The fix isn't "don't give discounts" or "don't take small jobs." It's that price and scope have to move together, every time, out loud, before the work happens — not after you're three unpaid hours into a rebuild you didn't agree to.&lt;/p&gt;




&lt;p&gt;What's your version of the Corolla line? Every developer I know has one phrase that ends these conversations — I'd like to hear yours.&lt;/p&gt;

</description>
      <category>freelance</category>
      <category>webdev</category>
      <category>career</category>
      <category>business</category>
    </item>
    <item>
      <title>AI Built My Site in an Hour, Why Are You Charging So Much? — 4 Questions That Usually End the Conversation</title>
      <dc:creator>mhk sameera</dc:creator>
      <pubDate>Sun, 20 Sep 2026 07:16:31 +0000</pubDate>
      <link>https://dev.to/mhk_sameera/ai-built-my-site-in-an-hour-why-are-you-charging-so-much-4-questions-that-usually-end-the-545</link>
      <guid>https://dev.to/mhk_sameera/ai-built-my-site-in-an-hour-why-are-you-charging-so-much-4-questions-that-usually-end-the-545</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhzilx1c1ljya324pk632.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhzilx1c1ljya324pk632.jpeg" alt=" " width="799" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I've had this conversation more times this year than in the previous five combined.&lt;/p&gt;

&lt;p&gt;A client sends me a link. It's a genuinely nice-looking site — clean layout, good copy, responsive, built with an AI tool in an afternoon. Then comes the quote for the actual project, and the pushback:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"But I already built it. Why do I need to pay this much for web development?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I don't argue. I ask four questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four questions
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;Where is this going to be hosted?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;How does the contact form actually send an email?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Who's going to point the domain and set up DNS?&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Who's connecting this to a database?&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I've asked some version of this to maybe a dozen clients this year. Not one has had an answer to all four. Most have an answer to zero.&lt;/p&gt;

&lt;p&gt;And that's not a gotcha — it's the actual finding. What the AI tool built is a &lt;strong&gt;frontend&lt;/strong&gt;. What the client thinks they have is &lt;strong&gt;a website&lt;/strong&gt;. Those are not the same thing, and the gap between them is most of what I do for a living.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this keeps happening
&lt;/h2&gt;

&lt;p&gt;It's not that clients are being difficult. The tools are genuinely good now, and "generate a working UI from a prompt" is a real, valuable thing that used to take a developer a week. I'm not going to pretend otherwise — some of what shows up in my inbox now is better-looking than what agencies were charging thousands for three years ago.&lt;/p&gt;

&lt;p&gt;The problem is the tools stop exactly where the visible part ends. A generated page runs perfectly in the preview pane because the preview pane doesn't need a domain, doesn't send real email, doesn't persist data, and isn't reachable by the public internet. Every one of those is invisible until you try to actually launch.&lt;/p&gt;

&lt;p&gt;So the client's mental model is "the site is done, I just need someone to put it online" — and mine is "the UI is done, and everything that makes it a &lt;em&gt;product&lt;/em&gt; hasn't started."&lt;/p&gt;

&lt;h2&gt;
  
  
  What's actually missing, one question at a time
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Hosting
&lt;/h3&gt;

&lt;p&gt;A static export needs somewhere to live — Vercel, Netlify, a VPS — and someone has to choose based on what the site actually does. A pure static site is one decision. A site with a contact form, a login, or a database is a completely different one, with servers, environment variables, and a deploy pipeline. "Where does this run" is rarely a five-minute answer, and it's never zero cost.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The contact form
&lt;/h3&gt;

&lt;p&gt;This is the one that trips up the most clients, because it &lt;em&gt;looks&lt;/em&gt; finished. They fill it in, hit submit, nothing visibly breaks — and nothing happens on the other end either, because there's no backend to receive it.&lt;/p&gt;

&lt;p&gt;A real contact form needs: an endpoint to POST to, a way to send the email (SMTP, Resend, SendGrid...), spam protection, and validation so someone can't inject garbage or a script into it. None of that exists in a frontend-only build. I've had a client swear their form "worked" because the button had a nice hover animation.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. DNS and the domain
&lt;/h3&gt;

&lt;p&gt;Buying a domain from GoDaddy or Namecheap is step one, not step ten. Someone still has to point A/CNAME records at the host, add MX records if email should work on that domain, set up SSL, and get &lt;code&gt;www&lt;/code&gt; and the bare domain to agree on which one redirects to the other. Get this wrong and the site is either unreachable or half-secure. Every client I've asked has assumed this "just happens" when you buy the domain.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The database
&lt;/h3&gt;

&lt;p&gt;If the site needs to store anything — leads, bookings, products, user accounts — a frontend has nothing to talk to. Someone has to choose a database, design the schema, write the API that the frontend calls, secure it so it isn't wide open to the internet, and back it up. This is usually the biggest gap, and the one clients understand the least, because in the AI tool's preview, fake data just... appears.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tone I try to take
&lt;/h2&gt;

&lt;p&gt;I don't lead with "well actually, you don't know what you're talking about." Two things are both true at once: the client got real value out of the AI tool, and they're about to find out the hard way that value stopped short of a working product.&lt;/p&gt;

&lt;p&gt;So I ask the four questions, let the silence answer them, and then say something like: "What you've got is a genuinely solid starting point — better than a lot of what I used to have to build from scratch. What's left is the part that doesn't show up in a demo: hosting, the form actually working, DNS, and a database if you need one. That's what the quote covers."&lt;/p&gt;

&lt;p&gt;Most clients relax once they understand what they're actually paying for isn't "building the site" — they already half-did that — it's making it real.&lt;/p&gt;

&lt;h2&gt;
  
  
  For other developers
&lt;/h2&gt;

&lt;p&gt;Steal the four questions. They're not a trick, they're a genuinely fast way to find the edge of what a client has versus what they think they have, without a long technical explanation. Ask them early, in the first call, before you scope anything. The answers tell you exactly how much of the conversation you still need to have.&lt;/p&gt;




&lt;p&gt;If you're getting this pushback too, what's your version of these four questions? I'd like to add to the list.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>devops</category>
      <category>freelance</category>
      <category>ai</category>
    </item>
    <item>
      <title>I Built a Services Marketplace, Never Had Time to Run It, So I Open-Sourced All of It</title>
      <dc:creator>mhk sameera</dc:creator>
      <pubDate>Thu, 17 Sep 2026 12:04:20 +0000</pubDate>
      <link>https://dev.to/mhk_sameera/i-built-a-services-marketplace-for-sri-lanka-never-had-time-to-run-it-so-i-open-sourced-all-of-it-3njg</link>
      <guid>https://dev.to/mhk_sameera/i-built-a-services-marketplace-for-sri-lanka-never-had-time-to-run-it-so-i-open-sourced-all-of-it-3njg</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4cdalp3763i228zhibc1.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4cdalp3763i228zhibc1.gif" alt="Walkthrough" width="760" height="451"&gt;&lt;/a&gt;&lt;br&gt;
Last week I published the complete source code of a services marketplace I built and deployed — a finished product, live at &lt;a href="https://skilllanka.com" rel="noopener noreferrer"&gt;skilllanka.com&lt;/a&gt;, with payments, video, escrow and admin tooling all wired up. Not a tutorial project, not a starter template. The actual thing, minus the users I never had time to go and get.&lt;/p&gt;

&lt;p&gt;It's called &lt;strong&gt;SkillHub&lt;/strong&gt; on GitHub, it's MIT licensed, and you can clone it, rebrand it in two config files, and run your own marketplace with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repo:&lt;/strong&gt; &lt;a href="https://github.com/Sameera-MHK/service_marketplace" rel="noopener noreferrer"&gt;https://github.com/Sameera-MHK/service_marketplace&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This post is about why I did that, and a walkthrough of the one piece of it I'm most proud of: the trust score.&lt;/p&gt;
&lt;h2&gt;
  
  
  What it is
&lt;/h2&gt;

&lt;p&gt;A two-sided marketplace for local services — think plumbers, tutors, designers, trainers — with a business directory bolted on. Four roles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Clients&lt;/strong&gt; browse verified professionals, post jobs, book paid consultations, join live video classes, buy from a Pro's storefront, rate work, raise disputes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pros&lt;/strong&gt; onboard with ID verification, get a scored public profile, receive leads, sell consultations and classes, run a shop, request payouts, subscribe to a plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Businesses&lt;/strong&gt; register a company profile with services, photos and opening hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Admins&lt;/strong&gt; moderate, verify IDs, resolve disputes, approve payouts, set commissions, and read an audit log.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Under the hood: escrow-style job flow (30% deposit, balance released on client confirmation), a commission engine with per-category and per-Pro overrides, Free/Pro/Elite subscription tiers, LiveKit video, Stripe, i18n with a data-driven locale registry, and server-rendered Open Graph tags so links preview properly.&lt;/p&gt;

&lt;p&gt;Stack is React 18 + Vite + Tailwind on the front, Node + Express + Mongoose on the back. 21 models, 17 route modules, and every third-party integration is optional — without Stripe the payment endpoints return a clear error, without Cloudinary uploads go to local disk, without SMTP emails print to the console. You can run the whole thing with just MongoDB and two JWT secrets.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why open-source a production app?
&lt;/h2&gt;

&lt;p&gt;About a year ago I had an idea: a services marketplace for the Sri Lankan market. Sri Lanka has a huge pool of skilled tradespeople and freelancers, and no good platform connecting them to clients with any kind of trust layer. I designed it, built it, and deployed it at &lt;strong&gt;&lt;a href="https://skilllanka.com" rel="noopener noreferrer"&gt;skilllanka.com&lt;/a&gt;&lt;/strong&gt; — it's still up if you want to click around.&lt;/p&gt;

&lt;p&gt;Then reality. I run a SaaS company, a social media agency, and a steady flow of client development work. A marketplace doesn't grow by existing — it grows by someone promoting it every day, onboarding Pros one by one, answering support, posting on social media, chasing the first hundred users. I never had the time to be that person. The code was finished and deployed. The business side never started — no marketing, no Pro onboarding drive, and so no real users. A marketplace with zero users is just a very complete demo.&lt;/p&gt;

&lt;p&gt;I could have let it sit in a private repo. But a complete, working marketplace is worth far more in someone else's hands than it is idle in mine. Maybe someone in Sri Lanka, or Kenya, or the Philippines, or a small town anywhere, wants to be a marketplace entrepreneur and just needs the product part already done. That's who this is for.&lt;/p&gt;

&lt;p&gt;One honest note on how it was built: the idea, the architecture, the data model, the trust score design and the business logic are mine. For the actual coding I worked with AI assistants throughout — this is a lot of code for one person, and I'd rather say so than pretend otherwise. It's deployed and running, every flow is exercised end to end with the seeded data, and Stripe runs in test mode. What it hasn't had is real traffic — that's the part I'm handing over.&lt;/p&gt;

&lt;p&gt;Before releasing it I spent a couple of weeks doing what most open-source dumps skip:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Stripped every brand, region, currency, price and legal line into two config files driven by environment variables&lt;/li&gt;
&lt;li&gt;Wrote real docs — configuration, architecture, API, deployment, and a guide to hosting a demo&lt;/li&gt;
&lt;li&gt;Added a seed script that creates 20 Pros, 6 businesses, 5 clients and jobs in every state, so you can see the whole app working in five minutes&lt;/li&gt;
&lt;li&gt;Wrote a SECURITY.md that says plainly what the code does and doesn't protect against&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And then I put a note at the top of the README: &lt;strong&gt;shared as-is, not actively maintained.&lt;/strong&gt; Fork it, make it yours, don't expect me to answer issues. I'd rather be upfront than let people wait on PRs that won't get merged.&lt;/p&gt;
&lt;h2&gt;
  
  
  The trust score: how to rank professionals without getting gamed
&lt;/h2&gt;

&lt;p&gt;Every marketplace has the same problem. You need to rank providers, and the obvious signal — average star rating — is terrible.&lt;/p&gt;

&lt;p&gt;A Pro with three 5-star reviews from their friends shows 5.0. A Pro with 200 real jobs averaging 4.6 shows 4.6. The new guy wins. Your best professional loses.&lt;/p&gt;

&lt;p&gt;So the score in SkillHub is a 0–100 composite of five signals, recomputed on every job event:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Weight&lt;/th&gt;
&lt;th&gt;What it measures&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Completion&lt;/td&gt;
&lt;td&gt;30%&lt;/td&gt;
&lt;td&gt;Jobs completed vs cancelled by the Pro&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rating&lt;/td&gt;
&lt;td&gt;25%&lt;/td&gt;
&lt;td&gt;Client ratings — Bayesian-adjusted and time-decayed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Responsiveness&lt;/td&gt;
&lt;td&gt;20%&lt;/td&gt;
&lt;td&gt;Average response time to leads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disputes&lt;/td&gt;
&lt;td&gt;15%&lt;/td&gt;
&lt;td&gt;Disputes lost to the client, as a rate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trust signals&lt;/td&gt;
&lt;td&gt;10%&lt;/td&gt;
&lt;td&gt;ID verified, trade certification, referred by a high-score Pro&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The bands: Elite 90+, Trusted 75–89, Rising 55–74, Probation 35–54, Suspended ≤34.&lt;/p&gt;

&lt;p&gt;Most of those are simple ratios. The rating signal is where the real work is.&lt;/p&gt;
&lt;h3&gt;
  
  
  Fixing the "three friends" problem: Bayesian shrinkage
&lt;/h3&gt;

&lt;p&gt;Instead of trusting a Pro's raw average, we pull it toward the platform average until they've earned enough reviews to stand on their own.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;PLATFORM_MEAN_RATING&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;4.2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;// what a typical Pro on the platform scores&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BAYESIAN_THRESHOLD&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// how many reviews before we mostly trust yours&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ratedJobs&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bayesianAdj&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;weightedMean&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;BAYESIAN_THRESHOLD&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;PLATFORM_MEAN_RATING&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;BAYESIAN_THRESHOLD&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the same formula IMDb uses for its Top 250. Run the numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;3 reviews, all 5 stars:&lt;/strong&gt; (3 × 5.0 + 30 × 4.2) / 33 = &lt;strong&gt;4.27&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;200 reviews averaging 4.6:&lt;/strong&gt; (200 × 4.6 + 30 × 4.2) / 230 = &lt;strong&gt;4.55&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The experienced Pro wins. The new Pro isn't punished — 4.27 is above the platform average — they just haven't proved anything yet. Get to 30 reviews and your own average carries about half the weight; at 100+ it's nearly all yours.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fixing the "coasting" problem: time decay
&lt;/h3&gt;

&lt;p&gt;The second problem: a Pro who was excellent two years ago and mediocre since. A plain average barely moves.&lt;/p&gt;

&lt;p&gt;So each rating is weighted by how old it is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DECAY_LAMBDA&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.003&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;  &lt;span class="c1"&gt;// per day&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;weights&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ratedJobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;DECAY_LAMBDA&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nf"&gt;daysAgo&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;j&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;weightedMean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
  &lt;span class="nx"&gt;ratedJobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;sum&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;workerRating&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;
  &lt;span class="nx"&gt;weights&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At λ = 0.003, a rating's weight halves roughly every 230 days. A one-year-old review counts for about a third of a fresh one. A three-year-old review is nearly noise. Recent behaviour dominates, but history isn't erased.&lt;/p&gt;

&lt;p&gt;Then the adjusted rating on a 1–5 scale is mapped to 0–100:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;ratingScore&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;round&lt;/span&gt;&lt;span class="p"&gt;(((&lt;/span&gt;&lt;span class="nx"&gt;bayesianAdj&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The parts that aren't math: caps and hard overrides
&lt;/h3&gt;

&lt;p&gt;Weighted averages are fair, but a marketplace also needs rules that can't be averaged away.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// No ID verification? You can't score above 70, no matter how good your numbers are.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;idVerified&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;skillScore&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;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;skillScore&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// Lose two disputes in 30 days and you're suspended, full stop.&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;recentDisputesLost&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;isSuspended&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;skillScore&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;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;skillScore&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;34&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Fraud flag from the moderation service: score goes to zero.&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;profile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fraudFlag&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;skillScore&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;scoreBand&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;suspended&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The ID cap is the one I'd defend hardest. It means "Trusted" and "Elite" on this platform always means a real, verified human. You can't buy your way there with reviews.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I got wrong the first time
&lt;/h3&gt;

&lt;p&gt;The first version used a plain average and a simple job count. Even with seeded test data the failure mode was obvious: new Pros with a handful of perfect reviews sitting above people with dozens of jobs. The composite score with shrinkage and decay was the second attempt, and it's the one that shipped.&lt;/p&gt;

&lt;p&gt;The full function is about 120 lines: &lt;a href="https://github.com/Sameera-MHK/service_marketplace/blob/main/server/services/scoreService.js" rel="noopener noreferrer"&gt;&lt;code&gt;server/services/scoreService.js&lt;/code&gt;&lt;/a&gt;. Every constant is at the top. If your platform's ratings skew higher or lower than 4.2, change one number.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you want to use it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/Sameera-MHK/service_marketplace skillhub
&lt;span class="nb"&gt;cd &lt;/span&gt;skillhub/server &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
&lt;span class="c"&gt;# set MONGO_URI, JWT_SECRET, JWT_REFRESH_SECRET&lt;/span&gt;
npm run dev
&lt;span class="nb"&gt;cd&lt;/span&gt; ../client &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env.local &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm run dev
&lt;span class="nb"&gt;cd&lt;/span&gt; ../server &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; npm run reseed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Log in as &lt;code&gt;admin@skillhub.example.com&lt;/code&gt; / &lt;code&gt;Admin123!&lt;/code&gt; and you've got a fully populated marketplace on localhost. Rebranding is &lt;code&gt;client/src/config/site.js&lt;/code&gt; and &lt;code&gt;server/config/site.js&lt;/code&gt;. Deployment guide, API reference and architecture docs are in &lt;code&gt;/docs&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Things to know before you ship it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The legal pages are sample text. Get a lawyer.&lt;/li&gt;
&lt;li&gt;The bundled photos are placeholders. Replace them.&lt;/li&gt;
&lt;li&gt;Change every seeded password.&lt;/li&gt;
&lt;li&gt;Read SECURITY.md. It tells you what's your job.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's actually in it that took the longest
&lt;/h2&gt;

&lt;p&gt;For anyone building a marketplace from scratch, the features that ate the most time — and that you get for free here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The commission engine.&lt;/strong&gt; Platform default → per-category rate → per-Pro override → volume tiers, resolved in priority order per transaction type. Every marketplace gets this wrong the first time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escrow job flow with disputes.&lt;/strong&gt; Deposit, hold, release on confirmation, admin resolution path. The state machine has more edges than you'd think.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Graceful degradation.&lt;/strong&gt; Making every integration optional without &lt;code&gt;if (stripe)&lt;/code&gt; scattered through 50 files. It's a service layer with no-op fallbacks, and it's what makes the repo usable by a stranger in five minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trust score.&lt;/strong&gt; Above.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;If you fork it and build something, I'd like to see it. And if you've solved the provider-ranking problem differently — a different prior, a different decay, something learned — tell me in the comments. I'm sure this isn't the last word on it.&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>github</category>
      <category>opensource</category>
      <category>showdev</category>
    </item>
    <item>
      <title>I Connected Claude to 20 Years of Azure SQL Data with a Custom MCP Server. Here's How.</title>
      <dc:creator>mhk sameera</dc:creator>
      <pubDate>Tue, 15 Sep 2026 10:08:03 +0000</pubDate>
      <link>https://dev.to/mhk_sameera/i-connected-claude-to-20-years-of-azure-sql-data-with-a-custom-mcp-server-heres-how-53ba</link>
      <guid>https://dev.to/mhk_sameera/i-connected-claude-to-20-years-of-azure-sql-data-with-a-custom-mcp-server-heres-how-53ba</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcr76rsb4hp8feqwlmkg0.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcr76rsb4hp8feqwlmkg0.jpeg" alt=" " width="799" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A client came to me with a request that sounded simple:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"We have 20 years of data in our database. We want our managers to ask Claude questions and get reports. Marketing, HR, R&amp;amp;D, Production, Procurement, Sales — all of them."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;No dashboards, no BI tool, no SQL training. Just: type a question, get an answer from the actual data.&lt;/p&gt;

&lt;p&gt;The database is Azure SQL. It's been growing since the mid-2000s. It has hundreds of tables — real business data, but also legacy tables nobody remembers, staging tables, system tables, half-finished migrations, and a lot of columns that would confuse a human, let alone a language model.&lt;/p&gt;

&lt;p&gt;Here's how I built it, what I got right, and what I'd tighten up next.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Claude (each manager's account)
        │
        │  HTTPS + secret key
        ▼
mcp.company-domain.com  ──►  Nginx (reverse proxy, SSL)
                                    │
                                    ▼
                        Node.js MCP server on the company VPS
                        (table whitelist lives here)
                        + knowledge base table (which tables matter per department)
                                    │
                                    │  read-only SQL user
                                    ▼
                              Azure SQL Database
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four decisions drove everything:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Self-host the MCP server on the client's own VPS.&lt;/strong&gt; The data path is Claude → their server → their DB. No third-party middleware sitting on 20 years of business data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whitelist tables at the MCP layer.&lt;/strong&gt; Claude never sees the tables that aren't on the list. It can't query what it doesn't know exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read-only database user.&lt;/strong&gt; The MCP server connects with a SQL login that can only &lt;code&gt;SELECT&lt;/code&gt;. A hallucinated &lt;code&gt;DELETE&lt;/code&gt; is a syntax error, not a disaster.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One shared key, small audience.&lt;/strong&gt; More on this tradeoff below.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 1: The MCP server
&lt;/h2&gt;

&lt;p&gt;MCP (Model Context Protocol) is the standard Claude uses to talk to external tools. You write a server that exposes "tools" — functions with a name, a description, and a schema — and Claude decides when to call them.&lt;/p&gt;

&lt;p&gt;I built it in Node.js with the official SDK. Stripped down, the core looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;McpServer&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@modelcontextprotocol/sdk/server/mcp.js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;mssql&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ALLOWED_TABLES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SalesOrders&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SalesOrderLines&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Customers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Products&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Suppliers&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PurchaseOrders&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ProductionBatches&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Employees&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 salary columns exposed — see below&lt;/span&gt;
  &lt;span class="c1"&gt;// ... one line per table each department needs&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;server&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;McpServer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;company-data&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1.0.0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Tool 1: let Claude discover what it's allowed to see&lt;/span&gt;
&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;list_tables&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;List the tables available for querying&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt; &lt;span class="k"&gt;async &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="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ALLOWED_TABLES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
&lt;span class="p"&gt;}));&lt;/span&gt;

&lt;span class="c1"&gt;// Tool 2: schema for a whitelisted table&lt;/span&gt;
&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;describe_table&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Get columns and types for a table&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;table&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ALLOWED_TABLES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Table '&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;' is not available.`&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&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;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;t&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NVarChar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`SELECT COLUMN_NAME, DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = @t`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&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="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recordset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="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;// Tool 3: run a read-only query&lt;/span&gt;
&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;run_query&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Run a SELECT query against allowed tables. Max 500 rows.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="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;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="sr"&gt;/^select&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Only SELECT statements are allowed.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nf"&gt;extractTableNames&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;ALLOWED_TABLES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Table '&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;' is not available.`&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`SELECT TOP 500 * FROM (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;q&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;) AS sub`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&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="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recordset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three layers of protection on &lt;code&gt;run_query&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Regex rejects anything that isn't a &lt;code&gt;SELECT&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Every table referenced in the query is checked against the whitelist.&lt;/li&gt;
&lt;li&gt;Even if both of those somehow failed, the SQL login itself is read-only.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Defence in depth. Any one layer could have a bug; all three at once is unlikely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Whitelisting — the part that made it work
&lt;/h2&gt;

&lt;p&gt;The instinct is to give Claude the whole schema and let it figure things out. Don't.&lt;/p&gt;

&lt;p&gt;With hundreds of tables, Claude picks the wrong one constantly. There were three different "customer" tables from three different eras of the system. There were tables with &lt;code&gt;_old&lt;/code&gt;, &lt;code&gt;_bak&lt;/code&gt;, &lt;code&gt;_v2&lt;/code&gt; suffixes. There were tables with 80 columns where 70 were unused.&lt;/p&gt;

&lt;p&gt;The whitelist solves two problems at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Security&lt;/strong&gt; — sensitive and irrelevant tables are invisible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accuracy&lt;/strong&gt; — Claude only has to reason about the tables that matter, so it picks the right one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whitelist lives in the server code. When a department needs a new table, I add one line and redeploy. That's deliberately manual — I want a human to look at every table before Claude can see it.&lt;/p&gt;

&lt;p&gt;For the &lt;code&gt;Employees&lt;/code&gt; table, I went one step further and exposed a SQL view with the sensitive columns removed, rather than the raw table. The view name goes on the whitelist; the table doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2.5: The knowledge base table
&lt;/h2&gt;

&lt;p&gt;Whitelisting fixed &lt;em&gt;what&lt;/em&gt; Claude can see. It didn't fix &lt;em&gt;which&lt;/em&gt; table Claude should reach for when someone from Marketing asks a Marketing question.&lt;/p&gt;

&lt;p&gt;So I added one more table to the database — a knowledge base that describes the other tables in plain English:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;mcp_knowledge_base&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;department&lt;/span&gt;   &lt;span class="n"&gt;NVARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;    &lt;span class="c1"&gt;-- Marketing, HR, RnD, Production, Procurement, Sales&lt;/span&gt;
  &lt;span class="k"&gt;table_name&lt;/span&gt;   &lt;span class="n"&gt;NVARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="n"&gt;description&lt;/span&gt;  &lt;span class="n"&gt;NVARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;-- what this table holds, in business language&lt;/span&gt;
  &lt;span class="n"&gt;key_columns&lt;/span&gt;  &lt;span class="n"&gt;NVARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;-- the columns that matter and what they mean&lt;/span&gt;
  &lt;span class="n"&gt;notes&lt;/span&gt;        &lt;span class="n"&gt;NVARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;-- gotchas: "status 3 means cancelled", "amounts in AED", etc.&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And a fourth tool on the MCP server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;get_table_guide&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Find which tables are relevant for a department or topic, with descriptions&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;topic&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="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;result&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;pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;input&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;t&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NVarChar&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`%&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;topic&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;%`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`SELECT department, table_name, description, key_columns, notes
              FROM mcp_knowledge_base
              WHERE department LIKE @t OR description LIKE @t OR table_name LIKE @t`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&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="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recordset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now when a manager asks about campaign performance, Claude calls &lt;code&gt;get_table_guide("marketing")&lt;/code&gt;, gets back the three or four tables that actually matter with a description of each, and goes straight to them — instead of scanning fifty table names and guessing.&lt;/p&gt;

&lt;p&gt;This table is the one thing the client's team updates themselves. When a column's meaning changes or a new report pattern emerges, they add a row. No code change, no redeploy. It's turned into a living data dictionary — something this company never had in 20 years — and it exists because an LLM needed it.&lt;/p&gt;

&lt;p&gt;Two rules for writing the descriptions: write for a smart new employee, not a DBA, and put the gotchas in. "Amounts are in AED excluding VAT" saves Claude from a wrong answer far more often than a perfect schema does.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Nginx in front
&lt;/h2&gt;

&lt;p&gt;The MCP server runs on a local port on the VPS. Nginx sits in front on a company subdomain with an SSL certificate from Let's Encrypt.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;mcp.company-domain.com&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;ssl_certificate&lt;/span&gt;     &lt;span class="n"&gt;/etc/letsencrypt/live/mcp.company-domain.com/fullchain.pem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kn"&gt;ssl_certificate_key&lt;/span&gt; &lt;span class="n"&gt;/etc/letsencrypt/live/mcp.company-domain.com/privkey.pem&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

    &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://127.0.0.1:7544&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Upgrade&lt;/span&gt; &lt;span class="nv"&gt;$http_upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Connection&lt;/span&gt; &lt;span class="s"&gt;'upgrade'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_cache_bypass&lt;/span&gt; &lt;span class="nv"&gt;$http_upgrade&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_read_timeout&lt;/span&gt; &lt;span class="s"&gt;300s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;proxy_send_timeout&lt;/span&gt; &lt;span class="s"&gt;300s&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;Two lines matter more than they look:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The &lt;code&gt;Upgrade&lt;/code&gt; / &lt;code&gt;Connection&lt;/code&gt; headers.&lt;/strong&gt; MCP uses long-lived streaming connections. Without these, Nginx treats it as a plain HTTP request and the connection drops.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The 300-second timeouts.&lt;/strong&gt; Nginx defaults to 60s. Some of these queries run against 20 years of data. A report that takes 90 seconds to aggregate would silently fail with the defaults. Five minutes was the point where every real query finished.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 4: Connecting Claude
&lt;/h2&gt;

&lt;p&gt;Each manager adds the server in Claude as a custom connector: the subdomain URL plus the shared secret key. That's it — no software to install.&lt;/p&gt;

&lt;p&gt;To start a session, the user types the name of the MCP server in their first message so Claude knows to use it. After that, they just ask questions in plain English:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Compare procurement spend by supplier for the last three quarters and flag any supplier where cost per unit went up more than 10%."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Claude calls &lt;code&gt;get_table_guide("procurement")&lt;/code&gt;, reads which tables matter and what the columns mean, calls &lt;code&gt;describe_table&lt;/code&gt; where it needs more detail, writes the SQL, calls &lt;code&gt;run_query&lt;/code&gt;, and turns the result into a readable answer — a table, a summary, sometimes a chart.&lt;/p&gt;

&lt;p&gt;Anyone in the company whose Claude account &lt;em&gt;doesn't&lt;/em&gt; have the connector configured gets nothing. Claude has no knowledge of the data on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tradeoff I made on purpose: one shared key
&lt;/h2&gt;

&lt;p&gt;The client asked for a single shared key. The audience is a small group — CEO, CFO, general managers, department heads. People who already have cross-department visibility. Managing per-user keys for a group that size would have been more admin work than security benefit.&lt;/p&gt;

&lt;p&gt;I agreed, with two conditions: the key stays with senior management only, and the DB user stays read-only so the worst case is "someone saw a report they shouldn't have," not "someone changed data."&lt;/p&gt;

&lt;p&gt;Where this would break: if access expands to 50 people, or if departments genuinely need to be isolated from each other (HR from Sales, say), the shared key stops being acceptable. At that point the right move is per-department keys mapped to per-department whitelists, or OAuth through Claude's connector system so access follows the user's identity. The code change is small; the policy change is the hard part.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tighten next
&lt;/h2&gt;

&lt;p&gt;Being honest about the gaps:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Audit log.&lt;/strong&gt; Every query, who ran it, when, how many rows. This is a five-line addition and the client will want it the first time someone asks "who looked at that?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rate limiting&lt;/strong&gt; on the endpoint. A leaked key today means unlimited reads. A rate limit caps the damage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whitelist as config, not code.&lt;/strong&gt; A JSON file or a small admin table so the client can add tables without me redeploying.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Row limits per table.&lt;/strong&gt; &lt;code&gt;TOP 500&lt;/code&gt; is a global cap. Some tables should be lower.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query cost guard.&lt;/strong&gt; Reject queries with no &lt;code&gt;WHERE&lt;/code&gt; on the very large tables.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Was it worth it?
&lt;/h2&gt;

&lt;p&gt;The client went from "ask IT for a report, wait three days" to "ask Claude, wait thirty seconds." Managers who never touched SQL are pulling supplier comparisons, production yield trends, and sales-by-region breakdowns themselves.&lt;/p&gt;

&lt;p&gt;The whole thing is about 300 lines of Node, one Nginx config, a read-only SQL user, and one knowledge base table that the client now maintains themselves. The hard parts weren't technical — they were deciding what Claude should be allowed to see, and being disciplined about saying no to the rest.&lt;/p&gt;




&lt;p&gt;If you've connected an LLM to a legacy database, I'd like to hear how you handled the "too many tables" problem — whitelist, views, semantic layer, something else?&lt;/p&gt;

</description>
    </item>
    <item>
      <title>The Good, The Bad, and The Hydration Errors: Migrating a Production React SPA to Next.js App Router</title>
      <dc:creator>mhk sameera</dc:creator>
      <pubDate>Sun, 13 Sep 2026 17:39:24 +0000</pubDate>
      <link>https://dev.to/mhk_sameera/the-good-the-bad-and-the-hydration-errors-migrating-a-production-react-spa-to-nextjs-app-router-2hmd</link>
      <guid>https://dev.to/mhk_sameera/the-good-the-bad-and-the-hydration-errors-migrating-a-production-react-spa-to-nextjs-app-router-2hmd</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6rdw1d4kcdmhmfswnukn.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6rdw1d4kcdmhmfswnukn.jpeg" alt=" " width="799" height="436"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two and a half years ago I built a corporate website for a Dubai-based food manufacturer as a plain React SPA — about 20 pages covering their brands, factories, product categories and export markets. Create React App, react-router, &lt;code&gt;useEffect&lt;/code&gt; for data, react-helmet for meta tags. It looked good and the client was happy.&lt;/p&gt;

&lt;p&gt;Then the SEO reports came in. The company sells in 50+ countries, and search is where their distributors and private-label buyers find them. A client-rendered SPA where Google sees an empty &lt;code&gt;&amp;lt;div id="root"&amp;gt;&lt;/code&gt; and a loading spinner is a bad place to be.&lt;/p&gt;

&lt;p&gt;So I rewrote it in Next.js App Router. Not "migrated" — rewrote. Every single file. With two hard constraints from the client:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Every URL stays exactly the same.&lt;/strong&gt; &lt;code&gt;/about&lt;/code&gt;, &lt;code&gt;/our-brands&lt;/code&gt;, &lt;code&gt;/private-labels&lt;/code&gt;, &lt;code&gt;/travel-retail&lt;/code&gt; — all of it. Years of indexed pages and backlinks were not going to be thrown away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The site has to look identical.&lt;/strong&gt; No redesign. Users should not notice anything changed except that it's faster.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's what that actually involved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a full rewrite instead of incremental migration
&lt;/h2&gt;

&lt;p&gt;Everyone recommends incremental migration. I tried for about two days and gave up.&lt;/p&gt;

&lt;p&gt;The SPA's architecture was fundamentally client-first: routing in react-router, data fetching in components, layout state in context providers wrapping the whole tree. App Router wants the opposite — server-first, with client interactivity as the exception. Trying to run both mental models in one codebase meant every component needed a "which world am I in?" check.&lt;/p&gt;

&lt;p&gt;For a ~20-page marketing site, a clean rewrite was faster than a careful migration. If you have a 200-page app with complex client state, your answer might be different. But don't assume incremental is always the right call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Routes — react-router to the file system
&lt;/h2&gt;

&lt;p&gt;This was the easy part, and the most important one for SEO.&lt;/p&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Route&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/confectionery"&lt;/span&gt; &lt;span class="na"&gt;element&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Confectionery&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Route&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/snacks"&lt;/span&gt; &lt;span class="na"&gt;element&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Snacks&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Route&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/our-brands"&lt;/span&gt; &lt;span class="na"&gt;element&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Brands&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;app/
  confectionery/page.tsx
  snacks/page.tsx
  our-brands/page.tsx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One folder per route, named exactly as the old path. I literally opened the old router file and created folders line by line. Then I ran a script against the old sitemap to hit every URL on the new build and check for 200s.&lt;/p&gt;

&lt;p&gt;Two gotchas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Trailing slashes.&lt;/strong&gt; The old SPA served &lt;code&gt;/about&lt;/code&gt; and &lt;code&gt;/about/&lt;/code&gt; identically. Next.js redirects one to the other by default. Check which version Google had indexed and set &lt;code&gt;trailingSlash&lt;/code&gt; in &lt;code&gt;next.config.js&lt;/code&gt; to match.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Legacy URLs you forgot about.&lt;/strong&gt; Old PDFs, a &lt;code&gt;/catalogue&lt;/code&gt; vs &lt;code&gt;/catalog&lt;/code&gt; spelling inconsistency, a factory page that had been linked from a partner site. Grep your analytics for every path that got a hit in the last year and add &lt;code&gt;redirects()&lt;/code&gt; for anything that doesn't map cleanly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 2: The Server vs Client Component split
&lt;/h2&gt;

&lt;p&gt;This is where the real work was, and where the mental model shift happens.&lt;/p&gt;

&lt;p&gt;My rule became: &lt;strong&gt;everything is a Server Component until it can't be.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What stayed on the server (most of it):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Page layouts, headers, footers&lt;/li&gt;
&lt;li&gt;Static content sections — text, images, brand grids, factory listings&lt;/li&gt;
&lt;li&gt;Anything reading from a CMS or JSON at build time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What had to become &lt;code&gt;'use client'&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The mobile navigation (needs &lt;code&gt;useState&lt;/code&gt; for open/close)&lt;/li&gt;
&lt;li&gt;Image carousels and sliders&lt;/li&gt;
&lt;li&gt;The hero video with a mute/unmute button&lt;/li&gt;
&lt;li&gt;The contact form (form state, validation, submit)&lt;/li&gt;
&lt;li&gt;Anything using framer-motion or scroll-triggered animations&lt;/li&gt;
&lt;li&gt;Anything touching &lt;code&gt;window&lt;/code&gt;, &lt;code&gt;document&lt;/code&gt;, or &lt;code&gt;localStorage&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The trap I fell into early: marking a whole page &lt;code&gt;'use client'&lt;/code&gt; because one small piece needed interactivity. That throws away the entire point of the migration. The fix is to push the client boundary as far down as possible — the &lt;em&gt;button&lt;/em&gt; is a client component, the &lt;em&gt;section&lt;/em&gt; containing it stays on the server.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/about/page.tsx — Server Component, no directive&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;HeroVideo&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./HeroVideo&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 'use client' lives in here&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;AboutPage&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;section&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;...static content, rendered on server...&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;section&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;HeroVideo&lt;/span&gt; &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"..."&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;   &lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* only this hydrates */&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&amp;gt;&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 3: Data fetching — killing useEffect
&lt;/h2&gt;

&lt;p&gt;The old site had this pattern everywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;brands&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setBrands&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;useState&lt;/span&gt;&lt;span class="p"&gt;([]);&lt;/span&gt;
&lt;span class="nf"&gt;useEffect&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="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/api/brands&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;then&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;setBrands&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;Loading spinner, then content. Google sees the spinner.&lt;/p&gt;

&lt;p&gt;In App Router, the component is &lt;code&gt;async&lt;/code&gt; and just fetches:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;BrandsPage&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;brands&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;getBrands&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;BrandGrid&lt;/span&gt; &lt;span class="na"&gt;brands&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;brands&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No loading state, no spinner, HTML arrives with the content in it. This one change is 80% of the SEO win.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Meta tags — react-helmet to the Metadata API
&lt;/h2&gt;

&lt;p&gt;The old site used react-helmet to set titles and descriptions client-side. Which means crawlers that don't execute JS never saw them.&lt;/p&gt;

&lt;p&gt;App Router has this built in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;title&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 Name | Confectionery, Date &amp;amp; Snack Brands from Dubai&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;openGraph&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;images&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;website&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;alternates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://example.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Per page, statically, in the HTML. I also added &lt;code&gt;sitemap.ts&lt;/code&gt; and &lt;code&gt;robots.ts&lt;/code&gt; in the &lt;code&gt;app&lt;/code&gt; folder so those are generated automatically instead of being hand-maintained files that drift out of date.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Images
&lt;/h2&gt;

&lt;p&gt;The SPA used plain &lt;code&gt;&amp;lt;img&amp;gt;&lt;/code&gt; tags pointing at Cloudinary. I kept Cloudinary but wrapped everything in &lt;code&gt;next/image&lt;/code&gt; with a custom loader so I got proper &lt;code&gt;srcset&lt;/code&gt;, lazy loading, and no layout shift — without moving 200+ images anywhere.&lt;/p&gt;

&lt;p&gt;One annoyance: &lt;code&gt;next/image&lt;/code&gt; requires width and height (or &lt;code&gt;fill&lt;/code&gt;). The old code had none of that. I spent an afternoon adding dimensions. Worth it — CLS dropped to nearly zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bad: Hydration errors
&lt;/h2&gt;

&lt;p&gt;Now the part from the title.&lt;/p&gt;

&lt;p&gt;A hydration error happens when the HTML the server rendered doesn't match what React renders on the client's first pass. React then throws away the server HTML and re-renders from scratch — which is slow, ugly, and defeats the purpose.&lt;/p&gt;

&lt;p&gt;Where they came from in this project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;window&lt;/code&gt; and &lt;code&gt;localStorage&lt;/code&gt; checks in render.&lt;/strong&gt; Any &lt;code&gt;typeof window !== 'undefined'&lt;/code&gt; branch that changes output. Server takes one path, client takes another, mismatch. Fix: move it into &lt;code&gt;useEffect&lt;/code&gt;, or render a neutral state first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dates and locales.&lt;/strong&gt; &lt;code&gt;new Date().getFullYear()&lt;/code&gt; in the footer copyright. Rendered on a server in one timezone, hydrated in a browser in another — usually fine, until it's midnight somewhere. Fix: compute it once and pass it as a prop, or accept it's a client component.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Third-party scripts injecting DOM.&lt;/strong&gt; A chat widget and an analytics tag both modified the body before React hydrated. Fix: load them with &lt;code&gt;next/script&lt;/code&gt; using &lt;code&gt;strategy="afterInteractive"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nested &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt; tags.&lt;/strong&gt; The old site had a card component that was an &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt; wrapping content that also contained an &lt;code&gt;&amp;lt;a&amp;gt;&lt;/code&gt;. Browsers silently "fix" invalid HTML, and the fixed version doesn't match React's output. Fix: fix your HTML.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Random IDs.&lt;/strong&gt; A component generated &lt;code&gt;Math.random()&lt;/code&gt; IDs for accessibility attributes. Different on server and client every time. Fix: &lt;code&gt;useId()&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Debugging tip: the error message in dev tells you &lt;em&gt;which&lt;/em&gt; element mismatched. Don't ignore it and don't suppress it with &lt;code&gt;suppressHydrationWarning&lt;/code&gt; unless you understand exactly why (the footer year is the one legitimate case).&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: The contact form
&lt;/h2&gt;

&lt;p&gt;The old form POSTed to an external service from the client. I moved it to a Route Handler at &lt;code&gt;app/api/contact/route.ts&lt;/code&gt; — the API key is now server-side only, I added server validation with Zod, and rate limiting so the form can't be spammed. (If you read my last post, you know why I care about this now.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Caching — the part nobody explains well
&lt;/h2&gt;

&lt;p&gt;App Router caches aggressively by default, and the rules changed between versions. For a marketing site the simple approach is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pages with content that rarely changes: let them be static. Build once, serve from CDN.&lt;/li&gt;
&lt;li&gt;Pages pulling from a CMS: &lt;code&gt;export const revalidate = 3600&lt;/code&gt; so they rebuild at most hourly.&lt;/li&gt;
&lt;li&gt;Anything that must be fresh on every request: &lt;code&gt;export const dynamic = 'force-dynamic'&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Don't try to be clever. Start fully static, and only opt into dynamic where you actually saw stale content.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Good: what changed
&lt;/h2&gt;

&lt;p&gt;Before (SPA): a blank page and a spinner until the JS bundle loaded, meta tags set by JavaScript, Lighthouse performance in the 50s–60s on mobile.&lt;/p&gt;

&lt;p&gt;After: full HTML on first byte, every page with server-rendered meta and canonical tags, Lighthouse performance in the 90s, and the site indexed properly within a few weeks of launch.&lt;/p&gt;

&lt;p&gt;The site looks exactly the same to a visitor. That was the requirement. But Google sees a completely different site.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell someone about to do this
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Map every old route to a folder &lt;em&gt;first&lt;/em&gt;, before writing a single component. Routes are your SEO contract.&lt;/li&gt;
&lt;li&gt;Default to Server Components. Add &lt;code&gt;'use client'&lt;/code&gt; only when the compiler complains, and add it as low in the tree as possible.&lt;/li&gt;
&lt;li&gt;Hunt down every &lt;code&gt;window&lt;/code&gt;, &lt;code&gt;Date&lt;/code&gt;, &lt;code&gt;Math.random&lt;/code&gt;, and &lt;code&gt;localStorage&lt;/code&gt; in render paths before you deploy — those are your hydration errors.&lt;/li&gt;
&lt;li&gt;Test the build with JS disabled. If the page is readable, you did it right.&lt;/li&gt;
&lt;li&gt;For a small-to-mid site, seriously consider a clean rewrite over incremental migration. It's less scary than it sounds.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;If you've done a similar migration and hit a hydration error I didn't list, drop it in the comments — I'm sure I haven't seen them all.&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>seo</category>
      <category>webdev</category>
    </item>
    <item>
      <title>3 Things Vibe Coders Must Check Before Pushing to Production</title>
      <dc:creator>mhk sameera</dc:creator>
      <pubDate>Wed, 09 Sep 2026 07:47:50 +0000</pubDate>
      <link>https://dev.to/mhk_sameera/3-things-vibe-coders-must-check-before-pushing-to-production-3hph</link>
      <guid>https://dev.to/mhk_sameera/3-things-vibe-coders-must-check-before-pushing-to-production-3hph</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F25zcrjhjy69f7ya9wu9q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F25zcrjhjy69f7ya9wu9q.png" alt=" " width="800" height="570"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Vibe coding is amazing. You describe what you want, the AI writes it, it works on localhost, and you feel like a 10x developer.&lt;/p&gt;

&lt;p&gt;Then you push to production with real users and real data, and things get ugly.&lt;/p&gt;

&lt;p&gt;I've shipped a few projects this way, and I've learned — once at the cost of real money — that AI-generated code is great at making things &lt;em&gt;work&lt;/em&gt; and terrible at making things &lt;em&gt;safe&lt;/em&gt;. The AI optimizes for "it runs" — not for "it survives the internet."&lt;/p&gt;

&lt;p&gt;Here are the three things I now check every single time before going live. None of them are complicated, and skipping them is how you end up leaking user data.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Exposed environment variables (this one cost me real money)
&lt;/h2&gt;

&lt;p&gt;Let me tell you how I learned this.&lt;/p&gt;

&lt;p&gt;I run &lt;a href="https://askleya.io" rel="noopener noreferrer"&gt;AskLeya&lt;/a&gt;, a SaaS chat assistant for businesses. It uses an OpenRouter API key to call GPT-4o, and that model has exactly one job: read the customer's knowledge base and answer their customers' questions. That's it. One model, one purpose.&lt;/p&gt;

&lt;p&gt;One day a customer flagged that their chat widget had stopped responding. I checked the code. No errors. I checked the server. Fine. Then I opened the OpenRouter dashboard.&lt;/p&gt;

&lt;p&gt;My credit — which by my usage math should have lasted a long time — was gone. And the usage log showed calls to Claude Opus, Claude Sonnet, Kimi, GLM and a handful of other models I had never touched. Someone had my key and was using it as their personal free AI account.&lt;/p&gt;

&lt;p&gt;I rotated the key immediately and the app came back. I didn't spend more time hunting for exactly how it leaked — at that point the fix was the same regardless. But here's the uncomfortable part: I wasn't even fully vibe coding that project. I used AI help in some areas, wrote the rest myself, and I still got caught.&lt;/p&gt;

&lt;p&gt;The likely suspects were the usual ones: a key that ended up in a client-side bundle, a &lt;code&gt;.env&lt;/code&gt; that briefly touched a git commit, or a key pasted into a chat/tool while debugging. Any of them is enough.&lt;/p&gt;

&lt;p&gt;Two things I do differently now:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Set a spend limit and a model allowlist on every key.&lt;/strong&gt; OpenRouter (and most providers) let you cap credits per key and restrict which models it can call. If my key had been limited to GPT-4o with a small ceiling, the damage would have been capped and the alerts would have fired early. Do this on day one, not after.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat every key as already leaked.&lt;/strong&gt; Ask "what happens if this gets out?" and design so the answer is "not much."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now, the general checks:&lt;/p&gt;

&lt;p&gt;Your AI assistant needs an API key to make something work. So it puts it somewhere convenient. Sometimes that "somewhere" ends up in the browser.&lt;/p&gt;

&lt;p&gt;Things to check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Client-side prefixes.&lt;/strong&gt; In Next.js, anything starting with &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; is shipped to the browser. In Vite it's &lt;code&gt;VITE_&lt;/code&gt;. If your OpenAI key, Stripe secret key, or database URL has one of these prefixes, it's public. Anyone can open DevTools and copy it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.env&lt;/code&gt; committed to git.&lt;/strong&gt; Check that &lt;code&gt;.env&lt;/code&gt; is in your &lt;code&gt;.gitignore&lt;/code&gt; &lt;em&gt;before&lt;/em&gt; your first commit, not after. If it's already been pushed, rotating the keys is the only fix — deleting the file doesn't remove it from history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keys hardcoded in the code.&lt;/strong&gt; AI tools sometimes paste the actual key into a file "temporarily." Grep your codebase for &lt;code&gt;sk-&lt;/code&gt;, &lt;code&gt;pk_&lt;/code&gt;, &lt;code&gt;AKIA&lt;/code&gt;, and anything that looks like a token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server vs client boundary.&lt;/strong&gt; Any call that uses a secret should happen on the server (API route, server action, edge function). If your frontend is calling a third-party API directly with a secret, that's a leak.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Quick test: build your project, open the output folder, and search the bundled JS for your keys. If you find one, you have a problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. No rate limiting
&lt;/h2&gt;

&lt;p&gt;Your app works fine when you're the only user. Then someone writes a 10-line script that hits your signup endpoint 50,000 times, or your AI chat endpoint 5,000 times, and you wake up to either a crashed app or a $400 API bill.&lt;/p&gt;

&lt;p&gt;AI-generated code almost never adds rate limiting unless you ask for it.&lt;/p&gt;

&lt;p&gt;Where you need it most:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auth endpoints&lt;/strong&gt; — login, signup, password reset. Without limits, these are open to brute force and spam accounts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anything that costs you money per request&lt;/strong&gt; — LLM calls, email sending, SMS, image generation. This is the one that hurts your wallet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Public forms&lt;/strong&gt; — contact forms, comments, anything unauthenticated.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expensive database queries&lt;/strong&gt; — search, exports, reports.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You don't need to build this yourself. Most platforms have a one-line solution:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Vercel / Next.js: &lt;code&gt;@upstash/ratelimit&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Express: &lt;code&gt;express-rate-limit&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Supabase: enable rate limits in Auth settings&lt;/li&gt;
&lt;li&gt;Cloudflare: rate limiting rules in the dashboard, no code needed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Even a crude limit like "10 requests per minute per IP" is 100x better than nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. OWASP vulnerabilities
&lt;/h2&gt;

&lt;p&gt;OWASP publishes a Top 10 list of the most common web security holes. It's been around for years, and AI models were trained on plenty of code that ignores it — so your generated code probably does too.&lt;/p&gt;

&lt;p&gt;You don't need to memorize the whole list. These are the ones I see most often in vibe-coded projects:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Broken access control.&lt;/strong&gt; The API checks if you're logged in, but not if you're allowed to see &lt;em&gt;this specific record&lt;/em&gt;. Change the ID in the URL from &lt;code&gt;/orders/123&lt;/code&gt; to &lt;code&gt;/orders/124&lt;/code&gt; and you're reading someone else's data. This is the #1 issue on OWASP's list and the #1 thing I find in AI-generated apps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Injection.&lt;/strong&gt; If any user input goes into a SQL query, shell command, or HTML without being sanitised or parameterised, you're exposed. Using an ORM helps but doesn't make you immune — raw queries sneak in.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing input validation.&lt;/strong&gt; The frontend validates the form, but the API accepts anything. Validate on the server with something like Zod or Joi. The frontend is not a security boundary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Insecure defaults.&lt;/strong&gt; Debug mode on in production. CORS set to &lt;code&gt;*&lt;/code&gt;. Database with no row-level security. Admin routes with no auth check because "nobody knows the URL."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix is simple but boring: for every API endpoint, ask "who can call this, and what can they get?" Then actually test it — log in as user A and try to fetch user B's data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before you push
&lt;/h2&gt;

&lt;p&gt;Here's the checklist I run through:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt; Searched the built bundle for secrets&lt;/li&gt;
&lt;li&gt; &lt;code&gt;.env&lt;/code&gt; in &lt;code&gt;.gitignore&lt;/code&gt;, no keys in git history&lt;/li&gt;
&lt;li&gt; All secrets used server-side only&lt;/li&gt;
&lt;li&gt; Rate limits on auth, paid APIs, and public forms&lt;/li&gt;
&lt;li&gt; Every endpoint checks ownership, not just login&lt;/li&gt;
&lt;li&gt; Server-side validation on all inputs&lt;/li&gt;
&lt;li&gt; Debug mode off, CORS locked down&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Takes maybe an hour. Saves you from a very bad week.&lt;/p&gt;

&lt;p&gt;One more tip: you can literally paste this list into your AI assistant and say "audit my codebase for these." It's surprisingly good at &lt;em&gt;finding&lt;/em&gt; the problems — it just doesn't fix them unless you ask.&lt;/p&gt;

&lt;p&gt;What other things have bitten you after shipping an AI-built project? I'd like to add to this list.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Building Ask Leya: Lessons Learned from Local ONNX Embeddings, Prisma Migrations, and Express Async Handlers</title>
      <dc:creator>mhk sameera</dc:creator>
      <pubDate>Tue, 08 Sep 2026 09:14:30 +0000</pubDate>
      <link>https://dev.to/mhk_sameera/building-ask-leya-lessons-learned-from-local-onnx-embeddings-prisma-migrations-and-express-async-29e4</link>
      <guid>https://dev.to/mhk_sameera/building-ask-leya-lessons-learned-from-local-onnx-embeddings-prisma-migrations-and-express-async-29e4</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fylsww6yafxhlfjekm7vo.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fylsww6yafxhlfjekm7vo.png" alt=" " width="799" height="417"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Why build another chat widget? Because every widget I tried either followed a rigid script or confidently made things up. Ask Leya answers from a business's own content and hands off to a human when it can't.&lt;/p&gt;

&lt;p&gt;Here are three core architectural hurdles I ran into and how I solved them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Cross-language retrieval is local, not the LLM
Retrieval uses paraphrase-multilingual-MiniLM-L12-v2 through ONNX, in-process — 384 dims, trained across 50+ languages. A business writes its knowledge base in English; an Arabic visitor asks in Arabic; the local model embeds that question into the same vector space as the English entries and finds the right one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No translation step, no API call, no per-query embedding cost, and nothing leaves the server to be embedded. The chat model then only has to phrase the answer back in Arabic, which is the easy half.&lt;/p&gt;

&lt;p&gt;The trade: A cold start while the model loads, and it pulls in sharp, which cost me a 90-second production hang when a macOS binary got rsynced onto a Linux box.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;Managing a database shared with unreleased modules&lt;br&gt;
The database is shared with the rest of my broader app under development — 67 tables total right now, many of which belong to unreleased modules. Since Prisma migrate tries to treat unreleased tables as drift, every migration currently requires hand-filtering additive statements via prisma migrate diff until the full suite ships.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Express 4 doesn't forward async rejections&lt;br&gt;
An unguarded async handler that throws gives you no response at all — the caller just hangs until it times out. That took /chat down for 90 seconds a request before I tracked it down.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The Stack&lt;br&gt;
Frontend: Next.js App Router, React (with a dependency-free vanilla JS iframe widget so it can't collide with customer CSS).&lt;/p&gt;

&lt;p&gt;Backend: Node.js, Express.&lt;/p&gt;

&lt;p&gt;Database: Postgres (Neon) + pgvector.&lt;/p&gt;

&lt;p&gt;Real-time &amp;amp; AI: Socket.IO for live human handoff, gpt-4o-mini via OpenRouter for replies.&lt;/p&gt;

&lt;p&gt;Free tier, no credit card required. Features like WhatsApp, Instagram, and Messenger integrations are built out but kept behind the scenes until fully polished for self-serve.&lt;/p&gt;

</description>
      <category>node</category>
      <category>ai</category>
      <category>nextjs</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
