<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: shaojie gong</title>
    <description>The latest articles on DEV Community by shaojie gong (@shaojie).</description>
    <link>https://dev.to/shaojie</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4015883%2Ff495cde7-b3bb-432b-8da3-ff6aa5217ebe.png</url>
      <title>DEV Community: shaojie gong</title>
      <link>https://dev.to/shaojie</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/shaojie"/>
    <language>en</language>
    <item>
      <title>My account got suspended twice this week. Here's what it taught me about building on rented land.</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Thu, 13 Aug 2026 14:43:51 +0000</pubDate>
      <link>https://dev.to/shaojie/my-account-got-suspended-twice-this-week-heres-what-it-taught-me-about-building-on-rented-land-4774</link>
      <guid>https://dev.to/shaojie/my-account-got-suspended-twice-this-week-heres-what-it-taught-me-about-building-on-rented-land-4774</guid>
      <description>&lt;p&gt;My X account got suspended today. It's the second time this week, and my Reddit appeal is still sitting unanswered. I didn't post links, didn't spam, didn't run any automation — I just showed up as a brand-new account and started sharing what I'm building. Apparently that alone is enough to trip something you can't see.&lt;/p&gt;

&lt;p&gt;I was frustrated for about an hour. Then it turned into something more useful.&lt;/p&gt;

&lt;p&gt;Here's the thing I should have understood before I ever started promoting: every follower, every post, every little bit of "standing" I'd built up on those platforms is not mine. It lives on someone else's server, under someone else's rules, and it can disappear in a single click I never get to see coming. I'd been pouring all my launch energy into ground I don't actually own. I was building on rented land.&lt;/p&gt;

&lt;p&gt;So I did the thing that always calms me down when I'm spiraling — I made a list. Specifically, a list of what actually can't be taken away from me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;my domain&lt;/li&gt;
&lt;li&gt;the extension itself, and the code behind it&lt;/li&gt;
&lt;li&gt;my Chrome Web Store listing&lt;/li&gt;
&lt;li&gt;the emails of the handful of people who reached out directly&lt;/li&gt;
&lt;li&gt;the skill to build the whole thing again if I had to&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzuvcrc5i7uftik49kjct.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fzuvcrc5i7uftik49kjct.png" alt=" " width="800" height="411"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That list, it turns out, is the actual company. Everything else — X, Reddit, every platform I was anxiously refreshing — is a channel I'm borrowing. Useful, sometimes powerful, but borrowed.&lt;/p&gt;

&lt;p&gt;I'm not quitting the platforms. Reach is reach, and a suspended account doesn't change the fact that that's where a lot of my potential users hang out. What I got wrong was the order. I was treating the rented channels as the foundation and my own assets as an afterthought. It should be the reverse: own the asset first — a site, a store page, some way for interested people to actually email you — and then use the rented channels to point back at it. If a channel dies, the asset survives. That's the entire point, and I'd had it upside down.&lt;/p&gt;

&lt;p&gt;The quiet upside is that the one thing no suspension can touch is the product itself. NotebookBloom is a Chrome extension that turns NotebookLM (now Gemini Notebook) into a real research workbench — citation export (APA/MLA/Chicago/BibTeX), Anki export with the source kept attached, and cross-notebook search. It's on the Chrome Web Store, it's on my own site, and no ban wave decides whether it exists.&lt;/p&gt;

&lt;p&gt;If you're a solo maker doing your own launch right now: make the list. Figure out which of your eggs are in baskets you own versus baskets you rent. Then build the owned ones first. I learned it the annoying way so maybe you don't have to.&lt;/p&gt;

&lt;p&gt;Site: &lt;a href="https://notebookbloom.com" rel="noopener noreferrer"&gt;https://notebookbloom.com&lt;/a&gt; · Chrome Web Store: &lt;a href="https://chromewebstore.google.com/detail/fmnhogknjhlaicceielblddajjabjlha" rel="noopener noreferrer"&gt;https://chromewebstore.google.com/detail/fmnhogknjhlaicceielblddajjabjlha&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;— building NotebookBloom in public&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>webdev</category>
      <category>anki</category>
      <category>notebookbloom</category>
    </item>
    <item>
      <title>How to turn NotebookLM notes into Anki flashcards (free, by hand)</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Wed, 12 Aug 2026 14:47:15 +0000</pubDate>
      <link>https://dev.to/shaojie/how-to-turn-notebooklm-notes-into-anki-flashcards-free-by-hand-394d</link>
      <guid>https://dev.to/shaojie/how-to-turn-notebooklm-notes-into-anki-flashcards-free-by-hand-394d</guid>
      <description>&lt;p&gt;NotebookLM (now Gemini Notebook) is genuinely good at one thing: taking a pile of sources and helping you understand them. It summarizes, it answers questions, it connects ideas. But it stops there. It won't help you actually remember any of it a month later — and for studying, remembering is the whole point.&lt;/p&gt;

&lt;p&gt;That's what Anki is for. Spaced repetition is the difference between reading a summary once and still knowing it in three weeks. So the notes NotebookLM gives you are step 1, not the finish line. Here's how to bridge the two, by hand, for free.&lt;/p&gt;

&lt;p&gt;The manual method:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Grab a Q&amp;amp;A list or a set of key points from NotebookLM.&lt;/li&gt;
&lt;li&gt;Format each line as: question [TAB] answer — one card per line.&lt;/li&gt;
&lt;li&gt;Save it as a plain .txt file (UTF-8).&lt;/li&gt;
&lt;li&gt;In Anki: File → Import, and set the field separator to Tab.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc2rne1gx8l5e1a7g77mm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc2rne1gx8l5e1a7g77mm.png" alt=" " width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Anki turns each line into a card. That's the whole trick — no add-ons required.&lt;/p&gt;

&lt;p&gt;One step most people skip: keep the source attached. When a fact resurfaces in review weeks later and you can't remember where it came from, you stop trusting the card. Add a third field with the source title (question [TAB] answer [TAB] source) so every card stays traceable back to where you learned it.&lt;/p&gt;

&lt;p&gt;That manual route is free and works fine for a deck or two. I just got tired of doing it every single time — reformatting, tabbing, pasting the source by hand — so I automated the whole thing in NotebookBloom, source attribution included. But if you only need it occasionally, the steps above cost nothing. 🌱&lt;/p&gt;

