<?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: Parvej Shah</title>
    <description>The latest articles on DEV Community by Parvej Shah (@parvejshah).</description>
    <link>https://dev.to/parvejshah</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%2F2201171%2F86dbafc1-93e8-47bb-8531-3fc79b4456de.jpeg</url>
      <title>DEV Community: Parvej Shah</title>
      <link>https://dev.to/parvejshah</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/parvejshah"/>
    <language>en</language>
    <item>
      <title>The Product Behind the Viral Map</title>
      <dc:creator>Parvej Shah</dc:creator>
      <pubDate>Tue, 06 Oct 2026 14:24:35 +0000</pubDate>
      <link>https://dev.to/parvejshah/the-product-behind-the-viral-map-pf</link>
      <guid>https://dev.to/parvejshah/the-product-behind-the-viral-map-pf</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://parvejshah.com/blog/the-product-behind-the-viral-map" rel="noopener noreferrer"&gt;parvejshah.com/blog/the-product-behind-the-viral-map&lt;/a&gt; by &lt;a href="https://parvejshah.com" rel="noopener noreferrer"&gt;Parvej Shah&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We talk a lot about &lt;strong&gt;Product-Market Fit&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;But Unseen Bangladesh made me think about something else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The product didn't go viral. The content it created did.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you're reading this from outside Bangladesh, here's the context.&lt;/p&gt;

&lt;p&gt;Bangladesh is divided into &lt;strong&gt;64 districts across 8 administrative divisions&lt;/strong&gt;. For many Bangladeshis, travelling across these districts is a personal record of where they've been, what they've seen, and what parts of the country they still haven't explored.&lt;/p&gt;

&lt;p&gt;Unseen Bangladesh is a travel-focused web platform built around that idea.&lt;/p&gt;

&lt;p&gt;Its most talked-about feature is surprisingly simple: select the districts you've visited, choose a visual theme, optionally add your name and photo, and generate a personalized map showing your travel history. The map can then be downloaded as an image or PDF and shared.&lt;/p&gt;

&lt;p&gt;The platform itself goes beyond this feature. It also includes travel guides for Bangladesh's districts, a trip planner, quizzes, a map puzzle and a leaderboard. But in early October 2026, it was the personalized travel map that started spreading across Bangladeshi social media.&lt;/p&gt;

&lt;p&gt;The creator, &lt;strong&gt;Md. Sabbir Hossain Robin&lt;/strong&gt;, describes the project as something he originally built out of personal interest. He enjoys travelling and came up with the idea while trying to create a map of the places he had visited himself.&lt;/p&gt;

&lt;p&gt;That origin story is interesting.&lt;/p&gt;

&lt;p&gt;But what really caught my attention wasn't the story behind the product.&lt;/p&gt;

&lt;p&gt;It was the &lt;strong&gt;distribution loop hidden inside the product itself&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The product didn't go viral. The content did.
&lt;/h2&gt;

&lt;p&gt;Think about what normally happens when you use a product.&lt;/p&gt;

&lt;p&gt;You open it.&lt;/p&gt;

&lt;p&gt;You do something.&lt;/p&gt;

&lt;p&gt;You get a result.&lt;/p&gt;

&lt;p&gt;And then you leave.&lt;/p&gt;

&lt;p&gt;Unseen Bangladesh changes the last part.&lt;/p&gt;

&lt;p&gt;You don't just use the product and receive information.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You create something that represents you.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That distinction matters.&lt;/p&gt;

&lt;p&gt;When someone selects 23 out of Bangladesh's 64 districts, the resulting map isn't just a functional output.&lt;/p&gt;

&lt;p&gt;It says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"These are the places I've been."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Someone else sees that map in their Facebook feed and immediately has a reason to interact with it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"How many have I visited?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So they open the website, create their own map, and share it.&lt;/p&gt;

&lt;p&gt;Now their friends see it.&lt;/p&gt;

&lt;p&gt;And the cycle repeats.&lt;/p&gt;

&lt;p&gt;That's a very different growth mechanism from simply putting a "Share" button on a product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The viral loop was inside the product
&lt;/h2&gt;

&lt;p&gt;The basic interaction is almost embarrassingly simple:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Select → Customize → Generate → Share&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;But look at what happens after that:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use the product&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
↓&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Create a personal artifact&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
↓&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Share it&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
↓&lt;br&gt;&lt;br&gt;
&lt;strong&gt;Someone else sees it&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
↓&lt;br&gt;&lt;br&gt;
&lt;strong&gt;They compare themselves&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
↓&lt;br&gt;&lt;br&gt;
&lt;strong&gt;They want their own map&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
↓&lt;br&gt;&lt;br&gt;
&lt;strong&gt;They use the product&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The user-generated artifact becomes the distribution channel.&lt;/p&gt;

&lt;p&gt;That is the part I find most interesting from a product engineering perspective.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is where I started thinking about Product-Content Fit
&lt;/h2&gt;

&lt;p&gt;Product-Market Fit asks:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Do people want this product?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I think there is another question worth asking:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Does using this product naturally produce something people want to share?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I'm calling that &lt;strong&gt;Product-Content Fit&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I'm not presenting it as an established industry framework. It's simply the term I use for a product property I noticed while looking at Unseen Bangladesh.&lt;/p&gt;

&lt;p&gt;The distinction is useful.&lt;/p&gt;

&lt;p&gt;A product can have strong Product-Market Fit without producing anything shareable.&lt;/p&gt;

&lt;p&gt;For example, imagine a great accounting application. Businesses may desperately need it. They may use it every day. They may pay for it for years.&lt;/p&gt;

&lt;p&gt;But nobody is likely to post:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Look at my beautifully reconciled balance sheet."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The product can be valuable without being socially distributable.&lt;/p&gt;

&lt;p&gt;Unseen Bangladesh is different.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;output is inherently personal&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And personal things are often much easier to share.&lt;/p&gt;

&lt;h2&gt;
  
  
  People don't share products. They share themselves.
&lt;/h2&gt;

&lt;p&gt;This might be the most important part.&lt;/p&gt;

&lt;p&gt;People aren't necessarily sharing Unseen Bangladesh because they want to promote Unseen Bangladesh.&lt;/p&gt;

&lt;p&gt;They're sharing &lt;strong&gt;their own map&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The product is almost invisible in the final social interaction.&lt;/p&gt;

&lt;p&gt;What appears in the feed is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;where I have travelled&lt;/li&gt;
&lt;li&gt;how much of Bangladesh I have explored&lt;/li&gt;
&lt;li&gt;which districts I haven't visited&lt;/li&gt;
&lt;li&gt;what my travel history looks like&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The product turns personal data into a small social artifact.&lt;/p&gt;

&lt;p&gt;And that changes the motivation to share.&lt;/p&gt;

&lt;p&gt;You're not saying:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Check out this website."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You're saying:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"This is me."&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is a much stronger reason to post something.&lt;/p&gt;

&lt;h2&gt;
  
  
  The output matters as much as the interface
&lt;/h2&gt;

&lt;p&gt;As software engineers, we spend a lot of time thinking about interfaces.&lt;/p&gt;

&lt;p&gt;Buttons.&lt;/p&gt;

&lt;p&gt;Forms.&lt;/p&gt;

&lt;p&gt;Loading states.&lt;/p&gt;

&lt;p&gt;Navigation.&lt;/p&gt;

&lt;p&gt;APIs.&lt;/p&gt;

&lt;p&gt;Databases.&lt;/p&gt;

&lt;p&gt;Performance.&lt;/p&gt;

&lt;p&gt;But sometimes the most important product decision happens after the main interaction is finished.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does the user leave with?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In this case, the answer isn't simply "a result."&lt;/p&gt;

&lt;p&gt;They leave with a visual representation of themselves.&lt;/p&gt;

&lt;p&gt;That makes the output part of the product experience.&lt;/p&gt;

&lt;p&gt;And more importantly, it makes the output part of the distribution system.&lt;/p&gt;

&lt;p&gt;This leads to a principle I've started thinking about:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Don't just design the share action. Design the thing worth sharing.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A share button is a feature.&lt;/p&gt;

&lt;p&gt;A shareable artifact is a product decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  The friction was also unusually low
&lt;/h2&gt;

&lt;p&gt;The map doesn't ask users to learn a complicated system.&lt;/p&gt;

&lt;p&gt;The core flow is straightforward:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Select the districts you've visited.&lt;/li&gt;
&lt;li&gt;Choose a visual theme.&lt;/li&gt;
&lt;li&gt;Optionally personalize the map.&lt;/li&gt;
&lt;li&gt;Download it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The site supports PNG, JPG and PDF downloads.&lt;/p&gt;

&lt;p&gt;That matters because every additional step between &lt;strong&gt;"I want to try this"&lt;/strong&gt; and &lt;strong&gt;"I have something I can share"&lt;/strong&gt; creates another opportunity for the user to disappear.&lt;/p&gt;

&lt;p&gt;The product gets out of the way.&lt;/p&gt;

&lt;p&gt;The interesting part is the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  The creator didn't need to start with a growth strategy
&lt;/h2&gt;

&lt;p&gt;There is another lesson here that I find even more interesting.&lt;/p&gt;

&lt;p&gt;According to the site's own description, the idea started as a personal hobby. The creator wanted to map his own travels around Bangladesh and built something for people who share that interest.&lt;/p&gt;

&lt;p&gt;There is no guarantee that a hobby project will become popular.&lt;/p&gt;

&lt;p&gt;But building something personally meaningful can lead to a very useful product property:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You understand why someone would care about the output.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's difficult to manufacture through growth tactics alone.&lt;/p&gt;

&lt;p&gt;You can add referral systems.&lt;/p&gt;

&lt;p&gt;You can add social sharing.&lt;/p&gt;

&lt;p&gt;You can add notifications.&lt;/p&gt;

&lt;p&gt;You can add a "Share with friends" button.&lt;/p&gt;

&lt;p&gt;But if the thing being shared isn't interesting, none of those features solve the underlying problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deeper product engineering lesson
&lt;/h2&gt;

&lt;p&gt;When I build or think about products, I usually ask:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does the user do?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I'm starting to think we should ask another question:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What does the user produce?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And then one more:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens to that output after the user leaves the product?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For Unseen Bangladesh, the answer is powerful.&lt;/p&gt;

&lt;p&gt;The user produces a personalized travel map.&lt;/p&gt;

&lt;p&gt;That map leaves the product.&lt;/p&gt;

