<?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>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>
    <item>
      <title>AI Content Pipeline Safety: The Claims Gate That Stops Fabricated Customer Results</title>
      <dc:creator>Parvej Shah</dc:creator>
      <pubDate>Wed, 26 Aug 2026 20:20:06 +0000</pubDate>
      <link>https://dev.to/parvejshah/why-we-stopped-using-llm-agents-to-control-llm-agents-deterministic-multi-agent-fsm-4jpj</link>
      <guid>https://dev.to/parvejshah/why-we-stopped-using-llm-agents-to-control-llm-agents-deterministic-multi-agent-fsm-4jpj</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://parvejshah.com/blog/deterministic-multi-agent-systems-production" rel="noopener noreferrer"&gt;parvejshah.com/blog/deterministic-multi-agent-systems-production&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 interesting engineering problem in an automated content pipeline for &lt;strong&gt;Minions.AI&lt;/strong&gt; — a trade-contractor content engine — turned out not to be agent orchestration. It's not really about "researcher" and "writer" agents arguing in a loop, or a fragile hand-off protocol between them. The problem that actually mattered in production was narrower and more dangerous: stopping the pipeline from confidently publishing something false.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Pipeline
&lt;/h2&gt;

&lt;p&gt;Every draft moves through four Qwen (DashScope) model calls, each sized to what the stage actually needs rather than running a flagship model end to end:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Stage&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Strategist&lt;/td&gt;
&lt;td&gt;&lt;code&gt;qwen3.7-max&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Highest-leverage reasoning step — angle, audience fit, positioning. Errors here propagate through the whole draft, and the output is short, so flagship cost is negligible.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Writer&lt;/td&gt;
&lt;td&gt;&lt;code&gt;qwen-plus&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Long-form generation (700–1000 words). Highest token volume of the four calls, so this is where cost tier matters most.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Editor&lt;/td&gt;
&lt;td&gt;&lt;code&gt;qwen3.7-flash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A bounded task — tighten prose, enforce style, and extract + classify every factual claim in the draft.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Variants&lt;/td&gt;
&lt;td&gt;&lt;code&gt;qwen-flash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Reformatting already-finished copy for LinkedIn and Facebook — no new reasoning needed.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This runs as an n8n workflow, not a hand-rolled orchestrator: a scheduled harvester proposes ideas twice a week, and a webhook-triggered pipeline runs each one through the four stages above, generates a hero image, uploads it to Cloudflare R2 before the image host's signed URL expires, and either publishes or gates the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Claims Gate
&lt;/h2&gt;

&lt;p&gt;The Editor stage doesn't just tighten prose — it extracts every factual claim in the draft and classifies it: &lt;code&gt;STATISTIC&lt;/code&gt;, &lt;code&gt;FACT&lt;/code&gt;, &lt;code&gt;MARKET_CLAIM&lt;/code&gt;, &lt;code&gt;PRODUCT_CLAIM&lt;/code&gt;, &lt;code&gt;OPINION&lt;/code&gt;, or &lt;code&gt;CUSTOMER_RESULT&lt;/code&gt;. Each claim then needs a source URL to pass.&lt;/p&gt;

&lt;p&gt;One rule is hardcoded, not a judgment call: &lt;strong&gt;any claim classified &lt;code&gt;CUSTOMER_RESULT&lt;/code&gt; is blocked outright.&lt;/strong&gt; Not "flagged for review" — blocked. The reasoning is stated plainly in the pipeline's own documentation: the business has zero paying clients as of this writing, so any claim about a customer result would be fabricated by definition. There's no result to attribute yet, so the gate doesn't let the pipeline invent one.&lt;/p&gt;

&lt;p&gt;Unsourced statistics, facts, and market claims are blocked too, unless they carry a real source URL. Opinion claims pass without a source — they're not verifiable by nature and aren't gate-relevant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Enforcement, Not Just Reporting
&lt;/h2&gt;

&lt;p&gt;The gate isn't a lint warning that a human can shrug off — it runs in two places:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Inline, during generation.&lt;/strong&gt; If the claims gate doesn't pass, the asset skips straight past the auto-publish step instead of going live to the blog and Facebook Page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Again in a separate review-publisher workflow&lt;/strong&gt;, which polls the database every 5 minutes for anything a human has since approved manually in the review dashboard, and republishes it through the same gated path — so a claim can't sneak through by a human reviewer overriding the wrong field.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Either path leaves anything that isn't cleanly passed in a &lt;code&gt;BLOCKED_PENDING_REVIEW&lt;/code&gt; state rather than live on the site.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Buys
&lt;/h2&gt;