&lt;p&gt;—&lt;br&gt;
NotebookBloom is my Chrome extension for NotebookLM (Gemini Notebook): citation export (APA/MLA/Chicago/BibTeX), cross-notebook search, and Anki export with the source attached.&lt;br&gt;
Site: &lt;a href="https://notebookbloom.com" rel="noopener noreferrer"&gt;https://notebookbloom.com&lt;/a&gt; · Chrome Web Store: &lt;a href="https://chromewebstore.google.com/detail/fmnhogknjhlaicceielblddajjabjlha" rel="noopener noreferrer"&gt;https://chromewebstore.google.com/detail/fmnhogknjhlaicceielblddajjabjlha&lt;/a&gt;&lt;/p&gt;

</description>
      <category>notebooklm</category>
      <category>anki</category>
      <category>productivity</category>
      <category>studytips</category>
    </item>
    <item>
      <title>What one week of SEO actually looks like (real numbers)</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Tue, 11 Aug 2026 14:41:37 +0000</pubDate>
      <link>https://dev.to/shaojie/what-one-week-of-seo-actually-looks-like-real-numbers-gdd</link>
      <guid>https://dev.to/shaojie/what-one-week-of-seo-actually-looks-like-real-numbers-gdd</guid>
      <description>&lt;p&gt;I launched the site for NotebookBloom — my Chrome extension for NotebookLM (now Gemini Notebook) — about a week ago. Here's the first Search Console report, warts and all: 115 impressions, 0 clicks, average position ~34.&lt;/p&gt;

&lt;p&gt;Zero clicks. If you only read that number, it looks like a flop. But position 34 is page 3–4 of Google — nobody scrolls that far. Week one isn't about clicks; it's about getting indexed and seen. And that part worked: Google picked up 8 of my pages and daily impressions climbed from 3 to 44. That curve is the crawler waking up, not customers showing up.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbc3zhvjpyqst24g08w8l.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbc3zhvjpyqst24g08w8l.png" alt=" " width="800" height="389"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The pattern I didn't expect: my niche pages rank best. The post on getting NotebookLM sources into Zotero sits at position 20, "search across notebooks" at 24, and my Kortex-alternative page at 10. Meanwhile the broad "bulk import" page — the one with the most impressions — is stuck at 39. Lesson: a brand-new site can't win generic terms yet, but it can break in on specific, low-competition ones. So that's where I'll double down.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F55hf03ahlpe7a6cplaa2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F55hf03ahlpe7a6cplaa2.png" alt=" " width="800" height="341"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The best surprise was free keyword research. The queries surfacing my pages — "download all sources from notebooklm", "how to duplicate a notebook" — don't match anything I've actually written. Google is stretching nearby pages to cover them. That mismatch is a content roadmap handed to me for free: it's a list of posts people are already searching for.&lt;/p&gt;

&lt;p&gt;So the plan for month one: write for the gaps Search Console just revealed, strengthen the niche pages that are already ranking, and — most of all — be patient. SEO is a marathon, not a launch. I'll post these same numbers again in a month.&lt;/p&gt;

&lt;p&gt;Building in public, one honest report at a time. 🌱&lt;/p&gt;

&lt;p&gt;—&lt;br&gt;
NotebookBloom is my Chrome extension for NotebookLM (Gemini Notebook) — real citation export (APA/MLA/Chicago/BibTeX), cross-notebook search, and Anki export with sources.&lt;br&gt;
Site: &lt;a href="https://notebookbloom.com" rel="noopener noreferrer"&gt;https://notebookbloom.com&lt;/a&gt; · Chrome Web Store: &lt;a href="https://chromewebstore.google.com/detail/fmnhogknjhlaicceielblddajjabjlha" rel="noopener noreferrer"&gt;https://chromewebstore.google.com/detail/fmnhogknjhlaicceielblddajjabjlha&lt;/a&gt;&lt;/p&gt;

</description>
      <category>notebookbloom</category>
      <category>anki</category>
      <category>gemininotebook</category>
    </item>
    <item>
      <title>The Chrome Web Store rejected my extension for something it doesn't even do</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Mon, 10 Aug 2026 13:29:45 +0000</pubDate>
      <link>https://dev.to/shaojie/the-chrome-web-store-rejected-my-extension-for-something-it-doesnt-even-do-a4i</link>
      <guid>https://dev.to/shaojie/the-chrome-web-store-rejected-my-extension-for-something-it-doesnt-even-do-a4i</guid>
      <description>&lt;p&gt;The Chrome Web Store rejected my extension on the first try. The reason code was "Purple Potassium."&lt;/p&gt;

&lt;p&gt;I stared at that for a while. I thought I'd misread the email. But no — Google labels its rejection reasons with these random color-and-element names, and mine was Purple Potassium, which is their tag for "excessive permissions." Somewhere at Google there's a spreadsheet where rejecting an indie developer's six months of work is filed under a name that sounds like a Crayola.&lt;/p&gt;

&lt;p&gt;Here's the part that stung, though. They weren't rejecting me for something my extension did. They were rejecting me for two permissions I was requesting and never actually using: activeTab and scripting.&lt;/p&gt;

&lt;p&gt;I didn't even remember adding them. That's the honest truth. Way back when I scaffolded the project, some starter template or some Stack Overflow answer told me a Chrome extension "usually needs" activeTab and scripting, so they went into the manifest and sat there for months. Never questioned. Never used. Just... declared, like a résumé listing a language you took one semester of in college and can't actually speak.&lt;/p&gt;

&lt;p&gt;Before I touched anything, I made myself prove it. I've been burned enough times by "I'll just delete this, it's probably unused" — so I grepped the entire source tree. Every place those APIs could possibly show up: chrome.scripting, executeScript, insertCSS, chrome.tabs.query, the works. Zero hits. The only tab-related call in the whole codebase was chrome.tabs.create — the one that opens a URL in a new tab, which needs no permission at all.&lt;/p&gt;

&lt;p&gt;Turns out my extension does its work through static content scripts declared right in the manifest. Those run automatically on the pages I list. They don't need activeTab (that's for grabbing temporary access on a user click) and they don't need scripting (that's for injecting code dynamically at runtime). I was carrying two heavyweight permissions to do a job that required neither.&lt;/p&gt;