&lt;p&gt;It enters Facebook.&lt;/p&gt;

&lt;p&gt;Someone else sees it.&lt;/p&gt;

&lt;p&gt;That person becomes a potential user.&lt;/p&gt;

&lt;p&gt;So the product's output doesn't stop at the application boundary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It travels.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That's a product engineering consideration, not just a marketing consideration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Product-Market Fit vs Product-Content Fit
&lt;/h2&gt;

&lt;p&gt;I don't think Product-Content Fit replaces Product-Market Fit.&lt;/p&gt;

&lt;p&gt;They're answering different questions.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Product-Market Fit&lt;/th&gt;
&lt;th&gt;Product-Content Fit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Core question&lt;/td&gt;
&lt;td&gt;Do people want the product?&lt;/td&gt;
&lt;td&gt;Does using it create something worth sharing?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Focus&lt;/td&gt;
&lt;td&gt;User demand&lt;/td&gt;
&lt;td&gt;User-generated output&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Value&lt;/td&gt;
&lt;td&gt;Product solves a problem&lt;/td&gt;
&lt;td&gt;Output creates social interest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Distribution&lt;/td&gt;
&lt;td&gt;Usually requires a separate channel&lt;/td&gt;
&lt;td&gt;The output can become the channel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Example&lt;/td&gt;
&lt;td&gt;A useful accounting platform&lt;/td&gt;
&lt;td&gt;A personalized travel map&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The strongest products can potentially have both.&lt;/p&gt;

&lt;p&gt;People want the product.&lt;/p&gt;

&lt;p&gt;And using the product naturally creates something that attracts more people.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I would take from Unseen Bangladesh
&lt;/h2&gt;

&lt;p&gt;I'm not taking away the lesson that every product should try to become viral.&lt;/p&gt;

&lt;p&gt;Most products shouldn't.&lt;/p&gt;

&lt;p&gt;And "make it shareable" is not a strategy by itself.&lt;/p&gt;

&lt;p&gt;The more interesting lesson is this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Think about the artifact your product creates.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Could it represent the user?&lt;/p&gt;

&lt;p&gt;Could it help them express an identity?&lt;/p&gt;

&lt;p&gt;Could it make their progress visible?&lt;/p&gt;

&lt;p&gt;Could it give them something interesting to compare?&lt;/p&gt;

&lt;p&gt;Could they be proud of it?&lt;/p&gt;

&lt;p&gt;Could they show it to someone else?&lt;/p&gt;

&lt;p&gt;If the answer is yes, your product may have a distribution mechanism hiding inside the user experience.&lt;/p&gt;

&lt;p&gt;That's what I saw in Unseen Bangladesh.&lt;/p&gt;

&lt;p&gt;A simple travel map became a social artifact.&lt;/p&gt;

&lt;p&gt;The social artifact became content.&lt;/p&gt;

&lt;p&gt;The content became distribution.&lt;/p&gt;

&lt;p&gt;And the distribution brought people back to the product.&lt;/p&gt;

&lt;p&gt;That's why I think the more interesting question isn't only:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Do people want this?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;It's also:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Does using this give people something worth sharing?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Maybe that's another dimension of product thinking worth paying attention to.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Parvej Shah is a Lead Full-Stack Web Developer &amp;amp; Platform Architect based in Dhaka, Bangladesh. Explore full architecture case studies and production code at &lt;a href="https://parvejshah.com" rel="noopener noreferrer"&gt;parvejshah.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>product</category>
      <category>startup</category>
      <category>growth</category>
    </item>
    <item>
      <title>Before Building a Real Estate Voice Agent, I Needed to Understand the Calls</title>
      <dc:creator>Parvej Shah</dc:creator>
      <pubDate>Tue, 22 Sep 2026 05:08:33 +0000</pubDate>
      <link>https://dev.to/parvejshah/before-building-a-real-estate-voice-agent-i-needed-to-understand-the-calls-5gmc</link>
      <guid>https://dev.to/parvejshah/before-building-a-real-estate-voice-agent-i-needed-to-understand-the-calls-5gmc</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://parvejshah.com/blog/before-building-real-estate-voice-agent-understanding-calls" rel="noopener noreferrer"&gt;parvejshah.com/blog/before-building-real-estate-voice-agent-understanding-calls&lt;/a&gt; by &lt;a href="https://parvejshah.com" rel="noopener noreferrer"&gt;Parvej Shah&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I've been spending more time thinking about AI voice agents for real estate.&lt;/p&gt;

&lt;p&gt;At first, the product seemed fairly straightforward. A lead calls about a property. The AI answers, asks a few questions, qualifies the lead, checks availability, and hopefully books a showing.&lt;/p&gt;

&lt;p&gt;Technically, we can build that. But the more I looked at it, the more I realized I was starting with the wrong question.&lt;/p&gt;

&lt;p&gt;I was asking: &lt;strong&gt;"How do we build the voice agent?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Before answering a much more important one: &lt;strong&gt;"What is a good real estate agent actually trying to accomplish during this call?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That distinction changes almost everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The technology is becoming the easy part
&lt;/h2&gt;

&lt;p&gt;Building a voice agent today is surprisingly accessible. We can connect a phone number, speech models, an LLM, a CRM, calendars and other tools. We can make the system answer questions, collect information, and trigger actions.&lt;/p&gt;

&lt;p&gt;So it becomes tempting to think of the workflow as:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Call → AI → Appointment&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Nice and clean. Except the actual conversation between those three boxes contains most of the business logic.&lt;/p&gt;

&lt;p&gt;Someone might call about a specific property. Someone else might only know their budget and preferred neighborhood. One caller might be ready to tour this weekend. Another might not have financing sorted out. Another might be selling their current home first. Someone else may simply have a question that requires a human agent.&lt;/p&gt;

&lt;p&gt;Those aren't the same conversation.&lt;/p&gt;

&lt;p&gt;And the data reinforces how important this early interaction is. Zillow's 2025 research found that &lt;strong&gt;52% of successful buyers said contacting a real estate agent was their first home-buying activity&lt;/strong&gt;, and 80% had contacted one within their first three activities. A private in-person tour was also a common next step. &lt;a href="https://www.zillow.com/research/buyers-housing-trends-report-2025/" rel="noopener noreferrer"&gt;[1]&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So the conversation isn't some minor administrative step sitting on the edge of the buying process. It can be near the beginning of the relationship.&lt;/p&gt;

&lt;h2&gt;
  
  
  A booked showing isn't always the goal
&lt;/h2&gt;

&lt;p&gt;This was another assumption I had to challenge.&lt;/p&gt;

&lt;p&gt;It would be convenient to measure a voice agent like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;More calls → more booked showings → better agent.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But that's too simplistic. A successful call might end with a showing booked, a qualified lead captured, a question answered, a follow-up scheduled, or a handoff to the right human. The system needs to understand which outcome makes sense.&lt;/p&gt;

&lt;p&gt;That's especially important because people don't all want to communicate the same way. Zillow's 2025 survey of prospective U.S. buyers found phone calls were the preferred method for 33%, while SMS was preferred by 34% — and preferences varied considerably by age. &lt;a href="https://www.zillow.com/research/prospective-buyers-consumer-housing-trends-2025/" rel="noopener noreferrer"&gt;[2]&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A voice agent therefore shouldn't necessarily try to keep someone on the phone until it achieves its own definition of "conversion." Sometimes the best outcome of the call might be "I'll text you those details." Sometimes it's "let me connect you with the agent." And sometimes it's "I can schedule that showing."&lt;/p&gt;

&lt;p&gt;That's a much more interesting system to design.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;???&lt;/code&gt; is becoming the important part
&lt;/h2&gt;

&lt;p&gt;The mental model I started with looked like this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Incoming call → AI → Showing&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Now I think about it more like:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Incoming call → understand intent → gather relevant context → determine next action → execute or escalate&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The AI is only one component inside that flow. And suddenly the questions I'm interested in aren't primarily about models.&lt;/p&gt;

&lt;p&gt;What information does an agent need before arranging a showing? Which questions should be asked, and in what order? When should the AI stop asking questions? What should happen when someone doesn't know their financing situation? What happens when the caller asks something the system shouldn't answer confidently? When does a human need to take over?&lt;/p&gt;

&lt;p&gt;Those questions require understanding real estate, not just understanding LLMs. That matters because human agents still do much more than provide property information. The National Association of Realtors' 2025 U.S. buyer and seller research found that 88% of buyers purchased through an agent or broker. Buyers particularly sought help finding homes, negotiating terms, and navigating paperwork. &lt;a href="https://www.nar.realtor/news/real-estate-news/nar-2025-profile-of-home-buyers-sellers-reveals-market-extremes" rel="noopener noreferrer"&gt;[3]&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;AI doesn't make that complexity disappear. It has to operate inside it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Maybe "human-like" is the wrong benchmark
&lt;/h2&gt;

&lt;p&gt;A lot of voice AI demos focus on how natural the system sounds. Latency is tiny. It interrupts naturally. It says "um." You can barely tell it's AI.&lt;/p&gt;

&lt;p&gt;Those are impressive engineering achievements. But for a business, I think there's another benchmark that matters more:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Did the conversation move the customer toward the correct next step?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;An incredibly human-sounding agent that qualifies the wrong person, invents an answer, or pushes every caller toward an appointment isn't a good system. Meanwhile, a slightly less magical voice experience that correctly understands intent, retrieves reliable information, captures context, and knows when to hand over to a human may create much more value.&lt;/p&gt;

&lt;p&gt;That changes what "good" means.&lt;/p&gt;

&lt;h2&gt;
  
  
  Domain knowledge becomes part of the engineering
&lt;/h2&gt;

&lt;p&gt;There's a broader lesson here that goes beyond real estate. As AI makes implementation easier, &lt;strong&gt;understanding what should be implemented becomes more valuable.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A developer can learn a voice platform, its APIs, how to connect tools, how to improve prompts. But none of those automatically teach us how a brokerage operates.&lt;/p&gt;

&lt;p&gt;That's why, before trying to build the perfect real estate voice agent, I'm becoming more interested in the calls themselves. What do great agents ask? What do buyers ask repeatedly? Where does a conversation change direction? What information actually affects the next action? Where do leads disappear? Where should automation stop?&lt;/p&gt;

