# Sarthak Mishra - Designer who codes! > Welcome to my corner of the web where I write about design, code, startups and just about anything that interests me. I'm a self-taught product designer and developer with experience building bootstrapped ventures, focusing on design, AI and growth. ## About This Content This file contains all publicly available content from sarthakmishra.com, including: - Blog posts about design, development, and startups - Portfolio projects and case studies - Venture documentation and insights - Newsletter issues and archives Last updated: 2026-07-03 --- ## Blog Posts --- title: Where to draw the line between Imitation and Inspiration? description: AI commoditized execution, so "taste" is the new moat. But taste is too vague to act on. Here's the programmer's version: imitation is copy by value, inspiration is copy by reference. A moat is a rate, not a position. type: blog url: https://sarthakmishra.com/blog/imitation-vs-inspiration date: 2026-07-04 tags: ['opinion', 'ai', 'startup', 'business', 'design'] author: Sarthak Mishra excerpt: Your competitor can clone you in a weekend. By the time they ship, you've reversed two of your own decisions and made three they haven't seen. All they ever get is a photograph of where you used to be. --- # Where to draw the line between Imitation and Inspiration? I agree with every word of [Mitchell Hashimoto's essay on taste](https://x.com/mitchellh/status/2070665127331037290). I also can't _do_ anything with it. His argument: AI has commoditized the part that used to be hard — execution — so taste is now the differentiator. His definition is clean: "taste is the ability to consistently make high-quality qualitative judgments where no objective metric exists." Hard to build, and trivial to copy once it exists. The skeptics say the copying proves taste is fake; he says the copying proves the opposite — you needed someone with taste to copy _from_. All true. All inert. "Taste" sits in the same drawer as "vision" and "good judgment," the words you reach for after the fact to explain why one team won. I hate concepts that get lost in translation between the person who coined them and the person who has to act on them. So let me make it operational, in a language most of us already speak. ## Imitation vs Inspiration Every designer knows this distinction cold. It's the cleanest line in the language, and it maps to something programmers know even better. Imitation is **copy by value.** You duplicate the _output_ — the exact UI components, the layout, the illustration. What you get is a snapshot, captured at the time of copying. The moment the original changes, your copy is stale. You're chasing a moving target with a photograph of where it used to be. Inspiration is **copy by reference.** You don't duplicate the output; you internalize the principles, the _why_ behind the decision, the judgment that produced the thing. Now you point at the live thing and stay in sync. Better: you can produce the next decision yourself, including ones the original hasn't made yet. That's the operational version of Mitchell's insight: > Tasteful work is easy to copy — but only after it's been made. Imitate the results without understanding the decisions that produced them, and all you'll ever ship is what already exists. ## The position isn't worth defending anymore This is the part of Hashimoto's argument I'd push harder on. He's right that production is being commoditized faster than taste, and that one person with a defined vision can now build what used to take a team. Production got cheap partly because the companies selling you the tools are [losing money on every token you spend](/blog/ai-billion-dollar-lie). But cheap is cheap, and the subsidy doesn't pick favorites. Your competitor's clone runs on the same discounted execution you do. Which means execution is no longer where the gap lives. Any UI is a weekend. Any feature set is a prompt. The position — the thing you've built and can point to today — is the most copyable asset you own. So if your moat is the artifact, you don't have a moat. You have a head start, measured in weekends. ## The moat is the derivative Here's the turn. AI commoditized the position, so the only defensible thing left is the rate: the velocity and consistency of your next hundred judgment calls. Watch how the clone actually plays out in time. A competitor copies your current product. It takes them a weekend — generous. By the time they ship it, you've integrated another round of feedback, reversed two of your own flawed decisions, and made three new ones they haven't seen yet. What they ship is a photograph of where you used to be. Hashimoto puts the value of taste precisely: it matters "exactly because it defines what everyone else chooses to copy." The cloners are downstream of you. They can only copy decisions you've already made, which means they're sorting through your past while you're deciding your future. The asymmetry isn't that they can't catch up. It's that catching up is the only move the position allows them — and the position keeps moving. That's what a founder should take from all this. Stop guarding the result of your work like it's the asset. Spend the effort on the thing that generates results: the reasons, the constraints, the judgment that decides what's worth shipping next. Building from inspiration is how everything real gets made; nobody invents in a vacuum. And there's a clean test for which side of the line you're on: explain why each choice is right _without_ pointing at the thing you copied. If you can, you internalized the reference. If your only answer is "because that's how they did it," you copied the value. Draw the line there. Once you're on the right side of it, the cloners stop being a threat. They'll show up the moment your work is worth taking, and they'll take everything they can see. They own a photograph. You own the camera. --- --- title: Assume the Endpoint Is Hostile description: Lakhs of government PCs can reach the core, each one trusting whoever sits in front of it. Borrow Apple's closed system — on open foundations — and make the wrong move unavailable. type: blog url: https://sarthakmishra.com/blog/assume-the-endpoint-is-hostile date: 2026-06-27 tags: ['government', 'security', 'infrastructure', 'linux', 'opinion'] author: Sarthak Mishra excerpt: I learned to harden a server the hard way, and the scariest account on the box was never some foreign hacker. It was me — tired, at 2 a.m., with root. Government endpoints need a system that assumes exactly that. --- # Assume the Endpoint Is Hostile At the end of the last post I slipped in a line and kept walking: the highest-risk work happens inside managed development environments, not on someone's laptop. That line deserves its own post. Because it is not just about developers, and it is not just about the highest-risk work. It is about every laptop, desktop, and server the government runs. There are lakhs of them. Ministries, district offices, schools, counters. Most can reach the core — the same identity, payments, and records systems the rest of this series was about. And most are administered the way my first server was: by hand, by whoever is around, with the best of intentions and no real system. [I learned to harden a server the hard way](/blog/self-hosting-done-right). Default-deny firewall, fail2ban, SSH closed to the open internet, access only over a private Tailscale network, intrusion prevention watching the rest. The machine became boring, which is the highest compliment you can pay a server. But here is the thing I had to accept while doing it: the most dangerous account on that box was not some hacker in another country. It was me — tired, at 2 a.m., with root. {/* [PERSONAL EXPERIENCE]: author hardened his own server (self-hosting-done-right); the most dangerous account on the box was himself at 2 a.m. with root */} **So stop building government endpoints that assume the person using them will always do the right thing.** Assume the opposite. Assume the endpoint is hostile by default, the network access is hostile by default, and the safest thing the system can do is make the wrong move simply unavailable. This is not an insult to the people doing the work. It is the opposite. It is refusing to make their job depend on perfect vigilance, every day, forever, against threats they were never hired to spot. > **Key Takeaways** > > - The most hardened platform in the world is only as secure as the lakhs of general-purpose machines that log into it. The endpoint is the soft underbelly. > - Borrow the closed-system _property_, not the foreign vendor. Make the wrong move unavailable with an [immutable OS](https://microos.opensuse.org/), one [signed app store](https://support.apple.com/guide/security/app-code-signing-process-sec3ad8e6e53/web), no local admin, an allowlisted network, and 802.1X, all on open foundations so the state holds the keys. > - Lockdown handles the known-bad. Behavioral baselines catch the allowed-but-wrong (a clerk pulling 10,000 records, a 3 a.m. login from a new state) with step-up or freeze, monitored for security and never for productivity. > - A closed system is only as good as its steward. Unfunded, it rots into an out-of-date monoculture where every machine is identically vulnerable — fund the body that owns it, or do not lock anything down at all. ## Why is the endpoint the weakest link? You can build the most beautiful federated, HTML-first, breach-resistant platform in the world. None of it matters if the machine logging into it is a shared desktop with a decade-old browser, local admin rights for everyone, and a USB port that takes anything. The endpoint is where the assumptions break. Think about what actually happens on a government workstation today: - Someone installs a "free PDF converter" that turns out to be adware. - Someone plugs in a personal phone to charge it, and now an untrusted device is on the network. - Someone copies a sensitive file to a personal USB drive to finish the work at home. - Someone installs a VPN to reach a blocked site, and quietly punches a tunnel straight out of the secure network. - Someone clicks the thing. Everyone, eventually, clicks the thing. None of these need a sophisticated attacker. They need a normal person, on a normal day, on a machine that lets them. That is the soft underbelly of the whole stack — the layer where the platform's careful guarantees meet a general-purpose computer that will do whatever it is told. So the defense is not one thing. It is two layers, and they catch different failures. **Layer one makes the wrong move unavailable.** **Layer two catches the allowed-but-wrong move that gets through anyway.** Get the first one right and most of this list never happens. Get the second one right and the rest gets a second look before it gets access. ## What can government borrow from Apple's closed system? Apple's reputation was built on a closed system. You installed from one store. Apps were signed, reviewed, and sandboxed. You did not get root on your own phone. People complained, and on a personal device, fair enough — it is your phone. But notice what that closed system bought: a billion devices that are remarkably hard to brick, infect, or quietly turn into a botnet, used by people who never think about security once. The control is the feature. The wrong move is not punished after the fact. It is simply not available. That is the model government endpoints need. Not because the people using them are careless, but because the cost of one wrong click on a machine that can reach a citizen database is far too high to leave to vigilance. The good news: you do not need Apple, and you should not want a single foreign vendor holding the keys to the state's computers. You borrow the _property_, not the company. The same closed-system guarantees rebuild on open foundations, where the state holds the keys. Here is the first layer. ## What does a locked-down endpoint actually look like? This is layer one: make the wrong move unavailable. Five mechanisms, all on open foundations. **An immutable, locked-down base OS.** Start from an enterprise or immutable Linux base — RHEL or Ubuntu LTS for the conservative route, or a transactional system like openSUSE MicroOS where the OS image is read-only and updates apply atomically with clean rollbacks. SELinux or AppArmor enforce mandatory access control, so a compromised app cannot wander outside its box. The person using the machine does not get admin rights. The system drifts toward its known-good state, never away from it. **One app store, no sideloading.** Software comes from exactly one place: a managed, allowlisted repository — the government's own app store. Packages are signed; unsigned or unknown software simply will not install. Need a new tool? You request it, it gets reviewed, and it appears in the store. This is the honest lesson from every "decentralized app store" experiment I looked at: you can decentralize the catalog and even the payments, but the last mile of trust — signing, malware scanning, revocation, deciding what is actually allowed to execute — has to be owned by whoever owns the platform. On a government fleet, you want that control, not in spite of it. **Centralized management, fully open source.** This is the Landscape-shaped hole, filled without the proprietary lock-in. A device-management layer like OPSI for software distribution and patching, plus a configuration engine like Puppet or Salt to enforce policy continuously and quietly undo drift. One console patches the fleet, audits it, and proves its state. The machine becomes install-and-forget for the person using it — boring, again, on purpose. **Network access on a need-to-reach basis.** DNS, proxies, and destinations are allowlisted at the gateway. VPN and tunneling packages are removed and blocked at the policy layer, so no one can quietly route around the controls. Kernel-module loading is restricted. The endpoint reaches what its role needs, and nothing else. **The hardware has to prove itself too.** Software control is half the job. The other half is the network refusing to talk to a device it does not recognize. Network access control — the 802.1X family — means only enrolled, attested, healthy machines get onto the network at all. Plug in a personal phone or an unknown laptop and it lands in a quarantine segment, not the core. USB mass storage is disabled or tightly allowlisted, so a personal drive is just an inert lump of plastic. Map each open-desktop threat to the control that kills it, and the shape of the layer gets clearer: Notice what every row has in common. The wrong move is not detected and logged and escalated. It is removed from the menu. That is the whole point of layer one: you make the safe path the only path. ## When should "out of pattern" mean "stop"? Layer one handles the known-bad. The harder problem is the unknown-bad, the action that is technically allowed but wrong for this person, right now. That is layer two. A clerk who has only ever opened ten records a day suddenly pulls ten thousand. An account that always signs in from one district office logs in at 3 a.m. from a new device in another state. Someone starts exporting files they have never touched. None of these trip a simple rule. All of them are out of pattern. This is where behavioral analytics earns its place. Not to surveil people, but to learn the normal shape of each role and notice when reality stops matching it. Out of pattern does not have to mean "banned." It can mean step up: re-authenticate, confirm with a supervisor, throttle, or freeze the risky action until a human checks. It is the same conditional-access logic banks and large enterprises already run, pointed at protecting public data instead of profit. {/* [UNIQUE INSIGHT]: layer two is not a ban and not productivity surveillance — it is conditional access, learning the shape of a role and asking for a second look only when reality stops matching it */} I want to be careful here, because this is also the part that can go badly wrong. So let me come back to it. ## Where could this go wrong? This is a lot of control to hand a system, and control is exactly the thing that gets abused. So, honestly: **Lock it down too hard and people route around it.** This is the oldest law of IT security. If the managed machine cannot do someone's actual job, they will do that job on their personal laptop over their phone's hotspot, and now you have zero visibility instead of partial control. The closed system only works if it is genuinely usable, with a fast, respected path to request the app or the access you need. Control without a release valve just manufactures shadow IT. **Behavioral monitoring is surveillance, and it is surveillance of government workers.** A system that watches every action to spot anomalies is also a system that watches every action. That demands hard limits: monitor for security, not productivity; collect the minimum; be transparent with staff about what is logged; and never quietly repurpose the data. Anomaly detection aimed at protecting citizens is defensible. The same pipeline aimed at ranking employees is not. **A closed system is only as good as its steward.** This is the thread running through the whole series. A locked-down fleet with no one funding the patches, curating the app store, and answering the alerts does not stay secure. It freezes into an out-of-date monoculture — every machine identically vulnerable. Same lesson as [the platform](/blog/building-govtech-that-lasts) and [the commons](/blog/github-for-government): the architecture is load-bearing, but so is the body that owns it. **Open source, or it is just someone else's closed system.** If you solve all of this by buying one proprietary management suite, you have rebuilt the vendor lock-in the rest of this series argues against, on the most sensitive machines you own. The whole point is a closed system on open foundations, where the state holds the keys. ## Assume the endpoint is hostile The first four posts were about concentration. A leak, where trust collapsed into [one exam](/blog/stop-fixing-exams). A meltdown, where it collapsed into [one portal](/blog/bts-of-cbses-infra-meltdown). An [architecture](/blog/building-govtech-that-lasts) built to stop pouring everything into one place. And a [way of buying it](/blog/github-for-government) that traded the single multi-year vendor for an open commons. One machine, four times, failing at the single point where everything had been piled. The endpoint is the inversion. It is the same blind trust — that the thing in the middle will always behave — but instead of concentrated into one place, it is replicated across lakhs of points. Each machine on its own is a small door. Together they are the largest attack surface the state owns: lakhs of general-purpose computers, each one trusting whoever sits in front of it to never once have a bad day. Same disease, opposite topology. {/* [UNIQUE INSIGHT]: the endpoint is the inversion of the series — not trust concentrated into one point, but the same blind trust replicated across lakhs of points; same disease, opposite topology */} So we stop trusting that. Not the people — the assumption. Build the endpoint so the wrong move is unavailable, the unknown device is turned away at the gate, and the strange behavior gets a second look before it gets access. Make the safe path the only path, and you stop asking a clerk in a district office to be a security expert on top of the actual job. A closed system sounds like a cage. Built right, on open foundations, it is the opposite. It is the seatbelt — the thing that protects you automatically, on the one day you needed it and weren't thinking about it. So assume the endpoint is hostile. It is the kindest thing you can do for the person using it. --- ## Frequently asked questions **Why should a government treat its own endpoints as hostile?** Not the people, the assumption. The most dangerous account on a server is rarely a foreign hacker; it is the tired, logged-in human with too much access on a bad day. Treating the endpoint as hostile by default means refusing to make the job depend on perfect vigilance, every day, forever, against threats the person was never hired to spot. The hostility is aimed at the machine's defaults, not at the worker. **What does "make the wrong move unavailable" mean in practice?** It is the five-mechanism lockdown layer: an immutable, locked-down base OS with SELinux or AppArmor and no local admin; one signed, allowlisted app store with no sideloading; centralized open-source management like OPSI plus Salt or Puppet; network access allowlisted at the gateway with VPN and tunneling packages removed; and 802.1X network access control with USB mass storage disabled. The wrong move is not detected after the fact. It is removed from the menu. **Can you get Apple-style security without locking the state into a foreign vendor?** Yes. You borrow the closed-system property, not the company. Rebuild the same guarantees on open foundations: RHEL or Ubuntu LTS or openSUSE MicroOS for the base, OPSI with Salt or Puppet for management, a curated signed store for software, and 802.1X at the network edge. The state holds the keys, and nothing depends on one company continuing to sell you the lock. **Isn't behavioral monitoring just surveillance of government workers?** It can be, which is exactly why it needs hard limits. Monitor for security, not productivity. Collect the minimum. Be transparent with staff about what is logged. Never quietly repurpose the data. Anomaly detection that freezes a 3 a.m. bulk export until a human checks is defensible; the same pipeline pointed at ranking employees is not. The line is the use, and it has to be drawn before the system is built. **What's the one thing this approach cannot survive without?** A funded steward. A locked-down fleet with no one funding the patches, curating the app store, and answering the alerts freezes into an out-of-date monoculture where every machine is identically vulnerable. Same lesson as the platform and the commons: the architecture matters, but so does the body that owns it. --- _Sources and further reading - [SELinux mandatory access control (Red Hat)](https://docs.redhat.com/en/documentation/red_hat_enterprise_linux/8/html-single/using_selinux/index) · [openSUSE MicroOS (immutable, transactional)](https://microos.opensuse.org/) · [OPSI open-source device management](https://opsi.org/en/) · [Configuration management with Salt / Puppet](https://docs.saltproject.io/) · [Apple's closed-system app code signing](https://support.apple.com/guide/security/app-code-signing-process-sec3ad8e6e53/web) · [F-Droid: a curated, open app repository](https://f-droid.org/)_ --- ## What's next? This is part of a series on why India's government software keeps falling over, and how I'd build it so it doesn't: - [Stop Fixing India's Exams](/blog/stop-fixing-exams) -- why you can't patch a high-stakes exam into honesty, and the continuous competency profile that replaces it - [BTS of CBSE's Infra Meltdown](/blog/bts-of-cbses-infra-meltdown) -- why the result-day "cyberattack" was almost certainly just demand, and why the portal was built to fall over - [How I'd Actually Build Government Software That Doesn't Fall Over](/blog/building-govtech-that-lasts) -- a federated core, HTML-first frontends, and storage you can't make public - [GitHub for Government Work](/blog/github-for-government) -- trading the multi-year, single-vendor RFP for an open commons paid per merged pull request - Assume the Endpoint Is Hostile (**_you are here_**) -- locking down the lakhs of devices that reach the core, with a closed system on open foundations One machine, five layers: the exam, the meltdown, the architecture, the procurement, and the endpoint. --- --- title: GitHub for Government Work description: The multi-year, single-vendor RFP is the root cause. Treat the core platform as an open commons instead: small priced tasks, paid per merged pull request, governed hard. type: blog url: https://sarthakmishra.com/blog/github-for-government date: 2026-06-25 tags: ['government', 'procurement', 'open-source', 'india', 'opinion'] author: Sarthak Mishra excerpt: I ran an agency, so I know exactly how badly a big, vague contract maps onto reality, and how hard it is to judge work you didn't build. Government software procurement makes both mistakes at national scale — on purpose, in writing. --- # GitHub for Government Work When I ran [my agency](/blog/drool-is-shut-down), the two mistakes that hurt the most were these. I said yes to scopes I could not see the bottom of. And when I expanded into frontend dev, I could not reliably judge the quality of work I had not built myself, so by the time the problems surfaced we were already deep in delivery and the trust was gone. That is a small business making a survivable mistake. Now imagine making both of those mistakes on purpose, in writing, for five years, with crores on the line. That is the government IT contract. {/* [PERSONAL EXPERIENCE]: author ran an agency, said yes to scopes he could not see the bottom of, and could not reliably judge frontend work he had not built himself */} The last two posts were about the software: [the meltdown](/blog/bts-of-cbses-infra-meltdown), then [the architecture that prevents it](/blog/building-govtech-that-lasts). But here is the catch I kept dancing around at the end of each one. That architecture cannot survive the way we buy software today. **The monolithic, multi-year, single-vendor RFP is the real root cause.** It is slow to respond. It concentrates all the risk in one supplier. It draws from a tiny pool of firms big enough to bid in the first place. And it produces almost nothing anyone else can reuse. Remember the vendor from the first post, the one that underbid TCS and got 66 days. That was not a freak event. That was the RFP model working exactly as designed: lowest bid, one throat to choke, a ship date fixed before anyone understood the problem. > **Key Takeaways** > > - The multi-year, single-vendor RFP is the root cause: it concentrates every risk in one supplier and produces nothing reusable. Treat the core platform as an open commons instead — small, priced, security-governed tasks, paid per _merged_ pull request. > - India has already shipped a smaller version. When it [open-sourced Aarogya Setu](https://www.mygov.in/aarogya-setu-app/) in May 2020, it paid a ₹1 lakh bounty _each_ — for code improvements as well as security bugs. > - Open contracting has a track record: Ukraine's [Prozorro](https://oecd-opsi.org/innovations/eprocurement-system-prozorro/) saved roughly $1.9 billion in its first two years, and [publishing EU tenders above a threshold](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=3483868) raised bids per call by about 12%. > - The one thing you cannot skip is the steward. Fund it, staff it, give it authority — or do not open the commons at all. An ungoverned commons is worse than the monolith it replaces. ## The alternative is an open commons So flip it. Treat the core government platform as an open commons, and treat the work as a stream of small, well-specified, security-governed tasks that any qualified developer or team can pick up and get paid for when their change is _merged_. Call it GitHub for government work. Concretely: - A public repository hub: the platform code, reference architectures, and standards, out in the open. - An issue marketplace: work broken into classified, priced issues, each with a clear spec and acceptance criteria. - Payment tied to merged, reviewed pull requests. Not to a signature on a contract, not to hours logged. - Credential and environment checks before anyone touches anything sensitive. - Governance dashboards, so the whole thing is measurable in public. The work gets decomposed the way real engineering work already is. Task types: feature, hardening, bugfix, reference-data, documentation. Fixed award bands by size, XS through L. A risk classification on every task. And a "claim versus compete" rule: low-risk tasks you can just claim and do, high-risk ones go through a tighter, contested process. Walk one task through it. Someone files an issue, and it gets a risk tier and a price. A qualified contributor claims it, or competes for it if it is high-risk, and does the work in a checked-out environment, not on a random laptop. They open a pull request. The pipeline runs the security gates. A maintainer reviews it, and only a merge releases the payment, to a verified account. No merge, no money. That single rule — pay on merge, not on signature — is what quietly fixes both mistakes I opened with. You cannot say yes to a scope no one can see the bottom of, because the scope is one reviewable change. And you cannot get paid for work nobody could judge, because the review _is_ the payment gate. None of this is exotic. It is [innersource](https://en.wikipedia.org/wiki/Inner_source), the open-source collaboration model large companies already run internally, scaled up to a national commons. ## Turn the bug bounty into a task bounty If this sounds utopian, it is worth remembering that the Indian government has already shipped a smaller version of it. When it open-sourced the Aarogya Setu Android app on GitHub in May 2020, it launched a bug bounty: ₹1 lakh each, not just for finding security vulnerabilities, but for code improvements too. It even dropped the prohibition on reverse-engineering. The MyGov team hosted it; NIC ran the process, with every code suggestion going through pull-request review. The leap is small in concept and large in effect. Apply that same bounty logic to all qualifying work, not just vulnerabilities. Features, hardening, documentation, anything with a clear spec and a clear acceptance bar. Wrap it in responsible-disclosure rules and you have an incentive system that pays for improvement instead of only punishing breaches. But the thing that makes a bounty model safe is not the payout. It is the trust layer underneath it. {/* [UNIQUE INSIGHT]: a bounty model is made safe by the trust layer beneath it — contributor scoring, identity that lines up (person = tax identity = payout account), MFA, managed environments — not by the payout */} - Contributors get scored on what actually matters: the quality of merged work, their security record, how they behave in review, whether they follow disclosure rules, whether they are reliable. - New contributors get brought in periodically, on purpose, so the system does not ossify into a closed guild of the same ten firms. Which is exactly the failure mode of the RFP world. - Rule-breakers get deprioritized, suspended, or banned. - And identity is the part you cannot fudge: the contributor's identity, their tax identity, and their payout account all have to line up. MFA is mandatory. The highest-risk work happens inside managed development environments, not on someone's laptop. ## Governance is the load-bearing wall Here is where I have to be blunt, because this is the part that turns a good idea into a disaster if you skip it. A commons without governance is not a commons. It is a bigger attack surface with a friendlier logo. So the non-negotiable is a real steward. A body, NIC or a dedicated public-software board, that owns the core frameworks, the standards, the backlog triage, the CI/CD, the supply-chain controls, the incident response, and the architecture. Not a committee that blesses things after the fact. An owner. India has the institutional bones for this already: NeGD's API Setu governance has a Schema Body, a Security Body, and a dispute-resolution group, and OpenForge exists specifically to run government open-source collaboration. The supply chain has to be defended in the pipeline, not in a memo: - Automated SAST and SCA scanning on every change. - An SBOM generated for every build, and SLSA-style provenance on every artifact. SBOMs tell you what you built; provenance tells you who built it, where, and how. - Signed artifacts, with keyless signing. - An internal proxy registry of vetted dependencies, instead of pulling arbitrary versions straight off the public internet. Zerodha runs exactly this kind of Go module proxy, and they are a private company with a fraction of the attack surface. - MFA on every developer and git account. No exceptions. And because it is 2026, an explicit AI-use policy: - Disclose it when AI generated significant code, config, or docs. - Never send secrets, production data, or non-public architecture to an external AI service. - Human review, understanding, and refactoring before merge. You do not merge code you cannot explain. - Licensing checks on generated content, and periodic audits of what people disclosed against what they actually shipped. The principle under all of it is simple. The person who merges the code owns the code, no matter how it was produced. ## The evidence is already in This is not a thought experiment. Open contracting has a track record. After Ukraine put its public procurement online through [Prozorro](https://oecd-opsi.org/innovations/eprocurement-system-prozorro/), it saved roughly $1.9 billion of public money in its first two years. And [the average number of suppliers per procuring entity](https://www.cgdev.org/sites/default/files/examining-impact-e-procurement-ukraine.pdf) rose from 3.4 in 2012–2015 to 5.9 in 2017–2018. In the EU, [simply publishing tenders above a threshold](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=3483868) raised the number of bids per call by around 12%. The mechanism is not magic. Transparency plus early, low-friction access to the work lets more players compete, and smaller ones too. More competition is cheaper and more resilient than one incumbent with a five-year lock. ## Where this absolutely could go wrong I would be selling you the same overconfidence I criticized in the first post if I pretended this has no failure modes. It has several. Crowdsourcing has hard limits. Task bounties work for well-specified, bounded units of work with strong review attached. They do not replace architecture, they do not replace incident response, and they do not replace accountable ownership. Point a bounty board at "design our national identity system" and you will get chaos. Open competition is not a free lunch either. The same EU study that found more bids per call also found the extra competition can come at the cost of weaker contract performance, when projects are complex or awarded purely on the lowest price. So you cannot just open the gates and walk away. You need real review and a hard acceptance bar — which is exactly what the merged-PR model puts back in. The pull request does not get paid until it passes; the cheapest bid does not win if the code does not merge. A commons without governance is worse than a monolith. If the stewardship body cannot be funded, staffed, and given authority, do not open the commons at all. An ungoverned commons is just the meltdown again, distributed across more people. And this is a complement, not a replacement. Large integrated programs still need prime contractors who can be held to a delivery. What changes is how the _core platform and its incremental work_ get built, not every government project everywhere. If anyone reads this as "abolish all RFPs," they have misread it. ## Stop buying software like it's a bridge We buy public software the way we build a bridge. One enormous contract, one winning firm, a ribbon-cutting, and then you walk away for five years and hope it holds. Software is not a bridge. It is never finished, it is attacked continuously, and the moment you stop maintaining it, it begins to rot. That is the whole story of the last two posts. So stop pouring the one big contract. Build a living commons instead: open by default, governed hard, paid out one merged improvement at a time. Three posts, one machine. [A leak](/blog/stop-fixing-exams) that showed the stakes. [A meltdown](/blog/bts-of-cbses-infra-meltdown) that showed the failure. [An architecture](/blog/building-govtech-that-lasts) that could prevent it. And a way of buying it that could keep it alive. Underneath all of them is the same mistake, made over and over: we concentrate enormous stakes into a single point — one exam, one vendor, one bucket, one contract — and then act surprised when the single point fails. A bridge you build once. Software you keep. So buy it like you mean to keep it. --- ## Frequently asked questions **What does "GitHub for government" actually mean?** The core platform run as an open commons: public repositories, an issue marketplace of classified, priced tasks each carrying a spec and acceptance criteria, payment tied to merged and reviewed pull requests, and credential and environment checks before anyone touches anything sensitive — all of it measurable on public governance dashboards. It is [innersource](https://en.wikipedia.org/wiki/Inner_source), the model large companies already run internally, scaled up to a national commons. **Has any government paid for code this way before?** Yes, a smaller version of it. When India [open-sourced Aarogya Setu](https://www.mygov.in/aarogya-setu-app/) in May 2020, the bug bounty paid ₹1 lakh each for code improvements, not just for vulnerabilities, with NIC running every suggestion through pull-request review. The task-bounty model just applies that same logic to all qualifying work with a clear spec and a clear acceptance bar. **Does open procurement actually save money or just add transparency?** Both. Ukraine's [Prozorro](https://oecd-opsi.org/innovations/eprocurement-system-prozorro/) saved roughly $1.9 billion in its first two years, while [the average number of suppliers per procuring entity](https://www.cgdev.org/sites/default/files/examining-impact-e-procurement-ukraine.pdf) rose from 3.4 to 5.9. In the EU, [publishing tenders above a threshold](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=3483868) lifted bids per call by around 12%. Transparency is the lever; the savings and the extra competition are the output. **Doesn't crowdsourcing government software create security risks?** Only without governance. The safety is not the payout — it is the trust layer beneath it: contributor scoring, mandatory MFA, identity that lines up so the contributor, their tax identity, and their payout account are all the same person, SAST and SCA plus SBOMs and signed artifacts in the pipeline, and the highest-risk work confined to managed environments. A commons without a real steward is worse than a monolith. **Does this mean abolishing all government RFPs?** No. It is a complement, not a replacement. Large integrated programs still need prime contractors who can be held to a delivery. What changes is how the core platform and its incremental work get built — not every project everywhere. --- _Sources and further reading - [Aarogya Setu open-sourced with a ₹1 lakh bug bounty (MyGov)](https://www.mygov.in/aarogya-setu-app/) · [Prozorro outcomes (OECD-OPSI)](https://oecd-opsi.org/innovations/eprocurement-system-prozorro/) · [The Impact of Open Data on Public Procurement (Duguay, Rauter & Samuels)](https://papers.ssrn.com/sol3/papers.cfm?abstract_id=3483868) · [SLSA provenance framework](https://slsa.dev/) · [OpenForge (Government of India)](https://www.openforge.gov.in/) · [API Setu](https://apisetu.gov.in/)_ --- ## What's next? This is part of a series on why India's government software keeps falling over, and how I'd build it so it doesn't: - [Stop Fixing India's Exams](/blog/stop-fixing-exams) -- why you can't patch a high-stakes exam into honesty, and the continuous competency profile that replaces it - [BTS of CBSE's Infra Meltdown](/blog/bts-of-cbses-infra-meltdown) -- why the result-day "cyberattack" was almost certainly just demand, and why the portal was built to fall over - [How I'd Actually Build Government Software That Doesn't Fall Over](/blog/building-govtech-that-lasts) -- a federated core, HTML-first frontends, and storage you can't make public - GitHub for Government Work (**_you are here_**) -- trading the multi-year, single-vendor RFP for an open commons paid per merged pull request - [Assume the Endpoint Is Hostile](/blog/assume-the-endpoint-is-hostile) -- locking down the lakhs of devices that reach the core, with a closed system on open foundations One machine, five layers: the exam, the meltdown, the architecture, the procurement, and the endpoint. --- --- title: How I'd Actually Build Government Software That Doesn't Fall Over description: You don't fix a meltdown with experts parachuting in after the breach. You fix it with defaults that make the breach hard to commit: a federated core, HTML-first frontends, and storage you can't make public. type: blog url: https://sarthakmishra.com/blog/building-govtech-that-lasts date: 2026-06-24 tags: ['government', 'software', 'architecture', 'india', 'opinion'] author: Sarthak Mishra excerpt: I'm not a policymaker. I'm a developer who once over-engineered a K3s cluster I didn't need, then tore it down. That "stop being clever, ship the boring thing" lesson is exactly what public software needs. --- # How I'd Actually Build Government Software That Doesn't Fall Over My first self-hosted setup was a Kubernetes cluster I did not need. K3s, then a multi-node Docker Swarm, then NFS, then a week debugging networking I did not understand, all for a handful of services that would have been perfectly happy on one box. I tore the whole thing down and [rebuilt it on a single boring node](/blog/self-hosting-done-right). It has not fallen over since. That is a lesson I keep relearning: most systems do not fail because they were too simple. They fail because someone got clever. Public software fails the same way, except the blast radius is a few million students instead of my home lab. {/* [PERSONAL EXPERIENCE]: author over-built a K3s, Swarm, and NFS cluster for a handful of services, tore it down, and rebuilt on one boring node that has not fallen over since */} [Last time](/blog/bts-of-cbses-infra-meltdown) I argued that CBSE's portal didn't need an attacker to fall over: it was built that way. So here is the obvious next question: what would you build instead? Not a heroic answer. Heroics are what you get when experts parachute in after the breach. I want the boring answer. **The fix for a CBSE-class meltdown is architectural, not heroic: a set of defaults that make the breach, the leak, and the meltdown hard to commit in the first place.** Four of them. Here they are. > **Key Takeaways** > > - The fix for a CBSE-class meltdown is architectural, not heroic: four defaults that make the breach, the leak, and the overload structurally hard to commit, instead of experts cleaning up after each one. > - Federate instead of building one national monolith. India already has the blueprint in [DIGIT](https://core.digit.org/): a hardened shared core with isolated cells, so one compromise or one traffic surge can't spill into the next agency. > - Make server-rendered HTML the canonical layer, and remove the public-storage toggle entirely. Since April 2023, [every new S3 bucket](https://aws.amazon.com/blogs/aws/heads-up-amazon-s3-security-changes-are-coming-in-april-of-2023/) blocks public access by default; the platform should make the insecure state impossible, not just discouraged. > - Pave a golden path so the secure deploy is the lazy deploy, then mind the catch: without sustained funding and central stewardship, a shared platform rots into a more elegant single point of failure. ## 1. Federated, not monolithic The first instinct is usually wrong: build one giant government platform and move everything onto it. One login, one stack, one place to maintain. It sounds like consolidation. It is a single point of failure with national reach. One compromise spills everywhere. One traffic surge degrades everyone. That is the monolith from the last post, just bigger. The fix is not less sharing. It is sharing the right layer. One hardened core, one security baseline, one review process, but many isolated cells, so a breach or an overload in one agency cannot cross into the next. India already has a model for this. [DIGIT](https://core.digit.org/), the platform from eGov Foundation, is built exactly this way: open source, data in shared registries, a federated architecture, role-based access, PII encrypted by default. The blueprint exists. It is just not the default. Picture three zones. A small trusted core that changes rarely and gets reviewed deeply: identity, payments, records, notifications, audit. A configurable middle where agency teams assemble forms, workflows, and dashboards from vetted parts. And a sandboxed edge for the genuinely custom stuff, where extensions are signed, least-privilege, and reviewed before they ever run. The closer you get to the core, the harder it is to touch. That is the whole point. {/* [UNIQUE INSIGHT]: a three-zone model (deeply reviewed trusted core, configurable middle, sandboxed signed-extension edge) where difficulty-to-touch rises as you approach the core */} ## 2. HTML is the canonical layer, not an afterthought Here is the take that will annoy half the people reading this: a React-first single-page app is the wrong default for a government portal. Not because React is bad. Because of what the job actually is. A public portal has to work for a decade, on every device a citizen owns, including the cheap Android phone on a weak signal, maintained by a rotating cast of contractors who did not write the original code. An SPA fights all three. It pushes work the browser does for free, focus management, page titles, history, scroll position, into JavaScript that someone has to reimplement correctly and usually does not. It drags in a large dependency graph, which is more supply-chain surface. And it churns: the framework patterns you build on today are deprecated advice in three years. Look at who actually runs high-stakes software in India and has to live with the maintenance. [Zerodha](https://zerodha.tech/stack), the largest stockbroker in the country, publishes its stack. Go for the throughput-critical services. Vue for web, after giving up on Angular. Flutter for mobile, after giving up on native and React Native. You do not have to copy the recipe. Copy the _values_: fewer moving parts, self-hosted control, and a willingness to throw out whatever churns. The boring foundation is server-rendered HTML with progressive enhancement. Build the page so it works as plain HTML over HTTP, then layer on speed and interactivity for browsers that can take it. This is not a step backward to 2008. It is what [Hotwire and Turbo](https://turbo.hotwired.dev/) do (swap the body for an SPA-like feel, still works with JavaScript off), what Unpoly does, and the philosophy [Remix](https://remix.run/) is built on: forms and URLs are the state model. The rule that makes it real is declaring compatibility tiers up front. **Tier 1** is baseline HTML and CSS with minimal JavaScript, and it must be able to complete every critical task on its own: check a result, pay a fee, file a re-evaluation. **Tier 2** is the enhanced experience for modern browsers. **Tier 3** is the nice-to-haves. If a feature only works in Tier 3, it is not allowed to be the only way to do something that matters. One caution, because I have been burned by it: do not make a niche platform feature load-bearing. Web components need polyfills on older browsers. Some CSS scoping silently breaks on old Safari. Use the broadly supported thing as the floor, and treat everything fancy as enhancement you can lose without breaking the task. ## 3. Make the insecure thing impossible, not discouraged The CBSE leak, the BHIM exposure, the NACH PDFs: same root cause. Raw object storage is too easy to misconfigure. The answer is not to ban S3. The providers have already raised the floor. Since April 2023, [every new S3 bucket](https://aws.amazon.com/blogs/aws/heads-up-amazon-s3-security-changes-are-coming-in-april-of-2023/) has Block Public Access on and ACLs off by default. Cloudflare R2 is never public unless you explicitly make it so. MinIO, which you can self-host on sovereign hardware, is private until an operator opts out. By default, the open bucket should not happen. And yet it keeps happening, because a default is not a wall. A careless operator can still flip the toggle, write a policy that is too broad, or paste a credential into a public object. As long as the switch exists where application developers can reach it, someone, someday, on a 66-day deadline, will throw it. So take the switch away. Move the control point up into one opinionated storage primitive that the whole platform goes through: - Private by default, with no developer-accessible public toggle at all. - Access only through signed, short-lived URLs from typed helpers. No hand-rolled bucket policies. - A separate, reviewer-approved publish workflow for the rare asset that is genuinely meant to be public. - Encryption, malware scanning, retention, and audit logging applied automatically. - Storage tied to application identity and authorization, not to a policy file someone wrote at 2 a.m. The win is not just developer convenience. It is that there is one narrow, documented, auditable path in and out of storage, instead of N hand-configured buckets each waiting for the next nineteen-year-old to find. {/* [UNIQUE INSIGHT]: move the control point up into one storage primitive with no developer-accessible public toggle, instead of N hand-configured buckets */} ## 4. Golden paths: make the secure way the lazy way Notice the pattern across all three: the secure choice is the only easy choice. Platform engineering has a name for this. The "paved road" at Netflix, the ["golden path" at Spotify](https://engineering.atspotify.com/2020/08/how-we-use-golden-paths-to-solve-fragmentation-in-our-software-ecosystem/): an opinionated, supported, well-documented way to build and ship, where the platform does not just tell developers the right thing to do. It does it for them. The experience to aim for is the one Vercel built around Next.js: zero-config deploys, a preview URL for every pull request, infrastructure that understands the framework. The difference is what it runs on. Build it on vendor-neutral, Kubernetes-first foundations, so the government never wakes up locked into one cloud's proprietary services. What the golden path hands you for free: standardized secrets, TLS, logging, metrics, and rollbacks. Branch previews. Audit trails baked in. And framework-aware checks that refuse to ship if the Tier 1 no-JS path is broken, so the baseline cannot quietly rot. And the specific lesson from the meltdown, encoded as infrastructure: isolate the peak-event systems. Results, re-evaluation, scanned-sheet serving, payments and refunds each run as independently scalable services. A results-day surge can hammer the results service without dragging payments down with it. Put a WAF, rate limiting, and DDoS protection in front of every public portal, because 1.5 million hits in two minutes is not a question of if. It is a date on the calendar. ## The honest part This is not free, and I would be doing the exact thing I just criticized if I pretended otherwise. A custom progressive-enhancement runtime is real, ongoing engineering work. It is only worth it as a shared platform amortized across hundreds of services. If you are building one portal, do not invent a runtime. Use a mature stack that already does this: Hotwire on Rails, Phoenix LiveView, Django or Go with Turbo. Inventing your own framework for a single product is its own kind of cleverness, the kind that bites you. And the biggest risk is not technical at all. A shared platform with no sustained funding, staffing, or authority does not stay a platform. It rots into an insecure dependency that everyone relies on and no one owns. Central stewardship is load-bearing. Without it, you have just built a more elegant single point of failure. None of this makes a system unbreakable. It removes whole _classes_ of failure: the public bucket, the coupled load spike, the security bolted on after the fact. It turns the common, catastrophic, embarrassing failures into ones that are structurally hard to commit. That is the most honest thing I can promise. ## Make the right thing the only easy thing Every move here is the same move. Federate, so a breach stays in one cell. Start from HTML, so the page works before the JavaScript does. Remove the public-bucket toggle, so no one can throw it. Pave the road, so the secure deploy is the lazy deploy. You are not asking overworked teams to be more careful. You are building a system where the careful thing is the path of least resistance. But there is a catch I have been dancing around, and it is the one that actually decides whether any of this gets built. You cannot maintain a platform like this under the procurement model that produced the meltdown: the giant, multi-year, single-vendor RFP. That contract shape is the real root cause, and it needs its own post. --- ## Frequently asked questions **What does a federated government platform look like in practice?** One hardened, deeply reviewed core for identity, payments, records, and audit, with agency teams assembling forms and workflows on top in isolated cells. India already runs the model: [DIGIT](https://core.digit.org/) from eGov Foundation is open source, federated, role-based, with PII encrypted by default. A breach in one cell stays in that cell. **Why not build a government portal as a React single-page app?** A public portal has to run for a decade on cheap phones and weak signals, maintained by rotating contractors. An SPA reimplements what the browser gives free, drags in supply-chain surface, and churns every few years. [Zerodha](https://zerodha.tech/stack), India's largest broker, picked Vue and Flutter for the same reason: fewer moving parts. **How do you actually stop the open-S3-bucket leak from recurring?** Defaults already help: since April 2023, [every new S3 bucket](https://aws.amazon.com/blogs/aws/heads-up-amazon-s3-security-changes-are-coming-in-april-of-2023/) blocks public access by default. But a default is not a wall; an operator can still flip it. Remove the toggle entirely, routing all storage through one private-by-default primitive with signed, short-lived URLs and a separate reviewed publish workflow. **What is a "golden path" in platform engineering?** The paved road at Netflix, the [golden path at Spotify](https://engineering.atspotify.com/2020/08/how-we-use-golden-paths-to-solve-fragmentation-in-our-software-ecosystem/): one opinionated, supported, well-documented way to build and ship, where the platform does the secure thing for developers instead of just recommending it. Standardized secrets, TLS, logging, metrics, rollbacks, and audit trails come with the path, not bolted on after a breach. **Is a shared government platform actually worth the cost?** Only as shared infrastructure amortized across hundreds of services. For a single portal, use a mature progressive-enhancement stack, [Hotwire](https://turbo.hotwired.dev/) on Rails, Phoenix LiveView, or Django with Turbo, rather than inventing a runtime. The real risk isn't technical: a platform with no funding or owner rots into an insecure dependency. --- _Sources and further reading - [DIGIT (eGov Foundation)](https://core.digit.org/) · [Zerodha tech stack](https://zerodha.tech/stack) · [Hotwire / Turbo](https://turbo.hotwired.dev/) · [S3 Block Public Access on by default (AWS)](https://aws.amazon.com/blogs/aws/heads-up-amazon-s3-security-changes-are-coming-in-april-of-2023/) · [Spotify Golden Paths](https://engineering.atspotify.com/2020/08/how-we-use-golden-paths-to-solve-fragmentation-in-our-software-ecosystem/)_ --- ## What's next? This is part of a series on why India's government software keeps falling over, and how I'd build it so it doesn't: - [Stop Fixing India's Exams](/blog/stop-fixing-exams) -- why you can't patch a high-stakes exam into honesty, and the continuous competency profile that replaces it - [BTS of CBSE's Infra Meltdown](/blog/bts-of-cbses-infra-meltdown) -- why the result-day "cyberattack" was almost certainly just demand, and why the portal was built to fall over - How I'd Actually Build Government Software That Doesn't Fall Over (**_you are here_**) -- a federated core, HTML-first frontends, and storage you can't make public - [GitHub for Government Work](/blog/github-for-government) -- trading the multi-year, single-vendor RFP for an open commons paid per merged pull request - [Assume the Endpoint Is Hostile](/blog/assume-the-endpoint-is-hostile) -- locking down the lakhs of devices that reach the core, with a closed system on open foundations One machine, five layers: the exam, the meltdown, the architecture, the procurement, and the endpoint. --- --- title: BTS of CBSE's Infra Meltdown. description: CBSE called its result-day collapse a cyberattack. The numbers say plain demand: 1.5 million hits in two minutes is barely four percent of its own students. This is what monolithic, one-vendor government IT produces under load. type: blog url: https://sarthakmishra.com/blog/bts-of-cbses-infra-meltdown date: 2026-06-23 tags: ['government', 'software', 'india', 'security', 'opinion'] author: Sarthak Mishra excerpt: A 19-year-old found scanned Class 12 answer sheets sitting in an open AWS bucket. I didn't read that as a scandal. I read it as a misconfiguration I've made myself, and as the predictable failure mode of how we build public software. --- # BTS of CBSE's Infra Meltdown. I have left a storage bucket open before. I caught it myself, which is the only reason I get to say it out loud. But open is open. It's never some dramatic act of negligence. It's one setting flipped the wrong way, one policy nobody reviewed, and suddenly anything with the URL can read everything inside. In [the last post](/blog/stop-fixing-exams) I argued the exam itself should go. This one is narrower, and more uncomfortable. It's about the software underneath the exam, and why the meltdown keeps happening. {/* [PERSONAL EXPERIENCE]: author has shipped the same open-bucket misconfiguration and caught it before it leaked */} > **Key Takeaways** > > - A 19-year-old researcher found scanned Class 12 answer sheets and question papers in an open AWS S3 bucket. CBSE first denied it, then admitted "vulnerabilities" on May 31 ([The Quint](https://www.thequint.com/news/education/cbse-class-12-result-2026-osm-portal-breach-s3-bucket)). > - CBSE called the result-portal load a "barrage of cyberattacks." Its own headline number, 1.5 million hits in two minutes, is about 12,000 requests a second, or roughly 75,000 page loads: about 4% of the 16.9 lakh students who sat the exam. > - Over four lakh students paid ₹100 to view their own answer sheets and ₹25 to re-check a question, many getting back blurred or half-missing scans ([Deccan Herald](https://www.deccanherald.com/education/nearly-9-lakh-scanned-answer-sheets-shared-through-cbse-osm-portal-pending-copies-by-may-27-4017791)). > - The marking contract went to a Hyderabad vendor that underbid TCS, with roughly 66 days before a nationwide rollout. The teachers testing it asked for another year ([The Week](https://www.theweek.in/news/india/2026/05/30/cbse-class-12-osm-controversy.html)). ## The open bucket The trigger was a 19-year-old researcher, [Nisarga Adhikary](https://x.com/ni5arga), who found an unsecured AWS S3 bucket exposing scanned Class 12 answer sheets and question papers. Students had been posting their own scanned sheets online too, some of them incomplete. CBSE first denied the issue, then admitted vulnerabilities on May 31 and thanked "alert citizens and ethical hackers." This wasn't bad luck, and it wasn't just a bad vendor. It's what monolithic, vendor-led government IT produces when everything that should be fundamental gets treated as an afterthought. A public bucket is a data-exposure event even when nobody "hacks" anything. The Indian precedents are not subtle. vpnMentor reported a misconfigured S3 bucket exposing roughly 409 GB and 7.26 million Aadhaar-linked records on BHIM, which NPCI denied ([vpnMentor](https://www.vpnmentor.com/blog/report-bhim-app-leak/)). In the 2025 NACH exposure, UpGuard found bank-mandate PDFs left publicly readable ([UpGuard](https://www.upguard.com/news/nach-data-exposure)). Same root cause, every time: storage that was one toggle away from public, and one tired operator who flipped it. ## What actually happened That was the leak. The meltdown is the other half of the story. The marking ran on a new On-Screen Marking system, served through a portal called OnMark and built by a Hyderabad vendor, Coempt EduTeck. **16,92,794** students sat the Class 12 exams, and results landed on May 13 at an 85.2% pass rate. Over four lakh of them reported incorrect marks and paid to review their own answer sheets. A lot got back broken pages, blurred scans, and sheets that were only half there. Then came the verification portal, the one where you formally contest a mark. It was promised for May 29, slipped to June 1 "for a glitch-free process," failed to open on June 1 anyway, and finally went live on June 2, running until midnight on June 6, at **₹100 to see an answer book and ₹25 to re-check a single question.** When the complaints peaked, cyber experts "from across various arms of government as well as the IITs" had been deployed. The vulnerabilities, CBSE said, were "contained." Then the portal, [CBSE's @cbseindia29 account](https://twitter.com/cbseindia29) said, had survived a "barrage of cyberattacks." The numbers it gave: 8,000+ concurrent users, 16,000+ submissions by 3 p.m., a denial-of-service attempt of 1.5 million hits in two minutes, and over a lakh "unauthorised file access" attempts. Those last two are the ones worth slowing down on. ## What the numbers actually say 1.5 million hits in two minutes sounds like a siege. It isn't. Do the maths. That's about 12,000 requests a second. And a single modern web page fires twenty or more requests on its own: the HTML, the stylesheet, the scripts, the fonts, every image, every background call. So 1.5 million hits is closer to **75,000 actual page loads**, which works out to about 4% of the 16.9 lakh students who sat the Class 12 exams, in a two-minute window, on the one morning every single one of them was told to go and check their result. {/* [UNIQUE INSIGHT]: the "cyberattack" figure, divided by requests-per-page, is ordinary results-day demand, not an attack */} And that's before anyone refreshed. Before a parent opened the same link on their own phone, before a sibling sat down at the same laptop, before a friend tried to pull up a result alongside their own. The "over a lakh unauthorised file access attempts" works the same way. On a portal whose entire purpose is letting students open their own files, a mistyped roll number and a retried login both look like "unauthorised access" in a log. That isn't an intrusion. That's a queue. I can't prove there was no attack, and I won't pretend otherwise. Only CBSE has the server logs. But the simplest explanation, the one that needs no villain, fits every public figure they gave: this was _demand_, the single most predictable spike in the entire school calendar. "We were attacked" is just a very convenient thing to say, because it quietly turns an architecture problem into a victim story. The deeper root cause is procurement. Government IT is built one project, one vendor, one monolithic RFP at a time. Each system reinvents identity, payments, storage, and notifications. Each has its own undocumented config. Each is a fresh attack surface. That contract shape deserves its own post, and it gets one later in this series. ## Three failures, none of them unique Strip away the fuss and three plain failures remain. None of them are unique to CBSE. **Storage misconfiguration.** Publicly readable buckets expose sensitive data, and it's the single most common cause of large public data leaks. BHIM, NACH, and now CBSE are the same mistake wearing different logos. **Peak-event fragility.** Result and re-evaluation systems face enormous, predictable, spiky load. A monolith couples that spike to everything else, including payments, notifications, dashboards, and verification flows, so one surge degrades the whole system at once. **Bolt-on security and usability.** The experts arrive _after_ the breach. Accessibility and reliability get discovered through student complaints, not designed in. The vendor that underbid TCS got roughly **66 days** before a nationwide rollout; the teachers testing it asked for another year. Security and real load testing were supposed to live in that missing year. They got cut instead. This is the [exams piece](/blog/stop-fixing-exams) again, failing in the software layer instead of the assessment layer. ## Attacked or not, it was built to fall over Two stories broke this exam season. One was a leak. The other was a software meltdown. We filed them as separate scandals. They aren't. They're the same machine, one layer down. Nobody _**had**_ to attack that portal for it to fail. The bucket was open because someone left it open. The portal buckled because a few million people showed up at once, which on results day is not an attack. It is the entire job. That is not an incident you patch. It is an architecture you replace. So was it attacked? Maybe. I can't see the logs, and neither can you. But that question is a distraction, and I suspect that is exactly why it is the one we were handed. Attack or no attack, the failure was wired in long before results day. It was built to fall over. --- ## Frequently asked questions **What was the CBSE OSM data leak?** Researcher Nisarga Adhikary found an unsecured AWS S3 bucket exposing scanned Class 12 answer sheets and question papers. Students had also posted their own scans online. CBSE first denied any issue, then on May 31 admitted vulnerabilities and thanked "alert citizens and ethical hackers" ([The Quint](https://www.thequint.com/news/education/cbse-class-12-result-2026-osm-portal-breach-s3-bucket)). **Was CBSE's result portal actually hit by a cyberattack?** CBSE said it survived 1.5 million hits in two minutes. That's about 12,000 requests a second, and a single web page fires 20+ requests, so it works out to roughly 75,000 page loads, around 4% of the 16.9 lakh students, on results day. The simplest explanation is demand, not an attack. **How much did students pay to see their own answer sheets?** ₹100 to view an answer book and ₹25 to re-check a single question. The verification portal was promised for May 29, slipped to June 2, and ran to June 6. Over four lakh students applied; many got back blurred pages and half-evaluated sheets ([Deccan Herald](https://www.deccanherald.com/education/nearly-9-lakh-scanned-answer-sheets-shared-through-cbse-osm-portal-pending-copies-by-may-27-4017791)). **Who built the CBSE On-Screen Marking system?** The marking ran on a new On-Screen Marking system served through a portal called OnMark, built by Hyderabad vendor Coempt EduTeck. It underbid TCS and got roughly 66 days before a nationwide rollout; the teachers testing it asked for another year ([The Week](https://www.theweek.in/news/india/2026/05/30/cbse-class-12-osm-controversy.html)). **Why does this keep happening to Indian government IT?** Procurement. Each system is built one project, one vendor, one monolithic RFP at a time, reinventing identity, payments, storage, and notifications, each with its own undocumented config and fresh attack surface. The same misconfigured-bucket pattern hit BHIM and NACH too ([UpGuard](https://www.upguard.com/news/nach-data-exposure)). --- _Sources and further reading - [CBSE On-Screen Marking controversy (The Week)](https://www.theweek.in/news/india/2026/05/30/cbse-class-12-osm-controversy.html) · [Deccan Herald scanned-answer-sheet figures](https://www.deccanherald.com/education/nearly-9-lakh-scanned-answer-sheets-shared-through-cbse-osm-portal-pending-copies-by-may-27-4017791) · [Researcher report of the open S3 bucket (The Quint)](https://www.thequint.com/news/education/cbse-class-12-result-2026-osm-portal-breach-s3-bucket) · [BHIM/vpnMentor exposure](https://www.vpnmentor.com/blog/report-bhim-app-leak/) · [NACH/UpGuard exposure](https://www.upguard.com/news/nach-data-exposure) · [CBSE @cbseindia29 statements](https://twitter.com/cbseindia29)_ --- ## What's next? This is part of a series on why India's government software keeps falling over, and how I'd build it so it doesn't: - [Stop Fixing India's Exams](/blog/stop-fixing-exams) -- why you can't patch a high-stakes exam into honesty, and the continuous competency profile that replaces it - BTS of CBSE's Infra Meltdown (**_you are here_**) -- why the result-day "cyberattack" was almost certainly just demand, and why the portal was built to fall over - [How I'd Actually Build Government Software That Doesn't Fall Over](/blog/building-govtech-that-lasts) -- a federated core, HTML-first frontends, and storage you can't make public - [GitHub for Government Work](/blog/github-for-government) -- trading the multi-year, single-vendor RFP for an open commons paid per merged pull request - [Assume the Endpoint Is Hostile](/blog/assume-the-endpoint-is-hostile) -- locking down the lakhs of devices that reach the core, with a closed system on open foundations One machine, five layers: the exam, the meltdown, the architecture, the procurement, and the endpoint. --- --- title: Stop Fixing India's Exams description: NEET leaked, CBSE's new marking system melted down, and 23.6% of young Indian graduates are jobless. We keep debugging an exam machine we should replace. type: blog url: https://sarthakmishra.com/blog/stop-fixing-exams date: 2026-05-30 tags: ['education', 'ai', 'india', 'growth', 'opinion'] author: Sarthak Mishra excerpt: I did not take NEET. I did sit for CBSE Class 12 boards, and I did sit for JEE, badly. Then I sat for NID-DAT and got an All India Rank of 33. The same person, measured twice, came out useless once and promising once. --- # Stop Fixing India's Exams I did not take NEET. I did sit for CBSE Class 12 boards, and I did sit for JEE, badly. A few thousand ranks behind the cutoff, if I remember right. Then I sat for NID-DAT and got an All India Rank of 33. That gap still confuses me. The same person, measured twice within a year, came out useless once and promising once. The difference wasn't me. It was what each test was actually built to measure. Years later, hiring people who came through the same pipeline, I keep noticing the same thing: **the signal is getting weaker while the noise gets louder.** So when May 2026 delivered a double failure, I didn't see two separate scandals. NEET-UG was cancelled after a "guess paper" matched the real exam, and CBSE's new On-Screen Marking system produced blurred scans and broken portals. I saw the same machine failing in two different places. {/* [PERSONAL EXPERIENCE]: author's own NID-DAT AIR 33 vs JEE failure, plus years hiring from the same pipeline */} > **Key Takeaways** > > - NEET-UG 2026 reached 2M+ aspirants, then was cancelled in nine days after a 410-question leak ([SCC Online](https://www.scconline.com/blog/post/2026/05/15/neet-2026-paper-leak-examination-incident-explained/)). > - 23.6% of young graduates are jobless, double the overall rate ([NCAER](https://ncaer.org/publication/elusive-jobs-how-ai-will-demand-even-greater-patience-of-indias-unemployed-youth/)). > - High-stakes exams exist to sort scarcity, not to measure learning, so they can't be patched into honesty. > - A signed, continuous competency profile makes a single leaked paper worthless. ## What broke the exam machine this month? Two systems failed in the same month. NEET-UG reached over two million aspirants on May 3, 2026, then was cancelled by May 12 after investigators found a 410-question "guess paper," with around 120 chemistry questions reportedly matching the real exam ([SCC Online](https://www.scconline.com/blog/post/2026/05/15/neet-2026-paper-leak-examination-incident-explained/)). The CBI has made arrests in the alleged leak network. The Supreme Court told the NTA it still hadn't learnt its lesson, which is a polite way of saying this is a sequel. The very next day, CBSE declared Class 12 results using a shiny new "On-Screen Marking" system. Students seeking scanned copies found blurred pages, half-evaluated answers, portals crashing under load, and physics marks that made no sense. The contract went to a small Hyderabad vendor that underbid TCS, about **66 days** before the nationwide rollout. Teachers who tested it said it needed another year or two. It got zero. [Over four lakh students](https://www.deccanherald.com/education/nearly-9-lakh-scanned-answer-sheets-shared-through-cbse-osm-portal-pending-copies-by-may-27-4017791) have since applied to see their own scanned answer sheets. One exam got cheated. The other got "modernized." Both broke. And the response, predictably, is a queue of fixes. Tighter security. Biometric verification. Computer-based testing. Better vendors. More audits. A new committee. Every single fix quietly assumes the same thing: **That the exam is worth saving.** I don't think it is. ## What are we actually patching? India runs one of the world's most concentrated high-stakes testing systems: a single NEET-UG cycle drew more than two million aspirants in 2026 for a few tens of thousands of medical seats ([SCC Online](https://www.scconline.com/blog/post/2026/05/15/neet-2026-paper-leak-examination-incident-explained/)). Strip away the outrage cycle and the machine itself is brutally simple. A decade of a kid's life narrows down to a handful of scores on a handful of mornings. Those scores decide medicine or engineering or "general degree, figure it out later." The stakes for jobs, status, and family honour all load onto a single number. When you concentrate that much value into one test, the incentives become automatic. A student who actually understands physics can still lose to one who has memorized the last ten years' question patterns. A leaked paper is worth a fortune, not because students are bad people, but because one morning has become the entire game. A blurry scan or a crashed portal doesn't cost you a few marks. It can cost you the year. {/* [UNIQUE INSIGHT]: the leak is a symptom of concentrated stakes, not the disease itself */} This is the part nobody says out loud: NEET wasn't cheated _despite_ being a serious exam. It was cheated _because_ it is the kind of exam where cheating pays for itself. The leak isn't the disease. It's the most honest symptom we have. ## We are cramming for a future that is already gone Here's the part that should scare every parent: in India, a degree has become a liability. Roughly two in three unemployed young adults are graduates, not dropouts ([State of Working India 2026, Azim Premji University](https://azimpremjiuniversity.edu.in/publications/2026/report/swi-2026)). The system grinds kids toward a shrinking set of "safe" careers. Doctor. Engineer. IT job. Government post. That destination is being quietly demolished while we argue about the entrance ticket. Look at the numbers: - **Roughly two in three unemployed young adults are graduates** ([State of Working India 2026, Azim Premji University](https://azimpremjiuniversity.edu.in/publications/2026/report/swi-2026)). Not dropouts. Graduates. - The graduate unemployment rate is **23.6%**, more than twice the rate for this age group overall. For engineering grads specifically, it's about **25%** ([NCAER](https://ncaer.org/publication/elusive-jobs-how-ai-will-demand-even-greater-patience-of-indias-unemployed-youth/)). - In most countries a degree is a shield against joblessness. [In India it has become a liability](https://www.businesstoday.in/india/story/unemployment-crisis-66-of-unemployed-are-graduates-expert-breaks-down-indias-graduate-job-struggle-520539-2026-03-13). And now stack AI on top. The IT and back-office economy, the ladder millions of families climbed into the middle class, is exactly the layer generative AI hits first: routine code, support, testing, data processing. IT majors have slowed hiring and shrunk net additions as automation spreads. Early research also suggests entry-level hiring in AI-exposed fields has softened since the LLM wave hit ([GlobalSouth.AI analysis](https://www.globalsouth.ai/ai-workforce-global-south-india-hidden-risks-case-study/)). So here's the actual situation. We put a sixteen-year-old through two years of grinding hell, in a system riddled with leaks and run on software that can't render a scanned page, for a shot at a "safe" career. And the safe careers are the ones being automated fastest. We built a brutal, riggable lottery for tickets to a destination that's being knocked down. That's the system everyone is so eager to "fix." ## Why do we test like this? Be fair to the system for a second. It does one job ruthlessly well: it **sorts scarcity**. With over two million NEET aspirants chasing a few tens of thousands of MBBS seats ([SCC Online](https://www.scconline.com/blog/post/2026/05/15/neet-2026-paper-leak-examination-incident-explained/)), you need a clean, defensible way to rank a million people and hand out limited tickets. That's the real function. Not learning. _Sorting._ Once you see it as a sorting machine, everything makes sense, including why it can't be patched into honesty. The cheating, the cramming, and the catastrophic fragility all flow from concentrating life-or-death stakes into one rankable number. You can biometric-scan the room and encrypt the question bank all you like. As long as one paper decides everything, somebody will pay to get that paper. You're not fixing a bug. You're fighting the design. ## What would I build instead? Start from a different measurement. Recommendation engines already predict what you'll watch next with eerie accuracy, while 23.6% graduate unemployment ([NCAER](https://ncaer.org/publication/elusive-jobs-how-ai-will-demand-even-greater-patience-of-indias-unemployed-youth/)) says the current signal isn't working. So here's the one principle I'd build on: stop measuring **time served** and start measuring **what someone can actually do**. {/* [PERSONAL EXPERIENCE]: author has built recommendation systems and watched them go wrong */} I'm not a policymaker. I'm a developer who has built recommendation systems and seen how quickly they can go wrong. But if I were asked to sketch an alternative, everything else falls out of that one move. **1. A public skill graph instead of a single syllabus.** Replace "the Class 12 syllabus" with an open, national map of competencies: literacy, numeracy, computational thinking, trades, arts, communication, and how they connect. Not one ladder everyone climbs in lockstep. A graph everyone navigates differently. Think of it as open infrastructure, like UPI for skills: a public standard anyone can build on. **2. An adaptive layer that figures out where you are and what's next.** This is where AI actually earns its place, not as an exam invigilator, but as a tutor that never sleeps. It reads what you've done, infers what you know, and recommends the next problem, project, or person to learn from. We already build recommendation engines that are scarily good at suggesting the next video. Why not point that same machinery at learning instead of attention span? **3. A portable, signed "learning ledger" instead of a marksheet.** Every real thing you do gets logged as verifiable evidence: a project, a solved problem, a peer review, a mentor sign-off. It's cryptographically signed and owned by you. A git history of what you can do, that an employer or university can actually inspect. Not a rank on a curve. A profile with receipts. **4. Assessment as a stream, not a one-shot.** Instead of one high-stakes morning, you get hundreds of tiny, continuous signals embedded in actual work. And here's the quiet superpower: **when no single test decides anything, leaking a single test buys you nothing.** You can't cram a portfolio of two years of real output the night before. The incentive to cheat doesn't get policed away. It evaporates. Here's the whole thing side by side: Notice what the right column does to this month's headlines. A NEET-style leak stops being an apocalypse, because there's no single paper to leak. A CBSE-style marking meltdown stops being possible, because there's no one-shot script to mis-scan. You don't make the exam more secure. You make the exam _unnecessary_. And it maps better onto the AI-shaped job market too. A continuous, evidence-rich profile of what you can do across domains is exactly what a volatile labour market can read, far better than "94.2%, Science stream." And it travels. A signed, machine-readable profile is something a foreign university or an employer abroad can actually read, without you re-proving yourself through one more standardized gauntlet (SAT, GRE, IELTS, and the rest) for every border you want to cross. The one thing Indians grind hardest for, a way _out_ and _up_, gets easier, not harder. ## Where could this go wrong? The prettier the diagram, the more I distrust it, and I've shipped enough products to know how this breaks. The CBSE OSM rollout already showed the pattern: bolt half-baked tech onto a high-stakes process and you don't remove human error, you industrialize it ([The Week](https://www.theweek.in/news/india/2026/05/30/cbse-class-12-osm-controversy.html)). So, honestly, here is how a competency system breaks if you build it badly. {/* [PERSONAL EXPERIENCE]: author has shipped products and learned to distrust the pretty diagram */} **The digital divide eats it alive.** A kid in a metro with fibre internet and three tutors will lap a kid on a shared phone with patchy data. Build this as a premium app and you don't dissolve inequality. You encode it, permanently, in the infrastructure. This _has_ to be publicly funded, open-source, and offline-first from day one, or don't bother. **Surveillance.** A system that watches every keystroke to "personalize learning" is also a data goldmine and a privacy disaster waiting to happen. Continuous assessment means continuous data collection on minors. That demands hard, legislated limits: data minimization, no secondary use, kids owning their own ledger. Not a privacy policy nobody reads. **Algorithmic bias.** Auto-scoring trained on Indian data inherits Indian biases: caste, language, region, gender. An AI that quietly marks down a dialect or a name is worse than a human examiner, because it does it at national scale and calls it objective. This needs independent audits, not blind trust. **Teachers don't get cheaper. They get more important.** The failure mode here is a policymaker reading "AI tutor" as "fire the teachers." Wrong. The machine handles drilling and tracking. Humans handle the part that matters: mentoring, judgment, ethics, the messy social stuff a model can't hold. Staffing is the investment, not the cost to cut. I'm not pretending these are small. I'm saying they're _design problems_: hard, solvable, and worth solving, versus the exam system's problems, which are _structural_ and, I'd argue, not solvable at all without changing the structure. ## "But you cannot flip the whole country overnight" Correct. You don't. You run both systems in parallel for a decade. Competency profiles ship _alongside_ the old marksheet. You pilot the new system in real districts and measure it honestly. You give employers and colleges time to start trusting the profile. The moment they trust it more than the rank, the rank quietly dies on its own. No big bang. Boring, phased, multi-year. That's a feature, not a bug. That's how you actually migrate a system a billion people depend on. The hard part was never the technology. We have adaptive learning, signed credentials, and AI tutors today. The hard part is political will and an entire test-prep industry whose business model is the disease. ## Cancel the format We spent this month treating a leak and a software meltdown as two separate scandals to be patched. They aren't. Two million aspirants, one cancelled exam, and a marking system that couldn't render a page ([SCC Online](https://www.scconline.com/blog/post/2026/05/15/neet-2026-paper-leak-examination-incident-explained/)) are the same machine showing us its wiring under stress: a machine that turns learning into a riggable, fragile, all-or-nothing sort, and aims it at a future that's disappearing. We cancelled NEET this month. We should have cancelled the format. --- ## Frequently asked questions **Why was NEET-UG 2026 cancelled?** NEET-UG reached more than two million aspirants on May 3, 2026, and was scrapped by May 12 after investigators found a 410-question "guess paper," with roughly 120 chemistry questions reportedly matching the real exam. The CBI has made arrests in the alleged leak network ([SCC Online](https://www.scconline.com/blog/post/2026/05/15/neet-2026-paper-leak-examination-incident-explained/)). **What went wrong with CBSE's On-Screen Marking system?** CBSE handed its new On-Screen Marking system to a vendor that underbid TCS roughly 66 days before launch. Students hit blurred scans, half-evaluated sheets, and crashing portals; over four lakh have since applied to see their own answer sheets ([Deccan Herald](https://www.deccanherald.com/education/nearly-9-lakh-scanned-answer-sheets-shared-through-cbse-osm-portal-pending-copies-by-may-27-4017791)). **Is a degree still worth it in India?** The data is brutal: 23.6% of young graduates are unemployed, more than double the overall rate, and about 25% for engineering grads ([NCAER](https://ncaer.org/publication/elusive-jobs-how-ai-will-demand-even-greater-patience-of-indias-unemployed-youth/)). Two in three jobless young adults hold degrees ([State of Working India 2026](https://azimpremjiuniversity.edu.in/publications/2026/report/swi-2026)). A degree has shifted from shield to liability. **What should replace high-stakes exams?** A public skill graph, an adaptive AI tutor, a portable signed "learning ledger," and continuous assessment instead of one-shot tests. When no single exam decides everything, leaking one paper buys nothing, and a signed competency profile reads better in an AI-shaped job market than "94.2%, Science stream." **Can India switch overnight?** No, and it shouldn't try. Run both systems in parallel for a decade: competency profiles ship alongside the marksheet, piloted in real districts and measured honestly. When employers trust the profile more than the rank, the rank quietly dies on its own. --- _Sources and further reading: [NEET-UG 2026 timeline (SCC Online)](https://www.scconline.com/blog/post/2026/05/15/neet-2026-paper-leak-examination-incident-explained/) · [CBSE On-Screen Marking controversy (The Week)](https://www.theweek.in/news/india/2026/05/30/cbse-class-12-osm-controversy.html) · [State of Working India 2026](https://azimpremjiuniversity.edu.in/publications/2026/report/swi-2026) · [Graduate unemployment breakdown (NCAER)](https://ncaer.org/publication/elusive-jobs-how-ai-will-demand-even-greater-patience-of-indias-unemployed-youth/)_ --- ## What's next? This is part of a series on why India's government software keeps falling over, and how I'd build it so it doesn't: - Stop Fixing India's Exams (**_you are here_**) -- why you can't patch a high-stakes exam into honesty, and the continuous competency profile that replaces it - [BTS of CBSE's Infra Meltdown](/blog/bts-of-cbses-infra-meltdown) -- why the result-day "cyberattack" was almost certainly just demand, and why the portal was built to fall over - [How I'd Actually Build Government Software That Doesn't Fall Over](/blog/building-govtech-that-lasts) -- a federated core, HTML-first frontends, and storage you can't make public - [GitHub for Government Work](/blog/github-for-government) -- trading the multi-year, single-vendor RFP for an open commons paid per merged pull request - [Assume the Endpoint Is Hostile](/blog/assume-the-endpoint-is-hostile) -- locking down the lakhs of devices that reach the core, with a closed system on open foundations One machine, five layers: the exam, the meltdown, the architecture, the procurement, and the endpoint. --- --- title: I Tried 4 Search Engines So You Don't Have To description: Here's the definitive guide to choosing between Orama, Pagefind, Meilisearch, and Google for your static site. type: blog url: https://sarthakmishra.com/blog/astro-search-comparison date: 2026-01-26 tags: ['astro', 'search', 'performance', 'comparison', 'engineering'] author: Sarthak Mishra excerpt: I've implemented search four different ways: Orama for simplicity, Pagefind for scalability, Meilisearch for power, and Google for disappointment. Here's the breakdown of which one you actually need. --- # I Tried 4 Search Engines So You Don't Have To I've implemented search four different ways on this blog. First with **Orama**, then tried **Google Programmable Search**, migrated to **Meilisearch**, and finally settled on **Pagefind**. Each solution claims to be the "best," but they solve fundamentally different problems. Here's my honest comparison from the perspective of a design engineer running a small(ish) static site. ## The Quick Breakdown | Feature | Orama (Open Source) | Pagefind | Meilisearch | Google Programmable | | :------------------- | :------------------------- | :------------------------- | :-------------------- | :------------------ | | **Architecture** | In-Memory (Monolith) | Static (Chunked) | Server-Based | External API | | **Setup Complexity** | ⭐ Very Easy | ⭐ Easy | ⭐⭐⭐ Moderate | ⭐⭐ Easy | | **Initial Load** | High (Downloads full DB) | Low (~5KB + Chunks) | Low (API Call) | Low (API Call) | | **Typo Tolerance** | ✅ Excellent (Levenshtein) | ⚠️ Basic (Stemming/Prefix) | ✅✅ Industry Leading | ✅ Good | | **Server Cost** | $0 | $0 | $5-10/mo (VPS) | $5/1000 queries | | **Best For** | Small Apps (`<`100 pages) | Content Sites (Any Size) | E-commerce / SaaS | Legacy/Enterprise | ## 1. Orama (Open Source) Orama is a modern, in-memory search engine written in TypeScript. It's designed to be the "SQLite of search"—running entirely inside the browser or on the edge. **The Good:** It's incredibly easy to set up. You feed it a JSON array, and it builds a highly optimized index that supports fuzzy matching, stemming, and even vector search (if you bring embeddings). The query performance is sub-millisecond because there's no network latency—it's all in RAM. **The Bad:** The architecture is a bottleneck for content sites. Because Orama is _in-memory_, the browser must download the **entire index** before it can search. You're effectively forcing users to download your entire database just to search for one keyword. **Verdict:** Use Orama for **small documentation sites** or **client-side apps** where the dataset is small and guaranteed to fit in memory. ## 2. Pagefind Pagefind feels like it was built specifically to solve the bandwidth problem Orama creates. It's a "static" search library that runs after your build process. **The Good:** Pagefind splits your search index into thousands of tiny binary "chunks." When a user searches for "React", it only downloads the specific chunk containing "React". - **Bandwidth Efficient:** It scales to 100,000 pages without bloating the initial page load. - **Zero Config:** It scrapes your HTML automatically. No schema definitions required. - **Multi-Site:** It can merge indexes from different domains (e.g., your blog and your docs). **The Bad:** It trades precision for performance. Pagefind uses **stemming and prefix matching**, not true fuzzy logic. - Searching "running" finds "run". (Stemming: ✅) - Searching "astor" finds "astro". (Typo Tolerance: ❌) - _Note: Pagefind will fallback to prefixes if no match is found, but it lacks the sophisticated Levenshtein distance algorithms of Orama._ **Verdict:** Use Pagefind for **blogs, portfolios, and marketing sites**. It's the most scalable "static" option. ## 3. Meilisearch Meilisearch is a production-grade search server built in Rust. It's an open-source alternative to Algolia. **The Good:** It's powerful. It handles typos better than anything else I've tested. It supports complex faceted filtering (e.g., "Show items `<`$50 in Blue"), sorting, and even hybrid vector search (Semantic Search). Since it runs on a server, your client bundle is tiny, and updates happen in real-time via API without rebuilding your site. **The Bad:** It breaks the "static" philosophy. You need a server. - You have to pay for a VPS ($5-10/mo). - You have to maintain Docker containers. - You have to secure API keys and set up rate limiting. - If the server crashes, your search dies. **Verdict:** Use Meilisearch for **E-commerce, SaaS dashboards, or dynamic apps** where data changes frequently and faceted filtering is a requirement. ## 4. Google Programmable Search Google Programmable Search (formerly Custom Search Engine) seems like the obvious choice—Google already indexes your site, and their search is best-in-class. But the reality is disappointing. **The Good:** It leverages Google's actual search index. Setup is straightforward: create a search engine in Google's console, get an API key, and call their JSON API. The search quality is excellent because it's the same algorithm that powers google.com. **The Bad:** The pricing model is brutal. You get **100 free queries per day**. After that, it's **$5 per 1,000 queries**. For a modest blog with 200 visitors doing 2 searches each, that's ~$45/month. Worse, **indexing delays** mean new content won't appear in search for hours—or even _days_. You publish a post, but Google hasn't crawled it yet. Meanwhile, Pagefind and Orama index at build time—instant availability. You also have **zero control** over relevance tuning. No boosting title matches, no filtering by tags, no customizing fuzzy tolerance. **Verdict:** Skip Google for static sites. You're paying to search your own content with someone else's crawler. ## The Decision Matrix **Use Orama if:** - You have `<`100 pages. - You need true typo tolerance. - You have zero budget ($0). **Use Pagefind if:** - You have `>`100 pages. - You want a "set it and forget it" solution. - You care about initial page load speed. - _This is what I use on this blog._ **Use Meilisearch if:** - You're hosting a large dataset or documentation site. - You need complex filters (Tags + Date + Price). - You need real-time updates (no rebuilds). **Avoid Google Programmable Search if:** - You have a personal blog or portfolio (the cost doesn't justify it). - You publish frequently and need instant indexing. - You want control over relevance and ranking. ## Conclusion For 99% of static blogs, **Pagefind** is the correct choice. It respects the user's bandwidth and requires zero maintenance. Orama is fantastic technology, but its "load everything" architecture fights against the grain of web performance for larger content sites. Meilisearch is brilliant, but overkill for most cases. Stick to Pagefind. Your lighthouse score will thank you. --- ## What's next? This is part of a series of posts on implementing search for static sites: - [The Right Way to Add Orama Search to Astro](/blog/orama-astro) -- simple, zero-config search for small to medium sites - [Why I Switched from Orama to Pagefind](/blog/pagefind-astro) -- chunked index for better scalability - [Meilisearch is the Best Search You'll Never Need](/blog/meilisearch-astro) -- server-side search with advanced features - [Why I Didn't Use Google Programmable Search](/blog/google-custom-search-astro) -- the hidden costs and indexing delays that make it impractical - I Tried 4 Search Engines So You Don't Have To (**_you are here_**) -- comprehensive comparison from a small blog perspective All with practical examples from a real production blog. --- --- title: Why I Didn't Use Google Programmable Search description: Google's search API sounds perfect for static sites. It isn't. Here's why the pricing model and indexing delays make it impractical for most blogs. type: blog url: https://sarthakmishra.com/blog/google-custom-search-astro date: 2026-01-22 tags: ['astro', 'search', 'google', 'api', 'typescript'] author: Sarthak Mishra excerpt: I built a working implementation with Google Custom Search API. Then I calculated the costs and realized why no one actually uses it. --- # Why I Didn't Use Google Programmable Search When I started researching search solutions for this blog, Google Programmable Search (formerly Custom Search Engine) seemed like the obvious choice. Google already indexes my site. Their search is best-in-class. There's a JSON API. What could go wrong? Everything. ## The Implementation I'll walk through the implementation first because it's actually pretty clean. Then I'll explain why I threw it away. ### 1. The Search Metadata Endpoint Google's API returns links and snippets, but we need richer metadata for our search results (images, formatted dates, descriptions). So we create a metadata endpoint that maps paths to content data. ```typescript // src/pages/search-metadata.json.ts type SearchMetadata = Record< string, { title: string; description: string; date: string; image?: string; } >; export async function GET(_context: APIContext) { const metadata: SearchMetadata = {}; const blogPosts = await getCollection('blog', ({ data }) => !data.draft); for (const post of blogPosts) { const path = `/blog/${post.id}`; metadata[path] = { title: post.data.title, description: post.data.description, date: new Date(post.data.date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric', }), image: post.data.image?.src, }; } // Add works, ventures, newsletters... return new Response(JSON.stringify(metadata), { headers: { 'Content-Type': 'application/json', 'Cache-Control': 'public, max-age=3600', }, }); } ``` ### 2. The Search Utility The core logic is simple. Call the Google API, normalize the results, and merge with our local metadata. ```typescript // src/utils/google-search.ts export type GoogleSearchItem = { title: string; link: string; snippet?: string; }; export type SearchResult = { id: string; title: string; path: string; description?: string; date?: string; image?: string; }; export async function searchGoogle(options: { apiKey: string; searchEngineId: string; query: string; limit?: number; }): Promise { const { apiKey, searchEngineId, query, limit = 8 } = options; const searchUrl = new URL('https://www.googleapis.com/customsearch/v1'); searchUrl.searchParams.set('key', apiKey); searchUrl.searchParams.set('cx', searchEngineId); searchUrl.searchParams.set('q', query); searchUrl.searchParams.set('num', String(limit)); const response = await fetch(searchUrl.toString()); const data = await response.json(); return data.items ?? []; } export function normalizeSearchResults( items: GoogleSearchItem[], metadata: Record, ): SearchResult[] { return items.map((item) => { const path = new URL(item.link).pathname; const meta = metadata[path]; return { id: item.link.replace(/[^a-zA-Z0-9]/g, '_'), title: meta?.title ?? item.title, path: item.link, description: meta?.description ?? item.snippet, date: meta?.date, image: meta?.image, }; }); } ``` ### 3. The Search Component The component fetches metadata on load, then calls the Google API on each search. ```typescript // Inside SearchModal.tsx const [results, setResults] = useState([]); useEffect(() => { const term = query.trim(); if (!term) return; const apiKey = import.meta.env.PUBLIC_GOOGLE_CUSTOM_SEARCH_API; const searchEngineId = import.meta.env.PUBLIC_SEARCH_ENGINE_ID; const search = async () => { const items = await searchGoogle({ apiKey, searchEngineId, query: term }); const metadata = await fetch('/search-metadata.json').then((r) => r.json()); setResults(normalizeSearchResults(items, metadata)); }; const timeoutId = setTimeout(search, 180); return () => clearTimeout(timeoutId); }, [query]); ``` It works. The search feels native. Results are fast. So what's the problem? ## Problem 1: The Pricing Google gives you **100 free queries per day**. After that, it's **$5 per 1,000 queries**. Let's do the math for a modest blog: | Scenario | Daily Queries | Monthly Cost | | :---------------------------------- | :------------ | :----------- | | 50 visitors/day, 2 searches each | 100 | $0 (barely) | | 200 visitors/day, 2 searches each | 400 | ~$45/month | | 1,000 visitors/day, 2 searches each | 2,000 | ~$285/month | For a personal blog, paying $285/month for search is absurd. That's more than most people spend on hosting, domains, and email combined. And this assumes 2 searches per visitor. If someone types "astro" and then refines to "astro search", that's 2 queries. Power users exploring your content will burn through queries fast. **Compare this to alternatives:** - **Orama:** Free. Zero API calls. Runs in the browser. - **Pagefind:** Free. Zero API calls. Static files. - **Meilisearch (self-hosted):** ~$5/month for a small VPS. Unlimited queries. Google's pricing only makes sense if you're a large company with a search-heavy product and a marketing budget. ## Problem 2: Indexing Delays Google's Programmable Search relies on **Google's main search index**. This means: 1. You publish a new blog post. 2. Google's crawler discovers it (hours to days). 3. Google indexes it (more hours to days). 4. Your search can finally find it. For a static site where you control the content, this is backwards. Why wait for Google to crawl your own site when you can build the index yourself at deploy time? I've published posts that didn't appear in Google search for **over a week**. During that time, anyone searching my blog for the new content would find nothing. With Orama or Pagefind, new content is searchable **the moment you deploy**. ## Problem 3: No Control Google decides what's relevant. You can't: - Boost title matches over body matches - Filter by tags or categories - Exclude certain pages from results - Customize fuzzy matching tolerance With Orama, I can do `boost: { title: 2 }` and title matches rank higher. With Google, I get whatever their algorithm decides. ## Problem 4: External Dependency Your search stops working if: - Google's API has an outage - Your API key gets rate-limited - Google changes their pricing - Google deprecates the API (they've done this before) With a static search solution, your search is part of your site. It deploys together and fails together. ## When Google Search Makes Sense There are valid use cases: 1. **Large sites (10k+ pages)** where building your own index is impractical 2. **Multi-site search** where you need to search across multiple domains 3. **Enterprise budgets** where $300/month is a rounding error 4. **Legacy content** that's already in Google's index but not in a structured format For a personal blog or portfolio? It's overkill. ## The Alternative I Actually Use I went with [Pagefind](/blog/pagefind-astro). Here's why: - **Free:** No API costs, ever - **Instant:** New content is searchable on deploy - **Bandwidth efficient:** Only downloads chunks relevant to the query - **No external dependencies:** It's just static files For content sites of any size, it's the most practical choice. ## Conclusion Google Programmable Search is a solution looking for a problem. For static sites, you own the content. You can build the index yourself. Why pay Google to crawl your own site and then charge you to search it? The implementation was a fun experiment. But when I calculated the costs and realized I'd be waiting days for new posts to appear in search, I deleted the branch and moved on. --- ## What's next? This is part of a series of posts on implementing search for static sites: - [The Right Way to Add Orama Search to Astro](/blog/orama-astro) -- simple, zero-config search for small to medium sites - [Why I Switched from Orama to Pagefind](/blog/pagefind-astro) -- chunked index for better scalability - [Meilisearch is the Best Search You'll Never Need](/blog/meilisearch-astro) -- server-side search with advanced features - Why I Didn't Use Google Programmable Search (**_you are here_**) -- the hidden costs and indexing delays that make it impractical - [I Tried 4 Search Engines So You Don't Have To](/blog/astro-search-comparison) -- comprehensive comparison from a small blog perspective All with practical examples from a real production blog. --- --- title: Meilisearch is the Best Search You'll Never Need description: Self-hosting Meilisearch with Docker, Traefik, and Astro - complete with API key management, content indexing, and rate limiting. type: blog url: https://sarthakmishra.com/blog/meilisearch-astro date: 2026-01-14 tags: ['astro', 'search', 'meilisearch', 'docker', 'traefik', 'self-hosting', 'typescript'] author: Sarthak Mishra excerpt: Meilisearch offers powerful search features like typo tolerance and real-time indexing. But for a blog with 50 posts? It's complete overkill. Here's how to set it up anyway - and why you probably shouldn't. --- # Meilisearch is the Best Search You'll Never Need This is the third post in my search implementation series. I previously covered [Pagefind](/blog/pagefind-astro) for static search and [Orama](/blog/orama-astro) for client-side full-text search. Now I'm exploring Meilisearch - a self-hosted search engine with typo tolerance, faceted filtering, vector search, and real-time indexing. For a small blog like mine, it's total overkill. But I learned a lot setting it up, so here's the complete walkthrough. ## Why Meilisearch Exists Meilisearch is a production-grade search engine built in Rust. It's designed for scenarios where client-side search falls short - large e-commerce catalogs, documentation sites with thousands of pages, or applications needing real-time search updates without rebuilds. The key features that set it apart are typo tolerance that actually works, hybrid search combining keyword and semantic capabilities, and vector search for AI-powered recommendations. Unlike Pagefind or Orama, Meilisearch runs as a separate server, which means your search index never touches the client. But these features come with a cost. You need to run and maintain a server, manage API keys, handle backups, and monitor performance. For my 50-post blog, this is like using a semi-truck to buy groceries. ## Setting Up Meilisearch with Docker The basic Docker setup is straightforward: ```bash docker pull getmeili/meilisearch:latest ``` But running it properly requires more thought. Here's a production-ready setup: ```bash docker run -d \ --name meilisearch \ -p 7700:7700 \ -e MEILI_MASTER_KEY='your-secure-master-key-here' \ -v $(pwd)/meili_data:/meili_data \ --restart unless-stopped \ getmeili/meilisearch:latest ``` The `MEILI_MASTER_KEY` is critical for securing your Meilisearch instance. Without it, your Meilisearch server is completely open to the public. The key must be at least 16 bytes and should be a strong, random value. You can generate a secure 32-character key using `openssl rand -hex 16`. The volume mount `-v $(pwd)/meili_data:/meili_data` ensures your search index persists between container restarts. Without this, you'll lose all your indexed data every time the container stops. ### Using Docker Compose For a more maintainable setup, use Docker Compose: ```yaml services: meilisearch: image: getmeili/meilisearch:latest container_name: meilisearch restart: unless-stopped environment: - MEILI_MASTER_KEY=${MEILI_MASTER_KEY} - MEILI_ENV=production ports: - '7700:7700' volumes: - meilisearch_data:/meili_data healthcheck: test: ['CMD', 'curl', '-f', 'http://localhost:7700/health'] interval: 30s timeout: 10s retries: 3 networks: - app-network volumes: meilisearch_data: networks: app-network: external: true ``` ## Integrating with Traefik If you're self-hosting Meilisearch behind Traefik (like I do), you need to consider security and rate limiting. The self-hosted version doesn't include rate limiting or CORS handling by default, so you need to add middleware. The best practice is to define middlewares in a separate static configuration file and reference them in your Docker Compose labels. This keeps your configuration organized and reusable. ### Docker Compose Configuration Here's the Docker Compose setup that references middlewares from a static configuration file: ```yaml services: meilisearch: image: getmeili/meilisearch:latest container_name: meilisearch restart: unless-stopped environment: - MEILI_MASTER_KEY=${MEILI_MASTER_KEY} - MEILI_ENV=production - MEILI_HTTP_ADDR=0.0.0.0:7700 volumes: - meilisearch_data:/meili_data networks: - app-network labels: - 'traefik.enable=true' # Router for search endpoints (GET, POST, OPTIONS for CORS preflight) # Matches: /indexes/{index}/search and /health - 'traefik.http.routers.meilisearch-search.rule=Host(`search.yourdomain.com`) && (PathRegexp(`/indexes/[^/]+/search`) && (Method(`GET`) || Method(`POST`) || Method(`OPTIONS`)) || Path(`/health`))' - 'traefik.http.routers.meilisearch-search.entrypoints=websecure' - 'traefik.http.routers.meilisearch-search.tls.certresolver=letsencrypt' - 'traefik.http.routers.meilisearch-search.service=meilisearch' # Middleware chain: security headers, rate limiting, and CORS # The @file suffix references middlewares defined in static config - 'traefik.http.routers.meilisearch-search.middlewares=security-headers@file,meilisearch-ratelimit@file,meilisearch-cors@file' # Service configuration - 'traefik.http.services.meilisearch.loadbalancer.server.port=7700' healthcheck: test: ['CMD-SHELL', 'curl -f http://localhost:7700/health || exit 1'] interval: 30s timeout: 10s retries: 3 volumes: meilisearch_data: networks: app-network: external: true ``` ### Middleware Configuration Create a `middlewares.yml` file (or add to your existing Traefik static configuration) with the middleware definitions: ```yaml http: middlewares: # Global security headers middleware security-headers: headers: stsSeconds: 315360000 browserXssFilter: true contentTypeNosniff: true forceSTSHeader: true stsIncludeSubdomains: true stsPreload: true frameDeny: true referrerPolicy: 'strict-origin-when-cross-origin' # Rate limiting middleware for Meilisearch # Uses Redis/Valkey for distributed rate limiting across multiple instances meilisearch-ratelimit: rateLimit: average: 100 burst: 200 period: 1s redis: endpoints: - 'valkey:6379' readTimeout: 3s writeTimeout: 3s dialTimeout: 5s sourceCriterion: ipStrategy: depth: 0 excludedIPs: - '173.245.48.0/20' - '103.21.244.0/22' # CORS middleware for Meilisearch search endpoints meilisearch-cors: headers: accessControlAllowMethods: - 'GET' - 'POST' - 'OPTIONS' accessControlAllowHeaders: - '*' accessControlAllowOriginList: - 'https://yourdomain.com' accessControlMaxAge: 86400 addVaryHeader: true accessControlExposeHeaders: - 'Content-Type' ``` ### How It Works Together 1. **Router Matching**: The Traefik router matches requests to `/indexes/{index}/search` and `/health` endpoints on your search domain. 2. **Middleware Chain**: Requests flow through the middleware chain in order: - `security-headers@file`: Adds security headers (HSTS, XSS protection, etc.) - `meilisearch-ratelimit@file`: Enforces rate limits (100 req/s average, 200 burst) - `meilisearch-cors@file`: Handles CORS headers for cross-origin requests 3. **CORS Handling**: The CORS middleware is essential for allowing your frontend to access the search API. Without it, browser security will block all search requests. The `OPTIONS` method is included to handle CORS preflight requests. 4. **Rate Limiting**: The rate limiter prevents abuse and protects your server. If you're running multiple Traefik instances, use Redis/Valkey for distributed rate limiting. Otherwise, you can remove the `redis` section for in-memory rate limiting. 5. **Service Routing**: Finally, the request is forwarded to the Meilisearch service on port 7700. **Note**: Admin endpoints (like creating indexes or managing API keys) should be accessed directly via your internal network or VPN, not through the public Traefik router. Only expose the search endpoints publicly. ## Creating API Keys Never expose your master key to the client. Instead, create a search-only key with restricted permissions. Here's how to create a proper search key using the Meilisearch API: ```bash curl \ -X POST 'http://localhost:7700/keys' \ -H 'Authorization: Bearer YOUR_MASTER_KEY' \ -H 'Content-Type: application/json' \ --data-binary '{ "description": "Public search key for blog", "actions": ["search"], "indexes": ["blog"], "expiresAt": null }' ``` Or create a script to generate it programmatically: ```typescript // scripts/create-search-key.ts const client = new MeiliSearch({ host: process.env.MEILISEARCH_URL || 'http://localhost:7700', apiKey: process.env.MEILISEARCH_MASTER_KEY, }); async function createSearchKey() { const key = await client.createKey({ description: 'Public search key for blog', actions: ['search'], indexes: ['blog'], expiresAt: null, }); console.log('Search Key:', key.key); console.log('\nAdd this to your .env file:'); console.log(`PUBLIC_MEILISEARCH_SEARCH_KEY=${key.key}`); } createSearchKey().catch(console.error); ``` Run it once with: ```bash npx tsx scripts/create-search-key.ts ``` The generated key can only perform searches on the `blog` index. It can't add, modify, or delete documents, making it safe to expose in your frontend code. ## Indexing Your Content The indexing script needs to parse your Astro content collections and send them to Meilisearch. Here's a complete implementation: ```typescript // scripts/index-meilisearch.ts const client = new MeiliSearch({ host: process.env.MEILISEARCH_URL || 'http://localhost:7700', apiKey: process.env.MEILISEARCH_MASTER_KEY, }); const mdxProcessor = unified().use(remarkParse).use(remarkMdx); function extractTextFromAST(node: any): string { const texts: string[] = []; visit(node, (n: any) => { // Skip code blocks, inline code, and JSX elements if ( n.type === 'code' || n.type === 'inlineCode' || n.type === 'mdxJsxFlowElement' || n.type === 'mdxJsxTextElement' ) { return SKIP; } if (n.type === 'text') { texts.push(n.value); } }); return texts.join(' ').trim().replace(/\s+/g, ' '); } async function indexPosts() { console.log('Starting indexing process...'); // Get all blog posts const posts = await getCollection('blog'); const documents = []; for (const post of posts) { // Skip drafts if (post.data.draft) { continue; } // Read the MDX file const filePath = path.join( process.cwd(), 'src/content/blog', `${post.slug}.mdx`, ); const fileContent = await fs.readFile(filePath, 'utf-8'); // Parse frontmatter and content const { content } = matter(fileContent); // Extract text from markdown AST const ast = mdxProcessor.parse(content); const textContent = extractTextFromAST(ast); documents.push({ id: post.slug, path: `/blog/${post.slug}`, title: post.data.title, description: post.data.description, content: textContent, tags: post.data.tags || [], date: post.data.date.toISOString(), author: post.data.author || 'Sarthak Mishra', }); } console.log(`Indexing ${documents.length} documents...`); // Get or create index const index = client.index('blog'); // Configure index settings console.log('Configuring index settings...'); // Set searchable attributes (order matters for relevance) await index.updateSearchableAttributes([ 'title', 'description', 'tags', 'content', ]); // Set filterable attributes await index.updateFilterableAttributes(['tags', 'date']); // Set sortable attributes await index.updateSortableAttributes(['date']); // Add documents const task = await index.addDocuments(documents, { primaryKey: 'id', }); console.log('Waiting for indexing to complete...'); await client.waitForTask(task.taskUid); console.log('✓ Indexing complete!'); console.log(`Indexed ${documents.length} posts`); } indexPosts().catch(console.error); ``` Add it to your `package.json`: ```json { "scripts": { "meilisearch:index": "tsx scripts/index-meilisearch.ts", "build": "astro build && pnpm meilisearch:index" } } ``` The script extracts clean text from your MDX files, skipping code blocks and JSX components. It configures searchable attributes with proper weighting (title ranks higher than content) and enables filtering and sorting. ## Using Meilisearch in Your Search Component The TypeScript/JavaScript SDK is excellent and makes integration straightforward. ```bash pnpm add meilisearch ``` Here's a complete search component for Astro with Preact: ```tsx // src/components/SearchModal.tsx interface SearchResult { id: string; path: string; title: string; description: string; tags: string[]; date: string; } export default function SearchModal() { const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); // Initialize Meilisearch client const client = new MeiliSearch({ host: import.meta.env.PUBLIC_MEILISEARCH_URL, apiKey: import.meta.env.PUBLIC_MEILISEARCH_SEARCH_KEY, }); const index = client.index('blog'); useEffect(() => { if (!query.trim()) { setResults([]); return; } const searchTimeout = setTimeout(async () => { setLoading(true); setError(null); try { const searchResults = await index.search(query, { limit: 8, sort: ['date:desc'], }); setResults(searchResults.hits as SearchResult[]); } catch (err) { console.error('Search error:', err); setError('Search failed. Please try again.'); } finally { setLoading(false); } }, 300); // Debounce search return () => clearTimeout(searchTimeout); }, [query]); return (
setQuery(e.currentTarget.value)} class="search-input" /> {loading &&
Searching...
} {error &&
{error}
} {results.length > 0 && ( )} {!loading && query && results.length === 0 && (
No results found for "{query}"
)}
); } ``` The client initialization uses your public search key, making it safe to expose. The SDK handles typo tolerance automatically - searching for "javascirpt" will still find "javascript" posts. ## Advanced Features You Probably Don't Need ### Typo Tolerance Meilisearch handles typos out of the box. No configuration needed. It just works. This is genuinely impressive - searching for "astr" finds "astro", "javascirpt" finds "javascript", and so on. For most blogs, this is overkill. Your readers can spell. ### Faceted Search and Filtering Want to filter by tags or date ranges? Meilisearch makes it easy: ```typescript const results = await index.search(query, { filter: 'tags = "astro" AND date > 2024-01-01', limit: 10, }); ``` But honestly, for a blog, a simple tag page works fine. You don't need real-time faceted filtering. ### Vector Search and Hybrid Search Meilisearch ships with a vector store, and recent releases made the newer store the default for newly created indexes. You can do semantic search by generating embeddings and mixing them with keyword relevance: ```python # Example from Meilisearch docs results = client.index('books').search(query, opt_params={ 'hybrid': { 'semanticRatio': 0.7, 'embedder': 'openai' }, 'limit': 4 }) ``` The `semanticRatio` controls the balance between keyword and semantic search. A value of 0.7 means 70% semantic, 30% keyword matching. But here's the catch - you need to generate embeddings for all your content. This requires an OpenAI API key, adds complexity to your indexing pipeline, and costs money for the API calls. For a small blog, this is complete overkill. ### Multimodal Search Meilisearch added multimodal embeddings support, letting you search across text and images in newer releases. It's impressive, but it also increases operational complexity and cost, so it's hard to justify for a small site. ## The Honest Assessment After setting all this up, here's my take on when you should actually use Meilisearch. ### When Meilisearch Makes Sense Use Meilisearch if you have: - 1000+ documents that need search - Real-time content updates without rebuilds (like a CMS) - Complex filtering requirements (e-commerce, job boards) - Multiple languages requiring sophisticated typo tolerance - The infrastructure to maintain a production search server Good use cases include large documentation sites, e-commerce platforms, job boards, or any application where search is a core feature. ### Why It's Overkill for Small Blogs On a sub-50-post blog, Meilisearch mostly adds a server to babysit, recurring infra cost, and a lot of configuration surface area while delivering little beyond what static search already gives you. If you rebuild on deploy and only need simple tag filtering, Pagefind or Orama stay simpler, cheaper, and good enough. ## Conclusion Meilisearch is powerful, impressive software that I genuinely enjoy using. But for a static blog, it violates the principle of "keeping it simple." By choosing a static library like Pagefind or Orama, you eliminate an entire class of maintenance tasks and security risks. You get search that is fast, smart, and effectively free. Build this setup to learn. But deploy the static solution to production. --- ## What's next? This is part of a series of posts on implementing search for static sites: - [The Right Way to Add Orama Search to Astro](/blog/orama-astro) -- simple, zero-config search for small to medium sites - [Why I Switched from Orama to Pagefind](/blog/pagefind-astro) -- chunked index for better scalability - Meilisearch is the Best Search You'll Never Need (**_you are here_**) -- server-side search with advanced features - [Why I Didn't Use Google Programmable Search](/blog/google-custom-search-astro) -- the hidden costs and indexing delays that make it impractical - [I Tried 4 Search Engines So You Don't Have To](/blog/astro-search-comparison) -- comprehensive comparison from a small blog perspective All with practical examples from a real production blog. --- --- title: Why I Switched from Orama to Pagefind description: Why I use Pagefind: A deep dive into chunked indexing, bandwidth scaling, and setting up a production-grade search for Astro. type: blog url: https://sarthakmishra.com/blog/pagefind-astro date: 2026-01-13 tags: ['astro', 'search', 'pagefind', 'performance', 'typescript'] author: Sarthak Mishra excerpt: Orama is great, but downloading a 2MB JSON index is not. Here is how Pagefind solves the scalability problem with binary chunks and zero-config ranking. --- # Why I Switched from Orama to Pagefind I previously wrote about [building search with Orama](/blog/orama-astro). It's a fantastic piece of engineering—an in-memory database running entirely in the browser. But it has a fatal flaw for content sites: **Scalability.** Orama (and Fuse.js/Lunr) are **monolithic**. To search _anything_, the user must download _everything_. If you have 500 posts, the user downloads 500 posts' worth of metadata before they can type a single letter in the search bar. **Pagefind** flips this model. Instead of sending the database to the user, it splits the index into thousands of tiny binary "chunks." The browser only downloads the chunks relevant to the specific search query. Here is how I implemented Pagefind in Astro, covering the parts the docs skip, like custom ranking, complex filtering, and fixing the "Dev Mode" headache. ## Why I love the chunking approach When you build your site, Pagefind scans your HTML and generates a static "API" in your `dist/` folder. 1. **`pagefind.js` (30KB):** The tiny client entry point. 2. **`pagefind-entry.json` (4KB):** A map of all words to "index chunks." 3. **Index Chunks (10-20KB each):** Binary files containing lists of documents that match specific word hashes. **The Flow:** 1. User types "React". 2. `pagefind.js` hashes "React" and looks up which _chunk_ contains "R" words. 3. It fetches _only_ `index_chunk_a1b2.pf_index`. 4. It finds the matches and fetches the _fragments_ for those specific URLs to display the snippet. **Total bandwidth:** ~50KB. **Orama bandwidth:** ~600KB (for the same dataset). ## 1. The Setup The `npx pagefind --site dist` command works in prod, but it breaks your local dev workflow because `astro dev` doesn't generate a `dist` folder. We need a setup that works in both environments. **Installation:** ```bash pnpm add -D pagefind ``` **The Config:** Create a `pagefind.yml` in your root. This keeps your CLI commands clean. ```yaml # pagefind.yml site: dist exclude_selectors: - 'nav' - 'footer' - '.related-posts' glob: '**/*.html' ``` **The "Dev Mode" Fix:** Since Pagefind needs static HTML to crawl, it _cannot_ index your site while it's in hot-reloading dev mode. The workaround is to build the index once, and then tell Astro to serve it as a static asset. Add this to `package.json`: ```json "scripts": { "dev": "pnpm build:search && astro dev", "build": "astro build && pnpm build:search", "build:search": "pagefind --site dist" } ``` Now, we need to allow Astro to serve the `pagefind.js` file from the `dist` folder during dev. Update `astro.config.mjs`: ```javascript export default defineConfig({ vite: { server: { // Allow serving files from the dist directory during dev fs: { allow: ['dist'], }, }, }, }); ``` _Note: In dev, the search results will be "stale" (from the last build). This is the trade-off._ ## 2. The Schema: Decorating HTML Pagefind doesn't use a JSON schema. It uses your DOM. You control the index using **Data Attributes**. ### The Basics Wrap your main content in a `data-pagefind-body` attribute and add metadata attributes to the elements you want to index: ```html

{title}

``` ### Advanced: Weighting & Filtering This is where Pagefind becomes powerful. You can boost specific terms and create facets without writing config files. ```html

{description}

{tags.map(tag => ( ))} ``` Now, searching for "Astro" in the intro paragraph ranks higher than "Astro" in the footer. To learn more, checkout the [weighting](https://pagefind.app/docs/weighting/) and [metadata](https://pagefind.app/docs/metadata/) docs. ## 3. The Search UI Since `pagefind.js` is a generated asset, we can't `import` it normally. We must use a dynamic import pointing to the URL. ```typescript export default function SearchModal() { const [pagefind, setPagefind] = useState(null); const [results, setResults] = useState([]); // Lazy load on open useEffect(() => { async function load() { // 1. Dynamic import from the public URL const lib = await import(/* @vite-ignore */ '/pagefind/pagefind.js'); await lib.init(); setPagefind(lib); } load(); }, []); const handleSearch = async (query) => { if (!pagefind || !query) return; // 2. Run Search const search = await pagefind.search(query); // 3. Load data for top 5 results // Pagefind returns "pointers" first, then we fetch the data const topResults = await Promise.all( search.results.slice(0, 5).map((r) => r.data()), ); setResults(topResults); }; // ... Render UI } ``` ## Where Pagefind Shines (and Fails) ### Where it is Extremely Effective 1. **Long-Form Documentation:** Pagefind supports **Sub-Result Anchors**. If you search "Permissions", and you have a massive guide called "Linux Basics", Pagefind can return a direct link to the `## Permissions` H2 inside that page (`/linux-guide#permissions`). Orama struggles with this without massive index bloat. _Enable it with `await pagefind.init({ showSubResults: true })`._ 2. **Multi-Site Search:** If you have a Blog (Astro) and a Documentation site (Starlight), Pagefind can **merge indexes**. You can run one search bar that queries both `blog.com/pagefind` and `docs.com/pagefind` seamlessly in the browser. ### Where it Fails 1. **Typo Tolerance:** This is the biggest trade-off. Orama uses [Levenshtein distance](https://github.com/oramasearch/orama/blob/main/packages/orama/src/components/levenshtein.ts) to match, for example, "astor" to "astro." Pagefind **does not**. [Pagefind](https://pagefind.app/docs/multilingual/) is a _stemming_ engine. It knows "run" matches "running." But if you type "rnning", it finds nothing. It relies on prefix matching, so typing "prog" finds "programming", but "pgramming" finds zero. 2. **Dev Experience:** There is no way around it: having to run a build to update the search index is annoying. If you write a new post, you won't see it in search until you restart the server with a fresh build. ## Conclusion Pagefind is the "Adult" choice for static search. It isn't as flashy as Orama, and it doesn't do vector embeddings or any other AI magic. But it scales from 10 pages to 10,000 pages without changing a line of code. --- ## What's next? This is part of a series of posts on implementing search for static sites: - [The Right Way to Add Orama Search to Astro](/blog/orama-astro) -- simple, zero-config search for small to medium sites - Why I Switched from Orama to Pagefind (**_you are here_**) -- chunked index for better scalability - [Meilisearch is the Best Search You'll Never Need](/blog/meilisearch-astro) -- server-side search with advanced features - [Why I Didn't Use Google Programmable Search](/blog/google-custom-search-astro) -- the hidden costs and indexing delays that make it impractical - [I Tried 4 Search Engines So You Don't Have To](/blog/astro-search-comparison) -- comprehensive comparison from a small blog perspective All with practical examples from a real production blog. --- --- title: The Right Way to Add Orama Search to Astro description: Stop using the Orama Astro plugin. Here is how to build a type-safe, metadata-rich search engine that actually works in Dev Mode. type: blog url: https://sarthakmishra.com/blog/orama-astro date: 2026-01-12 tags: ['astro', 'search', 'orama', 'preact', 'typescript', 'ui-design'] author: Sarthak Mishra excerpt: The official Orama plugin is great for demos, but it breaks in dev mode and sucks at metadata. Here is how I built a production-grade, zero-config search engine using Astro Endpoints. --- # The Right Way to Add Orama Search to Astro I love static sites. They are fast, secure, and cheap to host. But **search** has always been the weak point. You usually have two options: 1. **External Services:** Algolia or similar self-hosted services like Meilisearch. 2. **Client-Side Libraries:** Orama, Pagefind, Lunr or Fuse. I was looking for something that runs **entirely in the browser**, supports **fuzzy matching**, handles **typos**, and doesn't cost me a dime. Enter [Orama](https://orama.com/) (formerly Lyra). It's an immutable, in-memory, full-text search engine written in TypeScript. It's fast. However, most tutorials (including the official docs) tell you to use the `@orama/plugin-astro`. **Do not do this.** The plugin has three major problems: 1. **No Dev Mode:** It only generates the index during `astro build`. If you run `pnpm dev`, your search bar 404s. 2. **Weak Metadata:** It scrapes your HTML output. If you want to display a "Thumbnail" or "Author" in your search results, you have to build awkward side-channel maps. 3. **No Control:** You can't easily boost specific fields (like `title` > `body`) without fighting the config. Here is how to implement Orama the _right_ way using Astro Endpoints. ## The Architecture Instead of scraping HTML, we generate the index directly from **Astro Content Collections**. 1. **Build Time:** We create a `.json.ts` endpoint that fetches all posts, builds an Orama DB, and serializes it to JSON. 2. **Client Time:** The browser fetches this JSON file (lazy-loaded) and hydrates a local Orama instance. This solves the Dev Mode problem immediately because Astro treats `.json.ts` files as live routes. ## 1. The Index Endpoint Create a file at `src/pages/search-index.json.ts`. This acts as our "Search API." Defining a strict schema is important. Orama performs best when it knows exactly what fields to expect. By specifying fields like `title`, `slug`, `date`, and `description`, you optimize the index for performance and ensure each search result contains everything needed to render its card. ```typescript export async function GET() { const posts = await getCollection('blog', ({ data }) => !data.draft); // 1. Create the DB instance const db = await create({ schema: { title: 'string', slug: 'string', description: 'string', date: 'string', // Store as string for easy display tags: 'string[]', }, }); // 2. Insert Data const records = posts.map((post) => ({ title: post.data.title, slug: `/blog/${post.slug}`, description: post.data.description, date: post.data.date.toISOString(), tags: post.data.tags, })); await insertMultiple(db, records); // 3. Serialize to JSON const index = await save(db); return new Response(JSON.stringify(index), { headers: { 'Content-Type': 'application/json', }, }); } ``` ## 2. The Search Component (Client-Side) On the client, we fetch this JSON and "hydrate" Orama. I use Preact here to save bundle size, but the logic is framework-agnostic. We use `load` to restore the database state. This is much faster than re-indexing data on the client. ```typescript // Define the same schema for type safety const SCHEMA = { title: 'string', slug: 'string', description: 'string', date: 'string', tags: 'string[]', } as const; export default function SearchModal() { const [db, setDb] = useState | null>(null); const [results, setResults] = useState([]); // Lazy load: Only fetch the index when the user interactions with search const initSearch = async () => { if (db) return; // 1. Fetch the raw JSON index const response = await fetch('/search-index.json'); const index = await response.json(); // 2. Initialize an empty DB const newDb = await create({ schema: SCHEMA }); // 3. Load the snapshot await load(newDb, index); setDb(newDb); }; const handleSearch = async (term: string) => { if (!db || !term) return; const searchResult = await search(db, { term, limit: 5, threshold: 0.2, // Tolerance for fuzziness boost: { title: 2, // Title matches are 2x more important }, }); setResults(searchResult.hits); }; return ( // ... UI Markup (Input + Results List) ); } ``` ## Why This Approach Wins ### 1. Zero-Config Dev Mode Because `src/pages/search-index.json.ts` is just a standard Astro route, it works perfectly in `pnpm dev`. You don't need custom scripts to force-build an index or manual file copying. ### 2. Rich, Type-Safe Metadata We aren't scraping `

` tags and hoping for the best. We use the Content Collection schema directly. The search result object contains exactly what we need to render the UI: ```typescript // The `hit.document` is fully typed { title: "Building Static Search...", slug: "/blog/building-static-search", date: "2026-01-12T00:00:00.000Z", tags: ["astro", "search"] } ``` ### 3. Relevance Tuning Notice the `boost: { title: 2 }` in the search query? If a user searches for "Astro", a post with "Astro" in the title should rank higher than a post that just mentions it in the footer. The official plugin abstracts this away. Here, we have full control. ## When NOT to use it I've talked Orama up, but let's be honest about the trade-offs. ### The Bandwidth Issue Orama is an **In-Memory** search engine. This means the _entire_ index must be loaded into the user's browser RAM. - **50 Posts:** Index is ~30KB (Gzipped). **Perfect.** - **500 Posts:** Index is ~300KB. **Manageable.** - **5,000 Posts:** Index is ~3MB+. **Unusable.** If you have thousands of pages, forcing the user to download a 3MB JSON file just to search is bad UX. **The Alternative:** For larger sites, use **Pagefind**. Pagefind splits the index into small "chunks." It only downloads the chunks relevant to the search query. It scales to 100k+ pages while keeping initial bandwidth low. In my implementation above, I indexed the `description`, not the full body. Why? **Size.** Indexing the full body content of 100 posts bloats the JSON file significantly. For a blog, searching titles, descriptions, and tags is usually 95% of what users need. If you _must_ search the full body text, be prepared for the file size to triple. ### Solving the Bandwidth Problem The `src/pages/search-index.json.ts` architecture solves the dev mode and metadata issues, but as your blog grows, you will hit the **Bandwidth Wall**. If you index the full body of 200 posts, your JSON file could easily exceed 2MB. Here is how to solve this using **Schema Optimization** and **AI-Powered Semantic Compression**. #### 1. Schema Optimization (The Low-Hanging Fruit) The fastest way to bloat your index is to index fields you don't actually search. **Bad Schema:** ```typescript const schema = { title: 'string', slug: 'string', body: 'string', // 🚨 DANGER: Indexing 5,000 words per post author: 'string', tags: 'string[]', image: 'string', // 🚨 DANGER: Why are you indexing a URL string? }; ``` **Optimized Schema:** ```typescript const schema = { title: 'string', slug: 'string', summary: 'string', // Index the description or a generated summary tags: 'string[]', // Store metadata (image, date) but DO NOT index it for search }; ``` **Tip:** Orama allows you to store data without indexing it. In our JSON generation script, we only feed Orama the _searchable_ text. The metadata needed for the UI (like `image` or `date`) can be stored in the `document` payload but ignored by the indexing engine. #### 2. AI-Powered "Semantic Compression" This is the interesting part. Instead of indexing your raw Markdown (which is full of noise like "the", "and", code blocks, and imports), use an LLM at build time to generate a **Search Profile** for each post. **The Concept:** 1. **Read** the full blog post content during the build. 2. **Pass** it to a cheap LLM (like GPT-4o-mini or a local Llama 3 via Ollama). 3. **Generate** a dense, keyword-rich summary specifically designed for search. 4. **Index** that summary instead of the body. **Why this works:** A 3,000-word tutorial on "Setting up Nginx" might only contain 50 words of unique, searchable signal. By indexing only the signal, you reduce your index size by ~90% while _improving_ relevance because you remove the noise. **The Build Script (`scripts/generate-search-embeddings.ts`):** ```typescript // This runs ONLY at build time. No client-side cost. async function generateSearchIndex() { const posts = await getCollection('blog'); const searchIndex = []; for (const post of posts) { // Check if we have a cached summary to save API costs let searchContent = post.data.search_summary; if (!searchContent) { const { text } = await generateText({ model: openai('gpt-4o-mini'), prompt: ` Analyze this blog post about "${post.data.title}". Extract the core technical concepts, libraries mentioned, problems solved, and key takeaways. Output a single paragraph of dense, keyword-rich text optimized for a search engine. Do not use filler words. Content: ${post.body.substring(0, 8000)} `, }); searchContent = text; } searchIndex.push({ title: post.data.title, slug: `/blog/${post.slug}`, // Index this AI-generated "dense" content instead of the body content: searchContent, tags: post.data.tags, }); } // Save to JSON... } ``` #### 3. Chunking Long Content If you have massive posts (like a "Complete Guide to Linux" with 10 chapters), a single search result pointing to the top of the page is annoying. The user wants to jump to the specific _section_ (like "Permissions"). **The Strategy:** Don't treat one Post as one Document. Treat one **H2 Section** as one Document. 1. **Split** your markdown by `## H2` headers. 2. **Create** an Orama document for each section. 3. **Add** an anchor hash to the slug (e.g., `/blog/linux-guide#permissions`). ```typescript // Transform 1 Post into N Search Documents const records = []; posts.forEach((post) => { const sections = splitByH2(post.body); // Helper function sections.forEach((section) => { records.push({ title: `${post.data.title} > ${section.heading}`, slug: `/blog/${post.slug}#${section.slug}`, content: section.text.substring(0, 1000), // or use AI summary of section }); }); }); ``` This increases the number of documents (which increases index size slightly), but it improves the user experience for long-form content. #### Summary of Techniques | Technique | Implementation Difficulty | Index Size Reduction | Relevance Impact | | :-------------------------- | :------------------------ | :------------------------ | :---------------- | | **Schema Optimization** | Low | High (20-40%) | Neutral | | **Stop Word Removal** | Low (Orama config) | Moderate (10-15%) | Neutral | | **AI Semantic Compression** | High (Requires API/LLM) | **Massive (80-90%)** | **Positive** | | **Section Chunking** | Moderate | Negative (Increases Size) | **Positive (UX)** | For a personal blog, **Technique #2 (AI Compression)** is the sweet spot. It keeps your search index tiny (sub-50KB) while making your search feel "smart" because it matches concepts, not just exact keywords. ## Conclusion For a developer blog, portfolio, or documentation site under 500 pages, **Orama + Astro Endpoints** is the best option. You get instant, app-like search without the infrastructure headache. But if you are building the next Wikipedia, don't make your users download the whole database. --- ## What's next? This is part of a series of posts on implementing search for static sites: - The Right Way to Add Orama Search to Astro (**_you are here_**) -- simple, zero-config search for small to medium sites - [Why I Switched from Orama to Pagefind](/blog/pagefind-astro) -- chunked index for better scalability - [Meilisearch is the Best Search You'll Never Need](/blog/meilisearch-astro) -- server-side search with advanced features - [Why I Didn't Use Google Programmable Search](/blog/google-custom-search-astro) -- the hidden costs and indexing delays that make it impractical - [I Tried 4 Search Engines So You Don't Have To](/blog/astro-search-comparison) -- comprehensive comparison from a small blog perspective All with practical examples from a real production blog. --- --- title: Honey, I Shrunk my Frontend Bundle Size! description: How I replaced Lottie with Canvas sprites, migrated to Preact, and removed heavy dependencies to cut over 300KB from my bundle. type: blog url: https://sarthakmishra.com/blog/optimizing-astro-bundle-size date: 2026-01-11 tags: ['performance', 'preact', 'astro', 'optimization', 'typescript', 'canvas'] author: Sarthak Mishra excerpt: I audited my site's bundle and found a 200KB monster hiding in the sidebar. Here is how I replaced Lottie with Canvas sprites, migrated to Preact, and eliminated blocking scripts to make the site fly. --- # Honey, I Shrunk my Frontend Bundle Size! We love dropping big libraries on small problems. Need an animated icon? Install `lottie-react`. Need a toast? Install `sonner`. Need a hover effect? Install `react-spring`. It works until you look at the bundle. After adding `rollup-plugin-visualizer`, I found my sidebar icons alone were pulling ~200KB of JavaScript. React added another ~40KB. Time to trim. ## The Problem: Lottie was crushing performance `lottie-web` plus its React wrapper were bundled just to animate five icons. That hit first paint, parse time, and memory. A 40-frame interaction does not need a full animation engine. ## Solution: Canvas sprite sheets I reused the sprite-sheet approach from my [hero animation](/blog/building-animated-sprite-hero). - Input: a 7x6 PNG (41 frames) - Logic: `requestAnimationFrame` + `drawImage` - Trigger: only runs when `isAnimating` is true (hover) Result: ~3KB instead of ~210KB. ```typescript // SpriteIcon.tsx (simplified) export default function SpriteIcon({ spriteName, isAnimating }) { const canvasRef = useRef(null); // Pure Canvas animation return ; } ``` ## Migration 1: React to Preact (~40KB saved) For a [content-heavy site](/blog/building-perfect-toc-component), I do not need full React. Preact gives a near-identical API at ~3KB. I began with compat mode, then moved components to Preact primitives over time. ```javascript // astro.config.mjs export default defineConfig({ integrations: [preact({ compat: true })], vite: { resolve: { alias: [ { find: 'react-dom/server', replacement: 'preact-render-to-string' }, { find: 'preact/compat/server', replacement: 'preact-render-to-string', }, ], }, }, }); ``` ## Migration 2: Ditching react-spring (~20KB saved) I was using `react-spring` for simple hover transitions. A physics engine for a 50px move is wasteful. Native CSS transitions with a custom cubic-bezier did the job. **Before:** ```typescript const style = useSpring({ transform: `translateY(${position}px)` }); ``` **After:** ```typescript
``` ## Migration 3: Custom toast (~20KB saved) `sonner` is great, but overkill here. I replaced it with ~120 lines of vanilla TypeScript that manipulates the DOM directly. ```typescript toast.error('Something went wrong'); ``` Here's the full implementation: ```typescript /** * Standalone toast notifications for use in Astro pages and scripts * This is a vanilla TS implementation that doesn't depend on React */ type ToastType = 'error' | 'warning' | 'success' | 'info'; const styleConfig = { error: { iconColor: '#DC2626', iconPath: '', }, warning: { iconColor: '#F59E0B', iconPath: '', }, success: { iconColor: '#16A34A', iconPath: '', }, info: { iconColor: '#2563EB', iconPath: '', }, }; let toastContainer: HTMLDivElement | null = null; function getToastContainer(): HTMLDivElement | null { if (typeof document === 'undefined') return null; if (!toastContainer || !document.body.contains(toastContainer)) { toastContainer = document.createElement('div'); toastContainer.className = 'toast-container'; // Fixed positioning, top-right document.body.appendChild(toastContainer); } return toastContainer; } function createToast(type: ToastType, message: string): void { const container = getToastContainer(); if (!container) return; const config = styleConfig[type]; const wrapper = document.createElement('div'); wrapper.className = 'toast-wrapper'; // Slide-in animation wrapper.innerHTML = ` `; container.appendChild(wrapper); // Animate in requestAnimationFrame(() => { wrapper.classList.add('visible'); }); const removeToast = () => { wrapper.classList.remove('visible'); setTimeout(() => wrapper.remove(), 200); }; const closeBtn = wrapper.querySelector('.toast-close'); closeBtn?.addEventListener('click', removeToast); // Auto-dismiss after 5 seconds setTimeout(removeToast, 5000); } export const toast = { error: (message: string) => createToast('error', message), warning: (message: string) => createToast('warning', message), success: (message: string) => createToast('success', message), info: (message: string) => createToast('info', message), }; ``` Same UX, zero dependencies, works outside the component tree. ## Optimizing the critical path PageSpeed showed **2,769ms** max critical path latency. The main offenders: analytics and my [pixelation effect](/blog/building-pixelation-hover-effect) loading too early. ### 1) Defer analytics Load analytics in `requestIdleCallback`, fall back to `load` if unavailable. ```html ``` ### 2) Smarter pixelation - Disable on mobile (performance over aesthetics) - Batch reads/writes with `requestAnimationFrame` - Use `offsetWidth` when possible to leverage cached layout ## The results - Lottie removal: ~230KB saved - React → Preact: ~40KB saved - Spring + sonner: ~40KB saved - **Total:** ~310KB (gzipped) Critical path latency dropped from ~2.7s to non-blocking. The site feels snappier. Animations stay smooth on low-end devices because they are CSS-native or Canvas-based. ## Takeaways Dependencies are debt on every page load. You do not need a physics engine for hover states. You do not need a full React runtime for a static blog. You do not need 200KB of JSON player logic to wiggle an icon. Audit your bundle. The numbers will surprise you. --- --- title: Automating Accessibility: Generating Alt Text with AI description: How I automated accessibility across my portfolio by building a build-time pipeline that uses Gemini Flash to generate accurate alt text for hundreds of images. type: blog url: https://sarthakmishra.com/blog/automating-image-alt-text date: 2026-01-10 tags: ['astro', 'accessibility', 'ai', 'automation', 'typescript', 'gemini', 'openrouter'] author: Sarthak Mishra excerpt: Manually writing alt text for hundreds of images is tedious, but skipping it hurts accessibility and SEO. Here is how I built an automated pipeline using Vision AI to handle it for me. --- # Automating Accessibility: Generating Alt Text with AI When building a portfolio site, you eventually hit a wall with images. You want them everywhere to show off your work, but every image needs alt text. Missing or poor alt text is a major accessibility failure. It hurts screen reader users and it hurts SEO. But manually writing "Black and white selfie of a smiling man..." for the hundredth time is exactly the kind of tedious work I try to avoid. So, I automated it. I built a pipeline that uses Vision AI models to analyze images at build time, generates concise descriptions, and injects them into my Astro components. Here is how it works. ## The Strategy I didn't want a heavy runtime solution. I wanted something that runs once, caches the results, and costs pennies. The workflow is simple: 1. **Scan** the project for images. 2. **Hash** them to check if they have changed. 3. **Send** new images to a Vision model (Gemini 3 Flash via OpenRouter). 4. **Save** the descriptions to a JSON map. 5. **Inject** the text automatically in my image components. ## The Generation Logic The core of the system is the prompt. I need descriptions that are functional, not poetic. I used Google's Gemini 3 Flash because it's fast, incredibly cheap, and good at visual analysis. ```typescript async function generateAltText( imagePath: string, openrouter: ReturnType ): Promise { // Convert AVIF/SVG to PNG base64 for the API const imageBase64 = await convertImageToPngBase64(imagePath); const { text } = await generateText({ model: openrouter.chat('google/gemini-3-flash-preview'), messages: [ { role: 'user', content: [ { type: 'text', text: 'Generate a concise, descriptive alt text for this image. Max 10-15 words. Focus on the main subject. Return only the text.', }, { type: 'image', image: imageBase64, }, ], }, ], }); return text.trim(); } ``` One technical hurdle: APIs rarely accept optimized formats like AVIF. I had to include a step using `sharp` to convert images to PNG buffers on the fly before sending them out. ## Smart Caching [Processing hundreds of images](/blog/automating-image-placeholders) on every build would be slow and expensive. I implemented a [content-addressable cache](/blog/semantic-recommendations) using SHA256 hashing. The script hashes the file content directly to detect changes: ```typescript function getFileHash(filePath: string): string { const content = fs.readFileSync(filePath); return crypto.createHash('sha256').update(content).digest('hex'); } ``` If the hash matches what is in my `image-metadata.json`, we skip the API call. This means subsequent builds are nearly instant, and I only pay when I add or modify an image. **One detail to watch out for:** If you update an image's content but run the script without the AI flag (e.g., just to update placeholders), you must ensure you don't save the new hash with the old text. My script explicitly clears the cached alt text if the hash changes but AI generation is skipped, preventing stale descriptions from persisting. ## Integration with Astro The output of the script is a simple JSON file mapping filenames to metadata: ```json { "sarthak-photo.avif": { "altText": "Black and white selfie of a smiling man wearing sunglasses on a beach.", "hash": "22150ba448..." } } ``` Now, instead of manually passing `alt` props every time, my components look it up automatically. ### The PostImage Component In my `PostImage.astro` component, I import the metadata and perform a lookup based on the filename. ```astro --- const { thumbnail, alt } = Astro.props; // Lookup metadata by filename const filename = thumbnail; const metadata = filename ? (imageMetadata as Record)[filename] : null; // Allow manual override, otherwise use AI text const altText = metadata?.altText || alt || ''; --- ``` This pattern extends to my Carousels and Project Cards. If I forget to add an alt prop manually, the AI coverage kicks in. ## Handling Conflicts Since I map metadata by filename, duplicates in different directories were a risk. I added a pre-validation step that scans the filesystem and kills the build if it detects ambiguous filenames. ```typescript if (conflicts.length > 0) { console.error('❌ Filename conflicts found:'); conflicts.forEach(c => console.error(` ${c}`)); process.exit(1); } ``` It's a strict rule, but it keeps the lookup logic fast and simple. ## The Result I added a script to my `package.json` to run this before the main build. ```json "scripts": { "alt-text": "tsx scripts/generate-image-metadata.ts --alt-texts", "build": "tsx scripts/generate-image-metadata.ts --alt-texts && astro build" } ``` The result is comprehensive accessibility coverage with zero runtime overhead. The site remains a static build, the images remain optimized, but now screen readers actually get useful context instead of filenames or silence. It is a small automation, but it solves a persistent problem. And honestly, the AI is often better at describing the images than I am. --- --- title: Building a Smooth Pixelation Reveal Effect with Canvas description: How I built a high-performance, interactive pixelation effect for image transitions using HTML5 Canvas and custom easing interactions. type: blog url: https://sarthakmishra.com/blog/building-pixelation-hover-effect date: 2026-01-09 tags: ['javascript', 'canvas', 'typescript', 'animation', 'ui-design', 'performance'] author: Sarthak Mishra excerpt: CSS filters weren't giving me the control I needed. Here is how I used the Canvas API to built a smooth, 60fps pixelation reveal effect that works perfectly with Astro view transitions. --- # Building a Smooth Pixelation Reveal Effect with Canvas I wanted to add a specific interaction to my blog cards: images that start as a retro, blocky mess and snap into high-fidelity focus when you hover over them. CSS `backdrop-filter` or `image-rendering` properties can get close, but they lack the granular control needed for a smooth transition animation. Here is how I built a performant, framework-agnostic pixelation effect. ## The Concept The logic behind pixelation is surprisingly simple. You don't need complex shaders or WebGL. You just need to abuse the `drawImage` method. 1. **Shrink it:** Draw the image onto a canvas at a tiny fraction of its size (e.g., 5% width). 2. **Enlarge it:** Draw that tiny version back onto the full-size canvas. 3. **Important:** Disable image smoothing. When you scale an image up with smoothing disabled, the browser renders hard edges between the data points (Nearest Neighbor interpolation), creating the perfect pixelated look. ## The Implementation I wrapped the logic in a TypeScript class to handle state, events, and the animation loop. ### The Draw Loop The core algorithm calculates a `pixelFactor` based on the current animation progress. ```typescript // 1. Turn off smoothing to get that blocky look this.ctx.imageSmoothingEnabled = false; // 2. Calculate the 'tiny' dimensions based on the pixelation level // level = 1.0 (full quality) -> 0.01 (giant pixels) const w = Math.floor(this.width * level); const h = Math.floor(this.height * level); // 3. Draw down to the tiny size (offscreen or just logic) this.ctx.drawImage(this.img, 0, 0, w, h); // 4. Draw back up to full size this.ctx.drawImage( this.canvas, // Source is the canvas itself (containing the tiny image) 0, 0, w, h, // Source rect 0, 0, this.width, this.height // Dest rect ); ``` ### Adding Physics (Easing) Linear animations feel robotic. To make the reveal feel satisfying, I used a custom easing function. The goal is to spend more time in the "semi-pixelated" state and snap quickly into full focus at the end. I track a `progress` variable from 0 to 1. When the user hovers, I animate progress to 1. When they leave, I animate it back to 0. ```typescript // Cubic ease-in-out for that snappy feel function easeInOut(p: number) { return p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2; } // In the animation loop: const eased = easeInOut(this.progress); const pixelFactor = 1 + (MAX_PIXEL_SIZE - 1) * (1 - eased); const level = 1 / pixelFactor; this.draw(level); ``` ### Handling High DPI Screens One "gotcha" with Canvas is that it looks blurry on Retina/High-DPI displays by default. You have to manually account for `window.devicePixelRatio`. I set the internal canvas dimensions (the buffer) to `width * dpr`, but style it with CSS to the original width. ```typescript const dpr = window.devicePixelRatio || 1; this.canvas.width = rect.width * dpr; this.canvas.height = rect.height * dpr; this.ctx.scale(dpr, dpr); ``` ## Integrating with Astro (or React) Since I'm using Astro, I wanted this to work without [heavy framework overhead](/blog/optimizing-astro-bundle-size). I attach the effect to any container with a `data-pixelate` attribute. The markup is simple: ```html
``` And the initialization script runs on page load. Because I'm using [Astro's View Transitions](/blog/building-animated-sprite-hero) with client-side navigation, I have to make sure to clean up old instances or re-initialize on navigation. ```typescript document.addEventListener('astro:page-load', () => { const containers = document.querySelectorAll('[data-pixelate]'); containers.forEach(container => new PixelateEffect(container)); }); ``` ## Performance & Optimization Canvas operations are generally fast, but doing them on hover for multiple cards simultaneously is a bit much. **1. Lazy Rendering:** I only run the `drawImage` loop if the visual state actually changed. If the animation is idle (fully clear or fully pixelated), we stop the `requestAnimationFrame` loop. **2. [Image Preloading](/blog/automating-image-placeholders):** You can't draw an image that hasn't loaded. The class checks `img.complete`. If it's false, it waits for the `onload` event before attempting to draw. **3. Offscreen Buffering:** For the intermediate "tiny" image, using a separate offscreen canvas can sometimes be cleaner than drawing to the main canvas and overwriting it, though modern browsers are quite good at optimizing the draw-on-self pattern. ## The Result The result is a highly performant interaction that adds character without distracting from the content. It works smoothly at 60fps and gracefully degrades if the canvas fails (the original image is just CSS-hidden behind it). Sometimes the native web APIs are still the best tool for the job. --- --- title: How to generate blurry placeholders for your images description: How I eliminated layout shift and improved perceived performance by generating 20px base64 placeholders automatically at build time. type: blog url: https://sarthakmishra.com/blog/automating-image-placeholders date: 2026-01-08 tags: ['astro', 'performance', 'automation', 'typescript', 'ux-engineering', 'image-optimization'] author: Sarthak Mishra excerpt: Waiting for images to load is bad user experience. Watching the layout jump around is worse. Here is how I automated the generation of tiny, base64 placeholders to fix both problems without manual work. --- # How to generate blurry placeholders for your images When building a portfolio site heavily reliant on visuals, you hit a specific performance wall: images take time to load. On a fast connection, you might not notice it. But on a spotty mobile network, the user experience degrades fast. You get empty white boxes, or worse, massive layout shifts as images pop in and push text around. The standard solution is lazy loading or setting explicit aspect ratios. It helps with the layout shift, but it doesn't solve the empty box problem. I wanted something better: instant visual feedback. A [blurry, low-resolution version](/blog/building-pixelation-hover-effect) of the image that loads immediately with the HTML, then cross-fades into the real image. This is a pattern used by Medium, Unsplash, and Next.js. Here is how I built an automated pipeline to handle it. ## The Strategy: 20px Base64 Thumbnails The logic is straightforward: 1. Take every image in the project. 2. Generate a tiny (20px) version of it. 3. Convert that tiny version to a base64 string. 4. Inject that string directly into the component's CSS. Because the data is inline (base64), it requires zero extra HTTP requests. Because it is 20px, the payload is negligible. ## The Build Script I needed a script that runs before the build, scans my assets, and generates a metadata file. I used a two-tier approach for generation: `ffmpeg` for speed and quality, falling back to `sharp` if that fails. Here is the core generation logic: ```typescript async function generatePlaceholder(imagePath: string): Promise { try { // Try ffmpeg first (better quality/speed) const base64String = execSync( `ffmpeg -nostdin -i "${imagePath}" -vf "scale=20:-1" -f image2pipe -c:v mjpeg - 2>/dev/null | base64 -w 0`, { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 } ); return `data:image/jpeg;base64,${base64String}`; } catch (error) { // Fallback to sharp try { const buffer = await sharp(imagePath) .resize(20, 20, { fit: 'inside', withoutEnlargement: true }) .jpeg({ quality: 50 }) .toBuffer(); const base64 = buffer.toString('base64'); return `data:image/jpeg;base64,${base64}`; } catch (sharpError) { console.error(`Failed: ${imagePath}`, sharpError); return null; } } } ``` This output is saved to `src/data/image-metadata.json`. It maps filenames to their base64 placeholders and a content hash (more on that later). ```json { "sarthak-photo.avif": { "placeholder": "data:image/jpeg;base64,/9j/4AAQSkZJRg...", "hash": "22150ba448a81f2ea638a5f2a980268b061cb32758e1a818eb2c43cda8a76db4" } } ``` ## Integrating with Astro Components To render the placeholders, I updated my `PostImage` component to look up these placeholders. The trick is using the placeholder as a CSS `background-image` on the container div. This ensures it displays immediately, occupying the exact same space the final image will fill. ```astro --- const { thumbnail, image, ...props } = Astro.props; // Lookup metadata by filename const filename = thumbnail; const metadata = filename ? (imageMetadata as Record)[filename] : null; const placeholder = metadata?.placeholder || ''; ---
``` When the high-res `Picture` loads, it simply covers the background placeholder. ## Optimizing the Build Process Running image processing on every build is slow. To keep my deployment times under control, I implemented a caching system using file hashing. Before processing an image, the script calculates a [SHA256 hash](/blog/automating-image-alt-text) based on the file's modification time, size, and a sample of its content. ```typescript function getFileHash(filePath: string): string { const stats = fs.statSync(filePath); const content = fs.readFileSync(filePath); return sha256( `${stats.mtime.getTime()}-${stats.size}-${content.toString('hex').substring(0, 1000)}` ); } ``` If the hash in `image-metadata.json` matches the file on disk, we skip it. This cache hit rate is usually around 95%+, meaning sub-2-second processing times for the entire image library. ## Handling Collisions One real-world edge case I hit immediately: filename conflicts. If you have `header.jpg` in two different blog folders, a flat JSON map will break. I added a pre-validation step to the script. It scans all directories and throws a hard error if it detects duplicate filenames, forcing me to name files descriptively (which is better for SEO anyway). ```typescript const conflicts: string[] = []; for (const [filename, paths] of filenameMap.entries()) { if (paths.length > 1) { conflicts.push(`Duplicate: ${filename} in ${paths.join(', ')}`); } } if (conflicts.length > 0) { process.exit(1); // Fail the build } ``` ## The Result The impact on user experience is immediate. 1. **Zero Layout Shift:** Because the container has the background image immediately, the aspect ratio is reserved before the browser even parses the `img` tag. 2. **Perceived Speed:** The user sees _something_ instantly. The page feels responsive, even if the actual heavy assets are still coming down the pipe. 3. **Developer Experience:** I don't have to manually create thumbnails or configure lazy loading plugins. I drop an image in the folder, reference it in my markdown, and the build system handles the rest. This is the kind of automation I love: high impact on the frontend, zero friction in the workflow. --- --- title: How to generate animated pixel art with AI and Python description: How I built a pixel-perfect animated hero section using Midjourney, a custom Python processing pipeline, and Astro View Transitions. type: blog url: https://sarthakmishra.com/blog/building-animated-sprite-hero date: 2026-01-07 tags: ['astro', 'python', 'design', 'midjourney', 'automation', 'web-animation'] author: Sarthak Mishra excerpt: I wanted a retro, pixel-perfect animation for my hero section. Here is how I turned a Midjourney video into an optimized sprite sheet using Python and canvas. --- # How to generate animated pixel art with AI and Python I recently decided to overhaul my site's hero section. I wanted something that felt personal and retro--specifically, an animated pixel art profile. The easy way would be to find a GIF and slap it in an `` tag. But GIFs are bulky, hard to control, and don't scale well without artifacts. I wanted full control over the frame rate, the color palette, and the rendering sharpness. So I built a pipeline that takes an AI-generated video, processes it with Python into an optimized sprite sheet, which is then rendered inside Astro with a canvas element. ## The Pipeline The workflow looks like this: 1. **Creation:** Text-to-Image (ChatGPT) → Polish (Photoshop) → Animation (Midjourney). 2. **Processing:** Python script to extract frames, quantize colors, and fix jitter. 3. **Rendering:** HTML Canvas. ## Step 0: How to generate the video Getting consistent pixel art from AI is harder than it looks. Most models try to add "detail" that ruins the 8-bit aesthetic, or they hallucinate a grid that doesn't align. I started with ChatGPT to get the base composition. It took a bit of prompt engineering to force it into a strict low-res style. ```text First attempt (with an image of myself for reference): Please generate an image in the style of Retro 8-bit pixel art; pixel avatar; indie 2d game character art; bright orange flat background; for the given reference photograph. The image should be 1:1 aspect ratio. Edit (with the same reference image and the failed attempt): Try again; but enlarge the pixel blocks, and make the image flatter; make the background yellow ``` The raw result was decent but messy. The pixels weren't uniform, and the edges were fuzzy. I pulled it into Photoshop to clean up the artifacts and enforce a strict pixel grid. Once the static base was solid, I used Midjourney for the animation. ```text Animate this pixel art with a slight head tilt and smile. ``` ## Step 1: The Python Processing Script Midjourney creates great animations, but it outputs MP4s. MP4s are full of compression artifacts and "noise" that ruin the crisp pixel-art look. To fix this, I wrote a Python script `create_sprite_sheet.py`. It doesn't just extract frames; it enforces a strict color palette and smooths out temporal jitter. ### Color Quantization To get that authentic retro look, you can't have thousands of slightly different colors. I force the image down to a specific palette size (24 colors). ```python # Quantize combined image to get shared palette combined_quantized = combined.quantize( colors=PALETTE_SIZE, dither=Image.Dither.NONE ) # Apply unified palette to each frame for img in raw_frames: img_p = img.quantize( colors=PALETTE_SIZE, dither=Image.Dither.NONE, palette=combined_quantized, ) ``` ### Temporal Smoothing The biggest issue with AI video is flicker. Pixels that should be static tend to dance around. So I created a mask that detects "static" regions—pixels that don't change much between frames—and locks them to a single color across the entire animation loop. ```python # Detect static regions static_mask = np.ones((h, w), dtype=bool) for i in range(num_frames - 1): diff = np.abs(frame_stack[i] - frame_stack[i + 1]) static_mask &= np.max(diff, axis=2) < CHANGE_THRESHOLD # Apply mode color to static regions to stop flickering for y in range(h): for x in range(w): if static_mask[y, x]: pixel_stack = frame_stack[:, y, x, :] smoothed_stack[:, y, x, :] = get_mode_color(pixel_stack) ``` The result is a sprite sheet that looks hand-drawn, not AI generated, and it weighs just **46KB**, compared to the original 1.18MB midjourney video. ## Step 2: Rendering with Canvas Displaying the sprite sheet is done via an [HTML Canvas element](/blog/building-pixelation-hover-effect). This gives me two advantages: 1. **Crisp Rendering:** I can set `imageSmoothingEnabled = false` and CSS `image-rendering: pixelated`. 2. **Performance:** I use `requestAnimationFrame` to control the loop, which is much more efficient than a DOM-heavy solution. The drawing logic is straightforward: ```javascript // Calculate grid position const col = frameIndex % cols; const row = Math.floor(frameIndex / cols); // Draw specific frame from sprite sheet ctx.drawImage( spriteImage, col * 62, row * 62, 62, 62, // Source x, y, w, h 0, 0, canvas.width, canvas.height // Dest x, y, w, h ); ``` ## The Result The final output is a 5x5 sprite sheet, optimized to indexed color mode, weighing in at just **46KB**. It renders sharply on high-DPI screens and animates smoothly at 10 FPS. It was definitely the hard way to put a picture on a website, but the level of control it offers is worth it. --- --- title: How I built a semantic recommendation engine description: Building a "related content" engine using Vercel AI SDK, OpenRouter, and a hybrid scoring algorithm for pennies. type: blog url: https://sarthakmishra.com/blog/semantic-recommendations date: 2026-01-06 tags: ['ai', 'typescript', 'vercel-ai-sdk', 'automation', 'embeddings', 'openrouter', 'astro'] author: Sarthak Mishra excerpt: Most 'related posts' widgets are terrible. They rely on manual tagging or simple string matching. I wanted something smarter, so I built a build-time semantic search engine that costs next to nothing. --- # How I built a semantic recommendation engine When you finish reading an article, the most important thing for engagement is what you read next. The way I used to handle "Related Posts" was: match a few tags, maybe check the category, and hope for the best. The problem is that tags are manual, prone to human error, and don't actually capture the _meaning_ of the content. I wanted a system that actually understands context. If I write about "optimizing React performance," it should link to "Astro bundle sizes," even if they don't share the exact same tag. So I built a semantic recommendation engine. But here's the constraint: I didn't want to spin up a vector database. I wanted a script that runs at build time, costs nearly nothing, and lives right in the repo. Here is how I built it using the Vercel AI SDK, Qwen3 embeddings, and some clever caching. ## The Strategy: Hybrid Scoring Pure vector search (semantic similarity) is great, but it has blind spots. Sometimes, distinct topics connect semantically in ways that aren't actually helpful for a reader. To get the best results, I implemented a **hybrid scoring system**. It's not enough for two articles to sound similar; we need structural relevance too. The formula looks roughly like this: 1. **Semantic Similarity (85%):** Using cosine similarity on vector embeddings. 2. **Tag Overlap (10%):** A Jaccard index to measure shared tags. 3. **Category Bonus (5%):** A small bump if they belong to the same section (e.g., Blog vs. Works). This weighting ensures that while content meaning drives the recommendation, we prefer items that are structurally related. FYI: [Cosine similarity](https://www.ibm.com/think/topics/cosine-similarity) compares two vector embeddings by looking at the angle between them. If they point in the same direction, they mean something similar. And [Jaccard index](https://en.wikipedia.org/wiki/Jaccard_index) is a simple way to measure how similar two groups are by checking how much they overlap. ## Generating Embeddings on a Budget For the embeddings, I used the **Qwen3 Embedding 8B** model via OpenRouter. It's incredibly cheap, holds a high rank in the [MTEB leaderboard](https://huggingface.co/spaces/mteb/leaderboard), and performs well enough for content recommendation. The logic is simple: 1. Flatten the markdown content (strip syntax). 2. Chunk it into segments (~1200 characters). 3. Generate embeddings for each chunk. 4. Mean-pool them into a single vector for the document. Here is the core logical flow effectively using `embedMany` from the Vercel AI SDK to handle batching: ```typescript // Breaking down the document into chunks const chunks = chunkText(doc.textForEmbedding); // Batch embedding generation const { embeddings } = await embedMany({ model: openai.embeddingModel('qwen/qwen3-embedding-8b'), values: chunks, maxParallelCalls: 2, maxRetries: 2, }); // Averaging chunks into one document vector const pooled = normalize(meanPool(embeddings.map(normalize))); ``` ## The Caching Layer The fastest way to burn money on AI APIs is to regenerate embeddings for content that hasn't changed. Since this script runs every time I deploy or build the site, I needed a way to skip 99% of the work. I implemented a content-addressable cache. Before processing a file, I generate a SHA256 hash of its content and metadata. The hash is then verified against a local `.cache/embeddings-cache.json` file. ```typescript const contentHash = sha256(textForEmbedding); const cached = cache[doc.key]; if (cached && cached.hash === contentHash) { // Content hasn't changed, reuse the old vector docEmbeddings[doc.key] = cached.embedding; continue; } // ... otherwise, call the API and pay the fraction of a cent ``` This makes subsequent builds nearly instant. I only pay for the API when I actually write a new post or edit an existing one. ## The Scoring Algorithm Once we have vectors for every document, we need to compare them. I used a simple O(n²) loop to compare every document against every other document. For a blog with hundreds or even a few thousand posts, this is negligible (milliseconds). If I ever hit 100,000 posts, I'll reconsider a real database. This is where that hybrid logic comes in: ```typescript // Inside the comparison loop const semantic = cosineSimilarity(aVec, bVec); const tagSim = jaccard(a.meta.tags, b.meta.tags); const sameCategory = a.category === b.category ? 1 : 0; // The weighting magic const score = 0.85 * semantic + 0.1 * tagSim + 0.05 * sameCategory; ``` The result is a simple `recommendations.json` file generated at build time. ```json { "blog/building-animated-sprite-hero": [ { "key": "blog/building-perfect-toc-component", "score": 0.847, "reason": "sem=0.842 tag=0.33 cat=1" } // ... ] } ``` ## Integrating into Astro This step is trivial. I added the script to my `package.json` to ensure it runs before the main build. ```json "scripts": { "recommendations": "tsx scripts/build-recommendations.ts", "build": "pnpm run recommendations && astro check && astro build" } ``` Inside the actual blog post component, I import the JSON and render the cards. No client-side fetching, no API latency, no database connection strings. Just static JSON. ## Why this works better By combining semantic search with the structure I already had (tags and categories), the recommendations feel strictly better. If I write a technical deep dive, the system focuses on the tech stack (semantic). If I write a life update, it focuses on tags (journaling, life). It's "smart enough" automation that stays out of the way. And since it uses Qwen via OpenRouter with [extensive caching](/blog/automating-image-alt-text), the total cost to run this for the entire year will likely be less than a cup of coffee. Not bad for a JSON file. --- --- title: New Year 2026: Little by little description: Little by little, a little becomes a lot. Reflecting on the small wins of 2025 and laying out the roadmap for 2026. type: blog url: https://sarthakmishra.com/blog/new-year-2026 date: 2026-01-02 tags: ['life', 'goals', 'automation', 'mental-health', 'building-in-public'] author: Sarthak Mishra excerpt: 2025 wasn't about overnight success; it was about compounding small wins. Here is how I'm taking those lessons into 2026 to build my Outreach Automation Platform. --- # New Year 2026: Little by little There is a Tanzanian proverb that stuck with me recently: > Little by little, a little becomes a lot. Looking back at 2025, that is exactly how the year felt. There wasn't a single "ta-da" moment where everything fell into place. Instead, it was a collection of small wins that eventually compounded into something solid. I spent the year diving deep into the weeds of self-hosting and infrastructure, obsessively optimizing workflows until they actually worked for me, not against me. I leveled up my React, TS, and Python skills. Experimented with AI and automation. And built a few side projects. But most importantly, I learned to take care of myself. Dealing with anxiety, breaking the cycle of procrastination-induced burnout, and actually maintaining a sleep schedule. Life happened, too. I watched one of my best friends from college get engaged, spent a week having fun with friends in Goa, took long solo bike rides to clear my head, and made time for daily hangouts. Professionally, the biggest lesson was simple: Ship smaller. Iterate faster. If it doesn't have a direct impact on the user, it goes in the backlog. ## The roadmap for 2026 I am done with vague resolutions. This year is about building. I plan to deepen my infrastructure automation and expand the design-to-code tooling I've been hacking on. I'm also going to write more. But the main character for 2026 is the **Outreach Automation Platform**. I have [written extensively](/blog/is-cold-outreach-dying) about how I [automate outreach](/blog/how-to-find-and-validate-work-emails) - seeing what works and what fails in [cold outreach](/blog/scaling-highly-personalized-outbound). Now, I am turning those scripts and experiments into a proper product. ## Action plan: Q1 The goal for the next three months is clear: **Launch the MVP for the outreach platform.** To keep myself honest, I will be blogging weekly to document the build process. I have a few technical deep-dives already in the pipeline which includes continuing the [LinkedIn automation series](/blog/linkedin-automation), expanding it to cover email integration and the full end-to-end outreach pipeline. You might have noticed the new aesthetic creeping in, inspired by sprite animations and 2D indie games. It is a pixelated retro design that I am slowly rolling out. It is not fully adopted yet, but there are a lot more changes to come. I am also forcing myself to do the one thing engineers hate: talking to humans. Client communication is still not my strong suit, but I need to validate assumptions with potential customers. Here is to staying adaptable, focusing on meaningful progress, and letting the little things add up. --- --- title: How to automate LinkedIn outreach? description: A step-by-step guide to LinkedIn outreach automation concepts using OpenOutreach, an open-source Playwright-based tool. type: blog url: https://sarthakmishra.com/blog/linkedin-automation date: 2025-12-24 tags: ['linkedin-automation', 'openoutreach', 'cold-outreach', 'b2b-sales', 'outbound', 'multi-channel'] author: Sarthak Mishra excerpt: A step-by-step guide to LinkedIn outreach automation concepts using OpenOutreach, an open-source Playwright-based tool. --- # How to automate LinkedIn outreach? If you've been following my previous posts on [cold outreach](/blog/is-cold-outreach-dying) and [personalized outbound campaigns](/blog/scaling-highly-personalized-outbound), you know the infrastructure and targeting matter more than volume. LinkedIn automation is a logical next step--but it's also where many people get their accounts nuked. --- **Disclaimer (read this first):** This post is **educational** and **not** an endorsement to automate LinkedIn. Automation may violate LinkedIn's Terms of Service and/or applicable laws depending on your jurisdiction and use case. --- This post is a deep dive into LinkedIn outreach automation using OpenOutreach as a case study. I'm looking at it from a systems perspective (tooling, constraints, and failure modes). Before we get there, let's establish what you're actually risking. ## LinkedIn's Crackdown LinkedIn has made its position crystal clear. In April 2025, the platform officially banned two of the largest automation platforms: [Apollo.io](http://Apollo.io) and [Seamless.ai](http://Seamless.ai). These weren't small accounts. These were enterprise tools used by thousands of sales teams. The message is unambiguous: LinkedIn sees automation as a threat to its user experience. The detection mechanisms are sophisticated. LinkedIn doesn't just look at volume. It analyzes: - **Timing patterns** between actions (bots are predictable; humans aren't) - **Message similarity** across your outreach (variation is humanlike; templates are not) - **Account history and behavioral change** (a dormant account suddenly sending 50 connections? Red flag) Exceeding your connection request limits or getting caught using unapproved third-party tools can result in: - Temporary account restrictions (most common) - Permanent shadow-banning (your visibility tanks, but you don't know why) - Complete account suspension (nuclear option, but it happens) **Mitigating this risk:** If you decide to experiment despite the risks, don't do it on your main professional profile. Warm up new accounts slowly--spend a week engaging manually before doing anything aggressive. And respect the limits. ## There is no official LinkedIn DM API LinkedIn provides APIs for corporate use cases (recruiter tools, sales navigator), but there is **no official DM or connection request API for individuals**. This means every automation tool on the market--including OpenOutreach--relies on one of two approaches: 1. **Web browser-based automation** (Selenium, Playwright) 2. **Android app-based automation** (higher limits, but difficult to implement) Web browser automation is more accessible and battle-tested. Android-based tools can hit higher rate limits because they mimic native app behavior, but they're harder to maintain and less stable. ## Why OpenOutreach? Simply put, OpenOutreach is a solid, well-structured starting point for LinkedIn automation. I forked it for a few reasons: 1. It's purpose-built for a specific set of LinkedIn automations. 2. I [refactored it](https://github.com/SarthakMishra/OpenOutreach) into a FastAPI server to drop CSV inputs, templating, and AI generation, so campaign logic can live elsewhere. 3. I wanted it more atomic and modular—structured around an API layer, atomic touchpoints, and runs/scheduling/account-locking—so it can plug into a broader campaign manager. 4. I wanted to use Patchright instead of Playwright Stealth. [Patchright](https://github.com/Kaliiiiiiiiii-Vinyzu/patchright-python) is a fork of Playwright which uses a patched version of the chrome/chromium driver. The server exposes touchpoints like **`profile_visit`**, **`connect`**, **`direct_message`**, **`post_react`**, **`post_comment`**, **`inmail`**, and **`profile_enrich`** (names may change in the future). ### Prerequisites - Docker and Docker Compose installed - A LinkedIn account (preferably a dedicated one) - VNC viewer (to monitor the browser, optional) ### Installation Clone the repository (choose one): ```bash # Upstream project # git clone https://github.com/eracle/OpenOutreach.git # My API-server fork git clone https://github.com/SarthakMishra/OpenOutreach.git cd OpenOutreach ``` Build and run with Docker Compose: ```bash docker compose build docker compose up ``` By default, the API server runs on `http://localhost:8000`. VNC access for browser debugging is typically exposed on port `5900` (check `docker-compose.yml` in the repo you're using). ## Creating a LinkedIn automation pipeline In my fork of OpenOutreach, you trigger actions via API calls (one touchpoint per run). This separation is what makes it easier to integrate into a broader campaign manager later. Here is a simple example that: 1. Loads leads from `input/leads.csv` with `linkedin_url` and `note` 2. Creates a `profile_visit` run 3. Polls until the run is `completed` (or `failed`) 4. If the visit completes, creates a `connect` run with the note --- _This exact flow lives in my `cold-email-tools` repo under the LinkedIn automation pipeline: [SarthakMishra/cold-email-tools](https://github.com/SarthakMishra/cold-email-tools)._ --- **1. Creating a run (one touchpoint per API call):** ```python endpoint = f"{self.api_url}/api/v1/runs" payload = { "handle": handle, "touchpoint": { "type": touchpoint_type, # "profile_visit", "connect", etc. **touchpoint_data, # url, note, duration_s, scroll_depth... }, "dry_run": dry_run, "tags": tags or {}, } resp = requests.post(endpoint, headers=self.headers, json=payload, timeout=30) resp.raise_for_status() return resp.json() ``` **2. Polling a run until it finishes (terminal state):** ```python start_time = time.time() last_status = None while True: if time.time() - start_time > timeout: raise TimeoutError(f"Run {run_id} did not complete within {timeout}s") run_data = self.get_run(run_id) status = run_data.get("status") if status != last_status: logger.info("Run %s: %s → %s", run_data.get("run_id"), last_status or "initial", status) last_status = status if status in ["completed", "failed"]: return run_data time.sleep(poll_interval) ``` **3. The pipeline control flow (visit first, then connect):** ```python # Step 1: visit profile visit = self.client.create_run( handle=self.account_handle, touchpoint_type="profile_visit", touchpoint_data={ "url": linkedin_url, "duration_s": PROFILE_VISIT_DURATION_S, "scroll_depth": PROFILE_VISIT_SCROLL_DEPTH, }, tags={"campaign": "linkedin_automation", "lead_idx": str(lead_idx)}, ) visit_final = self.client.poll_run_until_complete(visit["run_id"]) # Step 2: connect only if visit completed if visit_final.get("status") == "completed": connect = self.client.create_run( handle=self.account_handle, touchpoint_type="connect", touchpoint_data={"url": linkedin_url, "note": note}, tags={"campaign": "linkedin_automation", "lead_idx": str(lead_idx)}, ) connect_final = self.client.poll_run_until_complete(connect["run_id"]) ``` ## LinkedIn touchpoints you can automate My fork of OpenOutreach supports several LinkedIn touchpoints. Here's the priority ranking: | Activity | Risk Level | Effectiveness | Recommendation | | ------------------- | ---------- | ------------- | ----------------------------- | | Profile Views | Low | Medium | Safe, use regularly | | Connection Requests | Medium | High | Use with caution, | | Post Reactions | Low | Low | Safe but limited impact | | Direct Messages | High | High | Only if account is expendable | | Comments | Medium | Medium | Somewhat safe | | InMails | Very High | High | Use official credits only | **Connection requests** are the highest-impact activity with manageable risk. DMs are tempting but riskier--LinkedIn monitors message patterns aggressively. Comments are safer but less direct. My preferred workflow is: 1. profile view 2. like a recent post (if posted in the last 7 hours) 3. connection request with a note 4. direct messages (if the connection request is accepted) LinkedIn limits all touchpoints, especially connection requests, to prevent abuse, and these limits keep changing. Always do your research and keep an eye on your logs to catch changes early. - **Free Account:** 10/month with note (severely restricted) - **Premium:** 100-200/week (depends on SSI score) - **Sales Navigator:** 150-200/week (highest limit) - **High SSI Score:** Up to 200/week (consistent engagement helps) ## Best Practices for LinkedIn Automation 1. **Use a dedicated account.** Don't automate your main profile. Create a secondary LinkedIn account specifically for outreach. 2. **Always have a backup account.** Assume your automation account will eventually get flagged. If you only have one account and it gets restricted, you're forced into a dead period while you create a new account, warm it up, and rebuild trust. Keep a second account ready (and warmed) so you can switch without losing weeks. 3. **Keep your behavior consistent.** Avoid constantly switching devices, networks, and login patterns. For manual actions, use the mobile app. Avoid repeatedly signing in or creating new accounts on the web from multiple fresh sessions. Your automation already uses a web browser, and lots of web sign-ins/sign-ups can be a tell. 4. **Warm up the account.** Before running any automation, spend at least a week engaging manually. Like posts, comment on content, send a few manual connection requests. This builds a behavioral history that's less suspicious. 5. **Respect the weekly limits.** Even if you think you can push higher, don't. Send 80% of your weekly limit, not 100%. Leave buffer. 6. **Randomize timing.** OpenOutreach includes delay mechanisms. Use them. Connection requests should happen at different times--not always 9 AM, not always in batches of 10. 7. **Personalize.** Use AI to [vary your messages](/blog/scaling-highly-personalized-outbound). Minor variations in tone and reference points matter. 8. **Mix manual and automated actions.** Regularly approve connection requests, send replies, or engage with content manually. This reduces sudden behavioral shifts that can trigger restrictions. 9. **Monitor your account health.** Watch for warning signs: - Connection requests showing "Pending" indefinitely (usually normal) - Sudden inability to send requests (you've hit the limit; wait a week) - Profile visibility dropping (possible shadow-ban; slow down immediately) - Account restrictions message (stop automation; you've been flagged) 10. **Have an exit plan.** If your account gets restricted, don't panic. Most temporary restrictions lift within a week. If it's a permanent ban, accept it and move to your next account. ## The ROI of it all **LinkedIn automation doesn't scale the way email does.** You can send 1,000 personalized emails for practically nothing. You can have 100 people replying to your email sequences in parallel. With LinkedIn automation, you're constrained to 100-200 connection requests per week, with unpredictable conversion rates (depends heavily on your profile, messaging, and targeting). LinkedIn is best for: - Building authority in your niche - Creating warm introductions (LinkedIn connections are more likely to respond) - B2B industries where decision-makers actively use LinkedIn - Relationship-building over time (not immediate conversions) LinkedIn is worst for: - Bulk cold outreach (use email) - High-volume lead generation (use email + forms) - Startups with zero brand recognition (build on email first) - Quick campaign testing (move too slowly) The bottom line is, for most people, LinkedIn automation is a high-risk lever with a low ceiling. If you're considering it at all, treat it as an experiment, keep expectations realistic, and don't build your growth motion on something that can be shut down overnight. --- ## What's next? This is part of a series of posts on building a modern cold outreach system: - [What works in cold outreach](/blog/is-cold-outreach-dying) -- infrastructure, targeting, and multi-channel strategy - [Scaling personalization with AI](/blog/scaling-highly-personalized-outbound) -- enriching leads and generating personalized copy for under $5 per 1,000 leads - [Finding and validating work emails](/blog/how-to-find-and-validate-work-emails) -- generating email patterns and validating them at scale for under $0.001 per email - [How to automate LinkedIn outreach](/blog/linkedin-automation) -- LinkedIn outreach automation using open-source tools **Coming soon:** I'll show you how to build a **100% automated appointment booking pipeline**, end-to-end: - Automatically finding leads from LinkedIn, Reddit, and other sources - Automatically sending emails + LinkedIn DMs as a complete outbound sequence to get appointments booked All built with **Python**, open-source tools, and cheap "pay-per-result" services. --- --- title: How to find verified work emails for cold outreach description: Find verified work emails that never made it into public databases, in a way that is cheaper and more effective than Apollo/Hunter. type: blog url: https://sarthakmishra.com/blog/how-to-find-and-validate-work-emails date: 2025-12-20 tags: ['sales', 'cold-outreach', 'email-marketing', 'business', 'marketing', 'sales-strategy', 'entrepreneurship', 'email-validation'] author: Sarthak Mishra excerpt: A step-by-step guide to generating email pattern variations and validating them using SMTP--find verified work emails that public databases never discovered, for under $0.001 per email. --- # How to find verified work emails for cold outreach This is the follow-up to the last two posts on [cold outreach](/blog/is-cold-outreach-dying) and [scaling personalization](/blog/scaling-highly-personalized-outbound). This one covers the boring bottleneck that quietly kills campaigns: **getting accurate, verified work emails without depending on exhausted public databases**. If an email is sitting in Apollo/Hunter, there's a decent chance it's already been hammered by 50 other sequences. What we want instead are emails those platforms never discovered in the first place. ## Stop relying on public email databases The problem is simple: the most accessible emails are the most contacted. When everyone is reaching the same address, yours gets lost in the noise. Since everyone pulls from the same pool, the problem compounds. Every complaint, bounce, or "mark as spam" reduces deliverability for everyone who hits that address next. The better strategy is finding emails that never made it into any public database. This involves 1. Understanding common email patterns 2. Generating variations systematically 3. Validating at scale (either with your own setup or a managed service) --- _The code for this post is in this repo: [GitHub](https://github.com/SarthakMishra/cold-email-tools). I'll keep it updated as we extend the pipeline in future posts._ --- ## Part 1: Generating email address variations Given a prospect's first name, last name, and company domain, you can generate dozens of plausible addresses. Companies usually standardize on 1-2 formats. Once you confirm one real employee email, you can often infer the pattern for entire departments. Here are some of the most common email patterns: | Pattern | Example | Prevalence | | ---------------------------------- | ----------------- | ---------- | | firstname + @ + domain | john@acme.com | Very High | | firstname.lastname + @ + domain | john.doe@acme.com | Very High | | firstlast + @ + domain | johndoe@acme.com | High | | f.lastname + @ + domain | j.doe@acme.com | Medium | | first_last + @ + domain | john_doe@acme.com | Medium | | first-last + @ + domain | john-doe@acme.com | Low | | firstname.lastinitial + @ + domain | john.d@acme.com | Low | And here is a script that generates emails based on the patterns above: ```python def generate_email_patterns(first_name, last_name, domain): first = first_name.lower().strip() last = last_name.lower().strip() patterns = [ f"{first}@{domain}", f"{first}.{last}@{domain}", f"{first}{last}@{domain}", f"{first[0]}.{last}@{domain}", f"{first[0]}{last}@{domain}", f"{first}_{last}@{domain}", f"{first}-{last}@{domain}", f"{first}.{last[0]}@{domain}", f"{last}.{first}@{domain}", ] # Remove duplicates while preserving order seen = set() unique_patterns = [] for pattern in patterns: if pattern not in seen: seen.add(pattern) unique_patterns.append(pattern) return unique_patterns emails = generate_email_patterns("John", "Doe", "acme.com") ``` For hyphenated names like "Jean-Pierre" or accented names like "María José", generate name variants: ```python import unicodedata def generate_name_variants(first, last): variants = [ (first, last), (first.replace("-", ""), last), # Remove hyphen (first.split("-")[0], last), # Use first part only ] # Handle accented characters: María -> Maria, José -> Jose if any(ord(c) > 127 for c in first + last): first_ascii = ''.join( c for c in unicodedata.normalize('NFD', first) if unicodedata.category(c) != 'Mn' ) last_ascii = ''.join( c for c in unicodedata.normalize('NFD', last) if unicodedata.category(c) != 'Mn' ) variants.append((first_ascii, last_ascii)) # Remove duplicates while preserving order seen = set() unique_variants = [] for variant in variants: if variant not in seen: seen.add(variant) unique_variants.append(variant) return unique_variants ``` ## Part 2: Email validation using SMTP Don't skip validation and blast 100 variations hoping one lands. That tanks your sender reputation fast. **Validate first. Always.** ### How SMTP verification works Email validation connects directly to the recipient's mail server using SMTP. VRFY and EXPN are usually disabled, so validators rely on **RCPT TO** (the same command used during real email delivery) to check if a mailbox exists without sending anything (Source: [RFC 5321 SMTP Specification](https://datatracker.ietf.org/doc/html/rfc5321)). The process: 1. Resolve the mail server (MX record) for the domain 2. Connect via SMTP to that server on port 25 3. Issue an RCPT TO command with the test email address 4. Server responds with 250 (mailbox exists) or 550+ (invalid) 5. Disconnect without sending ### Building your own email validator If you just want results without the infrastructure overhead, use a third-party API like [Reacher](https://app.reacher.email/en/pricing) or [Bulk Email Checker](https://bulkemailchecker.com/pricing/). If you need more control or want a cheaper solution at scale, self-host [Reacher](https://github.com/reacherhq/check-if-email-exists). It handles 100K+ validations/month for under $10/month. All you need is a VPS with port 25 open ($5-15/month) and Docker installed. **Can you run this locally instead?** Technically yes, if your ISP allows outbound port 25. But most residential ISPs block it, and even when they don't, home IPs face issues: - Mail servers may rate-limit or reject connections from residential IPs - Your IP could get flagged or blacklisted after bulk validation attempts - ISPs sometimes allow the TCP connection but block actual SMTP traffic For testing, local works. For production validation at scale, **use a VPS** instead. To check **outbound** port 25 access, use: ```bash nc -zv -w 3 aspmx.l.google.com 25 2>&1 || echo "Port 25 connection failed" # or nmap -p 25 aspmx.l.google.com ``` Many providers block outbound port 25 to prevent spam. If blocked on a VPS, contact support with your legitimate use case (email validation service). Some approve within hours; others don't support it at all. Deploy Reacher using Docker: ```bash docker run -d -p 8080:8080 reacherhq/backend:latest # Test curl -X POST http://localhost:8080/v0/check_email \ -H "Content-Type: application/json" \ -d '{"to_email": "test@example.com"}' ``` ### Handling catch-all addresses Some companies run catch-all policies where any email to their domain gets accepted. Validators mark these as "risky" instead of "safe." You can still send to them, but there's higher bounce risk. Either filter them out or accept the calculated risk depending on your strategy. ## Part 3: Compliance and ethics I'm not an attorney, so this isn't legal advice. But validating mailbox existence via SMTP is legitimate and widely practiced. You're using the same protocol mail servers use daily. You're **not**: - Breaking into servers - Accessing mailbox contents - Spoofing emails - Violating terms of service (if you're using a reputable validator) ### Important technical clarification There's a common misconception about SMTP MAIL FROM. For validation specifically, the **MAIL FROM address doesn't need to match your actual sending domain**. During RCPT TO verification (what validators use), servers typically don't authenticate MAIL FROM. They respond based on whether the recipient mailbox exists ([RFC 5321 SMTP](https://datatracker.ietf.org/doc/html/rfc5321)). However, for actual email campaigns: - MAIL FROM (Return-Path) must align with the From: header for DMARC to pass ([DMARC Alignment Requirements](https://redsift.com/guides/email-protocol-configuration-guide/all-you-need-to-know-about-spf-dkim-and-dmarc)) - SPF records authorize the sending IP for the MAIL FROM domain - DKIM signatures sign the message with your domain - DMARC policies enforce alignment between SPF/DKIM results and the visible From: address Bottom line: validators can use any MAIL FROM because they're not sending mail. But when you send campaigns, use your actual domain and align SPF/DKIM/DMARC properly. ### Best practices 1. **Use a managed service if infrastructure isn't your thing** -- Bulk Email Checker or Reacher's hosted API means no VPS to manage 2. **Batch validate before outreach** -- test patterns in groups, not one-by-one 3. **Respect rate limits** -- don't overwhelm mail servers; add 1-2 second delays between checks 4. **Monitor bounces** -- if bounce rates spike, pause and diagnose your targeting or list quality 5. **Handle unsubscribes immediately** -- respect complaints and remove addresses the moment they ask ## Putting it all together Here's the end-to-end workflow: 1. **Generate** -- 7-8 email pattern variations for each prospect 2. **Batch validate** -- use self-hosted Reacher or a managed API 3. **Enrich** -- pull LinkedIn data using the pipeline from [my previous post](/blog/scaling-highly-personalized-outbound) 4. **Personalize** -- generate message copy with AI (GPT-4.5, Sonnet, etc.) 5. **Send** -- from a verified sender domain with SPF/DKIM/DMARC aligned 6. **Track** -- opens, clicks, and replies (replies matter most) 7. **Follow up** -- with sequences for non-responders, rotating angles each touch Validated emails + pattern generation + personalization + proper authentication is how outbound actually runs in 2025. --- ## What's next? This is part of a series of posts on building a modern cold outreach system: - [What works in cold outreach](/blog/is-cold-outreach-dying) -- infrastructure, targeting, and multi-channel strategy - [Scaling personalization with AI](/blog/scaling-highly-personalized-outbound) -- enriching leads and generating personalized copy for under $5 per 1,000 leads - [Finding and validating work emails](/blog/how-to-find-and-validate-work-emails) -- generating email patterns and validating them at scale for under $0.001 per email - [How to automate LinkedIn outreach](/blog/linkedin-automation) -- LinkedIn outreach automation using open-source tools **Coming soon:** I'll show you how to build a **100% automated appointment booking pipeline**, end-to-end: - Automatically finding leads from LinkedIn, Reddit, and other sources - Automatically sending emails + LinkedIn DMs as a complete outbound sequence to get appointments booked All built with **Python**, open-source tools, and cheap "pay-per-result" services. --- --- title: Scaling highly personalized outbound with AI. description: Build a pipeline to enrich leads with LinkedIn data and generate highly personalized cold emails with AI for under $5. type: blog url: https://sarthakmishra.com/blog/scaling-highly-personalized-outbound date: 2025-12-17 tags: ['sales', 'cold-outreach', 'email-marketing', 'business', 'marketing', 'sales-strategy', 'entrepreneurship'] author: Sarthak Mishra excerpt: A step-by-step guide to enriching leads with LinkedIn data and generating personalized cold emails with a simple local Python script—cheap, fast, and scalable. --- # Scaling highly personalized outbound with AI. Everyone talks about "AI-powered personalization at scale." Nobody actually does it. Why? Because the tools are expensive—until you look closer at what you actually get. If you Google "cold email personalization tools," you'll see the same trio over and over: Clay, Instantly.ai, Apollo. - **Clay**: Handles enrichment and message generation, but **no email sending**—you'll need another tool. For 1,000 leads with enrichment + AI, expect to consume 200,000-350,000 credits, requiring **$800+/month minimum**. Then add sending costs on top. - **Instantly.ai**: Originally a cold email inbox manager and sender, later added AI message generation and lead enrichment as separate products. Outreach starts at $37/month, but SuperSearch (lead database/enrichment) starts at $197/month—you'll need both for the full suite, totaling **$234+/month minimum**. - **Apollo Professional**: Has all features (enrichment, message generation, sending) in one platform at **$79/month**, but AI quality is template-based and requires heavy editing. You get exactly 1,000 export credits/month—no room for error. You're looking at **$75-$234+/month** for a complete solution, and costs scale quickly as you need more credits, mailboxes, or higher-tier plans. If you're just starting out, that's a lot. Here's the alternative: ### **Build it yourself for under $5 per 1,000 leads.** That's what we're building: a simple Python script you run locally. - Input: a CSV of leads (validated emails + LinkedIn URLs) - Enrich: scrape profile context via Bright Data - Generate: highly personalized cold emails in minutes --- _The code for this post is in this repo: [GitHub](https://github.com/SarthakMishra/cold-email-tools). I'll keep it updated as we extend the pipeline in future posts._ --- ## Part 1: What you need (local) - **Python 3.12+** and [uv](https://docs.astral.sh/uv/)(recommended) or pip - A leads CSV with: `email`, `first_name`, `last_name`, `profile_url` - API keys (Bright Data + whichever LLM you pick) Install deps once: ```bash uv init && uv venv && uv add requests pandas python-dotenv ``` --- ## Part 2: Lead enrichment with LinkedIn data This post assumes you already have a lead list with **validated emails** and **LinkedIn profile links**. The goal is straightforward: turn each LinkedIn URL into usable context (role, company, bio, experience) so your personalization has something real to stand on. ### Bright Data LinkedIn scraper Bright Data's LinkedIn scraper bypasses blocks, handles CAPTCHAs, and delivers structured data. Their free trial gives you a sandbox to test. **Pricing**: Pay per successful result. Pricing starts at [$1.5 / 1K records](https://brightdata.com/products/web-scraper/linkedin) (pay-as-you-go) — about [$0.0015 per profile](https://brightdata.com/products/web-scraper/linkedin). **Data delivered**: Name, current role, company, location, experience history, education, about section, follower count—you name it. **Formats**: JSON, CSV. All suitable for downstream processing. **Example workflow**: 1. Prepare a CSV with LinkedIn profile URLs 2. Submit to Bright Data via API or control panel 3. Poll the endpoint until results are ready 4. Download as JSON/CSV 5. Process locally ```python import requests import json import time class Bright DataClient: """Thin wrapper around Bright Data Scrapers Library trigger + snapshot APIs.""" def __init__(self, api_key: str, dataset_id: str) -> None: self.api_key = api_key self.dataset_id = dataset_id self.headers = { "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", } def submit(self, urls: list[str]): """ Submit LinkedIn profile URLs via the Scrapers Library trigger API. Returns either results (if synchronous) or a snapshot_id (if async). """ trigger_url = "https://api.Bright Data.com/datasets/v3/trigger" params = { "dataset_id": self.dataset_id, "include_errors": "true", "format": "json", } payload = [{"url": url} for url in urls] resp = requests.post( trigger_url, headers=self.headers, params=params, json=payload, timeout=60, ) resp.raise_for_status() data = resp.json() # API can respond synchronously (list) or async (dict with snapshot_id) if isinstance(data, list): return data snapshot_id = data.get("snapshot_id") or data.get("id") return snapshot_id def poll_results(self, snapshot_id: str): """Poll snapshots until results are ready.""" snapshot_url = f"https://api.Bright Data.com/datasets/v3/snapshot/{snapshot_id}" params = {"format": "json"} while True: resp = requests.get( snapshot_url, headers=self.headers, params=params, timeout=60, ) # HTTP 202: snapshot still building if resp.status_code == 202: time.sleep(5) continue # HTTP 200: snapshot ready resp.raise_for_status() return resp.json() or [] # Usage Bright Data_API_KEY = "your_api_key" PROFILE_DATASET_ID = "gd_l1viktl72bvl7bjuj0" # LinkedIn Profiles dataset client = Bright DataClient(Bright Data_API_KEY, PROFILE_DATASET_ID) profile_urls = [ "https://www.linkedin.com/in/someone/", "https://www.linkedin.com/in/someone-else/" ] submission = client.submit(profile_urls) # Handle sync or async response if isinstance(submission, list): results = submission else: results = client.poll_results(submission) # Save as JSON for next step with open('enriched_profiles.json', 'w') as f: json.dump(results, f, indent=2) ``` Yes, there are low-cost/free methods using internal LinkedIn APIs or scraping directly from LinkedIn. But those are risky: - Can get your account blocked - Often slower - Usually require logging in with a real LinkedIn account For production, I'd still recommend Bright Data. At **~$0.0015/profile** (pay-as-you-go), it's negligible compared to the risk, effort, and maintenance of rolling your own scraper. --- At this point, you should have: - A CSV of leads with **validated emails** + **LinkedIn profile URLs** - A JSON/CSV export of those LinkedIn profiles from Bright Data Now we turn that data into messages that actually get replies. ## Part 3: AI personalization Now that you have data, use it to personalize outreach messages. This is what separates "bulk mail" from outreach that actually gets replies. The idea: use an LLM (Claude, GPT-5.2, etc.) to generate personalized first messages based on the prospect's LinkedIn about section and job history. ```python from openai import OpenAI def generate_personalized_message(prospect_data): """ Use OpenAI to generate a personalized cold email based on prospect data. """ client = OpenAI() # Requires OPENAI_API_KEY env var about = prospect_data.get('about', '') or '' prompt = ( "You are a sales outreach specialist. Craft a concise, highly personalized cold email " "that feels written just for this person. Limit to 140-160 words, avoid fluff, and make one clear CTA.\n\n" "Use the data below thoughtfully—reference only what is relevant and authentic. If a field is empty, just skip it.\n" f"- Name: {prospect_data.get('first_name', '')} {prospect_data.get('last_name', '')}\n" f"- Title: {prospect_data.get('title', '')}\n" f"- Location: {prospect_data.get('location', '')}\n" f"- Education: {prospect_data.get('education', '')}\n" f"- About/Bio: {about[:600]}\n" f"- Company: {prospect_data.get('company', '')}\n" f"- Company about: {prospect_data.get('company_about', '')}\n" f"- Company industry: {prospect_data.get('company_industry', '')}\n" f"- Company size: {prospect_data.get('company_size', '')}\n" f"- Company website: {prospect_data.get('company_website', '')}\n\n" "Product: [YOUR_PRODUCT_DESCRIPTION_HERE]\n\n" "Structure:\n" "1) One-line opener that shows you've actually read their background (title, location, education, or company mission—pick the best hook).\n" "2) One-sentence bridge linking their context to your product's specific value (be concrete: metrics, outcomes, or workflow saved).\n" "3) One short bullet or micro-example that proves the benefit (no jargon; relevant to media/tech audiences if applicable).\n" "4) Close with a single, low-friction CTA (e.g., 10-minute intro this week) and offer to share a tailored example.\n" "Keep tone warm, professional, and direct." ) response = client.chat.completions.create( model="gpt-5.2", max_completion_tokens=400, messages=[{"role": "user", "content": prompt}], ) return (response.choices[0].message.content or "").strip() # Usage prospect = { 'first_name': 'John', 'last_name': 'Doe', 'title': 'VP Engineering', 'company': 'TechCorp', 'location': 'San Francisco, CA', 'about': 'Building infrastructure. Love DevOps. 10 years in SaaS.', 'company_about': 'Leading B2B SaaS platform for enterprise workflows', 'company_industry': 'Software', 'company_size': '200-500 employees', } email_body = generate_personalized_message(prospect) print(email_body) ``` Claude Sonnet 4.5 is $3 per million input tokens and $15 per million output tokens. OpenAI's GPT-5.2 is $1.75 per million input tokens and $14 per million output tokens. For a cold email (~300 input tokens, ~150 output), that's roughly **$0.0032** (Sonnet 4.5) or **$0.0026** (GPT-5.2) per personalized email — about **$3.20** or **$2.60** per 1,000 prospects. --- ## Part 4: Export (generic CSV) Every outreach tool wants the same thing: a **clean UTF-8 CSV**, one lead per row. Export the fields you care about, then map columns during import. ```python from datetime import datetime from pathlib import Path def export_leads_csv(df, output_dir='output'): """Export to UTF-8 CSV with timestamped filename.""" output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) # Generate timestamped filename to avoid overwriting timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") output_file = output_dir / f"leads_{timestamp}.csv" columns = [ 'email', 'first_name', 'last_name', 'company', 'title', 'custom_field_1', # contains the personalized message 'profile_url', 'company_url', 'company_about', ] df[columns].to_csv(output_file, index=False, encoding='utf-8') print(f"Exported {len(df)} leads to {output_file}") return output_file # Usage export_leads_csv(df) ``` **Note**: When importing into your outreach platform (Instantly, Smartlead, Lemlist, Apollo, etc.), map these columns to match the platform's required format during the import step. --- ## Part 5: Cost breakdown Here's the economics, without pretending it's magic: | Component | Cost | Notes | | ----------------------------- | ----------------------- | ----------------------------------- | | Bright Data LinkedIn scraping | $1.5 / 1K records | pay-as-you-go pricing | | AI personalization | $2.6-$3.2 per 1,000 | GPT-5.2 or Sonnet 4.5 | | **Total** | **$4.1-$4.7 per 1,000** | Bright Data + GPT-5.2 or Sonnet 4.5 | You're saving nearly **$300/month** (or more) by running this pipeline yourself, compared to the typical SaaS stack that can easily hit $300-$1000+/month for similar outbound tooling. --- ## What's next? This is part of a series of posts on building a modern cold outreach system: - [What works in cold outreach](/blog/is-cold-outreach-dying) -- infrastructure, targeting, and multi-channel strategy - [Scaling personalization with AI](/blog/scaling-highly-personalized-outbound) -- enriching leads and generating personalized copy for under $5 per 1,000 leads - [Finding and validating work emails](/blog/how-to-find-and-validate-work-emails) -- generating email patterns and validating them at scale for under $0.001 per email - [How to automate LinkedIn outreach](/blog/linkedin-automation) -- LinkedIn outreach automation using open-source tools **Coming soon:** I'll show you how to build a **100% automated appointment booking pipeline**, end-to-end: - Automatically finding leads from LinkedIn, Reddit, and other sources - Automatically sending emails + LinkedIn DMs as a complete outbound sequence to get appointments booked All built with **Python**, open-source tools, and cheap "pay-per-result" services. --- ## A few final notes 1. **LinkedIn ToS**: Bright Data operates legally (no login required, respects robots.txt). The alternative is riskier but free. Choose based on your risk tolerance. 2. **Email list quality**: The weakest link in cold email is list quality. No amount of personalization fixes a bad list. Spend time on [targeting and list hygiene](/blog/is-cold-outreach-dying) first. 3. **Deliverability**: Even with all this, ~30% of cold emails hit spam folders. Warm up your sending domains, use proper SPF/DKIM/DMARC setup, and test with tools like Mail-tester. 4. **Legally**: Always comply with CAN-SPAM (US), GDPR (EU), and local regulations. Include unsubscribe links. Don't scrape emails without consent where required. [Build smart](/codemap). Ship fast. And let me know what you build with this. --- --- title: Is cold outreach dying, or are you doing it wrong? description: A practical guide to what actually works in 2025: infrastructure, targeting, personalization, and multi-channel strategies that still get meetings. type: blog url: https://sarthakmishra.com/blog/is-cold-outreach-dying date: 2025-12-16 tags: ['sales', 'cold-outreach', 'email-marketing', 'business', 'marketing', 'sales-strategy', 'entrepreneurship'] author: Sarthak Mishra excerpt: Cold outreach isn't dying-the lazy version is. If your strategy is blasting 2,000 emails and praying, that's dead. Here's what actually works in 2025: tighter targeting, cleaner infrastructure, better messages, multi-channel approaches, and consistent follow-up. --- # Is cold outreach dying, or are you doing it wrong? Cold outreach isn't dead. The **lazy version** is. The old playbook (buy a list → blast 5,000 people per day → hope something sticks) used to barely work because inbox providers weren't as strict and the market had less noise. Now Gmail and Outlook enforce stricter standards, buyers are skeptical of mass outreach, and everyone's "AI-personalized" templates look identical. If your outbound numbers feel worse than they did in 2021-2023, you're not imagining it. [Average cold email open rates dropped to 27.7%](https://martal.ca/b2b-cold-email-statistics-lb/) and [reply rates fell to 5.1% in 2025](https://martal.ca/b2b-cold-email-statistics-lb/). That's not "email is dead." That's "you can't be sloppy anymore." Here's how to run outbound like a system instead of a lottery. ## Cold outreach is multi-channel now Most people think "cold outreach" = cold email. But the best results come from stacking channels. - **Cold email**: scale and tracking - **Cold calling**: fast qualification and objection handling in real-time - **LinkedIn DMs**: higher response rates, fewer deliverability headaches - **Short personalized video (Loom/Vidyard)**: stands out fast; great after they've seen your name once - **SMS/WhatsApp (permission-based)**: high-visibility follow-ups (best after any opt-in or prior touch) - **Communities (Slack/Discord/Reddit)**: relationship-first conversations where your buyers already hang out - **Direct mail / gifting**: a high-intent pattern interrupt for a small list of high-value accounts I've personally run cold email, cold DMs (LinkedIn + X), and Loom-based outreach. I've also used Slack/WhatsApp communities (for talent sourcing), so treat the rest here as what I'm seeing work across teams, not me claiming mastery of every channel. That being said, the key is that [multi-channel beats single-channel by about 101% in engagement metrics](https://expandi.io/blog/state-of-li-outreach-h1-2025/). Email plus LinkedIn gets significantly more traction than email alone. Running email-only in 2025 is leaving money on the table. ## Cold email vs LinkedIn (what wins where) ### Cold email **The metrics:** - **Average open rate:** 27.7% (down from 36% in 2023) (Source: [Martal](https://martal.ca/b2b-cold-email-statistics-lb/)) - **Average reply rate:** 5.1% (Source: [Martal](https://martal.ca/b2b-cold-email-statistics-lb/)) - **Average conversion rate:** 0.2% (roughly 1 deal per 500 emails sent) (Source: [Martal](https://martal.ca/b2b-cold-email-statistics-lb/)) - **95% of campaigns** fail to generate replies (Source: [Martal](https://martal.ca/b2b-cold-email-statistics-lb/)) **Why it works:** - Cheap, scalable, fully trackable - Good for rapid positioning tests **Why most fail:** - Deliverability is now a technical discipline (authentication, reputation, list quality) - Template-shaped emails get filtered or ignored instantly ### LinkedIn DMs **The metrics:** - **Average response rate:** 10.3% (roughly double cold email reply rates) (Source: [Expandi](https://expandi.io/blog/state-of-li-outreach-h1-2025/)) - **Industry response rates:** ~4.77% (Software/SaaS) to 10.42% (Legal/Professional services) (Source: [Belkins](https://belkins.io/blog/linkedin-outreach-study)) - **Multi-touch campaigns** (DMs + profile actions) can push reply rates up to 11.87% (Source: [Belkins](https://belkins.io/blog/linkedin-outreach-study)) - **Connection request acceptance rate benchmark:** ~30-45% for well-targeted requests (Source: [LeadLoft](https://www.leadloft.com/blog/linkedin-benchmarks)) **Why it works:** - No spam folder to disappear into - Built-in social proof (mutual connections, work history) **Where it fails:** - Volume limits prevent scaling - Platform penalties kick in fast if you move like a bot - Template DMs are becoming just as obvious and ignored ## Why spray-and-pray fails now The failure loop is always the same: 1. **Send generic mail** to a bulk list 2. **Engagement tanks** 3. **Mailbox providers recognize** the pattern 4. You get **throttled, junked, or blocked** 5. **Domain reputation** dies 6. You blame **"cold email is dead"** It fails faster now for specific reasons: - **Gmail** now enforces stricter **bulk sender requirements** (auth + alignment + unsubscribe) - **Google** recommends keeping spam rate **< 0.10%** and avoiding ever hitting **0.30%** - **Microsoft 365** uses SPF/DKIM/DMARC **plus additional signals** (composite authentication) to evaluate spoofing and delivery You can't tool your way out of this by adding more accounts or using more spintax. **That just signals:** _"I'm a bulk sender-please block me."_ ## Infrastructure (the non-negotiables) If you want cold email to work in 2025, infrastructure isn't optional. Think of it like production reliability: you don't get to "growth hack" your way around deliverability. ### Authentication: SPF/DKIM are table stakes Mailbox providers don't just check that you _have_ records. They check that what you send is consistent, aligned, and not getting reported as spam. Minimum requirements: - **SPF + DKIM** configured for the domain you're sending from (and kept up to date as you add tools) - **DMARC** set up for your sending domain (start with `p=none`, then harden once reports look clean) - **DMARC alignment**: your From: domain must align with either the SPF domain or the DKIM domain - **DKIM key length**: Gmail requires 1024-bit+; 2048-bit is recommended if your DNS supports it - **Monitor spam rate**: aim to keep it below 0.10% and avoid ever hitting 0.30% If this sounds like friction, that's the point. Most competitors skip it, and you win by being boring and correct. ### Warm-up: it's reputation shaping The goal isn't to "warm up inboxes" with fake threads. It's to ramp real sending patterns gradually so providers see a stable sender. Operator rules that actually help: - Be careful with **cheap/free warm-up networks**. If you're "warming" through spammy pools, you're teaching providers the wrong signals and can damage deliverability. - If you do use a warm-up tool, use a reputable deliverability-first network (I prefer **MailReach**, which is pricier but built around inbox placement + spam testing) ([MailReach](https://www.mailreach.co/)). - The safest default for most people: **skip warm-up**. Start extremely small and ramp **per mailbox**, keep sending consistent, and monitor deliverability/spam indicators as you scale ([Google sender guidelines](https://support.google.com/a/answer/81126?hl=en)). - Avoid link-heavy, template-y blasts on brand-new domains (they look like bulk) - **Misconception:** "Don't use a signature or you'll get blacklisted." A signature is usually better for trust-just keep it **plain** (no images, no fancy HTML, minimal links) ([signature.email](https://signature.email/guides/do-email-signatures-make-emails-go-to-spam)). - Use the **same simple signature** during warm-up that you'll use in real campaigns, so your content/format doesn't suddenly change when you start sending at scale. - If deliverability is the priority, avoid **open-tracking pixels** and heavy **click-tracking**. Extra tracking elements often mean more HTML, more links, and more "marketing email" fingerprints-focus on replies/booked meetings as your primary metric. ### List hygiene: lists decay fast [Email lists rot by about 28% annually](https://www.zerobounce.net/email-list-decay/). If you keep mailing stale data, you accumulate bounces and complaints. Basic hygiene: - Validate email addresses before sending using a dedicated tool like [Bulk Email Checker](https://www.bulkemailchecker.com/). - Treat **hard bounces + spam complaints** as production incidents: pause, diagnose, fix the source - Exclude obvious low-signal targets (role inboxes, generic aliases) unless you have a reason - Refresh data continuously (quarterly is a minimum if you're doing steady outbound) ### Volume + cadence: avoid bulk-sender footprints If you're a bulk sender, Gmail has explicit requirements starting at **5,000+ messages/day to Gmail accounts** ([Google sender guidelines](https://support.google.com/a/answer/81126?hl=en)). But even below that, you can still get throttled if you send like a bot: - Keep sending **steady** (no huge spikes, no "end of month" bursts) - Spread volume across timezones and work hours - Stop scaling on a domain the moment spam rate climbs toward 0.30% ([Google sender guidelines](https://support.google.com/a/answer/81126?hl=en)) ## Messaging: personalization isn't what you think Most outbound advice obsesses over copy. In reality: - If you don't land in the inbox, copy doesn't matter - If your targeting sucks, copy can't fix it - If your offer is unclear, nobody replies Here's what actually works now. ### Personalization has tiers (merge tags don't count) `{{FirstName}}` and `{{Company}}` alone don't cut it anymore. **Tier 1 (baseline):** Firmographics in the message and subject line. Table stakes. **Tier 2 (better):** Role-specific pain points + a recent trigger (job change, hiring, funding, new product, tech change). Example: `"Noticed you hired a VP Sales at {{Company}}-that role usually struggles with rep ramp time in the first quarter."` **Tier 3 (best, reserved for high-value accounts):** Specific research. Reference a recent post, product launch, or funding announcement. Mention a mutual contact. Show you actually understand their situation. Each tier takes more time. Use Tier 3 for strategic accounts, Tier 2 for mid-market, Tier 1 everywhere else. ### AI personalization at scale: what works in 2025 AI doesn't magically make outreach "personal." It makes **research + assembly** cheap, so you can earn Tier 2 relevance at Tier 1 volume. A practical workflow using tools like [Clay](https://www.clay.com/): - **Enrich first, then write**: build a list, waterfall enrich, and keep your CRM clean before you generate copy ([Clay](https://www.clay.com/)). - **Pull 1-2 real signals per account** (not 10 facts): job changes, news mentions, tech stack, thought leadership, hiring. Clay's “Signals” + AI research is built for this kind of trigger-driven outbound ([Clay](https://www.clay.com/), [Clay automate outbound](https://www.clay.com/automate-outbound)). - **Generate _snippets_, not full emails**: have AI produce three fields: reason now, proof, a single CTA. Then drop those into a human-written template so tone stays consistent. - **Human QA where it matters**: Tier 3 accounts get a quick manual pass (facts, tone, intent). Tier 1/2 can run with lighter QA if your inputs are clean. Done right, AI-driven personalization can materially move metrics. Teams use Clay to run iterative, trigger-driven outbound experiments at scale (e.g., [Rippling's case study](https://www.clay.com/blog/rippling-case-study)). If you want a low-cost solution to personalize cold emails with LinkedIn data, check out my [post on scaling personalized outbound with AI](/blog/scaling-highly-personalized-outbound). In my own experiments, I've **more than doubled reply rates** on Instantly and seen ~**3x higher LinkedIn connection acceptance** using the same "enrich → pick 1-2 signals → generate snippets → QA" method (I just built it with a Python script instead of Clay). This approach was crucial for [scaling Drool](/drool) early on. ### Buying signals beat clever copy Intent matters more than adjectives. Strong signals include: - Pricing or demo page visits - Repeated website visits in a short window - Recent job changes (especially hiring managers) - Funding announcements or expansion news - Engagement with your content or competitor content Timing is critical: [Acting within 5 minutes of a buying signal increases conversion likelihood by roughly 9x](https://martal.ca/buying-signals-lb/). Even accounting for imprecision in that estimate, the principle holds: early outreach looks helpful, late outreach looks creepy. ### Email length matters (but not how you think) Long emails kill response rates. [Shorter emails (75-125 words, roughly 120 words as a sweet spot) outperform newsletters](https://mailmeteor.com/cold-email/personalization). Practical rules that keep you out of the "wall of text" penalty: - **Optimize for skim, not prose**: 1-2 lines per paragraph, no giant blocks. - **One idea per email**: one trigger, one problem, one proof point, one ask. - **One CTA**: a single yes/no or a single choice (e.g., "Worth a 10-min chat next week?" or "Should I talk to you or `{{VPName}}`?"). - **Fewer links is safer**: links can be useful, but early touches should read clean even with zero clicks. ### Follow-ups: most people stop too early [About 80% of sales require 5+ touches, yet 50% of people stop after the first email](https://martal.ca/cold-email-sequences-lb/). That's not a motivation problem-it's a sequencing problem. What separates "persistent" from "spammy" is whether each touch adds _new value_: - **Rotate angles**: different proof each time (customer example → relevant insight → quick teardown → short Loom). - **Use a clean cadence**: space touches 48-96 hours apart, and don't stack multiple pings in the same day. - **Keep follow-ups shorter than Email 1**: often 1-3 sentences is enough. - **Include an out**: "If this isn't relevant, tell me and I'll close the loop." - **Have a stop rule**: if you've sent ~5-7 touches across channels with no signal, pause and recycle them only when a new trigger appears. ## A simple multi-channel sequence (10 days) Here is an example sequence for a hypothetical business: **NimbusOps**, a SaaS that helps mid-market B2B companies automate **vendor security questionnaires + SOC 2 evidence collection**. **Day 1 - Email 1 (trigger + problem + specific ask)** ```text Subject: quick question on vendor reviews at {{Company}} Hi {{FirstName}} - Noticed {{Company}} is {{Trigger}} (usually when vendor security reviews + SOC 2 evidence requests spike). When teams handle questionnaires in docs + Slack, it tends to create 2 problems: 1) answers drift (different versions across deals), and 2) security/engineering becomes the bottleneck. NimbusOps centralizes approved answers + evidence so sales can respond fast while security keeps control (audit trail + approvals). Worth a quick look? If so, should I send a 90-second overview here, or is there someone else who owns vendor reviews at {{Company}}? - {{YourName}} ``` **Day 3 - LinkedIn connect (no pitch)** ```text Connection note: {{FirstName}} - saw you're leading {{TheirTeamOrFunction}} at {{Company}}. I work with security/eng teams on speeding up vendor reviews without losing control. Would love to connect. ``` **Day 3 - LinkedIn DM (after they accept, still no pitch)** ```text Thanks for connecting, {{FirstName}}. Question for you: when vendor questionnaires come in, is the bottleneck usually (a) finding the right, approved answers or (b) tracking evidence/ownership across security + engineering? ``` **Day 5 - Email 2 (new angle + proof)** ```text Subject: are questionnaires slowing sales cycles at {{Company}}? Hi {{FirstName}} - Teams typically cut questionnaire response time by ~60% by keeping: - one approved answer library (with owners), and - evidence mapped to controls (so you're not hunting screenshots every deal). If it's useful, I can send the exact 5-minute checklist we use to spot “questionnaire drag.” Should I send it now, or would Thursday work better? - {{YourName}} ``` **Day 7 - Value touch (teardown / short video instead of a call)** ```text Subject: want a 45s teardown of where questionnaires get stuck at {{Company}}? Hi {{FirstName}} - I recorded a 45-second teardown of where vendor questionnaires usually get stuck (and what to standardize first). Option A (link): want the Loom? {{LoomLink}} Option B (no link): I can paste the 3 bullets here - which do you prefer? - {{YourName}} ``` **Day 7 - LinkedIn DM version (if you're connected)** ```text I made a quick teardown specific to how teams like {{Company}} typically handle questionnaires. Want the 3 bullets here, or a 45s Loom? ``` **Day 10 - Final touch (close the loop)** ```text Subject: should I send the 2 fixes to speed up vendor reviews at {{Company}}? Hi {{FirstName}} - the two moves that usually speed up vendor reviews fastest are: 1) centralizing approved answers (with owners), and 2) mapping evidence to controls (so sales isn't chasing screenshots). If that’s relevant at {{Company}}, reply with “1” (I’ll send the 90-sec overview) or “2” (I’ll send the 5-minute checklist). - {{YourName}} ``` ## Track metrics that actually matter Open rates are increasingly unreliable. Focus on what drives revenue and keeps you in the inbox. **Deliverability health:** - [Bounce rate: keep hard bounces below 1-2%](https://www.mailmunch.com/blog/email-bounce-rate) - Spam complaints: aim for **< 0.10%** and avoid ever hitting **0.30%** (Source: [Google sender guidelines](https://support.google.com/a/answer/81126?hl=en)) **Pipeline metrics:** - Reply rate (track positive replies separately from auto-responses) - Meetings booked - Cost per qualified meeting - Revenue per 1,000 emails sent **Segmentation metrics (this is where leverage lives):** - Which titles respond best - Which industries convert - Which triggers work If you don't segment, you don't learn. If you don't learn, you keep blasting and hoping. --- ## Roadmap (do this in order) ### Phase 1: Foundation - Define ICP tightly (industry, company size, titles, specific pains) - Set up SPF/DKIM/DMARC correctly - Start a gradual ramp (with or without a warm-up tool-don't rush) - Write one solid sequence for a specific role (not 10 generic ones) ### Phase 2: Testing - Run a small pilot (100-200 leads) - A/B test one variable at a time (subject line, opening, CTA) - Track reply rate and positive replies; ignore vanity metrics ### Phase 3: Refinement - Add segmentation by role and trigger - Add LinkedIn or calls to the sequence - Keep list hygiene tight ### Phase 4: Scale - Scale only segments that proved they convert - Add more domains/mailboxes only when reputation is stable - Establish a weekly review loop (deliverability, messaging, targeting) ## Bottom line Cold outreach **_isn't dead_**. What's dead: - Treating volume like strategy - Relying on fake personalization - Ignoring deliverability until it breaks - Running email-only and calling it "outbound" If you treat cold outreach as a **precision system** - clean data, real targeting, solid infrastructure, multi-channel touches - it still works. Just not for free. And not for sloppy operators. The difference between an okay campaign and a strong one isn't luck. It's discipline: on list quality, on targeting, on authentication, on testing, and on staying consistent when results don't come immediately. --- ## What's next? This is part of a series of posts on building a modern cold outreach system: - [What works in cold outreach](/blog/is-cold-outreach-dying) -- infrastructure, targeting, and multi-channel strategy - [Scaling personalization with AI](/blog/scaling-highly-personalized-outbound) -- enriching leads and generating personalized copy for under $5 per 1,000 leads - [Finding and validating work emails](/blog/how-to-find-and-validate-work-emails) -- generating email patterns and validating them at scale for under $0.001 per email - [How to automate LinkedIn outreach](/blog/linkedin-automation) -- LinkedIn outreach automation using open-source tools **Coming soon:** I'll show you how to build a **100% automated appointment booking pipeline**, end-to-end: - Automatically finding leads from LinkedIn, Reddit, and other sources - Automatically sending emails + LinkedIn DMs as a complete outbound sequence to get appointments booked All built with **Python**, open-source tools, and cheap "pay-per-result" services. --- --- title: Drool is shut down - An honest post-mortem. description: An honest post-mortem on shutting down my design agency. Lessons on scope creep, hiring pitfalls and remote ops friction. type: blog url: https://sarthakmishra.com/blog/drool-is-shut-down date: 2025-12-15 tags: ['startup', 'agency', 'business', 'freelancing', 'design', 'lessons-learned', 'entrepreneurship'] author: Sarthak Mishra excerpt: After scaling Drool to 6+ team members, I'm shutting it down. Here's what worked, what didn't, and why I'm going back to freelancing with better scars and better systems. --- # Drool is shut down - An honest post-mortem. This one sucks to write. Drool started as a way for me to get back to my roots, and it turned into my most successful venture by far. We grew fast, took on a lot, and for a while it genuinely felt like we had momentum. But as of today, **Drool (the agency) is officially shut down**. (You can read more about [Drool's journey here](/drool).) --- **Important note:** I'm still taking on **freelance** work personally. If you're looking for help with **product UI, landing pages, design systems, or React/front-end**, then please reach out. --- ## What worked - **Cold outreach worked insanely well early on.** It helped me build a real pipeline instead of waiting for referrals to magically appear. (I've written extensively about [what actually works in cold outreach in 2025](/blog/is-cold-outreach-dying).) - **The positioning was clear:** we worked best with early-stage startups in areas I genuinely cared about (AI, B2B, analytics, no-code SaaS). - We proved there was demand—fast. I even scaled up to a team of **6+ full-time + part-time support** at one point. ## What didn't work ### 1. I said “yes” way too often Not just out of fear of losing clients, but because I genuinely wanted to try new things. Problem is: once you normalize "sure, we'll handle that too", clients start assuming unlimited scope is included. The requests got weirder, timelines got fuzzier, and a lot of work went into outcomes that were hard to defend. ### 2. Hiring + selling at the same time is a great way to break your own neck At peak, demand forced hiring. And good designers take time to find. Meanwhile our sales motion was still mostly cold outreach which became **less predictable over time**. Leads kept coming in, but quality dropped and conversions tanked. Outsourcing sales made this worse. Huge mistake. I [learned a lot](/newsletter/issue-1), but it cost real money and months of focus. So when clients dropped off, I had two bad options: - let people go (brutal), or - keep them and eat losses hoping things bounce back (also brutal, just slower) I tried correcting this by building an open community of design freelancers (so capacity could flex), but by then the momentum was already gone. ### 3. Expanding into new verticals too early When the design pipeline got shaky, I tried adding frontend dev as a vertical. I hired freelancers, but because I didn't have a strong engineering background, I couldn't reliably judge quality early. By the time issues showed up, we were already deep in delivery and the work (and trust) suffered. (I eventually learned to code properly through projects like [Learnway](/learnway) and [Codemap](/codemap), but by then the damage was done.) ### 4. Remote ops friction adds up Remote teams can work, but the overhead is real: - time zones - async delays - coordination tax - context loss - “small” miscommunications that become expensive Over time, things started moving so quickly that it became hard to keep everything under control. ## Summary (the honest version) Drool shut down because I scaled headcount and scope too quickly, while the sales engine became unpredictable. I over-accommodated clients, expanded into markets/verticals too early, and the operational load of a remote agency made everything harder to sustain. ## What's next I'm back to **freelancing** - but with better scars and better systems. I'm also working on a few new projects (keeping them under wraps for now). If you need a **design engineer** who can handle **UI/UX + build (React/front-end)**, I'm available. --- --- title: AI's Billion-Dollar Lie: Is inference really profitable? description: OpenAI, Anthropic, and Google keep dropping wild revenue numbers, but once you cut through the hype and wild speculations, the numbers don't add up. type: blog url: https://sarthakmishra.com/blog/ai-billion-dollar-lie date: 2025-12-10 tags: ['ai', 'economics', 'openai', 'anthropic', 'startup-metrics', 'saas', 'revenue'] author: Sarthak Mishra excerpt: OpenAI, Anthropic, and Google love to flex with those massive 'annualized revenue' numbers, but underneath: GPU taxes, collapsing margins, and a business model that bleeds cash. --- # AI's Billion-Dollar Lie: Is inference really profitable? You've probably seen the headlines. OpenAI is hitting $12 billion in annualized revenue. Anthropic's climbing toward $9 billion. Google's Gemini has 450 million monthly active users. The AI money machine is printing cash, right? Wrong. And it's not even close. Here's the uncomfortable truth that gets buried under the excitement: these companies are hemorrhaging money. Not metaphorically. Actually. Billions of dollars. Every. Single. Year. OpenAI lost billions in 2024 and then burned another $2.5 billion in cash in just the first half of 2025—on about $4.3 billion in actual revenue. The math isn't mathing, and increasingly, it looks like it might never. ## The Smoke and Mirrors of Annualized Revenue Let's start with the magic word: **ARR** (Annual Recurring Revenue). This is how you get "$12 billion" from a company that actually generated $4.3 billion in six months. When OpenAI claims $12 billion in annualized revenue, what they're really saying is "if the run rate from our best month continued for 12 months, we'd hit this number." That's not actual revenue. That's naive and, frankly, misleading extrapolation. The actual numbers tell a very different story. OpenAI generated [$3.7 billion](https://taptwicedigital.com/stats/openai) in total 2024 revenue. In the first six months of 2025, they brought in [$4.3 billion](https://www.reuters.com/technology/openais-first-half-revenue-rises-16-about-43-billion-information-reports-2025-09-30/). Annualized, that's roughly $8.6 billion—about 70% of the $12 billion headline run rate being hyped in the press. Anthropic is playing the same game. Its mid-2025 run-rate was about $5 billion, guiding to $7-9 billion ARR ([Sacra](https://sacra.com/c/anthropic/), [PMInsights](https://www.pminsights.com/insights/anthropic-approaches-7b-run-rate-in-2025-outpaces-openai)). But actual revenue is far smaller—roughly $1 billion in 2024 and about $2.2 billion projected for 2025 ([Sacra Anthropic profile](https://sacra.com/c/anthropic/)). The $5-9 billion ARR headline is the marketing number; the $2.2 billion is the cash number. Google's Gemini sits in a slightly different position. The company doesn't break out Gemini as a separate business unit. Gemini is folded into Alphabet's broader stack, which posted about $96.4 billion in Q2 2025 revenue and reported around 450 million monthly active users for the Gemini app ([Adweek](https://www.adweek.com/media/google-posts-96b-in-q2-revenue-spurred-by-big-ai-investments/), [AIM TV recap](https://www.youtube.com/watch?v=xCPnmVevzXA)). But here's the catch: Gemini isn't generating standalone revenue. It's cannibalizing existing search and cloud revenue while potentially increasing costs. ## The Cash Burn Reality That Nobody Can Ignore This is where things get genuinely dark. **OpenAI's situation:** - 2024 full-year revenue: about $3.7 billion - H1 2025 revenue: about $4.3 billion - H1 2025 cash burn: around $2.5 billion Public reporting based on internal shareholder disclosures also points to multibillion-dollar operating losses and a target of roughly $13 billion in 2025 revenue with an $8-8.5 billion burn ([Reuters / Yahoo Finance recap](https://finance.yahoo.com/news/openais-first-half-revenue-rises-043012507.html)). Leaked projections suggest cumulative cash burn through 2029 could reach $115 billion. Leaked internal projections, as reported by business press, suggest OpenAI doesn't expect to be profitable until 2029 at the earliest, and even then, the projections are wildly optimistic. They're spending roughly $1.69 for every dollar of revenue they generate. **Anthropic's burns:** Independent analysis of leaked financials suggests Anthropic lost on the order of $5.3 billion in 2024 while scaling ARR from roughly $1 billion to $5 billion ([Where's Your Ed At](https://www.wheresyoured.at/why-everybody-is-losing-money-on-ai/)). Claimed 2025 loss: $3 billion—an estimate from a single leaked set of figures that industry skeptics already view as optimistic. Current trajectory: Breakeven expected in 2028. The difference is architectural. Anthropic is betting on enterprise customers and better unit economics. OpenAI is betting that building the most powerful AI and controlling the inference market is worth spending $115 billion by 2029. Neither path guarantees success. ## Why Inference Is a Margin Killer Here's the fundamental problem: **AI inference (actually serving users with models) loses money at scale.** Training a model is expensive upfront but happens once. You pay $100 million to train GPT-4, and you amortize that cost across years. Inference, though, happens every single time a user makes a request. ChatGPT processes hundreds of millions of queries per month. Each one costs real money in GPU compute, storage, bandwidth, and cooling. One widely cited breakdown estimates OpenAI's 2024 inference costs in the low-single-digit billions, compared with roughly $150 million to train GPT-4 ([Where's Your Ed At](https://www.wheresyoured.at/why-everybody-is-losing-money-on-ai/)). Do the math: inference costs 15x training for a single model over its lifetime and the gap keeps widening. Yet OpenAI sells ChatGPT Plus for $20/month. That's roughly $240 per year per user. If a heavy user makes 100 queries per month and each inference costs OpenAI anywhere from $0.01-$0.05 (a rough range pulled from cloud GPU/TPU pricing and third-party benchmarking), that's $12-60 per year in compute costs alone. Push usage higher or assume costs nearer the upper end and the math collapses before staff, infrastructure, and R&D even enter the equation. **This is the hidden truth:** Nobody has figured out how to make AI inference profitable at consumer scale. Not OpenAI. Not Anthropic. Not Google. Google's TPU (Tensor Processing Unit) infrastructure hints at a potential solution: Google's TPU v6e is marketed as delivering up to 4x better performance-per-dollar than high-end GPUs for LLM inference, and case studies like Midjourney's reported 65% cost reduction moving from GPUs to TPUs (from roughly $2M/month to about $700K) illustrate how big the delta can be in practice ([TPU economics explainer](https://introl.com/blog/google-tpu-v6e-vs-gpu-4x-better-ai-performance-per-dollar-guide)). But even Google doesn't disclose Gemini as profitable. It's strategic competitive positioning, not a profit center. ## The Hardware Bottleneck: NVIDIA's Stranglehold Here's where the ecosystem problem becomes apparent: the entire AI industry depends on NVIDIA's H100 and A100 GPUs, and NVIDIA knows it. Analysts estimate NVIDIA holds a dominant share of the AI accelerator market, often quoted at well over 70% by revenue ([CNBC](https://www.cnbc.com/2024/06/02/nvidia-dominates-the-ai-chip-market-but-theres-rising-competition-.html)). The company has created an extraordinary situation: **GPU Pricing Reality:** - Manufacturing cost for an H100: Industry teardown estimates put H100 manufacturing cost a bit above $3,000 ([Hacker News discussion of BOM cost](https://news.ycombinator.com/item?id=38503463)) - Retail price: Data-center buyers and resellers report street prices commonly in the $25,000-$40,000 range ([coverage of H100 pricing](https://www.reddit.com/r/hardware/comments/1ahgu7q/nvidias_h100_ai_gpus_cost_up_to_four_times_more/)) - Secondary market: $30,000-$40,000 due to scarcity - Cloud rental: $2.80-$7.00 per hour (depending on commitment and provider) This is extraction masquerading as market clearing. NVIDIA isn't just selling chips; it's printing money from artificial scarcity. **The supply chain strain is real:** - Global H100 demand up 35% year-over-year despite prices near MSRP - HBM (High Bandwidth Memory) production slots already sold out through 2026 - DRAM and NAND spot prices spiked dramatically: Contract prices for 16Gb DDR5 chips rose from roughly $6.84 to about $27.20 over Q4 2025—a near-300% increase tied directly to AI demand for HBM and DRAM ([Intuition Labs DRAM report](https://intuitionlabs.ai/articles/ram-shortage-2025-ai-demand)) - Memory shortage forecasted through 2027, with NAND expected to face a 4.9-point supply shortfall in 2026 The AI boom is literally starving consumer electronics of memory. SK Hynix and Samsung have reallocated 20% of front-end DRAM capacity toward HBM (specialized memory for AI chips), leaving conventional DDR5 scarce. That $27.20 spike? That's the cost of AI infrastructure. Meanwhile, smaller competitors are getting crushed. Companies without grey market access or relationships with cloud providers face "extinction-level threats" in their development cycles: unable to secure hardware, unable to train models, unable to compete. ## When Specialized Hardware Becomes the Real Battlefield Despite NVIDIA and AMD's market dominance, for AI workloads, specialized chips outperform general-purpose GPUs at a fraction of the cost. **Google's TPU v6e** delivers 4x better cost-per-inference than NVIDIA H100s for LLM serving (per Google's own positioning). On-demand pricing starts at $1.38/hour vs. $2.50+ for H100s. With commitments, TPU pricing drops to $0.55/hour. Preemptible instances offer 70% discounts. The economics are brutal for NVIDIA in pure inference scenarios. A reported Midjourney case study illustrated the impact: by moving from GPUs to TPUs, the company said it reduced monthly inference costs from roughly $2 million to about $700,000, a [65% reduction](https://introl.com/blog/google-tpu-v6e-vs-gpu-4x-better-ai-performance-per-dollar-guide) that directly extends runway and accelerates scaling. **Groq's LPU (Language Processing Unit)** takes a different approach, optimizing specifically for LLM inference speed. Groq's LPU cards, priced around $20,000, have demonstrated token throughput on Llama-class models that can double conventional GPU setups for certain inference workloads ([CryptoSlate](https://cryptoslate.com/groq-20000-lpu-card-breaks-ai-performance-records-to-rival-gpu-led-industry/)). While not cheaper per-unit than cloud TPU rentals, Groq enables on-premises deployment and avoids cloud vendor lock-in. **Cerebras CS-3**, using wafer-scale architecture, claims (per Cerebras marketing) 21x lower end-to-end latency than NVIDIA's Blackwell B200 GPU for inference workloads, while also delivering over 2x the tokens-per-second throughput of NVIDIA's Blackwell-based DGX B200 for Llama-scale models. The system costs roughly one-third less for comparable inference capacity ([Cerebras CS-3 vs DGX B200](https://www.cerebras.ai/blog/cerebras-cs-3-vs-nvidia-dgx-b200-blackwell)). These aren't marginal improvements. They're category shifts. The catch? These specialized chips only work for specific tasks. TPU dominates recommendation systems and transformer-heavy inference. Groq and Cerebras excel at LLM serving at scale. For training new models, diverse workloads, or research flexibility, NVIDIA GPUs remain superior. But here's what matters for the economics argument: **if companies can migrate inference workloads away from GPUs, NVIDIA's revenue base (currently $51.2 billion from data center operations alone in Q3 2025) becomes vulnerable**. Not today. But the architectural path is clear. ## The Chinese Alternative That's Closing Fast Western incumbents are comfortable assuming dominance. China's approach suggests they should be nervous. **DeepSeek's emergence changed the conversation.** Founded by former Baidu researchers, DeepSeek R1 achieved state-of-the-art reasoning performance while training for reportedly under $5 million. OpenAI spent tens of millions on GPT-4. The cost differential is staggering. Pricing advantage gets worse from there: - DeepSeek R1 API: DeepSeek R1's published pricing comes in around $0.14 per million tokens ([DeepSeek vs ChatGPT cost comparison](https://www.creolestudios.com/deepseek-vs-chatgpt-cost-comparison/)) - OpenAI o1 API: versus about $7.50 per million tokens for OpenAI's o1-mini—a roughly 50x difference - Difference: **50x cheaper** For large-scale operations, this isn't a marketing advantage. It's existential. A breakdown for an e-commerce platform generating 10 million words a month shows monthly costs of about $100 with ChatGPT o1 versus roughly $1.87 with DeepSeek R1—around 98% savings ([same source](https://www.creolestudios.com/deepseek-vs-chatgpt-cost-comparison/)). DeepSeek isn't alone. Baidu's ERNIE, Alibaba's Qwen, Tencent's Hunyuan, and ByteDance's Doubao are all benchmarking competitively with Western models on suites like MMLU and HumanEval ([Tech Buzz China on Chinese AI apps](https://techbuzzchina.substack.com/p/the-state-of-chinese-ai-apps-2025)). ### China's Hardware Strategy More concerning for NVIDIA: Huawei has publicly outlined a three-year roadmap for domestic AI accelerators aimed squarely at NVIDIA's data-center business ([Huawei AI chip roadmap](https://www.globalneighbours.org/huawei-unveils-three-year-ai-chip-roadmap-as-nvidia-faces-setbacks-in-china/)), while fresh US export restrictions on the H20 are expected to cost NVIDIA about $5.5 billion in lost sales to China ([CNN](https://www.cnn.com/2025/04/16/tech/nvidia-plunge-h20-chip-china-export-intl-hnk)). NVIDIA CEO Jensen Huang acknowledged in September 2025 that "China is not behind" in AI development, calling the race "very close" and "a long-term, infinite race." Here's what keeps strategists awake: if Chinese companies achieve cost-parity on model performance while maintaining 20-50x pricing advantages, the global AI market gets reshuffled. OpenAI's valuation depends on being the undisputed best. If "good enough and 50x cheaper" becomes the market standard, valuations collapse. ## The Amazon Parallel: Why History Doesn't Repeat This Time Every investor points to Amazon as proof that massive losses can precede profitability. Amazon lost billions through the dot-com era—analysts estimate about $2.8 billion over its first 17 quarters as a public company—with 1999 alone seeing $1.6 billion in sales against a $720 million net loss ([contemporary coverage](https://www.latimes.com/archives/la-xpm-2000-apr-27-fi-23807-story.html), [BBC retrospective](https://www.bbc.com/culture/article/20240628-a-36-year-old-jeff-bezos-talks-about-losing-money)). The company lost money for years while building logistics dominance, and when it finally turned profitable, it had created a moat that competitors couldn't breach. **But the structures are fundamentally different.** Amazon's losses funded: - Physical distribution infrastructure (fulfillment centers, logistics networks) - Customer acquisition in a winner-take-most market - Marketplace ecosystem development - These assets created lasting competitive advantages Amazon's path to profitability came from AWS: cloud services that generate 25-30%+ margins while subsidizing low-margin retail. AWS profitability eventually made the retail losses irrelevant. **OpenAI and Anthropic's losses fund:** - Massive compute spending for model training and inference - GPU rentals from NVIDIA (100% passed through to costs) - Personnel (salaries doubled in 2025) - Infrastructure (data centers) The difference is critical: these costs never decrease. Every new model requires more compute. Every user added means more inference cost. Unlike Amazon building lasting physical moats, OpenAI is on a treadmill where the speed keeps increasing. There's no "AWS moment" coming for OpenAI unless: 1. Inference becomes wildly profitable (it won't be at consumer prices) 2. Compute costs collapse dramatically (unlikely while GPU costs stay inflated) 3. A higher-margin business model emerges (licensing? Nobody's figured this out) Amazon's losses ended because the company built defensible advantages. OpenAI's losses are structural. They're baked into the unit economics of serving AI models at scale. ## The Fundamental Math Problem Strip away all the variables, and the equation is simple: **OpenAI 2025:** - Revenue: ~$13 billion (full year projection) - Operating expenses: ~$22 billion - Cash burn: $8-8.5 billion - Spending ratio: $1.69 per revenue dollar In other words, even under optimistic revenue assumptions, the core business currently burns about $0.69 for every dollar it takes in. **For comparison:** - AWS (AWS alone): ~$90+ billion revenue, ~30%+ margins, $27+ billion profit - Google Search: ~$218 billion revenue, 50%+ margins, $100+ billion profit - Microsoft (overall): positive cash flow, paying dividends OpenAI doesn't have a business model yet. It has a burn rate and a prayer that scaling eventually works. Leaked internal projections, as reported by business press, suggest OpenAI is betting on explosive revenue growth toward the low hundreds of billions by 2030, combined with gradual margin expansion, to reach profitability around 2029 ([Fortune analysis](https://fortune.com/2025/11/12/openai-cash-burn-rate-annual-losses-2028-profitable-2030-financial-documents/)). If revenue doesn't grow at 25%+ annually, or if margin expansion stalls (due to competition from DeepSeek), the 2029 profitability claim evaporates. ## The Uncomfortable Truth The ARR numbers are real in the sense that customers are genuinely spending this money. OpenAI and Anthropic aren't committing fraud. But they are optimizing their messaging around metrics that make their financial position look better than it actually is. They have incredible products and real user bases, but the business model is fundamentally broken. They're spending billions to serve users who pay pennies. The gap doesn't close without either: 1. Dramatically cheaper compute (5-10x improvements) 2. Dramatically higher pricing (which kills adoption) 3. External capital indefinitely subsidizing losses (which works until it doesn't) All three are possible. None are guaranteed. ## So what? As a consumer, treat today's AI experience as a subsidized preview, not a stable utility. Prices, rate limits, and model availability will keep shifting until someone finds real margins. Keep a fallback: export your data, try an alternative model, and be ready to switch when the meter moves. The companies building this future are brilliant, but until the economics catch up, the safest move is to enjoy the upside while staying portable enough to walk when the bill comes due. --- --- title: Setting Up Traefik: From Local to Production description: Building three different Traefik configurations - from simple local development to production-ready infrastructure with security layers. type: blog url: https://sarthakmishra.com/blog/setting-up-traefik-from-local-to-production date: 2025-08-09 tags: ['traefik', 'docker', 'devops', 'infrastructure', 'networking'] author: Sarthak Mishra excerpt: After setting up my Ubuntu server, I needed a proper reverse proxy. Traefik seemed perfect, but I learned there's a big difference between getting something working and making it production-ready. --- # Setting Up Traefik: From Local to Production After getting my Ubuntu server up and running in [my previous post](/blog/setup-ubuntu-server-in-10-minutes), I needed a proper reverse proxy. Traefik was the obvious choice - it's Docker-native, handles SSL certs automatically, and has solid documentation. I ended up creating three different Traefik configurations: one for local development, one for my [home server](/blog/self-hosting-done-right), and a hardened version for production. Here's what I learned along the way. **TL;DR:** You can find the complete, production-ready code in my [self-hosted repository on GitHub](https://github.com/SarthakMishra/self-hosted). ## The Docker Context File Mounting Problem Before diving into the configurations, let me share something that cost me a few hours of debugging. When using Docker context with a remote server, mounting local files to containers doesn't work as expected. Instead of the file being mounted correctly, Docker creates a new directory with the same name. This hit me when I tried to mount my Traefik middleware configuration: ```yaml volumes: - ./middlewares.yml:/etc/traefik/middlewares.yml # This creates a directory, not a file! ``` The workaround I used is simple - use a Dockerfile to copy the file during the build process: ```dockerfile FROM traefik:latest COPY middlewares.yml /etc/traefik/middlewares.yml ``` It's a small thing, but it'll save you from wondering why your middleware configuration isn't loading. ## Three Configurations for Three Use Cases ### Local Development: Keep It Simple The local setup is barebones - just Traefik handling HTTP traffic on port 80. No SSL, no security headers, no complications. Perfect for testing applications before deploying them. ```yaml services: traefik: image: traefik:v3.0 container_name: traefik restart: unless-stopped # Environment configuration env_file: - .env command: # API and Dashboard - --api.dashboard=false # Entry points - --entrypoints.web.address=:80 # Docker provider - --providers.docker=true - --providers.docker.exposedbydefault=false - --providers.docker.network=home-network # Logging - --log.level=${TRAEFIK_LOG_LEVEL:-INFO} - --accesslog=true ports: - '80:80' volumes: # Docker socket for service discovery - /var/run/docker.sock:/var/run/docker.sock:ro healthcheck: test: ['CMD', 'traefik', 'healthcheck', '--ping'] interval: 30s timeout: 10s retries: 3 start_period: 30s networks: - local-network # Networks networks: local-network: external: true ``` This configuration assumes you're working locally and don't need HTTPS. It's fast to spin up and perfect for development work. ### Home Server: Convenience with Some Security The home server configuration strikes a balance between convenience and security. It includes: - **Dashboard enabled** for easy monitoring - **Let's Encrypt with DNS-01 challenge** for real SSL certificates - **Cloudflare Tunnel integration** for secure external access - **Flexible HTTP/HTTPS** - no forced redirects Key differences from the production setup: ```yaml services: traefik: image: traefik:v3.0 container_name: traefik restart: unless-stopped # Environment configuration env_file: - .env environment: # Cloudflare DNS API for Let's Encrypt DNS-01 challenge - CF_API_EMAIL=${CLOUDFLARE_EMAIL} - CF_API_KEY=${CLOUDFLARE_API_KEY} # Alternative: CF_DNS_API_TOKEN (for API tokens instead of Global API Key) # - CF_DNS_API_TOKEN=${CLOUDFLARE_DNS_API_TOKEN} command: # API and Dashboard - --api.dashboard=true - --api.debug=${TRAEFIK_DEBUG:-false} - --api.insecure=${TRAEFIK_API_INSECURE:-false} # Entry points - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 # HTTP to HTTPS redirect (DISABLED for flexible home access) # - --entrypoints.web.http.redirections.entrypoint.to=websecure # - --entrypoints.web.http.redirections.entrypoint.scheme=https # - --entrypoints.web.http.redirections.entrypoint.permanent=true # Docker provider - --providers.docker=true - --providers.docker.exposedbydefault=false - --providers.docker.network=home-network # Let's Encrypt certificate resolver with DNS-01 challenge - --certificatesresolvers.letsencrypt.acme.tlschallenge=false - --certificatesresolvers.letsencrypt.acme.dnschallenge=true - --certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare - --certificatesresolvers.letsencrypt.acme.dnschallenge.resolvers=1.1.1.1:53,8.8.8.8:53 - --certificatesresolvers.letsencrypt.acme.dnschallenge.delaybeforecheck=30 - --certificatesresolvers.letsencrypt.acme.email=${LETSENCRYPT_EMAIL} - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json # Logging - --log.level=${TRAEFIK_LOG_LEVEL:-INFO} - --accesslog=true ports: # Bind to specific local IP address - '${TRAEFIK_LOCAL_IP}:80:80' - '${TRAEFIK_LOCAL_IP}:443:443' # Dashboard port (optional, can be accessed via domain instead) - '${TRAEFIK_LOCAL_IP}:8080:8080' volumes: # Docker socket for service discovery - /var/run/docker.sock:/var/run/docker.sock:ro # Let's Encrypt certificates storage - traefik_letsencrypt:/letsencrypt labels: # Enable Traefik for itself - 'traefik.enable=true' # Traefik dashboard router - 'traefik.http.routers.api.rule=Host(`traefik.${ROOT_DOMAIN}`)' - 'traefik.http.routers.api.service=api@internal' - 'traefik.http.routers.api.entrypoints=websecure' - 'traefik.http.routers.api.tls=true' - 'traefik.http.routers.api.middlewares=security-headers' # Simplified security headers for home use - 'traefik.http.middlewares.security-headers.headers.browserxssfilter=true' - 'traefik.http.middlewares.security-headers.headers.contenttypenosniff=true' - 'traefik.http.middlewares.security-headers.headers.referrerpolicy=strict-origin-when-cross-origin' # HTTPS redirect middleware - 'traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https' - 'traefik.http.middlewares.redirect-to-https.redirectscheme.permanent=true' healthcheck: test: ['CMD', 'traefik', 'healthcheck', '--ping'] interval: 30s timeout: 10s retries: 3 start_period: 30s # Network configuration networks: - home-network # Cloudflare Tunnel cloudflared-tunnel: image: cloudflare/cloudflared:latest container_name: cloudflared-tunnel restart: unless-stopped command: tunnel run environment: - TUNNEL_TOKEN=${TUNNEL_TOKEN} networks: - home-network # Networks networks: home-network: external: true volumes: traefik_letsencrypt: external: true ``` The Cloudflare Tunnel integration is particularly nice - it handles external access without exposing your home IP or dealing with port forwarding. There are tools like [traefik-cloudflare-companion](https://github.com/tiredofit/docker-traefik-cloudflare-companion) that can automatically create CNAME records for your Traefik containers, but I prefer keeping things simple by managing the exposed endpoints manually on the Cloudflare dashboard. If you want a fully automated setup where Traefik routes through Cloudflare Tunnel, [this blog post](https://mattdyson.org/blog/2024/02/using-traefik-with-cloudflare-tunnels/#part-3-cloudflare-tunnel) covers it exceptionally well. ## Production Security Layers The production configuration is where things get serious. This isn't just about routing traffic anymore - it's about protecting your infrastructure. ### Layer 1: Cloudflare IP Whitelisting The first line of defense is the UFW firewall script that only allows Cloudflare's IP ranges to reach ports 80 and 443. This script automatically fetches the latest Cloudflare IP ranges and configures both UFW rules and route rules for Docker containers. ```bash # The script handles both regular UFW rules and Docker route rules for ip in "${CLOUDFLARE_IPV4[@]}"; do ufw allow from "$ip" to any port 80 proto tcp ufw route allow proto tcp from "$ip" to any port 80 done ``` Why both types of rules? UFW rules control access to the host, while route rules control access to Docker containers. You need both for this setup to work properly. ### Layer 2: CrowdSec Integration CrowdSec provides real-time threat detection by analyzing logs and sharing threat intelligence. The integration uses a Traefik plugin that communicates with the CrowdSec API: ```yaml crowdsec: plugin: bouncer: enabled: true logLevel: INFO crowdsecMode: stream crowdsecLapiKey: CROWDSEC_API_KEY crowdsecLapiScheme: 'http' crowdsecLapiHost: 'crowdsec:8080' crowdsecLapiPath: '/' updateIntervalSeconds: 60 updateMaxFailure: 0 defaultDecisionSeconds: 60 remediationStatusCode: 403 httpTimeoutSeconds: 10 metricsUpdateIntervalSeconds: 600 ``` The stream mode is key here - it provides real-time protection with minimal performance impact. ### Layer 3: Security Headers and Middleware The middleware configuration includes comprehensive security headers: ```yaml security-headers: headers: stsSeconds: 315360000 # HSTS for 10 years browserXssFilter: true contentTypeNosniff: true forceSTSHeader: true stsIncludeSubdomains: true stsPreload: true frameDeny: true referrerPolicy: 'strict-origin-when-cross-origin' ``` I also included an API key authentication middleware for protecting internal APIs - useful for admin endpoints or service-to-service communication. ## The Complete Production Setup The production docker-compose.yml brings everything together: - **Traefik with custom Dockerfile** to include middleware configuration - **CrowdSec container** for threat detection - **Cloudflare DNS-01 challenge** for SSL certificates - **Disabled dashboard** for security - **Shared logs volume** for CrowdSec log monitoring **Why Custom Dockerfiles?** As mentioned earlier, Docker context with remote servers doesn't handle local file mounting correctly - it creates directories instead of mounting files. The custom Dockerfiles solve this by copying the configuration files during the build process: ```dockerfile # Dockerfile.traefik FROM traefik:latest COPY middlewares.yml /etc/traefik/middlewares.yml # Dockerfile.crowdsec FROM crowdsecurity/crowdsec:latest COPY acquis.yaml /etc/crowdsec/acquis.yaml ``` ```yaml services: traefik: build: context: . dockerfile: Dockerfile.traefik container_name: traefik restart: unless-stopped # Environment configuration env_file: - .env environment: # Cloudflare DNS API for Let's Encrypt DNS-01 challenge - CF_API_EMAIL=${CLOUDFLARE_EMAIL} - CF_API_KEY=${CLOUDFLARE_API_KEY} # CrowdSec API key for plugin - CROWDSEC_API_KEY=${CROWDSEC_API_KEY} command: # API and Dashboard (disabled for production) - --api.dashboard=false - --api.debug=false # Entry points - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 # HTTP to HTTPS redirect - --entrypoints.web.http.redirections.entrypoint.to=websecure - --entrypoints.web.http.redirections.entrypoint.scheme=https - --entrypoints.web.http.redirections.entrypoint.permanent=true # Trust forwarded headers configuration (handled by middlewares.yml) - --entrypoints.websecure.forwardedheaders.insecure=false # Docker provider - --providers.docker=true - --providers.file.filename=/etc/traefik/middlewares.yml - --providers.docker.exposedbydefault=false - --providers.docker.network=app-network # Let's Encrypt certificate resolver with DNS-01 challenge - --certificatesresolvers.letsencrypt.acme.tlschallenge=false - --certificatesresolvers.letsencrypt.acme.dnschallenge=true - --certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare - --certificatesresolvers.letsencrypt.acme.dnschallenge.resolvers=1.1.1.1:53,8.8.8.8:53 - --certificatesresolvers.letsencrypt.acme.dnschallenge.delaybeforecheck=30 - --certificatesresolvers.letsencrypt.acme.email=${LETSENCRYPT_EMAIL} # CrowdSec plugin configuration - --experimental.plugins.bouncer.modulename=github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin - --experimental.plugins.bouncer.version=v1.4.4 # API Key Authentication - --experimental.plugins.traefik-api-key-auth.modulename=github.com/Septima/traefik-api-key-auth - --experimental.plugins.traefik-api-key-auth.version=v0.3.0 # Logging - --log.level=${TRAEFIK_LOG_LEVEL:-INFO} - --accesslog=true - --accesslog.format=json ports: # Standard HTTP/HTTPS ports - '80:80' - '443:443' volumes: - '/var/run/docker.sock:/var/run/docker.sock:ro' - logs-traefik:/var/log/traefik # Network configuration networks: - app-network depends_on: - crowdsec # CrowdSec for threat detection crowdsec: build: context: . dockerfile: Dockerfile.crowdsec container_name: crowdsec restart: unless-stopped environment: # CrowdSec configuration - BOUNCER_KEY_TRAEFIK=${CROWDSEC_API_KEY} - COLLECTIONS=crowdsecurity/traefik crowdsecurity/appsec-virtual-patching crowdsecurity/appsec-generic-rules # Enable LAPI for Traefik plugin - LAPI_HOST=0.0.0.0 - LAPI_PORT=8080 volumes: # CrowdSec configuration and data - crowdsec_config:/etc/crowdsec - crowdsec_data:/var/lib/crowdsec # Log files to monitor - logs-traefik:/var/log/traefik networks: - app-network labels: - 'traefik.enable=false' # Health check healthcheck: test: ['CMD-SHELL', 'cscli lapi status || exit 1'] interval: 30s timeout: 10s retries: 3 start_period: 60s # Networks networks: app-network: external: true # Persistent volumes volumes: logs-traefik: crowdsec_config: crowdsec_data: ``` Here's the static configuration file for Traefik: ```yml # Traefik Static Configuration # This file defines global middlewares and configuration http: middlewares: # Global security headers middleware security-headers: headers: stsSeconds: 315360000 browserXssFilter: true contentTypeNosniff: true forceSTSHeader: true stsIncludeSubdomains: true stsPreload: true frameDeny: true referrerPolicy: 'strict-origin-when-cross-origin' # CrowdSec bouncer middleware crowdsec: plugin: bouncer: enabled: true logLevel: INFO crowdsecMode: stream crowdsecLapiKey: CROWDSEC_API_KEY crowdsecLapiScheme: 'http' crowdsecLapiHost: 'crowdsec:8080' crowdsecLapiPath: '/' updateIntervalSeconds: 60 updateMaxFailure: 0 defaultDecisionSeconds: 60 remediationStatusCode: 403 httpTimeoutSeconds: 10 metricsUpdateIntervalSeconds: 600 forwardedHeadersTrustedIPs: # Add Cloudflare IP ranges here # API Key Authentication middleware api-key-auth: plugin: traefik-api-key-auth: enabled: true authenticationHeaderEnabled: true authenticationHeaderName: 'X-API-KEY' bearerHeader: true bearerHeaderName: 'Authorization' queryParam: true queryParamName: 'token' keys: - 'your-very-secret-api-key' # Replace with your actual API key # Basic Authentication middleware basic-auth: basicAuth: users: - 'admin:$2y$10$your_hashed_password_here' # Generate with: htpasswd -nbB admin your_password removeHeader: true ``` ## UFW and Docker: The Missing Piece Understanding how UFW works with Docker was crucial. Docker manipulates iptables directly, which can bypass UFW rules. The solution involves: 1. **UFW rules** - Allow Cloudflare IPs to reach your server 2. **UFW route rules** - Allow that traffic to pass through to containers 3. **ufw-docker integration** - Ensures Docker doesn't bypass your firewall The [ufw-manager script](https://github.com/SarthakMishra/self-hosted/blob/main/service-templates/remote/traefik/ufw-manager.sh) handles all of this automatically, but understanding the flow helps with troubleshooting: `Internet → Cloudflare → UFW Rules → UFW Route Rules → Docker Containers` ## Next Steps With Traefik properly configured, I can now deploy services with confidence. Each service just needs the right labels: ```yaml labels: - 'traefik.enable=true' - 'traefik.http.routers.app.rule=Host(`app.yourdomain.com`)' - 'traefik.http.routers.app.entrypoints=websecure' - 'traefik.http.routers.app.tls.certresolver=letsencrypt' - 'traefik.http.routers.app.middlewares=security-headers@file,crowdsec@file' ``` The key takeaway? Infrastructure automation isn't just about getting things working - it's about making them work reliably and securely in different environments. You can find all the configurations discussed in this article in my [self-hosted repository on GitHub](https://github.com/SarthakMishra/self-hosted). --- --- title: Why I spent weeks perfecting a TOC component description: A deep dive into building a Table of Contents component with sophisticated visual debugging and pixel-perfect active state detection. type: blog url: https://sarthakmishra.com/blog/building-perfect-toc-component date: 2025-08-04 tags: ['react', 'astro', 'performance', 'component-refactoring', 'typescript', 'react-spring', 'visual-debugging', 'scroll-optimization', 'dom-positioning', 'ux-engineering'] author: Sarthak Mishra excerpt: What started as a simple navigation aid became an exercise in user experience, algorithm design, and visual debugging. How I built a TOC that highlights what you are actually reading, not just what is technically visible. --- # Why I spent weeks perfecting a TOC component Table of contents are one of those things you never really notice—until they're missing. Then suddenly, a blog post feels incomplete. When I first built mine, I fell into the same trap as everyone else: optimizing for SEO. There are hundreds of TOC designs out there, and most follow the same playbook—show everything, maintain hierarchy, make sure search engines can crawl every heading. But here's what I realized: the main purpose of a TOC isn't structure—it's skimming. It's like flipping a book over to read the synopsis before deciding if it's worth your time. Blog readers do the same thing. They want to know what they're getting into without committing to the full read. That's when I started questioning the conventional wisdom. Sure, for SEO it's better to display the full hierarchical structure. But honestly? I don't like that approach. When I'm skimming an article, I'm not looking for perfect h2-h3-h4 organization. I just want a bulleted list of what's inside—quick, scannable, no cognitive overhead. So I built mine differently. I extract all headings but present them as a flat list—no visual hierarchy, just the order they appear. And I only show 10 at a time. That number gives you a solid preview of what's coming without feeling overwhelming or intimidating. This design choice led me down a rabbit hole that started as "let's make a simple TOC" and ended with one of the more interesting components I've built. > TL;DR: I built a preview of this TOC component in this [CodeSandbox](https://codesandbox.io/p/sandbox/vz2jth). Toggle the debug overlay (Ctrl+Shift+D) to see the focus region detection in action. ## The Problem with "Good Enough" Most Table of Contents components follow the same pattern: detect when a heading enters or exits the viewport, highlight the corresponding nav item. Simple, functional, boring. Mine started that way too. A basic Intersection Observer watching headings, some CSS transitions for the active state. It worked fine until real users started scrolling through long articles. The feedback was consistent: the active states felt "too eager." Headings would highlight before their content was actually visible, creating a disconnect between what users were reading and what the TOC claimed they should be reading. That's when I realized the standard approach—treating each heading as a point in space—was fundamentally flawed. Reading isn't about headings; it's about content blocks. A heading represents the start of a section, not the entire thing. ## The Focus Region The solution came from rethinking how we define "active." Instead of asking "is this heading visible?" I started asking "what content is the user actually focused on right now?" I introduced what I call a **focus region**—the middle 40% of the viewport (30% from top, 30% from bottom). This is where focused reading happens. The top and bottom are transition zones where users are scrolling past content or anticipating what's coming. ```javascript const focusTop = viewportTop + viewportHeight * 0.3; const focusBottom = viewportTop + viewportHeight * 0.7; ``` But here's where it gets interesting: I don't just check if a heading is in this region. I calculate how much of each content block (heading + content until next heading) overlaps with the focus region. ```javascript const calculateBlockIntersection = (block, viewportTop, viewportHeight) => { const focusTop = viewportTop + viewportHeight * 0.3; const focusBottom = viewportTop + viewportHeight * 0.7; const focusHeight = focusBottom - focusTop; const intersectionTop = Math.max(block.startY, focusTop); const intersectionBottom = Math.min(block.endY, focusBottom); const intersectionHeight = Math.max(0, intersectionBottom - intersectionTop); return intersectionHeight / focusHeight; // Coverage percentage }; ``` When multiple blocks are in the focus region, the one with the largest coverage wins. When coverage is similar, scroll direction becomes the tiebreaker. The result feels natural—the TOC highlights what you're actually reading, not just what's technically visible. ### Handling Consecutive Headings One caveat I discovered: sometimes you have consecutive headings without meaningful content between them. Consider this markdown structure: ```markdown # Main Feature Overview ## Implementation Details ### Technical Specifications ### Performance Considerations ``` Showing all these in the TOC creates visual noise and jerky transitions. The algorithm filters out smaller headings that follow larger ones when there's no substantial content between them. This keeps the TOC clean and makes scrolling feel smoother. ### The Art of Practical Approximation The 40% middle region isn't scientifically precise—it's a practical compromise. In reality, reading patterns vary dramatically based on screen size, content type, and scroll direction. Users often focus on the middle initially, then read toward the bottom without scrolling (what I call "scroll fatigue"). When they finally do scroll, they've already consumed content outside the focus zone. Direction matters too. When scrolling up to find a reference, the entire logic reverses. Ideally, you'd account for scroll velocity, reading patterns around anchor elements (headings, quotes, code blocks), screen size adaptations, and even eye-tracking data (this is a whole other rabbit hole). > But optimizing for every variable will kill your sanity and most-likely wont make a noticeable difference. > Perfect is truly the enemy of productivity. ## Debug-Driven Development Building this logic was tricky. How do you debug viewport calculations, content block boundaries, and intersection math? Traditional console logging felt inadequate for spatial problems. So I built a visual debug system. Color-coded overlays show: - The focus region as a red transparent band - Content blocks as colored rectangles - Active intersections highlighted in different colors - Real-time debug info panel This debug system became essential for development. I could immediately see when my algorithm was making wrong decisions and iterate quickly. It's also incredibly useful for explaining the logic to others—visual proof is hard to argue with. ## The DOM Positioning Trap One nasty bug taught me about DOM positioning accuracy. Initially, I used `offsetTop` to calculate block boundaries. Everything seemed fine until I saw weird active states that didn't match the debug visualization. The issue: `offsetTop` includes positioning from unrelated page elements. With complex CSS layouts, this introduced significant errors. Switching to `getBoundingClientRect().top + window.scrollY` gave pixel-perfect accuracy. ```javascript // Wrong: Includes unrelated element offsets const startY = heading.offsetTop; // Right: Accurate position calculation const headingRect = heading.getBoundingClientRect(); const startY = headingRect.top + window.scrollY; ``` This change fixed boundary calculations and made the debug visualization match reality. Small detail, big impact. ## Performance Without Compromise Scroll events fire constantly, so performance matters. The solution uses several optimization strategies: - **Debounced scroll handlers** (50ms) for active state detection - **Memoized calculations** for expensive DOM operations - **Passive event listeners** to avoid blocking scroll - **React.memo** and strategic re-rendering prevention The visual debug updates run on every scroll for smooth overlays, while the actual logic runs debounced. This gives you immediate visual feedback during development without sacrificing production performance. ## What I Learned Building this TOC gave me some insights that apply beyond simple navigation components: **Visual debugging is a superpower.** For spatial or timing-based problems, seeing is understanding. Custom debug visualizations often solve problems faster than traditional debugging. **Browser positioning APIs can be tricky.** Complex CSS layouts often cause traditional offset calculations to fail. Trust `getBoundingClientRect` for reliable positioning data. **[Performance and features](/blog/optimizing-astro-bundle-size) aren't mutually exclusive.** With proper optimization strategies, you can build sophisticated interactions without sacrificing responsiveness. ## Try It Yourself You can explore the full implementation in this [CodeSandbox](https://codesandbox.io/p/sandbox/vz2jth). Toggle the debug overlay (Ctrl+Shift+D) to see the focus region detection in action. **Full disclosure:** This sandbox is a quick itteration that I genearted with AI only for this blog post. The code has lots of comments, and the debug system makes it easy to understand how each piece works. If you build your own version, I'd love to hear about your improvements or alternative approaches. Sometimes the best development happens in the margins—those side quests that start small and end up teaching you more than the main project. This TOC component was one of those for me. What started as a simple navigation aid became an exercise in user experience, algorithm design, and visual debugging. Not bad for a _**"mundane"**_ component. --- _This article is part of my development journey series. If you found it useful, consider subscribing to my newsletter where I share more insights from building with modern web technologies._ --- --- title: Building a Newsletter System Without the Headache description: How I added a complete newsletter system to my static Astro site using Cloudflare KV, Resend, and MDX - no external CMS required. type: blog url: https://sarthakmishra.com/blog/building-newsletter-with-astro-cloudflare-and-resend date: 2025-08-01 tags: ['newsletter', 'astro', 'cloudflare', 'resend', 'mdx', 'email', 'static-site', 'automation'] author: Sarthak Mishra excerpt: I wanted newsletters but didn't want another CMS dashboard to manage. Here's how I built a complete email system using Cloudflare KV, Resend, and simple MDX files while keeping my static site benefits. --- # Building a Newsletter System Without the Headache I've been putting off adding a newsletter to my site for months. Not because I didn't want one, but because every solution I looked at felt a little overkill. ConvertKit, Mailchimp, Beehiiv, Substack - they're all great, but I didn't want another dashboard to manage or monthly subscription to juggle. My site was **happily static**, hosted on Cloudflare Pages, built with Astro. Simple, fast, cheap. Adding a newsletter meant giving up some of that simplicity, but I wanted to do it my way: no external CMS, just my familiar MDX workflow for writing content. Here's how I built a complete newsletter system using Cloudflare KV, Resend for email delivery, and kept everything manageable with simple MDX files. ## The Requirements Before diving into code, I had a few non-negotiables: - **No external CMS** - I wanted to write newsletters like blog posts, in MDX - **Newsletter archive** - Each newsletter should be a full webpage for SEO - **Email compliance** - Support Gmail's bulk sender requirements - **Simple management** - Automated scheduling, minimal manual work - **Cost-effective** - Leverage existing Cloudflare infrastructure ## The Architecture Decisions ### From Static to Server-Side The biggest change was moving from a static site to server-side rendering. Astro v5 no longer supports hybrid mode, but I think that's actually a better choice - it forces you to think about the requirements of every route separately and set the pre-rendering directive explicitly for each page. No magic, just simple, straightforward config: ```js // astro.config.mjs export default defineConfig({ output: 'server', // Instead of hybrid adapter: cloudflare({ platformProxy: { enabled: true, configPath: './wrangler.jsonc', }, }), }); ``` For pages that should remain static, I just added `export const prerender = true` at the top. ### Cloudflare KV vs Resend Broadcast Resend has a broadcast API that seemed perfect initially, but the limitations were a deal breaker for me. While broadcasts automatically include the required unsubscribe headers, they only support basic template variables (`{{{FIRST_NAME}}}`, `{{{LAST_NAME}}}`, `{{{EMAIL}}}`) and force you to use their managed unsubscribe system. Cloudflare KV gave me the flexibility I needed: - Store subscriber data with custom tokens - Generate secure unsubscribe URLs per recipient - Use unlimited custom variables in email templates The data structure is straightforward, but I can expand it later as needed: ``` contact:{email} → Subscriber data, status, tokens token:{confirmationToken} → Maps tokens to emails (24h TTL) unsubscribe:{unsubscribeToken} → Maps unsubscribe tokens to emails newsletter:sent:{issueNumber} → Track sent newsletters ``` #### The Three-State Subscriber Lifecycle What makes this system robust is how it handles the complete subscriber lifecycle. Each contact moves through distinct states: ```typescript interface ContactData { email: string; firstName?: string; lastName?: string; status: 'pending' | 'subscribed' | 'unsubscribed'; confirmationToken: string; createdAt: string; confirmedAt?: string; unsubscribeToken?: string; unsubscribedAt?: string; } ``` **Pending State:** When someone subscribes, they start here. The system generates a cryptographically secure confirmation token and stores it with a 24-hour TTL. If they don't confirm within that window, the token expires and they'll need to re-subscribe. **Subscribed State:** After clicking the confirmation link, the system generates an unsubscribe token, stores it permanently in KV, and sends a welcome email. This unsubscribe token is what powers the one-click unsubscribe functionality Gmail requires. **Unsubscribed State:** The final state. Importantly, I don't delete subscriber data - I just mark them as unsubscribed. This prevents accidental re-subscriptions and maintains a complete audit trail. #### Security Through Cryptographic Tokens Instead of using predictable URLs or simple hashes, every token is generated using the Web Crypto API: ```javascript // Generate 32-byte cryptographically secure token const tokenArray = new Uint8Array(32); globalThis.crypto.getRandomValues(tokenArray); const confirmationToken = Array.from(tokenArray, byte => byte.toString(16).padStart(2, '0') ).join(''); ``` This means confirmation and unsubscribe URLs are impossible to guess or brute force. Each token is effectively a 256-bit secret that only the recipient knows. #### Smart Error Handling & Dual Interface Support One thing I'm particularly proud of is how the API endpoints handle both redirect-based flows (traditional email clicks) and JSON responses (for dynamic UI updates). The system detects whether a request is internal by checking headers: ```javascript const isInternalCall = request.headers.get('origin')?.includes(site?.origin || '') || request.headers.get('referer')?.includes('/newsletter/result'); ``` If it's an internal call from my result page, it returns JSON. If it's a direct email click, it redirects to the appropriate result page. This lets me build a seamless single-page experience while maintaining email compatibility. #### Gmail One-Click Unsubscribe Compliance The unsubscribe endpoint handles three different flows depending on how users arrive: ```javascript // GET requests (email link clicks) - immediate unsubscription if (request.method === 'GET') { // Directly update contact status to 'unsubscribed' const updatedContact = { ...contact, status: 'unsubscribed', unsubscribedAt: new Date().toISOString(), }; await kv.put(`contact:${email}`, JSON.stringify(updatedContact)); } // POST with Gmail one-click format - immediate unsubscription if (contentType.includes('application/x-www-form-urlencoded')) { const formData = await request.formData(); if (formData.get('List-Unsubscribe') === 'One-Click') { // Direct unsubscribe, return empty 200 response for Gmail return new Response('', { status: 200 }); } } // POST with JSON (website forms) - sends confirmation email first if (contact.unsubscribeToken) { await sendUnsubscribeConfirmationEmail(/* ... */); return new Response( JSON.stringify({ message: 'Please check your email to confirm unsubscription.', }) ); } ``` When users click unsubscribe links in emails, they're immediately unsubscribed. Only form submissions from the website trigger confirmation emails. Gmail's automated system gets the immediate response it requires. ### Email Templates with React Email I used `react-email` for maintainable, responsive templates, but building email templates in 2025 still feels like optimizing a web page for Internet Explorer. #### From Design to Code: The Workflow I started by designing the newsletters in Figma, then exported the HTML using this [plugin](https://markaplugin.com/), and converted it to a react-email template with Cursor & Claude. This design-first approach proved surprisingly effective (especially while vibe-coding with Cursor) having a clear visual reference made the template conversion process much more straightforward. #### The Email Styling Gotchas Modern web development spoils you. Flexbox, CSS Grid, SVGs, modern fonts - none of that works reliably in emails. **Tables for layout:** Every major structural element uses tables for positioning. Even in 2025, this is the only way to ensure consistent rendering across email clients. ```tsx
{newsletterTitle} #{newsletterNumber}
``` **SVGs to PNGs:** Every icon, logo, and graphic had to be converted to PNG and hosted statically. I put everything in Astro's `public/emails/` folder so they can be referenced with absolute URLs. **Tailwind limitations:** While react-email supports Tailwind, many utilities don't work in email contexts. I ended up writing custom CSS for mobile responsiveness: ```css @media (max-width: 520px) { .main-container { padding-left: 24px !important; padding-right: 24px !important; } .button { width: 100% !important; text-align: center !important; } } ``` Here are the three final templates I ended up with: #### Markdown in Emails I thought this would be the most complex part, but react-email has wonderful support for markdown - it's plug and play. React Email's `` component lets you define custom styles for every element: ```tsx {message} ``` This gives me the flexibility to write newsletter content in familiar markdown while ensuring it renders beautifully across email clients. Headers, lists, code blocks, blockquotes - everything just works. ## The Subscription Flow ### API Routes Three main endpoints handle the entire flow: 1. **`/api/subscribe`** - New subscriptions with validation and double opt-in 2. **`/api/confirm`** - Email confirmation processing 3. **`/api/unsubscribe`** - Unsubscription with Gmail one-click support The subscribe endpoint generates cryptographically secure tokens and stores everything in KV: ```js // Generate secure confirmation token const tokenArray = new Uint8Array(32); globalThis.crypto.getRandomValues(tokenArray); const confirmationToken = Array.from(tokenArray, byte => byte.toString(16).padStart(2, '0') ).join(''); // Store with 24-hour expiry await kv.put(`token:${confirmationToken}`, email, { expirationTtl: 86400, }); ``` ### Smart Result Page Instead of multiple pages for different states, I built one dynamic result page that handles all newsletter status messages. It reads URL parameters and updates content client-side: ```js const messages = { confirmed: { title: 'Subscription Confirmed! 🎉', isSuccess: true, }, 'token-expired': { title: 'Link Expired ⏰', isSuccess: false, }, // ... other states }; ``` The page shows appropriate actions based on the message type - "Check Gmail" for pending confirmations, "Read Latest Posts" for successful actions. ## MDX-Based Newsletter Management The real magic happens in the content system. Each newsletter is a simple MDX file with frontmatter: ```mdx --- title: 'Weekly Roundup' issueNumber: 7 date: '2025-01-14' recipientName: 'friend' draft: false blogPosts: - title: 'Self-hosting done right' url: '/blog/self-hosting-ansible-docker' image: '/posts/self-hosting.png' --- Happy Tuesday! This week I've been deep in infrastructure automation... ``` Astro's content collections handle the schema validation and static page generation. Each newsletter becomes a proper webpage at `/newsletter/issue-1` for SEO. ## Automated Scheduling The scheduling script was the final piece - a comprehensive automation system that runs locally as part of deployment. It connects to Cloudflare KV via REST API, scans for new newsletter issues, and orchestrates the entire scheduling process. ### The Script Architecture The scheduler is built around several key components: **1. Local KV Integration:** Instead of requiring server deployment to access KV, I built a REST API client that connects directly to Cloudflare's KV API from my local environment: ```typescript class LocalKVStore implements KVNamespace { private baseUrl: string; constructor(accountId: string, namespaceId: string, apiToken: string) { this.baseUrl = `https://api.cloudflare.com/client/v4/accounts/${accountId}/storage/kv/namespaces/${namespaceId}`; } async get(key: string): Promise { const response = await fetch(`${this.baseUrl}/values/${key}`, { headers: { Authorization: `Bearer ${this.apiToken}` }, }); return response.status === 404 ? null : await response.text(); } } ``` **2. MDX File Parsing:** The script scans `src/data/newsletter/` for MDX files, parses frontmatter, and finds the latest non-draft issue: ```typescript function getLatestNewsletterIssue(): ParsedNewsletter | null { const files = fs .readdirSync(newsletterDir) .filter(file => file.endsWith('.mdx') && file.startsWith('issue-')) .sort(); // Skip drafts and find highest issue number for (const file of files) { const { frontmatter } = parseFrontmatter(content); if (frontmatter.draft === true) continue; // Return latest by issue number } } ``` **3. Smart Scheduling Logic:** Calculates the next Thursday at 10am PDT, accounting for timezone conversion and edge cases. Note that this implementation is simplified for this article; a more robust solution would use a library like `date-fns-tz` to handle daylight saving changes automatically. ```typescript function getNextThursdayPDT(): Date { const nowPDT = new Date( new Date().toLocaleString('en-US', { timeZone: 'America/Los_Angeles', }) ); const currentDay = nowPDT.getDay(); const currentHour = nowPDT.getHours(); let daysUntilThursday: number; if (currentDay < 4) { daysUntilThursday = 4 - currentDay; } else if (currentDay === 4 && currentHour < 10) { daysUntilThursday = 0; // Today, but before 10am } else { daysUntilThursday = 7 - currentDay + 4; // Next week } // Convert back to UTC for Resend return new Date(nextThursday.getTime() + 7 * 60 * 60 * 1000); } ``` ### Individual Email Scheduling One of Resend's limitations is that batch emails cannot be scheduled - only individual emails can use the `scheduledAt` parameter. The script handles this by creating individual scheduling requests: ```typescript const emailPromises = subscribers.map(async email => { const unsubscribeUrl = await getUnsubscribeUrl(email, kv, baseUrl); return await resend.emails.send({ from: 'Sarthak Mishra ', to: [email], subject: `${newsletter.title} #${newsletter.issueNumber.toString().padStart(3, '0')}`, scheduledAt: scheduledDate.toISOString(), headers: { 'List-Unsubscribe': `<${unsubscribeUrl}>`, 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', 'X-Entity-Ref-ID': `newsletter-${newsletter.issueNumber}-${Date.now()}`, }, react: Newsletter({ /* ... */ }), }); }); await Promise.all(emailPromises); ``` ### Duplicate Prevention & Status Tracking The script maintains comprehensive state in KV to prevent duplicate sends: ```typescript // Check status before scheduling const status = await checkNewsletterStatus(issueNumber, kv); if (status.sent || status.scheduled) { console.log('Newsletter already processed'); return; } // Mark as scheduled after successful scheduling await kv.put( `newsletter:scheduled:${issueNumber}`, JSON.stringify({ scheduledAt: new Date().toISOString(), scheduledFor: scheduledDate.toISOString(), }) ); ``` ### Deployment Integration The script runs as part of my deployment pipeline. After pushing a new newsletter MDX file, the deployment process automatically: 1. Builds the site with the new newsletter archive page 2. Runs the scheduling script to check for new issues 3. Schedules emails if a new non-draft issue is found 4. Deploys everything to Cloudflare Pages This means I can write a newsletter in MDX, commit it, and the entire scheduling happens automatically. The script validates everything: Resend's 30-day scheduling limit, subscriber status, duplicate prevention, and proper unsubscribe token generation. ## The Result I now have a newsletter system that: - Costs $0 (Cloudflare Workers & KV + Resend - Free tiers) - Handles subscription, confirmation, and unsubscribe flows automatically - Creates SEO-friendly archive pages for each issue - Schedules newsletters automatically on deployment Writing a newsletter feels exactly like writing a blog post. I can focus on content instead of fighting with tools. P.S. This is my first [newsletter](/newsletter/issue-1). --- --- title: Setup your Ubuntu Server in Under 10 Minutes description: Skip the manual server setup with a three-stage Ansible approach that gets you from fresh Ubuntu install to production-ready in under 10 minutes. type: blog url: https://sarthakmishra.com/blog/setup-ubuntu-server-in-10-minutes date: 2025-07-29 tags: ['ubuntu', 'server-setup', 'ansible', 'automation', 'devops', 'linux', 'infrastructure'] author: Sarthak Mishra excerpt: Most Ubuntu server guides take 45+ minutes and leave you with an insecure setup. Here's my three-stage automation that consistently delivers production-ready servers in under 10 minutes - including all the security hardening everyone forgets. --- # Setup your Ubuntu Server in Under 10 Minutes I've deployed dozens of Ubuntu servers over the last couple of years, and I can tell you this: most "quick setup" guides aren't actually quick when you factor in all the security hardening, service configuration, and inevitable do-overs when something breaks. After countless hours of trial and error, I've distilled my server setup process into three automated stages that consistently get a [production-ready Ubuntu server](/blog/setting-up-traefik-from-local-to-production) running in under 10 minutes. > This is a follow-up to my [previous article](/blog/self-hosting-done-right) on self-hosting with Ansible and Docker, focusing specifically on the initial server preparation that everyone seems to gloss over. ## The Reality Check: Why Most Quick Setup Guides Fail Most Ubuntu server tutorials follow the same pattern: install the OS, run a few commands, maybe configure SSH, and call it done. But in practice, you're missing crucial pieces: - **No proper user management** - still logging in as root or ubuntu user - **Incomplete security hardening** - SSH is wide open, no firewall rules - **Manual Docker installation** - copy-pasting curl commands from the internet - **No network isolation** - everything exposed to the world - **Zero automation** - when something breaks, you start from scratch The tutorials that do cover these aspects take 45+ minutes and require you to stay glued to your terminal. ## My Three-Stage Approach Instead of one massive script that tries to do everything, I break server setup into three distinct phases: 1. **Stage 1: Bootstrap** (2 minutes) - Secure SSH access and admin user creation 2. **Stage 2: System Hardening** (5 minutes) - Security, networking, and Docker installation 3. **Stage 3: Production Ready** (3 minutes) - Service deployment and monitoring Each stage builds on the previous one, with clear validation points and the ability to resume from any stage if something goes wrong. ## Stage 1: Bootstrap - Getting Secure Access (2 minutes) The bootstrap stage solves the chicken-and-egg problem of server access. You start with a fresh Ubuntu server accessible via password authentication, and end up with proper SSH key access and a dedicated admin user. Here's what happens behind the scenes: ```yaml - name: Create admin user for final access ansible.builtin.user: name: '{{ admin_username }}' shell: /bin/bash groups: sudo append: true state: present - name: Configure passwordless sudo for admin user ansible.builtin.lineinfile: path: /etc/sudoers.d/{{ admin_username }} line: '{{ admin_username }} ALL=(ALL) NOPASSWD: ALL' create: true validate: 'visudo -cf %s' mode: '0440' - name: Install SSH public key for admin user ansible.builtin.authorized_key: user: '{{ admin_username }}' key: '{{ admin_ssh_public_key }}' state: present - name: Disable SSH password authentication ansible.builtin.lineinfile: path: /etc/ssh/sshd_config regexp: '{{ item.regexp }}' line: '{{ item.line }}' validate: 'sshd -t -f %s' loop: - { regexp: '^#?PasswordAuthentication', line: 'PasswordAuthentication no' } - { regexp: '^#?PubkeyAuthentication', line: 'PubkeyAuthentication yes' } notify: restart sshd ``` **Critical insight**: Most people make the mistake of disabling password authentication before testing SSH key access. My bootstrap stage keeps both users accessible via SSH keys initially, giving you a safety net. Run this stage with: ```bash ansible-playbook -i inventory/stage1-bootstrap.yml playbooks/bootstrap.yml ``` After 2 minutes, you have an admin user with SSH keys and password authentication disabled - but you can still access the server if something goes wrong. ## Stage 2: System Hardening - Production Security (5 minutes) This is where most DIY approaches fall apart. You need to coordinate multiple security changes without locking yourself out. My second stage handles: - **SSH hardening** with proper timeout and connection limits - **UFW firewall** configuration with Docker integration - **Tailscale VPN setup** for secure remote access - **Docker installation** with security configurations The key here is the [UFW-Docker](https://github.com/chaifeng/ufw-docker) integration. Standard Ubuntu setups have a massive security hole: Docker bypasses UFW rules entirely, potentially exposing container ports to the internet. ```yaml - name: Add UFW-Docker security rules ansible.builtin.blockinfile: path: /etc/ufw/after.rules block: | # BEGIN UFW AND DOCKER *filter :DOCKER-USER - [0:0] -A DOCKER-USER -j ufw-user-forward -A DOCKER-USER -j RETURN -s 10.0.0.0/8 -A DOCKER-USER -j RETURN -s 172.16.0.0/12 -A DOCKER-USER -j RETURN -s 192.168.0.0/16 # Block external access to Docker containers -A DOCKER-USER -j DROP ``` **Tailscale installation is also automated**, giving you secure VPN access even if the direct IP becomes unreachable: ```yaml - name: Install Tailscale ansible.builtin.apt: name: tailscale state: latest - name: Authentication with Tailscale ansible.builtin.command: cmd: 'tailscale up --authkey={{ tailscale_auth_key }} --hostname={{ server_hostname }} --accept-risk=lose-ssh' - name: Reboot the server to apply updates and hardening ansible.builtin.reboot: ``` Run this stage with: ```bash ansible-playbook -i inventory/stage2-hardened.yml playbooks/system-setup.yml ``` After this stage completes, the server reboots automatically. You can then access it via either its direct IP or Tailscale hostname. ## Stage 3: Production Ready - Service Deployment (3 minutes) The final stage focuses on getting your server ready for actual workloads: - **Storage configuration** (if using multiple drives) - **Service deployment** with Docker Compose - **Monitoring setup** with proper log aggregation - **Backup system configuration** For most self-hosted setups, this means launching your core services like Portainer, Traefik, monitoring tools, etc. ```bash ansible-playbook -i inventory/stage3-production.yml playbooks/services.yml ``` ## The Gotchas I Learned the Hard Way **1. Docker's UFW Bypass** Docker creates its own iptables rules that completely bypass UFW. Without proper configuration, your containers might be exposed to the internet even with UFW enabled. The fix requires modifying `/etc/ufw/after.rules` to force Docker traffic through UFW's chain. **2. SSH Configuration Testing** Always test SSH configuration syntax with `sshd -t` before restarting the service. I've locked myself out of servers because of typos in `sshd_config`. The playbook snippet in Stage 1 includes a `validate` step to prevent this. **3. Tailscale Authentication** The `--authkey` is essential for non-interactive setup, and `--accept-risk=lose-ssh` is crucial when setting up Tailscale on a remote server. Without them, the command waits for an interactive confirmation that never comes. The Stage 2 playbook example now includes both. **4. UFW Default Policies** Set UFW policies before enabling rules. The order matters: ```bash ufw default deny incoming ufw default allow outgoing ufw enable ``` **5. Admin User Sudo** Don't forget to add your admin user to the `sudo` group AND configure passwordless sudo. Otherwise, automation breaks on privilege escalation. The Stage 1 playbook example now handles this for you. ## Performance Insights **Total time breakdown:** - Stage 1 (Bootstrap): ~2 minutes - Stage 2 (Hardening): ~5 minutes - Automated reboot: ~1 minute - Stage 3 (Services): ~3 minutes **Network transfer:** ~200MB for all packages (Docker, Tailscale, system updates) **What takes the longest:** Docker installation and system package updates. Using a local apt cache or mirror can cut this in half. ## Why This Approach Works **1. Idempotent Operations** Every Ansible task can be run multiple times safely. If something fails, just re-run that stage. **2. Clear Validation Points** Each stage ends with connectivity verification. You know immediately if something went wrong. **3. Rollback Safety** SSH configuration changes include syntax validation and backup creation. Firewall rules are reset to known-good states. **4. Scalable** Need to set up 10 servers? Change the inventory file and run the same playbooks. ## Common Alternatives and Why They Fall Short **Cloud-init scripts**: These run once during instance creation. If they fail, you're rebuilding from scratch. **Docker containers for the OS**: Adds complexity and doesn't address the underlying security hardening needs. **Manual setup scripts**: Work once, break when Ubuntu versions change or packages get updated. **Configuration management alternatives (Puppet, Chef)**: Overkill for initial server setup, primarily designed for ongoing configuration drift management. ## Getting Started The complete automation is available in my [self-hosted repository](https://github.com/SarthakMishra/self-hosted). To adapt it for your environment: 1. **Fork and customize the inventory files** with your server IPs and user details 2. **Update group_vars/all.yml** with your domain and network settings 3. **Create group_vars/vault.yml** with your SSH keys and Tailscale auth key 4. **Run the three stages** in order The key is understanding that automation isn't just about speed - it's about repeatability and confidence. When your server setup is fully automated, you can experiment freely, knowing you can always get back to a known-good state in under 10 minutes. --- In my next post, I'll cover the service deployment patterns I use to get applications running reliably on this hardened foundation, including proper secrets management, backup automation, and zero-downtime updates. > Have questions about the setup process or want to share your own automation approaches? - Let me know on [Twitter](https://twitter.com/MishraMishry). --- --- title: Self-hosting done right with Ansible & Docker description: Learning infrastructure automation the hard way - from multi-node cluster-f*ck to a production-ready, single-node setup with Ansible. type: blog url: https://sarthakmishra.com/blog/self-hosting-done-right date: 2025-07-23 tags: ['self-hosting', 'infrastructure', 'ansible', 'devops', 'learning'] author: Sarthak Mishra excerpt: I thought a multi-node Docker Swarm cluster was the answer to my self-hosting dreams. I was wrong. Here's how I learned Infrastructure as Code by making every mistake possible. --- # Self-hosting done right with Ansible & Docker Like most developers, I've spent years using cloud services. AWS, DigitalOcean, Vercel—they're convenient, predictable, and someone else's problem when things break. But there's an undeniable appeal to owning your infrastructure. Full control, no vendor lock-in, and the satisfaction of building something from the ground up. So, naturally, I decided to [build my own self-hosted infrastructure](/blog/setup-ubuntu-server-in-10-minutes). How hard could it be? Spoiler alert: **Very f\*cking hard.** > TL;DR: the complete, up-to-date configuration for my entire setup is available in this [GitHub repo](https://github.com/SarthakMishra/self-hosted). I'll be updating it continuously as I learn and discover new things about self-hosting. ## Some Context This wasn't my first attempt at self-hosting. I've deployed [Coolify](https://coolify.io) on DigitalOcean for quick app deployments and set up [Mailcow](https://mailcow.email) on Hetzner for email campaigns. I was already running most of the services I'll mention in my local Docker setup. But Docker on Windows/WSL is a pain. The networking quirks, file system performance issues, and random crashes pushed me to find a more robust solution. The dream was simple: **reliable, automated infrastructure that I could scale and maintain with ease.** ## Mistake #1: Over-Engineering with Kubernetes and Docker Swarm Like any developer who's read too many articles about microservices, I started with what seemed like the "right" solution: [K3s](https://k3s.io), a lightweight Kubernetes distribution. The installation was deceptively simple: ```bash curl -sfL https://get.k3s.io | sh - ``` Within minutes, I had a single-node cluster. But when things went wrong, I was lost. With zero `kubectl` experience, debugging was a nightmare of `get pods`, `describe pod`, and `logs`. The vast Kubernetes ecosystem assumes a foundational knowledge I simply didn't have. I was drowning in complexity before I even deployed a single application. So, I pivoted to what I thought would be simpler: a multi-node Docker Swarm cluster. High availability! Load balancing! Scalability! All the buzzwords that get infrastructure engineers excited. ```bash # My second ambitious (and flawed) setup docker swarm init --advertise-addr 192.168.1.100 docker swarm join --token SWMTKN-xxx 192.168.1.100:2377 ``` I had grand plans for Swarm, too: - 3 manager nodes for high availability - Multiple worker nodes for redundancy - Traefik for load balancing - Shared storage with NFS or GlusterFS What I got was another lesson in complexity. High availability is great in theory, but a nightmare to implement for stateful services like databases without a cloud provider's help. Multi-node networking is notoriously unreliable, and don't even get me started on storage. **Key Realization #1:** Powerful tools don't help if you don't understand the fundamentals. Most self-hosted setups don't need the complexity of Kubernetes or Swarm. A single, powerful node is often more than enough. ## The Great Simplification After weeks of battling my over-engineered setups, I embraced simplicity. While horizontal scaling is the cloud standard, you can't beat the simplicity of vertical scaling for self-hosting. Most providers now offer one-click scaling with minimal downtime. My new philosophy: - **Single-node deployment:** One powerful server is easier to manage. - **Essential services only:** No experimental features. - **Docker Compose:** Simple, declarative service management. - **UFW & Tailscale:** Basic firewalling and secure remote access (port 22 blocked). The difference was night and day. Instead of debugging cluster coordination, I could focus on making my services work. Docker networking with UFW still has its quirks, but the community has provided excellent solutions like [ufw-docker](https://github.com/chaifeng/ufw-docker), which I use to manage firewall rules for my containers. You set up Traefik, open ports 80 and 443, configure Let's Encrypt, and deploy your services with Docker Compose. For added security, you can use [CrowdSec](https://crowdsec.net) for intrusion prevention or proxy traffic through Cloudflare and create UFW rules to only allow Cloudflare IPs. Now, the challenge shifted to automating the initial setup. Manually hardening Ubuntu, installing Docker, and configuring firewalls is tedious and error-prone. ## Enter Infrastructure as Code This is where I discovered **Ansible**. Instead of documenting setup steps in Markdown files that quickly go stale, I could codify my entire server configuration. ```yaml # A snippet from my Ansible playbook for basic server hardening - name: Update apt cache and upgrade packages apt: update_cache: yes upgrade: full - name: Install essential packages apt: name: ['curl', 'git', 'htop', 'ufw', 'fail2ban'] state: present - name: Configure UFW default policies ufw: direction: '{{ item.direction }}' policy: '{{ item.policy }}' loop: - { direction: 'incoming', policy: 'deny' } - { direction: 'outgoing', policy: 'allow' } ``` The beauty of Ansible is its **idempotency**—I can run the same playbook multiple times without breaking anything. It's perfect for those "did I remember to install Docker?" moments. ### Practical Example: Solving the Docker + UFW Puzzle Docker's tendency to bypass UFW rules is a well-known issue that can unintentionally expose your services. Here's how I solved it using the `ufw-docker` script: ```bash # 1. Install the ufw-docker script sudo wget -O /usr/local/bin/ufw-docker \ https://github.com/chaifeng/ufw-docker/raw/master/ufw-docker sudo chmod +x /usr/local/bin/ufw-docker # 2. Let the script manage Docker's firewall rules ufw-docker install # 3. Now, you can manage container access with standard UFW rules ufw route allow proto tcp from any to any port 80 ufw route allow proto tcp from any to any port 443 ``` ### Traefik Configuration Example Here's my go-to reverse proxy setup with automatic SSL: ```yaml # docker-compose.yml for Traefik version: '3.8' services: traefik: image: traefik:v3.1 container_name: traefik restart: unless-stopped command: - '--api.dashboard=true' - '--providers.docker=true' - '--providers.docker.exposedbydefault=false' - '--entrypoints.web.address=:80' - '--entrypoints.websecure.address=:443' - '--certificatesresolvers.letsencrypt.acme.httpchallenge=true' - '--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web' - '--certificatesresolvers.letsencrypt.acme.email=your-email@example.com' - '--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json' ports: - '80:80' - '443:443' volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - ./letsencrypt:/letsencrypt labels: - 'traefik.enable=true' - 'traefik.http.routers.dashboard.rule=Host(`traefik.yourdomain.com`)' - 'traefik.http.routers.dashboard.entrypoints=websecure' - 'traefik.http.routers.dashboard.tls.certresolver=letsencrypt' - 'traefik.http.middlewares.dashboard_auth.basicauth.users=user:$$apr1$$...$$' # Add Basic Auth for security - 'traefik.http.routers.dashboard.middlewares=dashboard_auth' ``` ### My Ansible Project Structure My current Ansible setup is organized into two main parts: one for my remote server and one for my home server. The remote server configuration, which handles public-facing services, follows this structure: Here's a simplified example from my `docker_installation` role: ```yaml # tasks/main.yml in roles/docker_installation --- - name: Install Docker dependencies become: true ansible.builtin.apt: name: - apt-transport-https - ca-certificates - curl - gnupg - lsb-release state: present update_cache: true - name: Add Docker GPG key become: true ansible.builtin.get_url: url: https://download.docker.com/linux/ubuntu/gpg dest: /etc/apt/trusted.gpg.d/docker.asc mode: '0644' force: true - name: Add Docker repository become: true ansible.builtin.apt_repository: repo: 'deb [arch=amd64 signed-by=/etc/apt/trusted.gpg.d/docker.asc] https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable' state: present - name: Install Docker Engine become: true ansible.builtin.apt: name: - docker-ce - docker-ce-cli - containerd.io - docker-buildx-plugin - docker-compose-plugin state: present - name: Ensure Docker service is started and enabled become: true ansible.builtin.systemd: name: docker state: started enabled: true ``` ## My Current Production Setup My infrastructure currently consists of a bare-metal server from **OVHcloud** and an old desktop running Ubuntu Server at home. The **remote server** handles public services: - **Traefik:** Reverse proxy and automatic SSL. - **CrowdSec:** Intrusion prevention. - **Netdata:** System monitoring. - **Tailscale:** Secure internal networking. - **Umami:** Web analytics. - A few other services I'll cover in future posts. The **home server** runs internal services: - **Cloudflared:** Exposes select services to the internet securely. - **TrueNAS:** Storage and backups. - **AdGuard Home:** Network-wide ad blocking. - **Vaultwarden:** Password and secret management. - **Home Assistant:** Home automation. - **Immich:** Photo management. - **Frigate:** Home security NVR. - **Plex:** Media streaming. - **n8n:** Business and workflow automation. All of this is managed with Ansible playbooks that can: - Provision a new server from scratch in under 10 minutes. - Apply security hardening automatically. - Deploy applications with consistent configurations. - Handle secrets securely. ## What's Next This is just the beginning of my self-hosting journey. In upcoming posts, I'll dive deeper into: - ✅ [How I setup my remote server in under 10 minutes](/blog/setup-ubuntu-server-in-10-minutes). - Why I chose Cloudflare Tunnels over Dynamic DNS. - The benefits of self-hosting your web analytics instead of using Google Analytics. - Setting up a local DNS server with custom certificates for `.home` domains. - Configuring TrueNAS and Docker contexts for seamless deployments. - Building the complete "arr-stack": Sonarr, Radarr, Prowlarr, qBittorrent, and Overseerr. --- The key lesson? **Start simple, automate early, and scale only when you absolutely need it.** Infrastructure as Code isn't just for large enterprises. For personal projects, the ability to recreate your entire setup with a single command is a superpower. > What's your experience with self-hosting? Are you facing similar challenges? Let me know on [Twitter](https://twitter.com/MishraMishry)—I'd love to hear your stories and solutions. ``` ``` --- --- title: The Greater Fool: Why I Chose the Hard Path description: On embracing risk, learning from failure, and why I believe the unconventional path leads to the most meaningful growth. type: blog url: https://sarthakmishra.com/blog/the-greater-fool date: 2025-07-15 tags: ['entrepreneurship', 'personal-growth', 'risk-taking', 'mindset'] author: Sarthak Mishra excerpt: There's a quote from HBO's The Newsroom about the greater fool - someone with the perfect blend of self-delusion and ego to think they can succeed where others have failed. I've never been afraid to be that person. --- # The Greater Fool: Why I Chose the Hard Path > Most people spend their lives trying not to be the greater fool; we toss him > the hot potato, we dive for his seat when the music stops. The greater fool is > someone with the perfect blend of self-delusion and ego to think that he can > succeed where others have failed. This whole country was made by greater > fools. > > — The Newsroom S01E10 ## The Economics of Risk Being a greater fool isn't about blind optimism or reckless abandon. It's about understanding that meaningful innovation requires someone willing to challenge conventional wisdom. When I started [Weaveu](/weaveu) - my first real attempt at building something from scratch - most people thought creating a fashion marketplace that connected consumers with boutique tailors was overly complex. They were definitely right, but someone had to try. The venture never made it past the ideation phase. The technical challenges were beyond our resources, and the market timing wasn't right. But that "failure" taught me more about feasibility, market research, and the importance of technical validation than any business school ever could. ## Learning Through Iteration Each venture has been a masterclass in being comfortable with uncertainty. [Altpack](/altpack) taught me that physical products require different skills than digital ones - and that sometimes knowing what you _don't_ want to do is as valuable as knowing what you do. [Drool](/drool) showed me that sometimes scaling down is the best way to scale up what truly matters. When I attempted [Learnway](/learnway) and aimed for Y Combinator's first Fall batch, I knew the odds were against us. Building an AI-powered learning platform in two months while learning full-stack development? Classic greater fool territory. We didn't get into YC, but that project transformed Drool from a design-only agency into a full-stack development partner. ## The Philosophy in Practice The greater fool mindset isn't just about taking big swings - it's about how you approach everyday decisions. When clients at Drool need something we've never built before, like the 3D interactive elements for [SellScale](/work/sellscale) or the AI-powered personalization for [My Drip](/work/mydrip), the logical response would be to refer them elsewhere. Instead, we figure it out. This approach led me to teach myself code, dive deep into AI development with [Codemap](/codemap), and now work on [Winitiate](/winitiate) - a unified platform for freelancers and agencies that might just be ambitious enough to work. ## The Compound Effect of Foolishness Here's what I've learned: being the greater fool compounds. Each "failed" experiment builds capabilities that enable the next one. The T-shirt business taught me about customer acquisition. The design agency taught me about service delivery. The failed YC application taught me about AI development. The AI development taught me about building developer tools. What looks like a series of disconnected ventures is actually a connected learning journey. Each failure makes the next attempt more informed, more capable, and ironically, less foolish. ## Why We Need More Fools In a world optimized for reducing risk and maximizing certainty, we need people willing to be the greater fool. Not because failure is fun - it's not - but because someone has to test the boundaries of what's possible. If you're reading this and you've ever felt out of place for choosing the unconventional path, for failing more times than you can count, or for believing in possibilities others overlook - you're in good company. Here's to the greater fools. --- ## Portfolio Projects --- title: UX-Folio - Design Explorations & Experiments description: A collection of interesting design work, hackathon projects, and experimental interfaces created during my time at Drool agency. type: works url: https://sarthakmishra.com/work/ux-folio date: 2024-11-15 tags: ['ui-ux', 'design-system', 'mobile-app', 'web-design', 'experiments'] featured: false --- # UX-Folio - Design Explorations & Experiments During my time building and running [Drool](/drool) agency, we had the opportunity to work on numerous experimental designs, hackathon projects, and [creative explorations](/work/logo-folio) that pushed the boundaries of user experience. These projects represent the intersection of innovation and practical design thinking - some were part of rapid prototyping sessions, others were discarded iterations that didn't make it to final products, and many were creative exercises that helped refine our design philosophy. This collection showcases the diverse range of work that comes from running a design agency focused on AI, B2B, and SaaS products. Each piece tells a story of problem-solving, creative exploration, and the iterative design process that drives meaningful user experiences. You can find more of these design explorations and experiments on our [Dribbble profile](https://dribbble.com/trydrool). --- --- title: My Drip - AI-Powered Personalized Shopping Experience description: An innovative MVP that combines generative AI with personalized eCommerce, delivering daily outfit recommendations with realistic photos of users wearing them. type: works url: https://sarthakmishra.com/work/mydrip date: 2024-09-15 tags: ['web-app', 'ai', 'ecommerce', 'mvp', 'personalization'] featured: true --- # My Drip - AI-Powered Personalized Shopping Experience When [ImageineMe](https://imagineme.ai/) approached us at [Drool](/drool), they had a compelling challenge: how to pivot their AI image generation technology into a practical eCommerce application. My Drip became the solution - an innovative platform that uses generative AI to personalize shopping experiences by showing users realistic photos of themselves wearing recommended outfits. ## The Challenge ImageineMe was facing the reality of competing in the crowded image generation industry without a clear, practical use case. They needed to pivot their business model and make their generative AI technology more commercially viable by integrating it into personalized eCommerce. The goal was ambitious yet focused: create an MVP web app that could demonstrate the feasibility of AI-powered personalized shopping before transitioning to mobile. The timeline was tight - just one month to deliver a complete solution that would validate their concept and provide insights for future development. ## Rapid Brand Development Given the tight timeline, I managed the design operations with assistance from a junior designer. The first week was dedicated to rapid brand development - we needed to create a cohesive visual identity that could support the unique value proposition of AI-powered personal styling. The branding exploration was intentionally quick and focused. We needed something that felt both tech-forward and fashion-conscious, appealing to users who would trust AI with their personal style decisions. ## Design Strategy & User Flow The core challenge was designing for both web and mobile platforms simultaneously, knowing that the user experience would be vastly different between devices. I needed to create a design foundation that could work as a web MVP while anticipating the eventual mobile transition. The user journey had to be intuitive yet sophisticated - users would upload photos, receive AI-generated outfit recommendations, see themselves wearing the clothes, and validate choices with friends before purchasing. Each step needed to feel seamless while handling complex AI processing in the background. ## Web App Implementation The web app needed to serve as both a functional MVP and a proof of concept for the mobile experience. I focused on creating an interface that could demonstrate the [AI capabilities](/work/phidata) while maintaining simplicity and usability. The onboarding process was crucial - we needed to collect enough information to power the AI recommendations while keeping the experience engaging and not overwhelming for users. ## Mobile Considerations While the primary deliverable was a web MVP, I designed with mobile-first thinking to ensure the eventual transition would be smooth. The interface patterns and information architecture were built to adapt to mobile constraints. ## Results & Impact Despite the compressed timeline and platform challenges, we delivered a functional MVP that successfully demonstrated the feasibility of AI-powered personalized shopping. The web app provided ImageineMe with valuable insights into user behavior and technical requirements for their vision. The project proved that generative AI could be practically applied to eCommerce, creating a unique value proposition that differentiated them from generic image generation tools. More importantly, it laid the groundwork for their mobile app development and validated the market potential for AI-driven personal styling. > "This project demonstrated how quickly a focused team can validate complex AI concepts when design thinking guides technical implementation." Working on My Drip reinforced the importance of designing for rapid validation while maintaining long-term scalability - sometimes the best MVPs are the ones that prove a concept without trying to solve every problem at once. --- --- title: Phidata - AI Assistant Platform Landing Page description: A comprehensive landing page redesign for an innovative AI assistant platform, focusing on clear communication and conversion optimization. type: works url: https://sarthakmishra.com/work/phidata date: 2024-04-20 tags: ['landing-page', 'ai', 'conversion-optimization', 'web-design', 'lottie'] featured: true --- # Phidata - AI Assistant Platform Landing Page When Phidata (now [AgnoAGI](http://agno.com/)) approached [Drool](/drool), they had an innovative AI Dev-kit but needed a compelling way to communicate their value proposition to potential users. The challenge was creating a landing page that could effectively showcase their complex AI capabilities while maintaining clarity and driving conversions. ## The Challenge Phidata's approach was simple yet groundbreaking, but their existing presence didn't reflect the sophistication of their AI assistant platform. They needed a landing page that would be visually appealing, easy to navigate, and optimized for conversions while clearly demonstrating the benefits and capabilities of their technology. The timeline was tight, but I saw this as an opportunity to create something that would truly set them apart in the crowded AI space. ## The Design Process We began this project with comprehensive research into Phidata's brand, target audience, and unique value proposition. Understanding their innovative edge was crucial for creating a landing page that would resonate with potential users and effectively communicate their technology's capabilities. ### Strategic Approach Our process followed a structured methodology: 1. **Initial Discovery and Planning** - Understanding the brand and technical requirements 2. **Wireframing and Low-Fidelity Prototypes** - Exploring multiple layout directions 3. **High-Fidelity Prototype Development** - Creating the first visual iteration 4. **Iterative Refinement** - Incorporating feedback and optimizing user experience 5. **Webflow Development** - Bringing the design to life with interactive elements 6. **Launch & Monitoring** - Ongoing optimization based on user behavior The key was balancing visual appeal with functional clarity - making complex AI concepts accessible without oversimplifying the technology. ## Iterative Design Evolution Throughout the design process, we explored multiple visual iterations to find the perfect balance. Each iteration involved experimenting with different color schemes, typography, iconography, and imagery to enhance visual appeal while aligning with Phidata's branding. ### Key Refinements After the first iteration, we focused on several critical improvements: - **Enhanced Information Flow** - Simplified navigation to make it more intuitive and user-friendly - **Content Restructuring** - Reorganized sections for better accessibility and understanding - **Visual Cohesion** - Refined color scheme, typography, and imagery for a more engaging experience - **Interactive Elements** - Integrated Lottie animations to add dynamic aspects while maintaining design consistency The most crucial section we designed was the Information Flow area, intended to showcase Phidata's features and efficiency in the simplest way possible. We structured this to highlight key functionalities through clear, concise descriptions and engaging visuals. ## Responsive Design Excellence One of the most important aspects was ensuring the experience worked seamlessly across all devices. We meticulously crafted both web and mobile versions of the landing page, along with product pages and blog pages, ensuring consistency and usability throughout. The responsive design wasn't just about making things fit different screen sizes - it was about optimizing the information hierarchy and user flow for each device type. Mobile users needed quick access to key information, while desktop users could engage with more detailed content and interactive elements. ## Technical Innovation Working with Phidata pushed me to explore new technologies and design approaches. Learning and implementing optimized Lottie animations for interactive elements was particularly rewarding - it allowed me to create dynamic experiences that matched the innovative nature of their [AI technology](/work/mydrip). ### Key Technical Achievements - **[Conversion-Optimized Layout](/work/sellscale)** - Every element placed strategically to guide users toward action - **Performance Optimization** - Fast loading times despite rich visual content - **Accessibility Standards** - Ensuring the design was inclusive and accessible to all users - **SEO Optimization** - Structure and content optimized for search engine visibility ## Results & Impact The final landing page significantly contributed to Phidata's rapid user growth and reduced the learning time required for users to understand their product. The clear information flow and engaging visuals helped potential users quickly grasp the benefits and capabilities of their AI assistant technology. By working through multiple iterations and maintaining close collaboration with Phidata's team, we delivered a landing page that not only met but exceeded their expectations. The project demonstrated how thoughtful design can transform complex technology into an accessible and compelling user experience. > "This project taught us that the best way to present complex technology is through clear, purposeful design that respects the user's time and intelligence." Working with Phidata was a reminder that innovation in AI requires equally innovative design thinking - sometimes the most advanced technology needs the most human-centered approach to communication. --- --- title: SellScale - AI-Powered Cold Outreach Platform description: A comprehensive design overhaul of an all-in-one cold outreach platform that automates LinkedIn and email campaigns for B2B sales teams. type: works url: https://sarthakmishra.com/work/sellscale date: 2023-12-10 tags: ['web-app', 'b2b-sales', 'design-system', 'dashboard', 'ui-ux'] featured: true --- # SellScale - AI-Powered Cold Outreach Platform [SellScale](https://www.sellscale.com/) came to [Drool](/drool) with a groundbreaking vision: to solve the complexity of cold outreach by creating an all-in-one platform that automates every aspect of LinkedIn and email campaigns. What made this project special wasn't just the technical challenge, but the opportunity to help a startup revolutionize how B2B sales teams approach lead generation. ## The Challenge When we started working with SellScale, they had an MVP dashboard that wasn't living up to their ambitious product vision. The interface was functional but lacked the polish and user experience needed to compete in the competitive sales automation market. The founder, Ishan, had a clear goal: create a complete design overhaul that would make their platform both visually appealing and highly functional. They needed a scalable design system that could grow with their rapidly expanding feature set while maintaining consistency across 200+ unique screens. ## The Design Process We approached this project with a comprehensive design strategy, starting with deep user research to understand the pain points of sales teams using cold outreach tools. The process involved extensive wireframing, prototyping, and iterative testing with short feedback loops. The biggest challenge was designing over 200 unique screens while maintaining consistency and usability. We needed to create a modular design system that could easily adapt to future updates and expansions while balancing aesthetic appeal with functional efficiency. ### Key Design Decisions - **Modular Design System**: Created a scalable component library that could grow with the platform - **Intuitive Navigation**: Simplified complex workflows into logical, easy-to-follow user journeys - **Data Visualization**: Transformed complex analytics into clear, actionable insights - **Accessibility Standards**: Ensured proper color contrast, keyboard navigation, and screen reader compatibility ## Landing Page & 3D Interactive Experience Beyond the dashboard redesign, we crafted a completely new [landing page](/work/phidata) that would showcase SellScale's innovative approach to cold outreach. The team wanted to incorporate 3D interactive elements that would set them apart in the crowded sales automation market. To meet this requirement, we quickly learned Spline and created an exceptional landing page featuring interactive 3D elements. Each section was crafted to highlight SellScale's unique offerings using clear, compelling visuals paired with concise, informative content. ## Seamless User Experience We went the extra mile to ensure a comprehensive user experience by designing a special onboarding flow and integrating crucial product features like Slack and a Chrome extension. The onboarding flow was crafted to guide new users seamlessly through the setup process, helping them understand and utilize SellScale's features effectively. The integrations were designed to enhance productivity and provide users with convenient access to SellScale's tools directly within their existing workflows. ## Results & Impact The redesigned platform resulted in significantly improved user engagement and retention. By focusing on reducing cognitive load and creating intuitive workflows, we helped SellScale stand out in the competitive sales automation market. The final dashboard screens were not only visually appealing but also optimized for usability, providing a seamless and efficient experience that set SellScale apart from their competitors. This project exemplifies our commitment to innovation and client satisfaction. By stretching our limits and adopting new technologies like Spline, we ensured SellScale could stand tall in the competitive market with a cutting-edge user experience that truly captured their essence. > "Working with SellScale taught us that sometimes the best way to solve complex problems is to simplify the user experience while maintaining the power of the underlying technology." --- --- title: Blend & Brew - Community-Driven Tea Brand description: An experimental venture creating a flavoured tea brand inspired by new-age coffee culture, focused on supporting small indigenous tea growers. type: works url: https://sarthakmishra.com/work/blendnbrew date: 2023-06-15 tags: ['branding', 'product-design', 'social-impact', 'packaging', 'venture'] featured: false --- # Blend & Brew - Community-Driven Tea Brand Before [Altpack](/altpack) taught me the harsh realities of physical products, there was Blend & Brew - an ambitious experimental venture inspired by the rise of new-age coffee brands. I envisioned creating a premium tea brand with a deeper purpose: building a community around supporting small indigenous tea growers in north-east India. ## The Vision Observing how brands like Blue Tokai and Third Wave Coffee had revolutionized the coffee scene in India, I saw an opportunity to do the same for tea. Blend & Brew was designed to bridge urban consumers seeking premium experiences with rural tea growers who deserved fair compensation and recognition for their craft. ## Brand Development & Design Philosophy The brand development focused on authenticity and premium positioning, communicating quality, heritage, and social impact. The design philosophy centered around three core principles: crafted authenticity through handcrafted typography and organic visuals, transparent impact by prominently featuring grower stories, and premium accessibility that remained educational and approachable. ## Product Strategy & Community Building The product strategy centered around a subscription-based model featuring different tea regions from north-east India each month, along with grower stories and educational content. The flavoured tea range was designed to introduce modern consumers to traditional Indian tea culture while incorporating contemporary flavor profiles that enhanced rather than masked the original taste. ## Packaging & Brand Identity The packaging design was crucial to communicating the brand's values and premium positioning. I created a system that balanced modern aesthetics with traditional Indian design elements, ensuring the product would stand out on shelves while honoring its cultural roots. The unboxing experience was designed to be a ritual in itself. Each package included not just the tea, but also educational materials about the source region, brewing instructions, and postcards from the tea gardens. This approach was inspired by the coffee subscription services that had successfully built communities around their products. ## The Feasibility Challenge Despite the compelling vision and strong brand development, Blend & Brew faced significant feasibility challenges that ultimately led to its abandonment. Several critical issues emerged as I dove deeper into the operational requirements: ### Key Challenges **Supply Chain Complexity** - Building direct relationships with small tea growers across north-east India required infrastructure and expertise beyond my resources. **Regulatory Hurdles** - Meeting FSSAI requirements and obtaining necessary licenses presented bureaucratic challenges requiring significant investment. **Market Education** - Unlike coffee's premiumization journey, tea consumers remained largely price-sensitive, requiring substantial marketing investment. **Financial Requirements** - Physical products demanded upfront investment for inventory, packaging, and operations that digital services don't require. ## Lessons Learned While Blend & Brew never launched, it provided invaluable lessons for future ventures: - **Market Validation** - The importance of testing consumer demand before heavy investment in brand development - **Industry Dynamics** - Each industry has unique challenges; coffee strategies don't automatically translate to tea - **Physical vs Digital** - Physical products require different expertise in supply chains and regulatory compliance - **Strategic Partnerships** - Building meaningful relationships requires existing connections and local expertise ## Legacy Though it never launched, Blend & Brew shaped my approach to future ventures, teaching pragmatic [feasibility assessment](/weaveu) while maintaining [ambitious vision](/learnway). The brand development skills proved valuable in client work, and understanding physical product challenges informed better decisions with Altpack. > "Sometimes the most valuable ventures are the ones that never launch. [The lessons learned in failure](/blog/the-greater-fool) often prove more valuable than successes that come too easily." This experimental venture remains a reminder that meaningful innovation requires not just vision and design skills, but deep understanding of market realities and operational complexities. --- --- title: Logo-Folio - Brand Identity & Logo Design description: A collection of logo designs and brand identity projects created for clients - showcasing versatile branding solutions across industries. type: works url: https://sarthakmishra.com/work/logo-folio date: 2022-01-01 tags: ['logo-design', 'brand-identity', 'branding', 'visual-identity', 'typography'] featured: false --- # Logo-Folio - Brand Identity & Logo Design Brand identity is the foundation of every successful business, and throughout my career journey from 2018 to 2024, I've had the privilege of crafting distinctive logos and visual identities across diverse industries. Starting as a freelance graphics designer, these projects include work for various clients as well as my own personal ventures, each presenting unique challenges that required deep understanding of brand positioning, target audiences, and market differentiation. This logo-folio represents years of brand development work spanning my entire design career, showcasing how thoughtful design can communicate complex brand values through simple, memorable symbols. While I no longer offer graphics design as a service - neither independently nor through my agency - I continue to create logos from time to time for personal satisfaction. Each piece in this collection tells a story of my evolution as a designer, demonstrating versatility in style while maintaining consistency in strategic thinking and design excellence. --- ## Ventures & Companies --- title: Winitiate description: An initiative management and productivity platform for teams to align on goals and execute effectively. type: ventures url: https://sarthakmishra.com/winitiate date: 2025-02-01 tags: ['productivity', 'project-management', 'saas', 'collaboration'] featured: true status: active companyUrl: https://winitiate.com --- # Winitiate Winitiate started life as the holding company and legal entity behind [Drool](/drool), but it's now evolving into something much more ambitious - a unified platform designed specifically for freelancers and agencies like mine. This is my main focus right now, and it's still very much a work in progress. ## From Holding Company to Product Running Drool, I quickly realized how fragmented and expensive the freelancer/agency ecosystem is. We were juggling Slack for communication, Zoho Books for CRM, Stripe for billing, Notion for task management - each tool solving one piece of the puzzle while creating integration headaches and mounting subscription costs. A few months into Drool's journey, I started building an internal tool to solve this for ourselves. The idea was simple: create a unified client dashboard that could handle everything we needed in one place. ## The Unified Dashboard Vision Winitiate is designed to replace the entire stack of tools that smaller operations typically need: - **Communication Hub**: Replacing Slack with integrated client messaging - **CRM System**: Replacing Zoho Books with streamlined client management - **Billing & Invoicing**: Replacing Stripe billing with built-in payment processing - **Unified Calendar**: Replacing Notion with a comprehensive overview of all tasks, meetings, and deadlines The goal is to give freelancers and agencies a single, affordable tool that handles their entire client workflow instead of forcing them to piece together expensive solutions that don't talk to each other. ## Work in Progress This is still early days. I'm actively developing the platform based on real needs from running [Drool](/blog/drool-is-shut-down) and conversations with other freelancers and agency owners who face the same challenges. The internal version has proven valuable enough that I'm confident there's a real market for this solution. I'll be updating this page as I make progress and as the platform takes shape. For now, it represents the next chapter in my journey - taking everything I've learned about design, development, and running a service business and building a tool that could help others navigate the same challenges. Sometimes the best products come from solving your own problems first. --- --- title: Codemap description: Interactive code visualization and mapping platform that helps developers understand complex codebases. type: ventures url: https://sarthakmishra.com/codemap date: 2025-01-01 tags: ['developer-tools', 'visualization', 'saas'] featured: true status: inactive companyUrl: https://codemap.run --- # Codemap [**Codemap**](https://github.com/SarthakMishra/codemap) began as a simple internal tool to help me with git commits, but it evolved into something much more ambitious - a comprehensive AI-powered coding assistant that represents the culmination of everything I learned from [Drool](/drool), [Learnway](/learnway), and my journey into development and AI. ## From Internal Tool to Coding Assistant The story of Codemap really begins with frustration. As I was working on Learnway and diving deeper into development, I found myself constantly struggling with git commit messages. I'd make changes, stare at the terminal, and waste time crafting commit messages that captured what I actually did. So I built a small script to analyze my git changes and generate commit messages automatically. It was nothing fancy - just a personal productivity hack that saved me a few minutes each day. But then something interesting happened. As I continued working on complex codebases, I realized the same AI techniques that helped with commits could solve much bigger problems. What if I could ask questions about my code? What if I could search semantically through my repositories? What if I could generate documentation automatically? ## The Evolution of Features Codemap grew organically based on actual needs: ```bash # Smart commit messages cm commit # AI-powered pull requests cm pr # Ask questions about your codebase cm ask "Which module handles authentication?" # Generate documentation cm gen src/ # Semantic code search cm index && cm search "error handling patterns" ``` The tool became a comprehensive developer toolkit with features I never originally planned: - **Multi-LLM Support**: Integration with various [AI providers](/blog/ai-billion-dollar-lie) through PydanticAI - **Semantic Search**: Vector-based code understanding using embeddings - **Documentation Generation**: Automatic creation of project documentation - **Git Integration**: Smart commits, PR management, and workflow optimization - **Code Analysis**: Deep understanding of codebase structure and dependencies ## The CodeStruct Innovation One of the most interesting developments was creating [**CodeStruct**](https://github.com/SarthakMishra/codestruct) - a domain-specific language for representing code structure in a compressed, hierarchical format designed specifically for LLM context compression. ``` module: user_management class: User doc: Represents a user in the system... attr: name [type: STRING] attr: age [type: INTEGER] func: greet doc: Greets the user... param: name [type: STRING] returns: STRING ``` This notation can be minified for efficient AI processing: ``` m:user_management;cl:User;at:name[t:STR],at:age[t:INT];fn:greet|p:name[t:STR],r:STR ``` CodeStruct emerged from the practical need to feed large codebases to AI models efficiently while preserving semantic meaning - a problem I encountered repeatedly while building AI-powered developer tools. ## The Reality Check Here's the honest truth: while Codemap became a sophisticated tool with comprehensive features, the landscape changed quickly. The advent of Claude Code and similar AI coding assistants has made many of Codemap's features somewhat redundant. I still maintain the project, but I don't update it as frequently as I once did. The market moved faster than a solo developer could keep up with, and the big tech companies built similar (and often better) solutions with massive teams and resources. ## The Real Value Despite not becoming a commercial success, Codemap delivered something invaluable: it completed my transformation from a designer who could dabble in code to a developer who could build complex, AI-powered systems from scratch. The project taught me: - **Advanced Python development** with proper testing, CI/CD, and package management - **AI orchestration** at scale with multiple model providers - **Domain-specific language design** through CodeStruct - **Developer tool UX** principles that actually matter to users - **Open source project management** with proper documentation and community building ## Current Status Codemap exists in a interesting space - it's still somewhat active and functional. But it's also a reminder that timing and market fit matter as much as technical execution. Sometimes the most valuable projects aren't the ones that achieve market success, but the ones that push you to grow in unexpected directions. Codemap represents the culmination of my [journey from designer to developer](/blog/the-greater-fool) - a journey that started with frustration over git commits and ended with a deep understanding of AI, developer tools, and the challenges of building in rapidly evolving markets. --- --- title: Learnway description: AI-powered learning platform that generates personalized learning paths, featuring intelligent pathway generation, content curation, and an AI tutor. type: ventures url: https://sarthakmishra.com/learnway date: 2024-03-01 tags: ['ed-tech', 'ai', 'personalization', 'learning'] featured: true status: archived companyUrl: https://learnway.io --- # Learnway Learnway was a strategic project that marked a significant evolution for [Drool](/drool). While it didn't achieve commercial success, it became the catalyst that transformed our agency from a design-only service into a full-stack development partner - and opened my eyes to the exciting world of [AI and development](/work/phidata). ## The Strategic Challenge At Drool, we had established ourselves as a trusted design partner for startups, but I saw an opportunity to expand our capabilities. Many of our clients were asking if we could handle the development side of their projects as well. The market was demanding full-stack solutions, and I wanted to position Drool at the forefront of this trend. Rather than just adding development as a service offering, I decided we needed to prove ourselves through action. We needed a real project that would challenge us, push our boundaries, and demonstrate our development capabilities. That's when the idea for Learnway was born. ## The YC Ambition Summer 2024 brought an exciting opportunity - Y Combinator had just announced their first-ever Fall batch, with applications due by August 27th. This was perfect timing. I decided to set an ambitious goal: we would design and develop a complete MVP within just two months, and aim for YC acceptance. The challenge was intentional. By setting such a tight deadline and aiming for the gold standard of startup accelerators, we would be forced to operate at the highest level and truly test our development capabilities. ## The Product Vision Learnway was designed to be an AI-powered learning path generator that would streamline the workflow of founders and freelancers working on new projects. The core insight was that people were constantly switching between multiple platforms when learning new skills, creating friction and reducing productivity. ### Key Features We Built: - **AI-Assisted Pathway Generation:** Automatically built sequences of steps tailored to project requirements - **Node-Based Visual Interface:** Used React Flow to create an intuitive visual representation of learning steps - **Curated Content Integration:** Selected the best resources from across the internet, including videos, articles, and assets - **Real-Time AI Agent:** Addressed doubts and adjusted learning pathways as projects evolved - **Integrated Tools:** Included quizzes, community features, and note-taking capabilities ## The Technical Challenge Building Learnway in just two months required us to leverage the latest technologies and work at an unprecedented pace. We chose a modern tech stack that would allow us to move fast while maintaining quality: - **Next.js 14** with App Router for the frontend - **tRPC** for type-safe API communication - **Supabase** for database and authentication - **Langchain & Langgraph** for AI integration - **React Flow** for the node-based interface - **Shadcn UI** for consistent design components The most challenging aspect was creating the AI-powered learning path generation system. We had to build custom scraping tools, integrate multiple AI models, and create a queue system to handle the computational load of generating personalized learning paths. ## The YC Journey We successfully completed the MVP within our two-month deadline and submitted our application to Y Combinator's Fall 2024 batch. The application process itself was invaluable - it forced us to articulate our vision clearly, understand our market, and present our technical capabilities convincingly. Unfortunately, Learnway wasn't selected for the [YC batch](/blog/the-greater-fool). While this was disappointing, it didn't diminish the value of what we had accomplished. The project had already achieved its primary goal: proving that [Drool](/blog/drool-is-shut-down) could deliver high-quality development services alongside our design expertise. ## The Real Victory Although Learnway didn't become a financial success, it was transformative for both Drool and my personal growth as a founder. The project achieved something even more valuable than YC acceptance - it opened entirely new possibilities for our agency. ### What We Gained: - **Development Capabilities:** We successfully added frontend development to Drool's service offerings, now positioned as an MVP development service - **AI Expertise:** Working with Langchain and AI models gave us deep insights into the rapidly evolving AI landscape - **Design Evolution:** Building a complex interface improved my skills as a designer, giving me practical insights into what's possible and what's not when creating realistic, user-friendly interfaces - **Technical Credibility:** Having a [full-stack project](/codemap) in our portfolio opened doors to more sophisticated client projects ## Current Status and Future Impact Today, Learnway exists as an archived project on [**GitHub**](https://github.com/SarthakMishra/learnway), but its impact continues to resonate through Drool's expanded capabilities. The MVP development service we now offer directly stems from the confidence and skills we gained through this project. The experience taught me that sometimes the most [valuable ventures](/work/blendnbrew) aren't the ones that succeed commercially, but the ones that push you to grow in unexpected ways. Learnway didn't make us money, but it made us better - as developers, designers, and strategic thinkers. For any founder considering a similar challenge, I'd recommend it. Sometimes the best way to prove you can do something is to set an ambitious goal and just do it. The journey itself becomes the real product. --- --- title: Drool description: Interactive code visualization and mapping platform that helps developers understand complex codebases. type: ventures url: https://sarthakmishra.com/drool date: 2023-01-01 tags: ['developer-tools', 'visualization', 'saas'] featured: true status: archived companyUrl: https://trydrool.com --- # Drool [Drool](https://trydrool.com) was my third venture and, for a time, my most successful one. What started as a way to get back to my roots after the [Altpack](/altpack) experience grew into a thriving design agency—before I ultimately decided to [sunset it](/blog/drool-is-shut-down). It was quite the journey. ## Finding My Way Back After the Altpack experience, I needed a change. Physical products had taught me valuable lessons, but they weren't for me. I took some time to travel, clear my head, and figure out what came next. Eventually, I realized I needed to go back to my roots - what I was actually good at. I started freelancing again, but this time I had discovered something that would change everything: cold outreach. Instead of waiting for clients to find me, I set up a proper funnel and started reaching out to potential clients myself. The response was incredible - soon enough, I had more interest than I could handle as a solo freelancer. ## The Birth of Drool Agency To formalize this growing demand, I decided to start a design agency. But this wasn't going to be just another design shop. The goal was to work specifically with startups in fields that genuinely interested me - [AI](/work/phidata), [B2B](/work/sellscale), data analytics, and no-code SaaS - so I could learn from them while helping them build interesting products. ## The Problem We Solve As entrepreneurs who've been through the startup grind ourselves, we understand how tough design operations can be for early-stage companies. We've been there, and we wanted to make it easier. Traditional design agencies often don't understand the unique challenges and constraints that startups face. ### What Makes Us Different: - **Startup-focused approach:** We tailor our design services specifically for early-stage companies - **Industry expertise:** We specialize in [AI, B2B, data analytics](/learnway), and no-code SaaS - **Entrepreneur mindset:** We're not just service providers; we're partners who understand your world - **Efficient and flexible:** Quality design that doesn't compromise on speed or adaptability ## Rapid Growth and Learning The first six months were a whirlwind. We managed to generate mid-five figures in revenue, which was exciting but also overwhelming. To keep up with demand, I hired a team of over 6 full-time people plus 4-5 part-time employees. We were growing fast, taking on multiple projects, and trying to scale our operations. During this period, I learned a lot about: - Team management and delegation - Client onboarding and [project management](/winitiate) - Quality control across multiple designers - The challenges of rapid scaling in a service business ## The Pivot to Solo Operations While the growth was impressive, it came with significant headaches. Managing a large team, maintaining quality consistency, and dealing with the operational complexity of a bigger agency wasn't aligning with my goals. I realized I was spending more time managing people than actually designing and working with clients. So I made a strategic decision: I scaled Drool down significantly. Now I operate it as a solo venture, working with a few selected clients. This approach allows me to: - Deliver much better quality work - Work more peacefully at my own pace - Maintain direct relationships with clients - Focus on projects that genuinely interest me ## Current State and Future Plans Today, Drool is still profitable but with a lot fewer headaches. It's mostly running on autopilot now, with a few quality leads coming in every month through the systems I've built. This gives me the freedom to be selective about the projects I take on and maintain the high standards that made us successful in the first place. The agency continues to focus on what we do best - providing efficient, flexible design for startups that doesn't compromise on quality. We're not just another design agency; we want to be a partner that startups can rely on. I'm planning to continue Drool for the foreseeable future. It's found its sweet spot as a lean, focused operation that serves both my clients and my own goals perfectly. Sometimes scaling down is the best way to scale up what truly matters. --- --- title: Altpack description: Modular backpacks designed for any environment - lightweight, durable, and adaptable to meet your needs. type: ventures url: https://sarthakmishra.com/altpack date: 2022-01-01 tags: ['physical-product', 'backpacks', 'modular', 'd2c'] featured: true status: archived companyUrl: https://trydrool.com --- # Altpack AltPack was my second major venture - an ambitious leap into the world of physical products that taught me invaluable lessons about manufacturing, marketing, and the challenges of D2C businesses. ## The Journey to Mumbai After COVID, I got the chance to move out of my hometown and settle in a new city - Mumbai. At the time, I was mostly freelancing. Physical goods had always been my Achilles heel - something I was scared of pursuing - but with my good friend [Adarsh](https://www.linkedin.com/in/adarsh-maradiya-242506154/), I decided to jump on this opportunity. He had a background in textile manufacturing, and I had just worked with a bunch of D2C startups helping with creating marketing content. This partnership seemed perfect - combining his manufacturing expertise with my marketing experience from the digital space. ## The Product Vision We created modular backpacks that were perfect for any environment. Our backpacks were designed to be easy to use, lightweight, and durable, meaning they could be adapted to meet various needs. Whether someone was looking for a bag to take to work or to the gym, AltPack was designed to have them covered. Our backpacks were spacious, organized, reliable, and comfortable. They were designed with the user in mind and came with plenty of compartments and pockets to keep items organized and secure. The backpacks were made with tough, durable materials, and featured adjustable straps and cushioned back panels for maximum comfort all day long. This is a stop motion video of the backpacks showing the modularity. ## My Role & Learning Experience My responsibility was web design, marketing content generation, and Meta ads. The website was built on WordPress - my first real taste of web development, self-hosting, server setup, and maintenance. This gave me a chance to put my learnings from working with D2C startups into practical use. ## The Harsh Reality Unfortunately, the product didn't work like we expected. We were burning a lot of money on marketing, but there was no interest. We had made a huge mistake with our supplier, and the quality of goods was very poor - a critical flaw that undermined everything we were trying to build. We tried everything: - Varying prices to find the sweet spot - Hiring influencers for brand promotion - Generating and experimenting with different types of content - Adjusting our messaging and targeting But nothing worked. The fundamental issue of product quality couldn't be overcome with marketing alone. ## Lessons Learned Finally, we had to shut it down. This was my last time working on a physical product - not a very good experience for me and not something that I found very interesting. After this experience, I've mostly stuck with service and software-based startups. However, it was still a great learning experience. AltPack taught me about: - The critical importance of supplier relationships and quality control - The challenges of inventory management and cash flow in physical products - The limits of marketing when the product-market fit isn't there - The complexities of manufacturing, logistics, and customer service for physical goods While AltPack didn't succeed, it was an essential stepping stone that helped me understand my strengths and preferences, ultimately guiding me toward the [digital products and services](/drool) that would define my [future ventures](/work/blendnbrew). --- --- title: Weaveu description: A fashion ecommerce marketplace connecting consumers, raw-material producers, and boutique tailors for custom clothing creation. type: ventures url: https://sarthakmishra.com/weaveu date: 2021-01-01 tags: ['fashion', 'ecommerce', 'marketplace', 'custom-clothing'] featured: true status: archived companyUrl: https://weaveu.com --- # Weaveu Weaveu was one of my first ventures - an ambitious attempt to create a new generation of [fashion ecommerce](/work/mydrip) that would serve as a middle ground between fast fashion and generic mass-produced clothing brands, all while maintaining an accessible budget for consumers. ## The Vision The idea was to revolutionize how custom clothing is created and delivered by building a marketplace that seamlessly integrated three key stakeholders: - **Consumers** - looking for unique, custom-fit clothing - **Raw-material producers** - providing quality fabrics and materials - **Small boutique tailors** - offering skilled craftsmanship ## How It Would Work The concept was elegantly simple yet innovative: 1. **Purchase raw materials** with varying designs from producers 2. **Mix and match** materials to create unique design combinations 3. **Community voting** through the app where users could vote on designs they liked 4. **Popular design selection** based on community preferences 5. **Tailored production** - reach out to tailors with raw materials, instructions, and user measurements 6. **Direct delivery** once the custom clothing items were completed ### The Innovation: Print-on-Demand for Tailoring The mix-and-match approach, combined with distributed manufacturing, would allow for significantly shorter lead times if operations were managed appropriately. This system offered the ability to provide fresh, new designs without worrying about inventory management - essentially extending the print-on-demand concept to the world of custom tailoring. ## The Technical Challenge One of the most important aspects of the app was enabling users to get accurate measurements using just their smartphone camera. Apple had just released their LiDAR-based Face ID system, which could theoretically be adapted for body measurements, but we wanted to create something more universally accessible for Android users as well. This measurement technology became one of our first major hurdles, as it required expertise outside our core skill set. We had to seek external consultation to explore feasible solutions for camera-based body measurement that would work across different devices and platforms. ## My Role & Lessons Learned As co-founder, I took care of: - **UX design** - crafting the user experience and interface - **Marketing content** - though we never really got to the marketing phase - **Graphics design** - creating visual assets and branding - **Strategic planning** - helping shape the overall vision Unfortunately, Weaveu never progressed beyond the ideation phase due to unfeasibility at the time and lack of sufficient budget to tackle the technical challenges we faced. However, it was an invaluable [learning experience](/blog/the-greater-fool) that taught me about the complexities of marketplace dynamics, the importance of technical feasibility assessment, and the challenges of coordinating multiple stakeholders in a supply chain. The venture highlighted the gap between ambitious ideas and [practical execution](/learnway), setting the foundation for more realistic and achievable projects in [my future endeavors](/work/blendnbrew). --- ## Newsletter Issues --- title: Rethinking my agency website description: Why I'm ditching the traditional agency website playbook and building a resource site targeting founders in my ICP, using programmatic SEO. type: newsletter url: https://sarthakmishra.com/newsletter/issue-1 date: 2025-08-07 tags: [] author: Sarthak Mishra issueNumber: 1 recipientName: friend --- # Rethinking my agency website Welcome to my first newsletter! If you're reading this, you probably found me through my blog or [agency work](/blog/drool-is-shut-down). I'm currently working on something different for my [agency site](/drool). Instead of building another polished agency homepage (they're everywhere), I'm creating a resource directory _**targeting founders that fit my ICP**_. **Here's why:** Traditional agency sites are impossible to rank. Everyone has the same pages, same messaging, same SEO strategy. Standing out is brutal. **My solution?** Build a resource site first. Think programmatic SEO, lead magnets, and actual value before any sales pitch. It's a loss leader that should be much easier to market. **The strategy:** Start with low-commitment digital products instead of jumping straight to "hire my agency." Less risk for new clients, easier trust building, then gradually introduce services. I'm documenting everything as I build this, so expect some interesting content in the coming weeks. Let's get back to building! P.S. If you're interested in how this newsletter system works, you can read about it in [Building a Newsletter System Without the Headache](/blog/building-newsletter-with-astro-cloudflare-and-resend). --- ## Additional Resources - Website: https://sarthakmishra.com - About: https://sarthakmishra.com/about - Work Portfolio: https://sarthakmishra.com/work - Blog: https://sarthakmishra.com/blog - Newsletter Archive: https://sarthakmishra.com/newsletter - Schedule Meeting: https://sarthakmishra.com/schedule - RSS Feed: https://sarthakmishra.com/rss.xml --- Content generated on: 2026-07-03T19:39:01.151Z Total posts: 30 | Projects: 6 | Ventures: 6 | Newsletter issues: 1