&lt;p&gt;So I deleted both lines. Bumped the version. Repackaged — and my pack script strips the manifest key on the way out, because the store rejects any upload that contains it. Re-submitted.&lt;/p&gt;

&lt;p&gt;Five days later: approved. Published. Live.&lt;/p&gt;

&lt;p&gt;And the whole way through, one thought kept circling: I didn't get rejected for what my product does. I got rejected for what it claimed it might do, and never did. The permissions weren't a feature. They were a liability I'd been carrying without noticing — extra surface area, extra reviewer suspicion, extra reason for a user to hesitate on the install screen. Deleting them didn't cost me a single feature. It made the product faster to review, cleaner to trust, and honestly, more honest.&lt;/p&gt;

&lt;p&gt;That's a lesson that reaches way past a manifest file. Every capability you ask for that you don't use is a tax. On a résumé, on a startup's feature list, on a permission dialog — the stuff you request "just in case" isn't free optionality. It's weight. Reviewers, users, and hiring managers all read it the same way: why are you asking for this if you don't need it?&lt;/p&gt;

&lt;p&gt;I wish the story ended at "approved." It didn't.&lt;/p&gt;

&lt;p&gt;Because getting approved, it turns out, is just putting your thing on the shelf. It doesn't mean anyone walks down the aisle. I searched the store for my own extension, found it, and — it looked pathetic. Just a lonely little logo floating next to competitors with rich, colorful promo cards. Same as showing up to a job fair in a plain gray t-shirt while everyone else has a booth.&lt;/p&gt;

&lt;p&gt;The store lets you upload a promo tile — a 440×280 image that becomes the big graphic on your search card. I built one: brand-orange gradient, the logo, a one-line pitch, three little feature pills. Made a 1400×560 marquee version too, for the day the store maybe features me. Went to upload them and got bounced again — the upload box only accepts 24-bit PNG with no alpha channel, and my files had transparency baked in. One flatten-and-remove-alpha later (channels: 4 → 3), they went through.&lt;/p&gt;

&lt;p&gt;So here I am, actually launched. Smaller permissions, a real search card, and a much better understanding of a thing I'd gotten backwards: shipping isn't the finish line. It's the first day anyone can even find you.&lt;/p&gt;

&lt;p&gt;If you've launched on a store — Chrome, Apple, Google Play, wherever — what's the thing you assumed was "done" at approval that turned out to be day one of the real work?&lt;/p&gt;

&lt;p&gt;notebookbloom.com&lt;/p&gt;

&lt;p&gt;— building NotebookBloom in public, #18&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>startup</category>
      <category>saas</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A playlist has 124 videos. My tool imported 15, then 100, then finally 124.</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Thu, 06 Aug 2026 13:23:31 +0000</pubDate>
      <link>https://dev.to/shaojie/a-playlist-has-124-videos-my-tool-imported-15-then-100-then-finally-124-1e9d</link>
      <guid>https://dev.to/shaojie/a-playlist-has-124-videos-my-tool-imported-15-then-100-then-finally-124-1e9d</guid>
      <description>&lt;p&gt;One of my extension's features imports a whole YouTube playlist into your notebook in one click — paste a playlist link, it pulls every video in as a source. I pointed a 124-video playlist at it to test. It imported 15.&lt;/p&gt;

&lt;p&gt;Not 124. Fifteen.&lt;/p&gt;

&lt;p&gt;That kicked off two days of chasing round numbers, and every round number turned out to be a lie told by someone else's system.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;My importer was reading YouTube's playlist RSS feed — clean, official, no scraping. Turns out that feed hard-caps at ~15 items no matter how big the playlist is. 15 wasn't my data. It was YouTube's feed limit wearing my data's clothes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So I switched to scraping the playlist page's embedded JSON (ytInitialData) — the same blob the page itself renders from. First run after the switch: 0 videos. The structure had shifted under me. The old playlistVideoRenderer.videoId path was gone; today's YouTube wraps each video in a lockupViewModel with the id sitting in contentId. Fixed the path.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Now it pulled 100. Progress! But the playlist had 124, and 100 is exactly one page. YouTube paginates: to get page 2 you send back a "continuation token" you find at the bottom of page 1. I was finding the token — I could log it — but my loop behaved like there was none.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This one was mine, and it's a good one. My tree-walker returned a single token as it recursed: walk each child, if a child returns a token, keep it. The problem is that after finding the real token, the walk kept going into sibling branches that returned nothing — and "nothing" overwrote my good token with null. The right answer was there for a moment, then a later, emptier branch clobbered it. A DFS that should have been "first non-null wins" was quietly doing "last write wins." Fix: stop returning one token; push every token into an array and take the first. Instantly: 124.&lt;/p&gt;

&lt;p&gt;Except — not from inside the actual extension. From a standalone console test, 124. From the extension, still 100.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;My panel runs in an iframe on the notebook page. When it fetched YouTube's pagination endpoint, the request carried Origin: chrome-extension://… — and YouTube's API takes one look at that and returns 403. The page fetch (a plain GET of HTML) worked fine; the API call (the one thing that gets you past 100) didn't. I only caught it because I finally logged the proxy's status codes: GET 200, POST 403.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Fix: move the fetches into the background service worker, and add a declarativeNetRequest rule that rewrites Origin/Referer to &lt;a href="https://www.youtube.com" rel="noopener noreferrer"&gt;https://www.youtube.com&lt;/a&gt; for that endpoint. Now the request looks like it came from YouTube itself. 200. 124 videos. Done.&lt;/p&gt;

&lt;p&gt;The thing I keep thinking about: 15 and 100 were both round numbers, and both were somebody else's limit masquerading as my result. When a scraper stops at a suspiciously clean number, that's rarely where your data ends — it's where a page size, a feed cap, or a rate limit begins. The number is a fingerprint of the wall you just hit, not of the thing you're counting.&lt;/p&gt;

&lt;p&gt;That's the tax on building a tool that lives on top of someone else's product with no API contract. Every layer — the feed, the DOM shape, the pagination, the CORS policy — can drift or bite, and none of it is yours to stabilize. You just get good at reading round numbers as clues.&lt;/p&gt;

&lt;p&gt;What's the most misleading "round number" bug you've hit — where the value looked like an answer but was actually a limit?&lt;/p&gt;