&lt;p&gt;I'm still researching those questions. And I think that's exactly the point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The goal isn't to build an AI that sounds like a real estate agent. It's to understand what a good real estate conversation accomplishes, then decide which parts AI should handle.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The technology can answer the phone. The real work starts after &lt;strong&gt;hello&lt;/strong&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Sources: &lt;a href="https://www.zillow.com/research/buyers-housing-trends-report-2025/" rel="noopener noreferrer"&gt;Zillow Consumer Housing Trends Report 2025 — Buyers&lt;/a&gt;, &lt;a href="https://www.zillow.com/research/prospective-buyers-consumer-housing-trends-2025/" rel="noopener noreferrer"&gt;Zillow Consumer Housing Trends Report 2025 — Prospective Buyers&lt;/a&gt;, &lt;a href="https://www.nar.realtor/news/real-estate-news/nar-2025-profile-of-home-buyers-sellers-reveals-market-extremes" rel="noopener noreferrer"&gt;NAR 2025 Profile of Home Buyers and Sellers&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Parvej Shah is a Lead Full-Stack Web Developer &amp;amp; Platform Architect based in Dhaka, Bangladesh. Explore full architecture case studies and production code at &lt;a href="https://parvejshah.com" rel="noopener noreferrer"&gt;parvejshah.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>programming</category>
      <category>tech</category>
    </item>
    <item>
      <title>We Can Build Software Faster Than Ever. But Can Users Actually Use It?</title>
      <dc:creator>Parvej Shah</dc:creator>
      <pubDate>Thu, 17 Sep 2026 05:52:02 +0000</pubDate>
      <link>https://dev.to/parvejshah/we-can-build-software-faster-than-ever-but-can-users-actually-use-it-57cb</link>
      <guid>https://dev.to/parvejshah/we-can-build-software-faster-than-ever-but-can-users-actually-use-it-57cb</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://parvejshah.com/blog/rapid-software-development-user-experience" rel="noopener noreferrer"&gt;parvejshah.com/blog/rapid-software-development-user-experience&lt;/a&gt; by &lt;a href="https://parvejshah.com" rel="noopener noreferrer"&gt;Parvej Shah&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Software development has become remarkably fast.&lt;/p&gt;

&lt;p&gt;Agile shortened development cycles. Modern component libraries stopped us from reinventing UI primitives. Cloud platforms removed infrastructure headaches, and APIs turned complex features into one-line integrations.&lt;/p&gt;

&lt;p&gt;Now AI-assisted development is accelerating that cadence even further. Prototypes that once required weeks can take shape in a few hours.&lt;/p&gt;

&lt;p&gt;That is genuine progress. But it brings us face-to-face with a harder question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;We can build software faster than ever. But can users actually use what we're building?&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Because development speed and product usability are not the same thing. And as our competitors get faster too, the standard users measure us against is constantly shifting.&lt;/p&gt;




&lt;h2&gt;
  
  
  We optimized the developer's side of software
&lt;/h2&gt;

&lt;p&gt;Inside software teams, progress usually sounds like this: sprint complete, API connected, tests passing, feature deployed.&lt;/p&gt;

&lt;p&gt;To an engineer, these are major milestones. But to the user, they are completely invisible.&lt;/p&gt;

&lt;p&gt;Users don't care whether you work in Scrum or Kanban, or whether code was written by hand or generated with an AI assistant. Their question is straightforward:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"I came here to accomplish something. Does this product help me do it?"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This creates two fundamentally different journeys:&lt;/p&gt;

&lt;p&gt;For developers:&lt;br&gt;
&lt;strong&gt;Idea → Sprint → Build → Test → Deploy&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;For users:&lt;br&gt;
&lt;strong&gt;Need → Understand → Act → Feedback → Outcome&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;We can complete the first journey flawlessly while making the second one terrible.&lt;/p&gt;

&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%2F5e73291qaa2hpfkmnel6.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%2F5e73291qaa2hpfkmnel6.png" alt="Two Different Perspectives: Development Team Journey vs User Experience Journey" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Shipping something quickly isn't the same as making it usable.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rapid prototyping was never meant to mean "build more"
&lt;/h2&gt;

&lt;p&gt;When AI lets a team build ten features in the time it previously took to build two, the default instinct is simple: &lt;em&gt;build ten features&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;That misses the real opportunity.&lt;/p&gt;

&lt;p&gt;The biggest advantage of rapid prototyping isn't producing more code. It's testing assumptions sooner.&lt;/p&gt;

&lt;p&gt;Instead of spending months perfecting an idea internally, we can get a working slice in front of real users, observe where they stumble, and iterate.&lt;/p&gt;

&lt;p&gt;The loop shifts from &lt;strong&gt;Build → Build → Build → Launch&lt;/strong&gt; to:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build → Observe → Learn → Improve → Repeat&lt;/strong&gt;&lt;/p&gt;

&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%2Fm86wxf2h8twzg3gusd2q.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%2Fm86wxf2h8twzg3gusd2q.png" alt="Why Rapid Prototyping Matters: The Old Linear Way vs The Iterative Learning Loop" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The first optimizes output. The second optimizes learning. If our core assumptions are flawed, increasing output just means producing the wrong thing faster.&lt;/p&gt;

&lt;p&gt;The true promise of rapid software development is &lt;strong&gt;shorter learning time, not just shorter coding time.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The rising UX bar: "Good enough" is moving
&lt;/h2&gt;

&lt;p&gt;Users' expectations don't stay still while tools improve.&lt;/p&gt;

&lt;p&gt;Years ago, rough edges were forgiven because building software was expensive. Today, AI-assisted development, mature design systems, and automated testing make iteration dramatically cheaper.&lt;/p&gt;

&lt;p&gt;And your competitors have access to those exact same tools.&lt;/p&gt;

&lt;p&gt;Users don't need to understand AI to raise their standards. They experience a product where signup takes 20 seconds, feedback is instant, and redundant steps are gone. When they return to your app, friction that once felt acceptable suddenly feels frustrating.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Users compare experiences, not development histories.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;They don't compare your app to what was possible five years ago. They compare it to the best experience they used five minutes ago.&lt;/p&gt;




&lt;h2&gt;
  
  
  What makes rapidly built software actually usable?
&lt;/h2&gt;

&lt;p&gt;Speed doesn't replace the fundamentals of UX — it amplifies their importance:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Clarity:&lt;/strong&gt; The user shouldn't have to decode the interface. Refinement means they intuitively know what to do next without friction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Low Friction:&lt;/strong&gt; Implementation complexity must never become the user's burden. Every redundant form field or unnecessary screen has a cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Responsive Feedback:&lt;/strong&gt; Technical correctness means nothing if the UI leaves users hanging. Communicate state immediately: &lt;em&gt;loading, processing, confirmed&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Predictability:&lt;/strong&gt; Consistent patterns build mental models. When software behaves as expected, users stop thinking about the tool and focus on their task.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real Outcomes:&lt;/strong&gt; Does the feature solve the user's problem? Software is a vehicle; delivering the user's intended outcome is the destination.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The bottleneck is moving
&lt;/h2&gt;

&lt;p&gt;As AI drives the marginal cost of writing code toward zero, implementation is no longer the primary bottleneck.&lt;/p&gt;

&lt;p&gt;The real constraints are understanding problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;em&gt;What should we build in the first place?&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;Where are real people struggling?&lt;/em&gt;&lt;/li&gt;
&lt;li&gt;&lt;em&gt;What should we remove instead of adding?&lt;/em&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;We are reducing the cost of building software much faster than the difficulty of understanding human behavior.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Velocity shouldn't just measure how many tickets were closed. It should measure &lt;strong&gt;how quickly a team moves from assumption to evidence&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If a feature takes half the time to build with AI, what should we do with the remaining time?&lt;/p&gt;

&lt;p&gt;Build another feature? Or use that time to observe, refine, and make the first one genuinely exceptional?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Parvej Shah is a Lead Full-Stack Web Developer &amp;amp; Platform Architect based in Dhaka, Bangladesh. Explore full architecture case studies and production code at &lt;a href="https://parvejshah.com" rel="noopener noreferrer"&gt;parvejshah.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>ux</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Why Browser Agents Fail in Production Without Semantic Layers</title>
      <dc:creator>Parvej Shah</dc:creator>
      <pubDate>Thu, 27 Aug 2026 18:17:15 +0000</pubDate>
      <link>https://dev.to/parvejshah/why-browser-agents-fail-in-production-without-semantic-layers-35hk</link>
      <guid>https://dev.to/parvejshah/why-browser-agents-fail-in-production-without-semantic-layers-35hk</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://parvejshah.com/blog/why-browser-agents-fail-in-production-without-semantic-layers" rel="noopener noreferrer"&gt;parvejshah.com/blog/why-browser-agents-fail-in-production-without-semantic-layers&lt;/a&gt; by &lt;a href="https://parvejshah.com" rel="noopener noreferrer"&gt;Parvej Shah&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Fragility of Machine Vision in Modern DOMs
&lt;/h2&gt;

&lt;p&gt;Maybe the next evolution of frontend engineering isn't just designing interfaces for humans. It is designing interfaces that machines can reliably understand too.&lt;/p&gt;

&lt;p&gt;Browser agents don't always fail because the AI model is bad. Often, the web page itself is fundamentally hostile to machine parsers. Modern single-page applications (SPAs) render deeply nested &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt; trees with ephemeral, auto-generated class names (such as Tailwind or CSS-in-JS hashes). While this provides fluid visual rendering for human users, it strips away semantic meaning for automated agents.&lt;br&gt;
&lt;/p&gt;

&lt;pre data-lang="mermaid"&gt;&lt;code&gt;graph TD
  A[AI Browser Agent] --&amp;gt;|Fragile Visual OCR / Coordinate Guessing| B[Opaque Div Hierarchy]
  B --&amp;gt;|Frontend Code Deploy / CSS Hash Shift| C[Broken Automation &amp;amp; Flaky Selectors]
  A --&amp;gt;|Direct Deterministic Query| D[Semantic Schema &amp;amp; data-agent Attributes]
  D --&amp;gt;|Refactor-Proof Contract| E[Deterministic Task Execution]&lt;/code&gt;&lt;/pre&gt;






&lt;h2&gt;
  
  
  Moving Beyond Ephemeral Selectors
&lt;/h2&gt;

&lt;p&gt;We already treat accessibility (a11y) as a non-negotiable contract between the frontend and assistive technologies through ARIA attributes. Why not extend that exact engineering rigor to AI agents?&lt;/p&gt;