&lt;p&gt;This doesn't solve "AI agents coordinating AI agents" in some general sense — there's no elaborate retry protocol or critic-agent handshake here, and there doesn't need to be. It solves the one failure mode that actually matters for a marketing content pipeline running largely unattended: don't let the system say something happened for a customer when nothing has happened yet. Everything else — tone, structure, formatting — can be imperfect and get caught in review. A fabricated customer result is the one mistake that isn't safe to publish and fix later.&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>architecture</category>
      <category>typescript</category>
      <category>softwareengineering</category>
    </item>
    <item>
      <title>Rendering Math Formulas When Your Content Has Two Different Pasts</title>
      <dc:creator>Parvej Shah</dc:creator>
      <pubDate>Wed, 26 Aug 2026 20:17:07 +0000</pubDate>
      <link>https://dev.to/parvejshah/rendering-math-formulas-without-making-students-wait-server-side-katex-in-rsc-26ik</link>
      <guid>https://dev.to/parvejshah/rendering-math-formulas-without-making-students-wait-server-side-katex-in-rsc-26ik</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://parvejshah.com/blog/rendering-katex-formulas-nextjs-server-components" rel="noopener noreferrer"&gt;parvejshah.com/blog/rendering-katex-formulas-nextjs-server-components&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;&lt;strong&gt;MathPro Academy&lt;/strong&gt; teaches JSC, SSC, and HSC mathematics to secondary students across Bangladesh. Formula rendering — quadratic formulas, trigonometric identities, the full range of secondary-school algebra — is not a cosmetic detail here; it's most of what the product actually shows a student.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Build-Time Rendering Assumes
&lt;/h2&gt;

&lt;p&gt;Pre-rendering LaTeX to HTML at build time (or on the server per-request, ahead of sending anything to the browser) works cleanly when content is fixed and known in advance. MathPro's content isn't: instructors edit course material continuously through the admin panel, and — because the platform migrated its editor from plain text to Lexical in December 2025 — the database holds a permanent mix of both formats side by side, not a one-time migration that finished and left a single clean shape behind.&lt;/p&gt;

&lt;p&gt;An earlier version of this rendering path leaned toward doing more of that work ahead of time. It didn't hold up against that mix: a renderer that assumes one content shape breaks the moment it meets the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Ships
&lt;/h2&gt;

&lt;p&gt;The real renderer, &lt;code&gt;SafeHtmlRenderer&lt;/code&gt;, runs entirely client-side and does two jobs in sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Format detection.&lt;/strong&gt; It checks whether a record contains HTML tags. If not, it's legacy plain text — escaped and wrapped for consistent display. If it does, it's Lexical-authored HTML, sanitized through a fixed allowlist of tags and attributes before it ever touches the DOM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LaTeX rendering.&lt;/strong&gt; After the sanitized content mounts, a &lt;code&gt;useEffect&lt;/code&gt; walks the container's text nodes looking for &lt;code&gt;$...$&lt;/code&gt; (inline) and &lt;code&gt;$$...$$&lt;/code&gt; (block) spans, and replaces each one with KaTeX-rendered markup — the same delimiter-matching logic the admin editor's own LaTeX plugin uses, so what an instructor sees while writing matches what a student sees while reading.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Runs after mount, not on the server — the content it walks&lt;/span&gt;
&lt;span class="c1"&gt;// isn't known until the sanitized HTML is actually in the DOM.&lt;/span&gt;
&lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;processedContent&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;html&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nf"&gt;renderLatexInElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;containerRef&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;current&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;processedContent&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 a real trade-off, not a free win: KaTeX ships to the browser, and rendering happens after mount rather than being baked into the HTML response. What it buys back is correctness across every record in the database, old and new, without a migration project or a format the renderer has to assume in advance.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Sanitization Boundary Matters More Than the Renderer
&lt;/h2&gt;

&lt;p&gt;The more interesting design decision isn't the KaTeX call — it's what happens before it. New content is run through a fixed allowlist of tags and attributes (&lt;code&gt;DOMPurify&lt;/code&gt;) before &lt;code&gt;dangerouslySetInnerHTML&lt;/code&gt; ever sees it. KaTeX's own markup is deliberately &lt;em&gt;not&lt;/em&gt; pushed through that same sanitizer — its output relies on precise inline styles, MathML, and SVG that would need a much larger, riskier allowlist to preserve faithfully. Instead, KaTeX renders into the DOM directly, after the surrounding content has already been sanitized. Getting that boundary right — sanitize the untrusted parts, trust the library you control — is the part of this that would have been easy to get wrong.&lt;/p&gt;

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

&lt;p&gt;If your content has one shape and it's fixed at build time, server-side pre-rendering is the better call — no argument. But "server-side" isn't automatically the more sophisticated choice. When the actual constraint is "the data has two generations mixed together and keeps changing," a runtime renderer that normalizes format first and renders second is the simpler, more honest design — even though it costs a client-side bundle and a post-mount pass that a cleaner dataset wouldn't need.&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>webperf</category>
      <category>typescript</category>
    </item>
  </channel>
</rss>