&lt;p&gt;— building NotebookBloom in public, #16&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>startup</category>
      <category>sass</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How do I start charging later without punishing the people who showed up first?</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Wed, 05 Aug 2026 13:38:02 +0000</pubDate>
      <link>https://dev.to/shaojie/how-do-i-start-charging-later-without-punishing-the-people-who-showed-up-first-104n</link>
      <guid>https://dev.to/shaojie/how-do-i-start-charging-later-without-punishing-the-people-who-showed-up-first-104n</guid>
      <description>&lt;p&gt;My plan has always been: launch almost everything free, get people using it, and start charging for individual features later, once there's a reason to. I wrote earlier about the remote switch that lets me flip a feature to paid without shipping a new release.&lt;/p&gt;

&lt;p&gt;But there's a nasty problem hiding inside "charge later," and it took me a while to name it.&lt;/p&gt;

&lt;p&gt;The day I flip, say, flashcard export from free to paid — what happens to the person who's been using flashcard export every day for three months? Under the naive version of my switch, they open the app tomorrow and a thing they relied on is suddenly locked. I just punished my most engaged user. Worse: I punished them specifically because they showed up early, before I figured out how to make money. The people who gave me my first reviews and told their friends would be the exact people I paywall first.&lt;/p&gt;

&lt;p&gt;That's backwards. Early users should be rewarded for being early, not billed for it.&lt;/p&gt;

&lt;p&gt;The answer is an old idea with an ugly name: grandfathering. When you start charging for something, the people who were already there keep it free — forever.&lt;/p&gt;

&lt;p&gt;The tricky part is that my switch had no sense of time. It knew "is this feature paid?" but not "paid for whom, since when?" So I taught it.&lt;/p&gt;

&lt;p&gt;Two pieces. First, every install now quietly records when it first ran — an installedAt timestamp, written the very first time the extension loads. Second, my paywall switch can now hold more than just on/off. Instead of "flashcard export = paid," I can set "flashcard export = paid for anyone who installed after March 1st." Everyone with an installedAt before that date sails through free, permanently. Everyone after pays. Same one-value flip, no new release — it just now carries a "since when" alongside the "paid."&lt;/p&gt;

&lt;p&gt;In code it's a tiny change: the gate value went from a boolean to either a boolean or a little object with a paid flag and a since date. The check became "if you're not paid, and your install predates the cutoff, you're exempt." That's it. But the behavior it buys is exactly the promise I want to make: get here early, keep it free, no asterisks.&lt;/p&gt;

&lt;p&gt;One honest limitation. installedAt is only accurate for people who install after I shipped this timestamp logic. Anyone already using the extension before that gets stamped with "now" the first time they upgrade — so they look slightly newer than they really are. The practical consequence is oddly motivating: the earlier I ship this, the more of my real early users get correctly grandfathered. Deploying the loyalty machinery is itself time-sensitive.&lt;/p&gt;

&lt;p&gt;What I like about this is that it dissolves a conflict I thought I had to live with. "Grow with free, monetize later" felt like it was in tension with "don't betray the people who believed in you early." It isn't — you just need your paywall to remember who was here first. The strategy decides when to charge; the timestamp decides who's exempt. Two separate knobs, no contradiction.&lt;/p&gt;

&lt;p&gt;I haven't charged anyone a cent yet. But the machine that will, someday, already knows to let my first users through for free. That feels like the right thing to build before I need it, not after someone gets burned.&lt;/p&gt;

&lt;p&gt;How do you handle your earliest users when pricing changes — grandfather them forever, give them a window, or something else?&lt;/p&gt;

&lt;p&gt;— building NotebookBloom in public, #15&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>startup</category>
      <category>saas</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I spent a day building the button that lets people cancel and leave me</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Tue, 04 Aug 2026 14:05:25 +0000</pubDate>
      <link>https://dev.to/shaojie/i-spent-a-day-building-the-button-that-lets-people-cancel-and-leave-me-562c</link>
      <guid>https://dev.to/shaojie/i-spent-a-day-building-the-button-that-lets-people-cancel-and-leave-me-562c</guid>
      <description>&lt;p&gt;I spent a full day building the one button that makes it easy for people to stop paying me. On purpose.&lt;/p&gt;

&lt;p&gt;Here's why that's not as dumb as it sounds.&lt;/p&gt;

&lt;p&gt;NotebookBloom, my NotebookLM extension, has a Pro tier. Payment goes through a Stripe Payment Link — you click, you pay, you're Pro. Clean. Done in an afternoon. And for a while I thought that was the whole billing story.&lt;/p&gt;

&lt;p&gt;It isn't. A Payment Link is a one-way door. It's great at letting money in. It does nothing about letting people out. There was no cancel screen anywhere in my product. If you subscribed and then wanted to stop, your options were: email me and wait, or... call your bank.&lt;/p&gt;

&lt;p&gt;That second option is the one that should scare every solo founder.&lt;/p&gt;

&lt;p&gt;When a customer can't find how to cancel, they don't shrug and keep paying. They dispute the charge — a chargeback. And chargebacks don't just cost you that one payment plus a fee. Stripe watches your chargeback rate like a hawk. Cross a threshold and they can freeze or close your account. One angry user who couldn't find a cancel button is an annoyance. A pattern of them is an existential threat to the thing collecting all your revenue.&lt;/p&gt;

&lt;p&gt;So the cancel button isn't a courtesy to users. It's insurance on my own business.&lt;/p&gt;

&lt;p&gt;The clean way to do it is Stripe's Customer Portal — a hosted page where a customer can cancel, change their card, or download invoices, none of which I have to build. I just have to send the right customer to the right portal session.&lt;/p&gt;

&lt;p&gt;That "right customer" part is where the actual work was. The portal link has to be generated per-customer, server-side, and I had to make sure user A can't open user B's billing portal. My flow: the extension sends the user's Google token to my Cloudflare Worker. The worker verifies that token with Google to get the real email — the client never just says "I'm bob@gmail" and gets believed, because that's how you'd let anyone cancel anyone's subscription. With the verified email, the worker looks up the Stripe customer id, asks Stripe for a one-time portal session, and hands the URL back. The extension opens it in a new tab. One "Manage subscription" button in settings, and everything else is Stripe's problem.&lt;/p&gt;

&lt;p&gt;The loop closes on its own: user cancels in the portal, Stripe fires a subscription.deleted webhook, my worker flips their record to inactive, and the next time the extension revalidates, they're back to free. I don't touch anything.&lt;/p&gt;