&lt;p&gt;Imagine components exposing explicit, stable machine intent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// The machine contract: deterministic, testable, refactor-proof&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;button&lt;/span&gt; 
  &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;checkout-submit-button&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;agent&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;complete-transaction&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;btn-primary&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;Confirm&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;Pay&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/button&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With explicit semantic attributes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Zero Layout Guesswork:&lt;/strong&gt; The agent does not need to guess which button to click based on pixel coordinates or fragile CSS selectors.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic Interaction Paths:&lt;/strong&gt; Continuous integration (CI) test suites can validate machine contracts alongside accessibility audits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reduced Latency &amp;amp; Token Costs:&lt;/strong&gt; Vision-language models (VLMs) introduce non-deterministic latency and high token costs when inspecting DOM trees. Semantic annotations impose near-zero runtime overhead while guaranteeing parse stability.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Machine Intent as a First-Class Frontend Concern
&lt;/h2&gt;

&lt;p&gt;Frontend development is expanding beyond human visual presentation. Treating machine intent as a first-class citizen transforms web applications into programmable, resilient interfaces that AI agents can navigate with 100% precision.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Parvej Shah is a Lead Full-Stack Web Developer &amp;amp; Platform Architect based in Dhaka, Bangladesh. Explore full architecture case studies and production code at &lt;a href="https://parvejshah.com" rel="noopener noreferrer"&gt;parvejshah.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>architecture</category>
    </item>
    <item>
      <title>The Stack I Keep Coming Back To and Why I Stop Reconsidering It</title>
      <dc:creator>Parvej Shah</dc:creator>
      <pubDate>Wed, 26 Aug 2026 20:34:30 +0000</pubDate>
      <link>https://dev.to/parvejshah/the-stack-i-keep-coming-back-to-and-why-i-stop-reconsidering-it-1kb9</link>
      <guid>https://dev.to/parvejshah/the-stack-i-keep-coming-back-to-and-why-i-stop-reconsidering-it-1kb9</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://parvejshah.com/blog/craft-of-high-velocity-software-delivery" rel="noopener noreferrer"&gt;parvejshah.com/blog/craft-of-high-velocity-software-delivery&lt;/a&gt; by &lt;a href="https://parvejshah.com" rel="noopener noreferrer"&gt;Parvej Shah&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There's a specific state of mind that a developer can get into where evaluating new tools feels productive. You read release notes, benchmark comparisons, and Twitter threads from early adopters. You build proof-of-concepts. You track GitHub stars and Hacker News reception. This can occupy a significant fraction of your available thinking time.&lt;/p&gt;

&lt;p&gt;The output from this activity is rarely a better product. It's usually a well-informed decision to continue using what you were already using.&lt;/p&gt;

&lt;p&gt;I've been building full-stack web applications across projects ranging from EdTech platforms to enterprise workforce dashboards to real-time telephony systems. I've done this primarily with PostgreSQL, TypeScript, and Next.js. I keep coming back to this combination — not because I haven't evaluated alternatives, but because I've evaluated enough alternatives to understand what I'd actually be trading.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "Boring Technology" Means in Practice
&lt;/h2&gt;

&lt;p&gt;The phrase "boring technology" gets misread as a preference for old or unsophisticated tools. That's not the point. The point is familiarity depth.&lt;/p&gt;

&lt;p&gt;When I encounter a bug in a Prisma query at 11pm during a client deployment, I know where to look. I understand how Prisma generates SQL, what its transaction semantics are, how connection pooling behaves under load. This knowledge was accumulated across dozens of projects and hours of debugging. When I encounter a problem with an ORM I've used for three weeks, my diagnostic path is much longer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;PostgreSQL&lt;/strong&gt; covers an enormous surface area of what applications actually need. Full-text search, JSON document storage, relational joins, atomic transactions, row-level security, triggers, recursive CTEs. Every application I've built eventually needed something that Postgres had native support for.&lt;/p&gt;

&lt;p&gt;For MathPro Academy, we use PostgreSQL for course data, enrollments, payments, and streak tracking. The streak tracking required a SQL UPSERT with CASE logic that would have been genuinely awkward to implement correctly in most NoSQL databases. PostgreSQL handled it cleanly with a single atomic statement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;TypeScript&lt;/strong&gt; is a net-positive before you're familiar with it and an unmistakable productivity multiplier after you are. The upfront cost — writing types for your data structures, understanding generics, configuring the compiler — is real. The payoff — catching a null access error at compile time instead of at 3am in a production error log — is also real.&lt;/p&gt;

&lt;p&gt;The strictness setting matters. Running TypeScript with strict: false gives you type annotations without most of the safety. strict: true is uncomfortable initially and significantly better in practice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next.js&lt;/strong&gt; solves the API/frontend split that used to require running and coordinating two separate development servers. API routes live in the same codebase as UI components. Server-side rendering and static generation are first-class primitives rather than configurations. The App Router's Server Components model — when you understand it — eliminates entire categories of client-side data fetching patterns that were always the wrong abstraction anyway.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Velocity Driver: Short Feedback Loops
&lt;/h2&gt;

&lt;p&gt;The biggest predictor of delivery speed on any project isn't the language or framework — it's the feedback loop length. How long does it take to go from writing code to knowing whether it works?&lt;/p&gt;

&lt;p&gt;In a fast feedback environment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The development server reflects file changes in under 500ms.&lt;/li&gt;
&lt;li&gt;Type errors appear in the editor as you type, not on the next build.&lt;/li&gt;
&lt;li&gt;Database schema changes are applied with a single command.&lt;/li&gt;
&lt;li&gt;Deployment to staging takes under three minutes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In a slow feedback environment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Build times over 30 seconds create cognitive context loss between iterations.&lt;/li&gt;
&lt;li&gt;Manual testing steps are required to verify basic functionality.&lt;/li&gt;
&lt;li&gt;Deployment pipelines take 15 minutes, so you batch changes instead of shipping incrementally.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The specific tools matter less than whether you've configured them to minimize feedback latency. Turbopack's fast HMR, TypeScript's language server integration with VS Code, Prisma's db push for rapid schema iteration during development — these are the practical affordances that compound into meaningful time savings across a project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Knowing What to Leave Out
&lt;/h2&gt;

&lt;p&gt;The hardest and most valuable engineering skill is deciding not to build something.&lt;/p&gt;

&lt;p&gt;Every feature you build is code that must be maintained, bugs that can occur, edge cases that must be handled, and cognitive load for future developers (often yourself). The features that get cut are free. The features that get built carry ongoing costs.&lt;/p&gt;

&lt;p&gt;This applies to infrastructure choices too. Distributed message queues are powerful and complex. For a project where a single Postgres instance is adequate, the complexity of Redis and BullMQ is pure overhead. Microservices offer genuine benefits at scale. For a team of one to three developers building a product for a few hundred users, they introduce deployment complexity that serves no one.&lt;/p&gt;

&lt;p&gt;The right time to add architectural complexity is when you have clear evidence that you need it. The evidence is usually a specific bottleneck, a specific scale requirement, or a specific capability gap — not a theoretical concern about future scale.&lt;/p&gt;

&lt;p&gt;I've seen more projects slow down from premature architectural complexity than from inadequate infrastructure. A well-designed monolith on a managed Postgres database has shipped and maintained more valuable software than any number of elaborate distributed systems built for scale problems that never materialized.&lt;/p&gt;

&lt;p&gt;The stack I keep coming back to isn't remarkable. It's just familiar, capable, and well-understood. That turns out to be enough.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Parvej Shah is a Lead Full-Stack Web Developer &amp;amp; Platform Architect based in Dhaka, Bangladesh. Explore full architecture case studies and production code at &lt;a href="https://parvejshah.com" rel="noopener noreferrer"&gt;parvejshah.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>programming</category>
      <category>webdev</category>
      <category>productivity</category>
      <category>career</category>
    </item>
    <item>
      <title>What I Actually Had to Change When Moving to React Server Components</title>
      <dc:creator>Parvej Shah</dc:creator>
      <pubDate>Wed, 26 Aug 2026 20:34:24 +0000</pubDate>
      <link>https://dev.to/parvejshah/what-i-actually-had-to-change-when-moving-to-react-server-components-1ee0</link>
      <guid>https://dev.to/parvejshah/what-i-actually-had-to-change-when-moving-to-react-server-components-1ee0</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://parvejshah.com/blog/nextjs-16-turbopack-deep-dive" rel="noopener noreferrer"&gt;parvejshah.com/blog/nextjs-16-turbopack-deep-dive&lt;/a&gt; by &lt;a href="https://parvejshah.com" rel="noopener noreferrer"&gt;Parvej Shah&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When Next.js introduced the App Router with React Server Components, the mental model shift was more significant than the API change. The API changes are well-documented. The mental model shift is harder to articulate and easier to get wrong.&lt;/p&gt;

&lt;p&gt;This is a practical account of what changed when building this portfolio site on Next.js 16 with Turbopack — which pages ended up as Server Components, which needed to be Client Components, and what the non-obvious boundary decisions looked like.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Instinctive Wrong Move
&lt;/h2&gt;

&lt;p&gt;The instinct when you hit a component that seems "complex" is to add "use client" to the top of the file. This works — the component now runs in the browser like it always did — but it often carries a hidden cost.&lt;/p&gt;

&lt;p&gt;When you mark a parent component as a Client Component, every component it imports transitively becomes part of the client bundle too. If you've put "use client" on a layout component that imports your navigation, your blog post renderer, and your analytics component, you've just made all of those things client-side JavaScript even if none of them need interactivity.&lt;/p&gt;

&lt;p&gt;The correct question isn't "does this component need to be a Client Component?" It's "what is the smallest leaf component that actually needs to run in the browser?"&lt;/p&gt;

&lt;h2&gt;
  
  
  The Actual Split on This Portfolio
&lt;/h2&gt;