&lt;p&gt;There was one detail I got wrong at first, mentally. I assumed "cancel" meant "Pro turns off now." It doesn't — Stripe defaults to canceling at the end of the paid period. Which is correct! They paid for the month, they get the month. So a canceled-but-still-active subscription is a real state I had to handle: the extension now shows "Pro access until [date] — won't renew" instead of just yanking it. Small thing, but it's the difference between feeling respected and feeling robbed.&lt;/p&gt;

&lt;p&gt;The reframe I'm keeping: the exit is part of the product. I spent the first afternoon on the part that takes money and thought I was done. The part that lets people leave gracefully turned out to matter more — to my Stripe account's health, and to whether anyone trusts me enough to subscribe in the first place.&lt;/p&gt;

&lt;p&gt;Did anyone else find out the hard way that "accept payments" and "handle subscriptions" are two completely different amounts of work?&lt;/p&gt;

&lt;p&gt;— building NotebookBloom in public, #14&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>startup</category>
      <category>sass</category>
      <category>webdev</category>
    </item>
    <item>
      <title>My billing worked. My expiry dates didn't. Here's why.</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Mon, 03 Aug 2026 13:53:47 +0000</pubDate>
      <link>https://dev.to/shaojie/my-billing-worked-my-expiry-dates-didnt-heres-why-1308</link>
      <guid>https://dev.to/shaojie/my-billing-worked-my-expiry-dates-didnt-heres-why-1308</guid>
      <description>&lt;p&gt;I paid $2.99 to my own product this week. That's how I found out my subscriptions had no expiry date.&lt;/p&gt;

&lt;p&gt;Quick context: my extension has a Pro tier. No login servers, no license keys — you pay through Stripe, and a tiny Cloudflare Worker listens for Stripe's webhook and writes a little record: this email is Pro, and here's when the subscription renews. That renewal date is the whole point — it's how I know when to stop giving someone Pro if they cancel or their card fails.&lt;/p&gt;

&lt;p&gt;So I did the thing every indie dev should do more often: I became my own customer. Test card, real checkout flow, $2.99. Payment went through. Then I opened the database to admire my handiwork.&lt;/p&gt;

&lt;p&gt;The record was there. Email, active: true, the Stripe customer id. But the renewal date — the one field the whole system exists to track — was just… missing. Not wrong. Missing. The field wasn't even there.&lt;/p&gt;

&lt;p&gt;Here's the part that made it confusing: nothing had failed. Stripe's dashboard showed every webhook delivered, every one a green 200 OK. My worker said "yep, got it, all good" to everything. And still, no date.&lt;/p&gt;

&lt;p&gt;I went to Stripe's event log and lined the events up by timestamp. When you pay, Stripe doesn't send one event — it sends a burst. checkout.session.completed, invoice.paid, subscription.created, a dozen others, all within the same second or two. And that's when I saw it: invoice.paid was delivered at 2:55:57. checkout.session.completed came at 2:55:58. One second later.&lt;/p&gt;

&lt;p&gt;That one second was the whole bug.&lt;/p&gt;

&lt;p&gt;My code had an unspoken assumption baked into it: that "checkout completed" always arrives first. That event is where I build the lookup table connecting a Stripe customer to their email. Every other event — including the one carrying the renewal date — uses that table to figure out whose record to update. So when invoice.paid showed up first, it went looking for an email that didn't exist yet, found nothing, and quietly moved on. No error. Just a shrug. The date it was carrying got dropped on the floor, and checkout.session.completed arrived a second later to build a record that would now never learn its own expiry date.&lt;/p&gt;

&lt;p&gt;Webhooks don't promise order. I knew that in the abstract, the way you know a tornado is theoretically possible. I just never built for it, because in every test I'd ever run, the events happened to arrive in the tidy order I expected. It took a real payment, with real network timing, to shuffle the deck the other way.&lt;/p&gt;

&lt;p&gt;The fix wasn't dramatic. Now, when an event arrives and can't find its email yet, instead of dropping the data it stashes it in a "pending" slot keyed by customer id. When checkout.session.completed finally lands, it merges whatever was waiting. Order stops mattering — whoever gets there first leaves a note for the others. I also found a second landmine while I was in there: Stripe had quietly moved the renewal-date field to a new location in a recent API version, so even the events I &lt;em&gt;was&lt;/em&gt; handling were reading an empty spot. Fixed that too.&lt;/p&gt;

&lt;p&gt;Bought Pro again after deploying. Opened the database. There it was — renewal date, plan, everything. A month out, exactly right.&lt;/p&gt;

&lt;p&gt;Two things I'm taking from this. One: buy your own product. Not a mock, not a test harness — the actual flow with actual money moving. Half the bugs that matter only show up when the timing is real. Two: any time your code assumes A happens before B, and you don't &lt;em&gt;own&lt;/em&gt; the thing deciding the order, you don't have a guarantee — you have a coin flip that's been landing heads in testing.&lt;/p&gt;

&lt;p&gt;Anyone else have a bug that only appeared the first time real money went through? I'd bet those are a special category.&lt;/p&gt;

&lt;p&gt;— building NotebookBloom in public, #13&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>startup</category>
      <category>saas</category>
      <category>chrome</category>
    </item>
    <item>
      <title>My Chrome extension has no server, so I put the paywall on a remote switch</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Fri, 31 Jul 2026 15:35:53 +0000</pubDate>
      <link>https://dev.to/shaojie/my-chrome-extension-has-no-server-so-i-put-the-paywall-on-a-remote-switch-57d0</link>
      <guid>https://dev.to/shaojie/my-chrome-extension-has-no-server-so-i-put-the-paywall-on-a-remote-switch-57d0</guid>
      <description>&lt;p&gt;I'm a solo dev with zero users right now, and I just spent an afternoon on a decision most people would've hardcoded in five minutes.&lt;/p&gt;

&lt;p&gt;Here's the setup. NotebookBloom is my Chrome extension for Google's NotebookLM. At launch I don't want to charge for much — I want people to actually use it, tell a friend, leave a review. So the plan is: only cloud sync (Google Drive backup) is Pro on day one. Everything else — flashcard export to Anki, citation export, bulk import — free.&lt;/p&gt;

&lt;p&gt;But "free on day one" implies "not free forever." Once there are enough users, I want to flip some of those to paid, one at a time, watching what happens.&lt;/p&gt;

&lt;p&gt;And that's where I hit a wall that only exists for extensions: there is no server runtime. My extension runs in the user's browser. So if I write "is this feature paid?" as a hardcoded &lt;code&gt;if&lt;/code&gt; in my code, then flipping it later means: edit code → rebuild → upload to the Chrome Web Store → wait for review[你查到的审核时长，如 "usually under a day, sometimes 3"].&lt;/p&gt;

&lt;p&gt;Think about that. A pricing change — arguably the most business-critical lever I have — would be stuck in a review queue. That's absurd.&lt;/p&gt;

&lt;p&gt;So I stopped and rebuilt it as a switch.&lt;/p&gt;

&lt;p&gt;One file, &lt;code&gt;features.ts&lt;/code&gt;, with a single decision function:&lt;/p&gt;

&lt;p&gt;canUse(feature, isPro, gates) → isPro OR the feature isn't currently gated&lt;/p&gt;

&lt;p&gt;Four flippable keys: cloudSync, ankiExport, citationExport, bulkImport. The default (compiled into the extension) is: cloudSync = paid, the rest = free. That's my day-one tiering.&lt;/p&gt;

&lt;p&gt;The switch values live in my Cloudflare Worker's KV. To flip Anki export to paid, I change one KV value — no rebuild, no store review. Every user picks it up within a day.&lt;/p&gt;

&lt;p&gt;The part I'm quietly proud of: it costs zero extra requests. The extension already calls &lt;code&gt;/status&lt;/code&gt; to check "does this Google account have a subscription?" (you can't trust the client to self-report that — that's how you get pirated). I just piggybacked the switch values onto that same response. The paywall config rides along on a request I was already making.&lt;/p&gt;

&lt;p&gt;And it degrades safely, three layers deep:&lt;/p&gt;

&lt;p&gt;Worker KV value  →  local cache (chrome.storage)  →  DEFAULT_GATES compiled in&lt;/p&gt;

&lt;p&gt;If the Worker is down, the extension uses the last value it cached. If it's a brand-new install that's never reached the Worker, it uses the defaults baked into the code. No matter what, the main features never break — the switch only decides whether a paid button lets you through or shows an upgrade nudge.&lt;/p&gt;

&lt;p&gt;Two more things I had to get right, both about &lt;em&gt;when&lt;/em&gt; a flip reaches users.&lt;/p&gt;

&lt;p&gt;An extension doesn't poll on its own. So I wired a &lt;code&gt;chrome.alarms&lt;/code&gt; timer that fires every 12h, plus a 24h throttle so I never hammer my own server. Sounds contradictory — 12h alarm but 24h throttle? It isn't. The alarm just knocks on the door; the throttle decides whether to actually go ask. Alarms in MV3 are flaky (the service worker gets recycled), so I knock more often than I need to, and the throttle guarantees at most one real check per 24h. Net effect: a flip reaches almost everyone within 24–36h, hands-off. Someone who wants it now can hit "Refresh" in settings.&lt;/p&gt;

&lt;p&gt;Then I almost over-optimized. I caught myself thinking "if I move the switch into an env var and redeploy, there'd be no request at all." Wrong. The &lt;code&gt;/status&lt;/code&gt; call has to happen regardless — it's verifying the subscription. Moving the switch off KV wouldn't remove a single request; it'd just trade one KV read for a redeploy every time I want to flip pricing. So I left it in KV and added a 300-second in-memory cache in the Worker instead. &lt;code&gt;config:gates&lt;/code&gt; is a value shared by every user that almost never changes, so caching it for 5 minutes cuts that half of my KV reads by ~99% at scale, and the flip still lands within minutes.&lt;/p&gt;

&lt;p&gt;Here's the thing I actually took away from the afternoon.&lt;/p&gt;

&lt;p&gt;I didn't build a feature. I turned an irreversible decision into a reversible knob. "When do we start charging for X" used to be a thing I'd ship — a commit, a build, a review, and if I got it wrong, another one to undo it. Now it's a value I set, watch, and set back if the conversion or the complaints tell me I was wrong.&lt;/p&gt;

&lt;p&gt;For a solo founder guessing at pricing with no data, that reversibility is worth more than any single pricing choice. Being able to &lt;em&gt;change my mind cheaply&lt;/em&gt; beats being right on the first try.&lt;/p&gt;

&lt;p&gt;Pricing is configuration, not code. I wish I'd believed that before I wrote the first hardcoded &lt;code&gt;if&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;What's a decision in your product you hardcoded that you now wish was a switch?&lt;/p&gt;

&lt;p&gt;— building NotebookBloom in public, #12&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>startup</category>
      <category>sass</category>
    </item>
    <item>
      <title>"I'll need a database to query this later" — the most expensive sentence in solo dev</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Thu, 30 Jul 2026 13:02:22 +0000</pubDate>
      <link>https://dev.to/shaojie/ill-need-a-database-to-query-this-later-the-most-expensive-sentence-in-solo-dev-1d95</link>
      <guid>https://dev.to/shaojie/ill-need-a-database-to-query-this-later-the-most-expensive-sentence-in-solo-dev-1d95</guid>
      <description>&lt;p&gt;I was one step from ripping out my key-value store and putting in a real database. The reason I gave myself sounded responsible: "I'll want to query subscription data later." I have zero users. There is no data to query. I almost spent a day building a warehouse for an empty box.&lt;/p&gt;

&lt;p&gt;Here's the setup. My extension's paid tier is validated by a tiny Cloudflare Worker. It stores exactly one kind of thing: given an email, is this person subscribed? That's it. &lt;code&gt;email -&amp;gt; { active, plan, periodEnd }&lt;/code&gt;. A lookup by key, returning one record. No joins, no reporting, no "show me everyone who churned last month." Key-value storage (Cloudflare KV) does this in one line and costs nothing.&lt;/p&gt;

&lt;p&gt;Then the thought crept in: "But what if I want to see all my subscribers? Revenue by month? Who's about to expire? KV can't do those queries. I should use a real database now, while it's early, so I don't have to migrate later."&lt;/p&gt;

&lt;p&gt;That sentence — "so I don't have to migrate later" — is how solo devs talk themselves into building things nobody asked for. I was about to trade a working, free, zero-maintenance store for a heavier one (schemas, SQL, per-row billing) to serve reports I will not read, about customers I do not have.&lt;/p&gt;