&lt;p&gt;Going through each section of the portfolio site with this question produced a clear pattern:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everything in the blog system stayed as a Server Component.&lt;/strong&gt; Blog posts fetch their content from PostgreSQL through Prisma. Markdown gets parsed and rendered. Cover images are served from the public directory. None of this involves user interaction. None of it changes based on client-side state. All of it is better handled on the server.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// This is a Server Component — no "use client"&lt;/span&gt;
&lt;span class="c1"&gt;// It runs at build time for static pages, at request time for dynamic ones&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;BlogPost&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findUnique&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PUBLISHED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;include&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;coverImage&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="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;post&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;notFound&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="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;article&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;PostHeader&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;      &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;MarkdownContent&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/article&lt;/span&gt;&lt;span class="err"&gt;&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;p&gt;&lt;strong&gt;The navigation required a hybrid approach.&lt;/strong&gt; The navbar is mostly static HTML, but it needs to highlight the active route — which requires knowing the current pathname, a client-side concern. The solution is to keep the navbar structure as a Server Component and extract only the active-state logic into a small Client Component.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// nav-link.tsx — Client Component (needs usePathname)&lt;/span&gt;
&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;use client&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;usePathname&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;next/navigation&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;NavLink&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;NavLinkProps&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;pathname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;usePathname&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;isActive&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;href&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="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="nx"&gt;className&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;isActive&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-emerald-400&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;text-slate-400&lt;/span&gt;&lt;span class="dl"&gt;"&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="nx"&gt;children&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/a&lt;/span&gt;&lt;span class="err"&gt;&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;p&gt;&lt;strong&gt;Forms are Client Components.&lt;/strong&gt; The contact form and the admin dashboard forms require useState, onChange handlers, and submission logic. These are genuinely client-side concerns. Making them Client Components is correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  Static Generation with Database Content
&lt;/h2&gt;

&lt;p&gt;The blog posts and project pages are statically generated at build time. Next.js calls generateStaticParams to enumerate all the slugs, then pre-renders each page to static HTML.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&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;generateStaticParams&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;posts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PUBLISHED&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="na"&gt;select&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;posts&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;post&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;slug&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&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;When a new blog post is published through the admin interface, it triggers a Vercel deployment. The new build runs generateStaticParams, discovers the new slug, generates its static HTML, and deploys. The post goes live without any runtime database queries for future visitors.&lt;/p&gt;

&lt;h2&gt;
  
  
  One Actual Gotcha
&lt;/h2&gt;

&lt;p&gt;There's a subtle issue with unstable_cache in Next.js when combined with static generation. The cache is keyed by the arguments you pass to it, but if you change the data in the database without triggering a new deployment, the cache will serve stale data indefinitely.&lt;/p&gt;

&lt;p&gt;For content that changes through the admin interface, the admin routes that write to the database call revalidatePath or revalidateTag after each write, which purges the relevant cache entries. Without this, editing a blog post would update the database but leave the static HTML unchanged until the next deployment.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;revalidatePath&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;next/cache&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&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;updateBlogPost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;PostUpdateData&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// Purge the static cache for this post's page&lt;/span&gt;
  &lt;span class="nf"&gt;revalidatePath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&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="c1"&gt;// Purge the blog index page too&lt;/span&gt;
  &lt;span class="nf"&gt;revalidatePath&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/blog&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;This is the part that takes the most deliberate thought — not the Server/Client boundary, but the cache invalidation strategy. Get it right and content updates feel instant. Get it wrong and editors wonder why their changes aren't appearing.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Parvej Shah is a Lead Full-Stack Web Developer &amp;amp; Platform Architect based in Dhaka, Bangladesh. Explore full architecture case studies and production code at &lt;a href="https://parvejshah.com" rel="noopener noreferrer"&gt;parvejshah.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>nextjs</category>
      <category>react</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>How We Made Academic Certificates Verifiable Without a Blockchain (or HMAC)</title>
      <dc:creator>Parvej Shah</dc:creator>
      <pubDate>Wed, 26 Aug 2026 20:33:44 +0000</pubDate>
      <link>https://dev.to/parvejshah/how-we-made-academic-certificates-verifiable-without-a-blockchain-3gd9</link>
      <guid>https://dev.to/parvejshah/how-we-made-academic-certificates-verifiable-without-a-blockchain-3gd9</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://parvejshah.com/blog/high-speed-edge-verification-institutional-credentials" rel="noopener noreferrer"&gt;parvejshah.com/blog/high-speed-edge-verification-institutional-credentials&lt;/a&gt; by &lt;a href="https://parvejshah.com" rel="noopener noreferrer"&gt;Parvej Shah&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;When the &lt;strong&gt;Center for Policy Research on Business and Development (CPRBD)&lt;/strong&gt; at the University of Dhaka approached us about their institutional web portal, certificate verification stood out immediately. CPRBD runs executive education cohorts for mid-career government officials and business professionals, who receive physical certificates used as credentials for government postings and senior roles — and nothing on the physical certificate had ever been independently verifiable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a Structured ID Beats a Cryptographic Scheme
&lt;/h2&gt;

&lt;p&gt;Every certificate gets an ID of the form &lt;code&gt;CPRBD-2025-EXEC-B1-001&lt;/code&gt; — year, program code, batch code, and a per-batch serial minted atomically when a certificate is issued. That ID is what's printed on the certificate and encoded in its QR code.&lt;/p&gt;

&lt;p&gt;Verification is a direct database lookup: given the ID, look up the enrollment record and return whether it exists and is still valid (not archived, not un-issued). No cryptographic signature, no HMAC, no blockchain. The reason this is enough: CPRBD controls both the issuing authority and the verification endpoint. The trust anchor is CPRBD's own server, not a distributed ledger or a shared secret — an attacker can't mint a valid-looking ID because the serials are sequential and tied to real enrollment rows, not derived from a formula they could reverse-engineer.&lt;/p&gt;

&lt;p&gt;What &lt;em&gt;does&lt;/em&gt; protect the endpoint is more mundane than cryptography: it's rate-limited to 20 requests per minute per IP, and responses are explicitly marked &lt;code&gt;no-store&lt;/code&gt; so nothing caches a stale "valid" or "not found" result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Payment-Gated, Not Module-Gated (Yet)
&lt;/h2&gt;

&lt;p&gt;Certificates are only issued in bulk to students whose tuition is marked complete — the endpoint filters candidates down to &lt;code&gt;paymentStatus === "complete"&lt;/code&gt; before minting anything, and reports back how many were skipped as unpaid. Course-module completion is tracked and shown to admins as a reference count, but it isn't currently enforced before issuance — that's a gap in the platform we're aware of, not a feature we're claiming.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tuition That Matches How Executive Programs Actually Get Paid
&lt;/h2&gt;

&lt;p&gt;Executive cohorts don't pay tuition in one lump sum. The platform tracks payments as a sequence of installments against SSLCommerz, each with its own status — pending, complete, failed, refunded — and computes the remaining balance as batch fee minus the sum of completed installments. A student's material access and certificate eligibility both key off that same installment ledger, so there's one source of truth for "has this person paid," not a synced copy of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Materials Are Private Because They're Never Public
&lt;/h2&gt;

&lt;p&gt;The same "one ledger, two gates" idea shows up again in how class materials are shared. Uploaded files live in a &lt;code&gt;data/&lt;/code&gt; directory outside &lt;code&gt;public/&lt;/code&gt;, so there's no static URL Next.js could accidentally serve — the only path to a file's bytes is an authenticated download route. That route re-runs the same check used for material access generally: enrolled, application approved, at least one completed installment. No signed URLs, no expiring tokens — just "don't put it somewhere the framework will serve for free, and check on every request instead."&lt;/p&gt;

&lt;p&gt;Each program can also extend its application form with its own questions — beyond the fixed profile fields (contact details, qualifications, nationality, and so on) every applicant fills in once, a program admin can add a text question, a file upload, a date picker, whatever that specific cohort's intake needs — without a code change or a deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  A CMS Built for People Who Don't Want to Touch Code
&lt;/h2&gt;

&lt;p&gt;The less visible, more used part of the platform: CPRBD staff maintain their own program pages. Each program's public page is assembled from nine section types — hero, a "why this program" block, stats, testimonials, FAQ, a course outline pulled live from the actual module list, and a few others — that staff can reorder, toggle on or off, and edit inline. Updating a program's pricing blurb or adding a new FAQ entry used to mean a code change; now it's a form.&lt;/p&gt;

&lt;p&gt;The same philosophy extends to communication and content: coordinators post batch-scoped announcements that automatically email every enrolled student in that cohort, and a separate news/media module lets CPRBD publish institutional press posts independent of both the CMS and the academic research repository — which itself replaced a static list of published papers buried in a Word document on the university website with structured, searchable entries and attached PDFs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Learned
&lt;/h2&gt;

&lt;p&gt;The interesting engineering here wasn't cryptographic — it was matching the platform's guarantees to what actually needed guaranteeing. A structured, sequential ID plus a rate-limited lookup is enough when you control the whole trust chain; reaching for HMAC or a blockchain would have added complexity without adding real security here. The harder, more valuable work was elsewhere: an installment ledger that's the single source of truth for two different gates (materials and certificates), and enough non-technical surface area — CMS, announcements, news — that CPRBD can run the platform day-to-day without opening a ticket.&lt;/p&gt;

&lt;p&gt;None of that came from a tidy requirements document. The people who understood how CPRBD actually ran a cohort were busy university staff, available in short bursts, not for a single upfront discovery phase — so the real user stories got built the same way the platform's admin surfaces did: incrementally, from repeated short conversations rather than one clean spec.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Parvej Shah is a Lead Full-Stack Web Developer &amp;amp; Platform Architect based in Dhaka, Bangladesh. Explore full architecture case studies and production code at &lt;a href="https://parvejshah.com" rel="noopener noreferrer"&gt;parvejshah.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>crypto</category>
      <category>typescript</category>
    </item>
    <item>
      <title>Streaks Without Race Conditions: What Actually Runs Behind Codervai CP</title>
      <dc:creator>Parvej Shah</dc:creator>
      <pubDate>Wed, 26 Aug 2026 20:33:38 +0000</pubDate>
      <link>https://dev.to/parvejshah/designing-the-learning-progression-engine-behind-codervai-cp-4i1p</link>
      <guid>https://dev.to/parvejshah/designing-the-learning-progression-engine-behind-codervai-cp-4i1p</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://parvejshah.com/blog/scaling-competitive-programming-lms-architectures" rel="noopener noreferrer"&gt;parvejshah.com/blog/scaling-competitive-programming-lms-architectures&lt;/a&gt; by &lt;a href="https://parvejshah.com" rel="noopener noreferrer"&gt;Parvej Shah&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Competitive programming requires building a specific kind of knowledge: algorithms and data structures that compose with each other. You can't understand dynamic programming without first being solid on recursion. You can't reason about graph traversal without understanding how to implement a queue. The dependency tree is real, and the order in which concepts are introduced matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Codervai CP&lt;/strong&gt; is a structured competitive programming learning platform. Separately, its daily-streak mechanic has to survive a predictable concurrency problem: a burst of submissions near midnight, all racing to protect a streak before the day resets.&lt;/p&gt;