&lt;p&gt;Two things stopped me.&lt;/p&gt;

&lt;p&gt;First: the reports I was worried about already exist, and someone else maintains them. It's called the Stripe dashboard. Revenue by month, active subscriptions, who churned, who's due to renew, MRR — it's all right there, built by a team, for free, better than anything I'd hand-roll off my own database. The "I need to query my data" itch was real; I'd just forgotten payments run through Stripe, and Stripe is the query layer. 90% of what I'd ever want to know is a tab I already have open.&lt;/p&gt;

&lt;p&gt;Second: the migration I was avoiding is cheaper than the migration I'd be creating. Switching KV to a database now, before the logic is even verified end-to-end, means changing storage AND payment logic at the same time — two moving parts, more places to break, right before launch. Whereas if I ever genuinely outgrow KV, I'll migrate then, when I know exactly what tables I need because I'll have real queries real users are asking for. "Migrate later with knowledge" beats "migrate now on a guess."&lt;/p&gt;

&lt;p&gt;So I kept KV. The whole detour cost me twenty minutes of doubt instead of a day of rewrite, and the product ships on the simple thing that already works.&lt;/p&gt;

&lt;p&gt;The pattern I keep catching myself in: dressing up "I want to build the fancy version" as "I'm being responsible about the future." The future-proofing instinct is real and sometimes right — but at zero users, almost every "I'll need this later" is a guess, and guesses are the most expensive code you can write, because you maintain them forever and they were never needed.&lt;/p&gt;

&lt;p&gt;The test I now run on myself: is this solving a problem I have, or a problem I imagine a future version of me might have? If it's the second one, and the cost is real today, I don't build it. I write it down and move on.&lt;/p&gt;

&lt;p&gt;What's a piece of infrastructure you added "for later" that later never came for?&lt;/p&gt;

&lt;p&gt;— building NotebookBloom in public, #11&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>startup</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A simple sync feature, and the five problems hiding inside it</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Wed, 29 Jul 2026 13:26:25 +0000</pubDate>
      <link>https://dev.to/shaojie/a-simple-sync-feature-and-the-five-problems-hiding-inside-it-3hdd</link>
      <guid>https://dev.to/shaojie/a-simple-sync-feature-and-the-five-problems-hiding-inside-it-3hdd</guid>
      <description>&lt;p&gt;"Sync your data to your own Google Drive" sounds like a checkbox. It was not a checkbox. It was five problems in a trench coat, and the last one would have let anyone unlock the paid tier for free.&lt;/p&gt;

&lt;p&gt;The premise is nice and clean: I don't want to run a server or hold anyone's data, so I let the extension back up to the user's own Google Drive. Their data, their account, my servers never touch it. Great story. Then I actually used it, and every layer had something wrong.&lt;/p&gt;

&lt;p&gt;Problem one was the button. I'd made a single "Sync now" that figured out on its own whether to upload or download based on timestamps. Clever, and completely confusing. As the user, I clicked it and had no idea what just happened — did it push my local data up, or pull the cloud copy down and overwrite what I had? Worse, if the timestamp logic ever guessed wrong, it could silently overwrite good local data with an old cloud copy. I split it into two honest buttons: "Back up to Drive" (upload, never touches local) and "Restore from Drive" (download). You should never have to guess which direction your data is about to move.&lt;/p&gt;

&lt;p&gt;Problem two: I enabled the toggle, it hung, and both buttons went grey and stayed grey. I couldn't even turn it back off. The cause was embarrassing and common — the enable path set a "syncing" flag to disable the buttons, then awaited the network call, then cleared the flag. But it had no try/finally. So when the auth popup got closed and the call threw, the "clear the flag" line never ran. The buttons were bricked until reload. Any state you set before an await needs a finally to unset it. No exceptions.&lt;/p&gt;

&lt;p&gt;Problem three showed up once auto-backup actually worked: it worked too well. Every data change triggered a backup. But a backup, on success, writes a "last synced at" timestamp to local storage — which is a data change — which triggered another backup. An infinite loop, quietly hammering Drive. The fix was to compare a fingerprint of the real content and ignore the sync-metadata fields, so writing the timestamp doesn't count as a change worth backing up.&lt;/p&gt;

&lt;p&gt;Problem four was subtler and I only caught it by asking "what happens if the two sides disagree?" If local has A, B, C and the cloud has B, C, D, what should Restore do? I'd been overengineering a merge with confirmation dialogs and safety copies. Then I stopped and made it dumb on purpose: Restore only works when local is empty — a fresh browser, a new machine, a cleared cache. If you already have data here, the button is disabled. No merge, no "are you sure," no way to accidentally nuke your local work. Simple and impossible to misuse beats clever and fragile.&lt;/p&gt;

&lt;p&gt;Problem five is the one that matters. I opened the file that got uploaded to Drive to check it, and there in the JSON was my license object: tier "pro". I'd been syncing the whole app state, license included. Which means: buy Pro on one machine, back up, then hit "Restore" on any other browser — even one that never paid — and you inherit tier: pro. One purchase, unlimited devices, for free. My own backup feature was a license bypass. The fix is a one-liner in spirit: strip the license before upload, and on restore keep whatever license the local machine already has. Entitlement gets decided by each device checking with the license server, never by whatever rode along in a synced file.&lt;/p&gt;

&lt;p&gt;None of these were hard once I saw them. That's the whole point of the post. "Back up to Drive" reads like a settings toggle, and inside it were: a UX direction trap, a stuck-state bug, an infinite loop, an over-engineered merge I had to talk myself out of, and a straight-up revenue hole. I found all five not by thinking harder, but by actually using the thing and, once, by just opening the file it produced and reading it.&lt;/p&gt;

&lt;p&gt;If you sync app state anywhere, go open the payload right now and read every field. Ask of each one: should this really travel to another device? I'm very glad I asked before a single real user did.&lt;/p&gt;

&lt;p&gt;What "simple" feature turned out to be a trench coat full of problems for you?&lt;/p&gt;