&lt;h2&gt;
  
  
  Publishing, Not Pacing
&lt;/h2&gt;

&lt;p&gt;The original plan for content release was a cohort calendar — module 1 from day 0, module 2 from day 7, and so on, mirroring how a university course paces itself. That's not what's actually running. The real mechanism is simpler: an instructor flips a chapter's &lt;code&gt;is_live&lt;/code&gt; flag from the admin CMS, and every enrolled student is notified the moment it happens.&lt;/p&gt;

&lt;p&gt;That ended up being the better call, not a fallback. A publish button doesn't have timezone edge cases, doesn't need a scheduler that has to stay correct forever, and gives instructors a real escape hatch — a chapter that isn't ready yet just doesn't get published, instead of unlocking on schedule whether it's ready or not.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Streak Concurrency Problem
&lt;/h2&gt;

&lt;p&gt;Daily streaks are one of the most effective engagement mechanics in learning platforms. At Codervai CP, streaks are awarded for solving at least one problem per day. A student who maintains a 30-day streak has real motivation to protect it.&lt;/p&gt;

&lt;p&gt;The concurrency issue is predictable: a significant fraction of streak activity happens near midnight, as students rush to maintain their streak before the day resets. This creates a burst of simultaneous database writes, and the naive implementation of streak tracking breaks under concurrent load.&lt;/p&gt;