&lt;p&gt;— building NotebookBloom in public, #10&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>programming</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>I built four products, made $0, and ended up in the ER. Here's the bug I finally found.</title>
      <dc:creator>shaojie gong</dc:creator>
      <pubDate>Tue, 28 Jul 2026 12:52:47 +0000</pubDate>
      <link>https://dev.to/shaojie/i-built-four-products-made-0-and-ended-up-in-the-er-heres-the-bug-i-finally-found-2nci</link>
      <guid>https://dev.to/shaojie/i-built-four-products-made-0-and-ended-up-in-the-er-heres-the-bug-i-finally-found-2nci</guid>
      <description>&lt;p&gt;6:30 in the evening, July 27th. I was in an emergency room.&lt;/p&gt;

&lt;p&gt;Chest tightness. Couldn't pull a full breath. I was sure it was my heart giving out. They ran everything — bloodwork, a cardiac ultrasound, the full panel. It all came back clean. The doctor looked at me and said the thing I least expected and most needed to hear: your heart is fine. Stop being so anxious. Stop panicking.&lt;/p&gt;

&lt;p&gt;Which is a strange thing to be told as a diagnosis. There was nothing mechanically wrong with me. My body was simply handing me the bill for six months of nights — and the currency it wanted was fear.&lt;/p&gt;

&lt;p&gt;And I want to be honest about what the fear actually was, lying on that bed. It wasn't fear of dying, exactly. It was: I can't afford to break down. I have a family. I have a kid. If something happens to me, what happens to them? That was the thought going around and around while the monitor beeped that I was fine.&lt;/p&gt;

&lt;p&gt;I'm an indie developer. I say that the way you'd admit something, not announce it.&lt;/p&gt;

&lt;p&gt;I didn't choose this after reading some blog about freedom and passive income. I chose it because I can feel the ground moving under me. I'm not young anymore, and AI hasn't just made me more capable — it's made everyone more capable. The work I've done my whole life is exactly the kind that's getting easier for anyone to do, which means my job could stop being mine at any moment. That's the trap: too old to feel safe where I am, and the ground I'm standing on getting thinner by the month.&lt;/p&gt;

&lt;p&gt;So I'm not chasing a dream. I'm trying to dig a second well before the first one runs dry — a second stream of income, something of my own, before the choice gets made for me. Building my own thing stopped being ambition and became insurance.&lt;/p&gt;

&lt;p&gt;So I built. And I don't mean toys.&lt;/p&gt;

&lt;p&gt;makelineart.com — a photo-to-line-art converter that runs entirely in your browser (nothing uploaded), with a dozen real modes: tattoo stencils, cross-stitch patterns, laser-engraving templates, pencil sketches. Actual Canny edge detection under the hood, not a filter.&lt;/p&gt;

&lt;p&gt;omniemoji.com — AI-generated emoji packs in five styles, downloadable as PNG/SVG/GIF for Discord, Slack, WhatsApp. Free tier, Pro tier, Stripe, the works.&lt;/p&gt;

&lt;p&gt;taplup.com — a multilingual HTML5 game portal, browser-based, no downloads, 13 languages, PWA-installable.&lt;/p&gt;

&lt;p&gt;GrowBook — a Flutter plant-care app, live on Google Play (com.yuntai.growbook): watering and fertilizing schedules, offline reminders, even on-device AI plant identification.&lt;/p&gt;

&lt;p&gt;Four products. Shipped, working, out in the world, in eleven and thirteen languages, with payment systems wired up and edge deployments and offline-first databases. I did all of it.&lt;/p&gt;

&lt;p&gt;Total revenue across all four: zero.&lt;/p&gt;

&lt;p&gt;Not "not much." Zero. I keep typing that number because part of me still can't believe it.&lt;/p&gt;

&lt;p&gt;Here's what the ER made me stop and stare at. I've gotten good at the part I was already good at — building. I can ship. What I cannot crack, after four attempts, are the two things that actually decide whether any of this feeds my family:&lt;/p&gt;

&lt;p&gt;How do you find a real niche — a group of people with a problem sharp enough that they'll pay to make it go away? And how do you reach them without getting flattened by the platforms?&lt;/p&gt;

&lt;p&gt;I'm not asking rhetorically. I've tried. GrowBook literally came from a Reddit thread — someone confessing they kept killing their plants, and a hundred people replying "same." That's supposed to be the whole playbook: go where people complain, listen for the pain, build the painkiller. So I went back to the well. I spent weeks on Reddit and X, reading, commenting, upvoting, trying to be a real person in real conversations. I didn't spam. I didn't drop links. I broke no rule I could point to.&lt;/p&gt;

&lt;p&gt;I got banned anyway. Both places. No warning I understood, no reason I could find. Whatever small standing I'd built — gone in a click I never saw coming. All that quiet groundwork, deleted.&lt;/p&gt;

&lt;p&gt;I sat in the unfairness of that for a long time. Doing the honest version of the thing and still getting punished for it.&lt;/p&gt;

&lt;p&gt;But lying in that ER bed, wired to a monitor that kept insisting my heart was fine while every other part of me clearly wasn't, I finally caught the real bug. It wasn't in any of the four products. It was in me. I'd been optimizing the wrong variable. My answer to "nothing is working" was always the same word: more. More hours, more nights, more products, more grinding. Push harder. Ship faster. Sleep later.&lt;/p&gt;

&lt;p&gt;But if you pour effort into a broken method, effort just gets you to the broken result faster — in my case, with chest pain attached. The intensity was never the answer. The method was broken. And working harder on a broken method is just a faster route to the same $0.00, and a hospital bracelet.&lt;/p&gt;

&lt;p&gt;I don't have this solved. That's the point of writing it down instead of some triumphant "how I hit $10k MRR" post I have no right to write. What I've decided is small and boring and, for me, radical: I'm going to stop being proud of hours. I'm going to spend less time building and more time on the two things I keep avoiding because they scare me and I'm bad at them — really understanding who a product is for, and finding an honest way to reach those people that a platform can't erase overnight.&lt;/p&gt;

&lt;p&gt;So I'll ask the people who've actually done it, because I clearly haven't yet:&lt;/p&gt;

&lt;p&gt;How did you find your niche — the one where people were ready to pay? And how do you build a presence and market yourself without your account getting wiped for reasons no one will explain?&lt;/p&gt;

&lt;p&gt;I'm asking for real. I'm tired, I'm a little scared, and I'd rather learn from you than from another night that ends in an ER.&lt;/p&gt;

&lt;p&gt;— building NotebookBloom in public&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>buildinpublic</category>
    </item>
  </channel>
</rss>