&lt;p&gt;Consider the naive approach:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// BROKEN: race condition when two submissions arrive simultaneously&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;updateStreak&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SELECT * FROM "UserStreak" WHERE "userId" = $1&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="nx"&gt;userId&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;streak&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;today&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;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toDateString&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;streak&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;streak&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastActiveDate&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toDateString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;today&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;yesterday&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;Date&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;yesterday&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;yesterday&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDate&lt;/span&gt;&lt;span class="p"&gt;()&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;wasActiveYesterday&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;streak&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;streak&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lastActiveDate&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toDateString&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;yesterday&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toDateString&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;newStreak&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;wasActiveYesterday&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;streak&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;currentStreak&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&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;`INSERT INTO "UserStreak" ("userId", "currentStreak", "lastActiveDate")
     VALUES ($1, $2, NOW())
     ON CONFLICT ("userId") DO UPDATE SET "currentStreak" = $2, "lastActiveDate" = NOW()`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;newStreak&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;If two problem submissions from the same user arrive within milliseconds of each other, both queries execute the SELECT before either has written. Both see the streak as needing an update. Both write. The streak increments by 2 instead of 1.&lt;/p&gt;

&lt;p&gt;The actual fix is an atomic upsert at the database level, in one parameterized query, with no application-code read-then-write step at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;recordActivityAndUpdateStreak&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="k"&gt;void&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;today&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;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;split&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;pool&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;`INSERT INTO "UserStreak" ("userId", "lastActiveDate", "currentStreak", "updatedAt")
     VALUES ($1, $2::date, 1, NOW())
     ON CONFLICT ("userId") DO UPDATE SET
       "currentStreak" = CASE
         WHEN "UserStreak"."lastActiveDate" = ($2::date - INTERVAL '1 day')
           THEN "UserStreak"."currentStreak" + 1
         WHEN "UserStreak"."lastActiveDate" = $2::date
           THEN "UserStreak"."currentStreak"
         ELSE 1
       END,
       "lastActiveDate" = $2::date,
       "updatedAt" = NOW()
     WHERE "UserStreak"."lastActiveDate" &amp;lt; $2::date OR "UserStreak"."userId" IS NULL`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;today&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;The entire logic — check yesterday, check today, compute new streak — is a single atomic database operation, guarded by the trailing WHERE clause: it doubles as same-day idempotency (a second submission the same day is a no-op, not a second increment) and as anti-backdating (a write can't apply against a date older than what's already stored). No application code reads a value and then writes a derived value. Concurrent calls for the same user serialize at the database lock level without corrupting the streak count.&lt;/p&gt;

&lt;h2&gt;
  
  
  Video: Buy, Don't Build
&lt;/h2&gt;

&lt;p&gt;Editorial code walkthroughs on a competitive programming platform have a specific quality challenge: the content is code on a dark background, and standard video compression optimized for natural scenes tends to blur the fine syntax details that make code legible.&lt;/p&gt;

&lt;p&gt;The instinct is to reach for a custom encoding profile — tuned quantization, reduced temporal compression, all the FFmpeg knobs. We didn't build that. Walkthroughs are delivered through BunnyCDN Stream, which handles HLS segmentation and adaptive bitrate on its own, or a plain YouTube embed where that's simpler. Legible video-of-code at reasonable cost is a solved problem one layer up the stack; building a custom transcoding pipeline would have meant maintaining infrastructure that mostly re-implements what a CDN already does well, for a marginal quality gain that never got prioritized against actual product work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Held Up, What Didn't
&lt;/h2&gt;

&lt;p&gt;The streak upsert design held up exactly as built — it's still the atomic, single-round-trip operation described above, running unmodified under real midnight traffic. The "cohort pacing" idea didn't survive contact with actual instructors using the platform; a manual publish flow turned out to be both simpler to build and easier for content creators to reason about than a scheduler would have been.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Parvej Shah is a Lead Full-Stack Web Developer &amp;amp; Platform Architect based in Dhaka, Bangladesh. Explore full architecture case studies and production code at &lt;a href="https://parvejshah.com" rel="noopener noreferrer"&gt;parvejshah.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>architecture</category>
      <category>database</category>
      <category>postgres</category>
    </item>
    <item>
      <title>AI-Assisted Data Entry: Teaching an LLM to Parse Telegram Donor Messages</title>
      <dc:creator>Parvej Shah</dc:creator>
      <pubDate>Wed, 26 Aug 2026 20:32:59 +0000</pubDate>
      <link>https://dev.to/parvejshah/building-software-for-places-where-the-internet-doesnt-work-43ec</link>
      <guid>https://dev.to/parvejshah/building-software-for-places-where-the-internet-doesnt-work-43ec</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://parvejshah.com/blog/offline-first-pwa-emergency-volunteer-networks" rel="noopener noreferrer"&gt;parvejshah.com/blog/offline-first-pwa-emergency-volunteer-networks&lt;/a&gt; by &lt;a href="https://parvejshah.com" rel="noopener noreferrer"&gt;Parvej Shah&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Bottleneck Wasn't Search, It Was Entry
&lt;/h2&gt;

&lt;p&gt;The app runs a Workbox service worker caching static assets, and a &lt;code&gt;NetworkFirst&lt;/code&gt; strategy on the API calls that matter — the core donor-lookup workflow still needs a network connection. Offline caching wasn't the interesting problem here.&lt;/p&gt;

&lt;p&gt;The real friction Badhan's coordinators had wasn't looking donors up — a blood-group-indexed Postgres query handles that fine. It was getting donor information &lt;em&gt;in&lt;/em&gt;. Volunteers were already reporting new donors the way people naturally coordinate things: as free text, in Telegram, in whatever format they happened to type it in. A form nobody consistently fills out correctly is worse than no form at all. So we built two different entry paths for two different shapes of input, instead of forcing one workflow on both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Path One: Telegram, No AI Involved
&lt;/h2&gt;

&lt;p&gt;Coordinators post donor info directly into a Telegram group. The bot detects donor-shaped messages with a keyword/pattern heuristic — blood group plus a phone number or date is usually enough — and hands the text to a deterministic parser: no LLM call, no API cost, no rate limit to worry about.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Parvej Shah
B+
IIT 23-24
01516538054
25-08-25
Hasanur Rahman
AEH Hall
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the expected shape: referrer name, donor name, then blood group / phone / date / batch / hall in any order, matched by what each token looks like rather than its position. A comma-separated single-line variant works too. Multiple donors in one message just need a blank line between blocks. The bot replies per donor — ✅ submitted, ⚠️ already exists, or ❌ with the specific validation error — so a bad phone number in donor 3 of 5 doesn't obscure that the other four went through fine.&lt;/p&gt;

&lt;p&gt;This path is intentionally &lt;em&gt;not&lt;/em&gt; AI. A Telegram group can get bursts of messages, and every one of them gets scanned for the donor-data pattern. Running an LLM call against every group message would be slow, costly, and unnecessary — the format volunteers actually use is regular enough that pattern matching gets it right without asking an API to guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Path Two: The Web Submit Page, Where AI Earns Its Keep
&lt;/h2&gt;

&lt;p&gt;There's a second entry point — a plain "paste your donor list" form on the web app — for the messier case: someone dumping a half-formatted list from a spreadsheet, a WhatsApp export, or a batch of records that don't line up with the strict positional format. That's where the AI parser actually lives, and it's built as a three-tier fallback, not a single point of failure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// 1. Attempt Gemini AI parsing (skipped if useAI is false)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;aiDonors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useAI&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;parseWithGemini&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trimmedText&lt;/span&gt;&lt;span class="p"&gt;)&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;aiDonors&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;aiDonors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;donors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;aiDonors&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;usedAI&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// 2. Try fixed-format block parser&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isFixedBlockFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trimmedText&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;fixedDonors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseFixedFormatBlocks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trimmedText&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;fixedDonors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;NextResponse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;donors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;fixedDonors&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;usedAI&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// 3. Fall back to regex parser&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;regexDonors&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;parseBulkFormattedText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;trimmedText&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gemini gets a strict extraction prompt: blank-line-separated blocks, a fixed referrer/donor-name convention for the first two lines, and explicit field-identification rules — a blood-group token can be "B(+ve)" or "o+" or "AB(positive)" and should normalize to &lt;code&gt;AB+&lt;/code&gt;; a date can be &lt;code&gt;5-5-26&lt;/code&gt; or &lt;code&gt;09/04/2026&lt;/code&gt; and should normalize to &lt;code&gt;DD-MM-YYYY&lt;/code&gt;; a hall name like "AEH" or "Ae hall" should normalize to &lt;code&gt;AE Hall&lt;/code&gt;. The model is told exactly what shape to return and nothing else — a bare JSON array, no markdown fences, no commentary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fallback Isn't Optional
&lt;/h2&gt;

&lt;p&gt;Gemini calls fail for boring reasons: rate limits, transient errors, a malformed response the model didn't quite get right. The parser rotates across multiple API keys, and when a key gets a 429 it's marked as cooling down for 10 minutes and skipped on the next attempt rather than retried into more failures:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;markKeyCooledDown&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;keyState&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;KeyState&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="k"&gt;void&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;keyState&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cooledUntil&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;COOLDOWN_MS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 10 minutes&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If every key is cooling down, or the response fails to parse as valid JSON, &lt;code&gt;parseWithGemini&lt;/code&gt; returns &lt;code&gt;null&lt;/code&gt; and the route falls through to the fixed-block parser, then the regex parser. A submission on the web form never just fails because the AI step had a bad moment — it degrades to a dumber but reliable path instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Buys, and What It Doesn't
&lt;/h2&gt;

&lt;p&gt;The honest version of this feature: two intake paths matched to two real usage patterns, a fallback chain that treats the AI step as an enhancement rather than a dependency, and a &lt;code&gt;UserFeedback&lt;/code&gt; table plus an internal review page where coordinators can flag a bad parse for someone to look at later. That last part is a correction log, not a self-improving system — the extraction prompt doesn't change itself based on feedback, and there's no active training loop running today. It's a place mistakes get recorded, not a model that gets smarter on its own.&lt;/p&gt;

&lt;p&gt;What it produced: 407 donors and 599 donation records for the Amar Ekushey Hall Unit, most of them entered as Telegram messages typed the way people already type, not through a form built assuming they'd type differently.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Parvej Shah is a Lead Full-Stack Web Developer &amp;amp; Platform Architect based in Dhaka, Bangladesh. Explore full architecture case studies and production code at &lt;a href="https://parvejshah.com" rel="noopener noreferrer"&gt;parvejshah.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>pwa</category>
      <category>react</category>
    </item>
    <item>
      <title>Self-Healing Chrome Extension: Building a DOM Injector That Fights Back</title>
      <dc:creator>Parvej Shah</dc:creator>
      <pubDate>Wed, 26 Aug 2026 20:32:53 +0000</pubDate>
      <link>https://dev.to/parvejshah/the-surprising-complexity-of-injecting-a-ui-into-someone-elses-web-page-32p</link>
      <guid>https://dev.to/parvejshah/the-surprising-complexity-of-injecting-a-ui-into-someone-elses-web-page-32p</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://parvejshah.com/blog/building-manifest-v3-ai-chrome-extensions" rel="noopener noreferrer"&gt;parvejshah.com/blog/building-manifest-v3-ai-chrome-extensions&lt;/a&gt; by &lt;a href="https://parvejshah.com" rel="noopener noreferrer"&gt;Parvej Shah&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  LinkedIn's DOM Doesn't Just Drift
&lt;/h2&gt;

&lt;p&gt;Most sites' markup drifts gradually over time. LinkedIn's is different: periodically, it ships builds where every semantic class name — &lt;code&gt;feed-shared-update&lt;/code&gt;, &lt;code&gt;social-actions-bar&lt;/code&gt;, all of it — is replaced with a short hashed token. Same page, same layout, but every selector you wrote against it stops matching overnight.&lt;/p&gt;

&lt;p&gt;A content script that hardcodes selectors against a page you don't control is fragile by default. Against a page that occasionally scrambles its own class names on purpose, hardcoded selectors aren't just fragile — they're a losing strategy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building Something That Fixes Itself
&lt;/h2&gt;

&lt;p&gt;Instead of chasing LinkedIn's markup by hand every time it changed, the extension eventually grew a self-healing layer: on load, it takes a snapshot of the feed DOM, sends it to an AI model, and asks for a fresh set of selectors.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;The&lt;/span&gt; &lt;span class="nx"&gt;snapshot&lt;/span&gt; &lt;span class="nx"&gt;isn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;t a raw DOM dump. It&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="nx"&gt;pruned&lt;/span&gt; &lt;span class="nx"&gt;aggressively&lt;/span&gt; &lt;span class="err"&gt;—&lt;/span&gt; &lt;span class="nx"&gt;ads&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;nav&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;and&lt;/span&gt; &lt;span class="nx"&gt;sidebars&lt;/span&gt; &lt;span class="nx"&gt;stripped&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tag&lt;/span&gt; &lt;span class="nx"&gt;depth&lt;/span&gt; &lt;span class="nx"&gt;capped&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;children&lt;/span&gt; &lt;span class="nx"&gt;per&lt;/span&gt; &lt;span class="nx"&gt;node&lt;/span&gt; &lt;span class="nx"&gt;capped&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;long&lt;/span&gt; &lt;span class="nx"&gt;hashed&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;names&lt;/span&gt; &lt;span class="nx"&gt;filtered&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;and&lt;/span&gt; &lt;span class="kr"&gt;any&lt;/span&gt; &lt;span class="nx"&gt;leaf&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="nx"&gt;run&lt;/span&gt; &lt;span class="nx"&gt;through&lt;/span&gt; &lt;span class="nx"&gt;PII&lt;/span&gt; &lt;span class="nx"&gt;stripping&lt;/span&gt; &lt;span class="nx"&gt;before&lt;/span&gt; &lt;span class="nx"&gt;being&lt;/span&gt; &lt;span class="nx"&gt;included&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nx"&gt;The&lt;/span&gt; &lt;span class="nx"&gt;whole&lt;/span&gt; &lt;span class="nx"&gt;thing&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;budgeted&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="nx"&gt;stay&lt;/span&gt; &lt;span class="nx"&gt;under&lt;/span&gt; &lt;span class="nx"&gt;roughly&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="mi"&gt;000&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt; &lt;span class="nx"&gt;serialized&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt; &lt;span class="nx"&gt;That&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;s a deliberate choice: sending less data isn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt; &lt;span class="nx"&gt;just&lt;/span&gt; &lt;span class="nx"&gt;cheaper&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;it&lt;/span&gt; &lt;span class="nx"&gt;also&lt;/span&gt; &lt;span class="nx"&gt;means&lt;/span&gt; &lt;span class="nx"&gt;the&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="nx"&gt;isn&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;t accidentally handed a stranger&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="nx"&gt;to&lt;/span&gt; &lt;span class="nx"&gt;reason&lt;/span&gt; &lt;span class="nx"&gt;about&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;

&lt;span class="nx"&gt;The&lt;/span&gt; &lt;span class="nx"&gt;AI&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;s job is narrow: look at this structural sketch of the page and return three CSS selectors — where posts live, where the post text is, where to inject a button — as JSON. That JSON becomes a "strategy," cached in `chrome.storage.local` and re-validated against the live DOM on every subsequent load before it&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt; &lt;span class="nx"&gt;trusted&lt;/span&gt; &lt;span class="nx"&gt;again&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;

&lt;span class="err"&gt;##&lt;/span&gt; &lt;span class="nx"&gt;The&lt;/span&gt; &lt;span class="nx"&gt;Fallback&lt;/span&gt; &lt;span class="nx"&gt;Chain&lt;/span&gt;

&lt;span class="nx"&gt;A&lt;/span&gt; &lt;span class="nx"&gt;single&lt;/span&gt; &lt;span class="nx"&gt;AI&lt;/span&gt; &lt;span class="nx"&gt;call&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;not&lt;/span&gt; &lt;span class="nx"&gt;something&lt;/span&gt; &lt;span class="nx"&gt;you&lt;/span&gt; &lt;span class="nx"&gt;want&lt;/span&gt; &lt;span class="nx"&gt;standing&lt;/span&gt; &lt;span class="nx"&gt;between&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="nx"&gt;and&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="nx"&gt;working&lt;/span&gt; &lt;span class="nx"&gt;feature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;so&lt;/span&gt; &lt;span class="nx"&gt;the&lt;/span&gt; &lt;span class="nx"&gt;actual&lt;/span&gt; &lt;span class="nx"&gt;flow&lt;/span&gt; &lt;span class="nx"&gt;on&lt;/span&gt; &lt;span class="nx"&gt;every&lt;/span&gt; &lt;span class="nx"&gt;page&lt;/span&gt; &lt;span class="nx"&gt;load&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="nx"&gt;five&lt;/span&gt; &lt;span class="nx"&gt;steps&lt;/span&gt; &lt;span class="nx"&gt;deep&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;br&gt;
javascript&lt;br&gt;
// 1. Load cached strategy, validate against live DOM&lt;br&gt;
// 2. If invalid/stale, regenerate via AI (rate-limited to 1 call / 30 min)&lt;br&gt;
// 3. If AI unavailable or fails, fall back to a bank of hardcoded selectors&lt;br&gt;
// 4. If that fails too, fall back to content.js's original 5-tier&lt;br&gt;
//    heuristic parser (text-content walking, data-view-name anchors)&lt;br&gt;
// 5. Log every outcome to a rolling debug buffer for the popup's debug panel&lt;/p&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;


The rate limit on AI regeneration (once per 30 minutes) exists because a validation failure loop without one would mean an AI call on every page load for every user whenever LinkedIn's DOM was in a bad state — expensive and pointless if the underlying cause won't resolve by calling again immediately.

## When LinkedIn Obfuscates on Purpose

The specific failure mode this was built for shows up as a distinct code path: an `isCorruptedDOM()` check looks for a feed marked with `data-view-name="feed-full-update"` but with none of the expected semantic classes present — the signature of an obfuscated build. When that's detected, the extension stops trying to match classes entirely and instead walks the DOM by structural role: `span[dir="ltr"]` elements tend to survive obfuscation intact, and a `TreeWalker` heuristic can still locate the action bar by matching visible text content ("Like", "Comment", "Repost") rather than any class name at all.

This is the part of the project I'm most proud of technically, and also the part that made it clear where the ceiling was. Text-content heuristics work until LinkedIn changes wording, adds another localization variant, or restructures the markup enough that even structural role stops being a reliable anchor. Every layer we added bought time, not permanence.

## Where It Ended Up

The extension is published and live on the Chrome Web Store today. But active development stopped after the self-healing system's last iteration. LinkedIn's anti-automation posture — DOM obfuscation, and behavior that reads as active bot detection rather than incidental markup churn — kept escalating faster than a side project could track, and at some point the honest call was to stop rather than keep building a more elaborate response to a page actively working against being scraped.

The lesson isn't "don't build content-script extensions." It's that self-healing buys real resilience against ordinary drift, but it doesn't change the fundamental asymmetry: the host page can always change faster than you can adapt, and eventually that stops being a technical problem you can architect your way out of.

---

*Parvej Shah is a Lead Full-Stack Web Developer &amp;amp; Platform Architect based in Dhaka, Bangladesh. Explore full architecture case studies and production code at [parvejshah.com](https://parvejshah.com).*
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>react</category>
      <category>typescript</category>
    </item>
    <item>
      <title>A Debouncer That Doesn't Scale Past One Worker (On Purpose, For Now)</title>
      <dc:creator>Parvej Shah</dc:creator>
      <pubDate>Wed, 26 Aug 2026 20:31:58 +0000</pubDate>
      <link>https://dev.to/parvejshah/building-a-chat-based-sales-bot-that-doesnt-drop-messages-during-flash-sales-2i77</link>
      <guid>https://dev.to/parvejshah/building-a-chat-based-sales-bot-that-doesnt-drop-messages-during-flash-sales-2i77</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://parvejshah.com/blog/conversational-commerce-webhook-architecture" rel="noopener noreferrer"&gt;parvejshah.com/blog/conversational-commerce-webhook-architecture&lt;/a&gt; by &lt;a href="https://parvejshah.com" rel="noopener noreferrer"&gt;Parvej Shah&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In Bangladesh and much of South and Southeast Asia, e-commerce doesn't look like what a Silicon Valley product manager pictures. Buyers don't browse a catalog and check out with a saved card. They send a message on Facebook or WhatsApp, ask if an item is in stock, negotiate slightly, confirm their address, and pay cash on delivery or by mobile banking transfer. The entire purchase funnel is a conversation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SellerVai&lt;/strong&gt; is built for exactly this reality — a sales agent that handles order inquiries across WhatsApp, Facebook Messenger, and Instagram, in Bengali and Banglish. The first engineering problem it has to solve isn't the AI. It's that customers don't send one message — they send four.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem With Multi-Message Bursts
&lt;/h2&gt;

&lt;p&gt;A real customer message looks less like a single query and more like this, sent as three separate texts twenty seconds apart:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"vai ei sneaker ta ki size 42 ache?"&lt;/em&gt;&lt;br&gt;
&lt;em&gt;"cash on delivery hobe?"&lt;/em&gt;&lt;br&gt;
&lt;em&gt;"dhaka te koto din lagbe?"&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A webhook handler that reacts to each message independently fires three separate completions for what is, semantically, one question. The customer gets three overlapping replies instead of one coherent answer, and the token bill triples for no benefit.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Debouncer, As It Actually Runs
&lt;/h2&gt;

&lt;p&gt;SellerVai's fix is a per-conversation buffer that waits for a customer to actually finish typing before generating a reply — not a fixed delay, a &lt;em&gt;quiet window&lt;/em&gt;. Every new message from the same conversation resets the timer; the buffered messages only get flushed to the agent once 7 seconds pass with nothing new arriving.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Simplified from the real handler — one buffer per conversation ID,
# reset on every new message, flushed after 7.0s of silence.
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MessageDebouncer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;7.0&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;buffers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&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="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;TimerHandle&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="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;add_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;on_flush&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;buffers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setdefault&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;conversation_id&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;buffers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;on_flush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

        &lt;span class="n"&gt;loop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_event_loop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;timers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;conversation_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;loop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;call_later&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ensure_future&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;flush&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 messages in twenty seconds become one joined prompt, and the agent replies once.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why In-Memory, Why Now
&lt;/h2&gt;

&lt;p&gt;This buffer lives entirely in process memory, keyed by conversation ID — there's no Redis, no external store. That's a deliberate, scoped tradeoff, not an oversight, and the code says so directly: this design only works correctly with a single running worker. Run two workers behind a load balancer and a customer's messages could land on different processes, each with no idea the other is buffering the same conversation — the debounce would silently stop working.&lt;/p&gt;

&lt;p&gt;For SellerVai's current traffic, one worker handling this path is genuinely fine — no infrastructure dependency, no network hop, no serialization cost, and a buffer that's trivial to reason about because it's just a dict. The migration path is already scoped for the day that stops being true: move the buffer and its timers into Redis, keyed the same way, so any worker can pick up any conversation. That's a known, deliberate future change, not a bug waiting to be found in production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Async Without a Queue
&lt;/h2&gt;

&lt;p&gt;The webhook handlers themselves don't push work onto a job queue — they hand off to FastAPI's &lt;code&gt;BackgroundTasks&lt;/code&gt;, which runs the debounce-and-reply logic after the HTTP response has already gone back to Meta or Telegram. That buys simplicity: no queue to operate, no broker to keep alive, no separate worker deployment. What it doesn't buy is durability — a task that's in flight when the process restarts is gone, the same way the debounce buffer is. Both tradeoffs point the same direction: this is a single-process design, made once, applied consistently, not different pieces of the system quietly disagreeing about how much reliability they promise.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We'd Tell the Next Team
&lt;/h2&gt;

&lt;p&gt;Single-process-first isn't a shortcut you apologize for — it's a legitimate starting point when your actual load doesn't yet justify the operational cost of a queue and a distributed buffer. The mistake isn't choosing it. The mistake is choosing it silently, so nobody knows it's there until two workers get deployed and debouncing quietly breaks. Say it once, in the code, in plain language, and the tradeoff stops being a hidden bug and starts being a decision someone made on purpose.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Parvej Shah is a Lead Full-Stack Web Developer &amp;amp; Platform Architect based in Dhaka, Bangladesh. Explore full architecture case studies and production code at &lt;a href="https://parvejshah.com" rel="noopener noreferrer"&gt;parvejshah.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>backend</category>
      <category>webdev</category>
      <category>node</category>
    </item>
    <item>
      <title>What It Actually Takes to Build a Workforce Management Platform for an AI Company</title>
      <dc:creator>Parvej Shah</dc:creator>
      <pubDate>Wed, 26 Aug 2026 20:31:54 +0000</pubDate>
      <link>https://dev.to/parvejshah/what-it-actually-takes-to-build-a-workforce-management-dashboard-for-an-ai-company-4pja</link>
      <guid>https://dev.to/parvejshah/what-it-actually-takes-to-build-a-workforce-management-dashboard-for-an-ai-company-4pja</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://parvejshah.com/blog/engineering-precision-data-platforms-sft-rlhf" rel="noopener noreferrer"&gt;parvejshah.com/blog/engineering-precision-data-platforms-sft-rlhf&lt;/a&gt; by &lt;a href="https://parvejshah.com" rel="noopener noreferrer"&gt;Parvej Shah&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The initial brief from &lt;strong&gt;GenMorphics AI Solutions&lt;/strong&gt; sounded straightforward: build a dashboard to help their team coordinate work across a global network of domain experts. As we got into the details, "dashboard" turned out to be underselling it — this became a systems-architecture problem touching compliance, access control, and payroll as much as task management.&lt;/p&gt;

&lt;h2&gt;
  
  
  Skill-Scoped Task Routing
&lt;/h2&gt;

&lt;p&gt;GenMorphics works with specialists across software engineering, mathematics, legal reasoning, and scientific writing. Routing doesn't try to auto-score someone's expertise — a specialist holds skills within categories, and eligibility for a task comes down to whether they hold the named skill in the required category.&lt;/p&gt;

&lt;p&gt;The real design problem wasn't the matching logic — it was making the categories specific enough to mean something. "Knows Python" is close to useless as a routing signal. Splitting software engineering into language-specific tracks, and mathematics into calculus, linear algebra, and discrete math as separate categories rather than one "math" bucket, is what actually made routing reliable. The hard part of this system was taxonomy design, not algorithms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Access Control as Data, Not Code
&lt;/h2&gt;

&lt;p&gt;The platform serves people with fundamentally different access needs — domain experts who should see only their own tasks, project managers scoped to their own client portfolios, admins with full reach. Instead of hardcoding role checks scattered through the app, permissions live in the database: each role is a row with a set of &lt;code&gt;resource.action.scope&lt;/code&gt; permissions — &lt;code&gt;payment.read.own&lt;/code&gt;, &lt;code&gt;nda.manage.all&lt;/code&gt;, and so on.&lt;/p&gt;

&lt;p&gt;That decision paid off the first time GenMorphics needed a new role with a slightly different permission mix. It didn't need a deploy — it needed a database row. For an app that has to keep pace with how a growing team actually organizes itself, that flexibility mattered more than a marginally simpler hardcoded system would have.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building an NDA Engine Instead of Buying One
&lt;/h2&gt;

&lt;p&gt;Client task data — code, documents, domain material — can't reach a specialist until they've signed an NDA specific to that engagement. Rather than bolt on a third-party e-signature product, we built the NDA lifecycle inside the platform: a TipTap-based rich text editor with a custom node type for inserting recipient-specific variables into a legal template, template versioning so a signed document stays tied to the exact terms it was signed under, and a cryptographic hash of the final document for tamper-evidence.&lt;/p&gt;

&lt;p&gt;Because NDA volume scales with contractor headcount, admins also needed to act on instances in bulk — voiding, expiring, counter-signing, reverting, or extending many at once rather than one dialog at a time. Building this in-house meant the NDA gate could be wired directly into task access instead of living as a disconnected compliance checkbox.&lt;/p&gt;

&lt;h2&gt;
  
  
  Payroll That Matches How the Org Actually Pays People
&lt;/h2&gt;

&lt;p&gt;Payroll logic isn't one formula — it mirrors two different employment models in the same system. Operational roles (annotators, reviewers) are paid hourly against logged, effective time. Managerial roles are paid a fixed salary unless they have an hourly rate set, in which case hourly wins. Bonuses layer on top of either. None of this is generic timesheet-software logic — it's a direct encoding of how GenMorphics structures compensation across two different kinds of contributors.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Learned
&lt;/h2&gt;

&lt;p&gt;The technical complexity here was never really about AI — it was a direct translation of how GenMorphics organizes people, compliance, and money. The skill taxonomy, the data-driven permission model, the in-house NDA engine, the dual payroll model — each one exists because encoding the org's actual structure mattered more than reaching for the nearest off-the-shelf pattern. A schema that's grown to 28 models across 61 migrations without a rewrite is really a record of that structure evolving over time, and the platform evolving with it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Parvej Shah is a Lead Full-Stack Web Developer &amp;amp; Platform Architect based in Dhaka, Bangladesh. Explore full architecture case studies and production code at &lt;a href="https://parvejshah.com" rel="noopener noreferrer"&gt;parvejshah.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>nextjs</category>
      <category>architecture</category>
      <category>security</category>
    </item>
  </channel>
</rss>
