<?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>Forem: LazyDev_OH</title>
    <description>The latest articles on Forem by LazyDev_OH (@lazydev_oh).</description>
    <link>https://forem.com/lazydev_oh</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3868972%2F81efd9f9-64e0-4189-93c4-9a8b3a18fff8.png</url>
      <title>Forem: LazyDev_OH</title>
      <link>https://forem.com/lazydev_oh</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/lazydev_oh"/>
    <language>en</language>
    <item>
      <title>I Spent a Week with the MCP Server I Built — 8 Real Cases for Apsity</title>
      <dc:creator>LazyDev_OH</dc:creator>
      <pubDate>Sat, 02 May 2026 15:09:50 +0000</pubDate>
      <link>https://forem.com/lazydev_oh/i-spent-a-week-with-the-mcp-server-i-built-8-real-cases-for-apsity-2cc4</link>
      <guid>https://forem.com/lazydev_oh/i-spent-a-week-with-the-mcp-server-i-built-8-real-cases-for-apsity-2cc4</guid>
      <description>&lt;p&gt;In EP.21 I bolted an MCP server onto Apsity. Four tools: &lt;code&gt;keyword_search&lt;/code&gt;, &lt;code&gt;app_lookup&lt;/code&gt;, &lt;code&gt;list_supported_countries&lt;/code&gt;, &lt;code&gt;keyword_search_history&lt;/code&gt;. While building it I half-doubted I'd actually use it. Then I used it for a week.&lt;/p&gt;

&lt;p&gt;Here's the result: &lt;strong&gt;I use it more than I expected.&lt;/strong&gt; With only four tools, the flow from market discovery to entry decision lands inside one conversation. A keyword search is one line, deep-checking the Top 5 is another, comparing markets is another. The dashboard is for visual exploration; MCP is for fast ask-and-answer — the two channels split naturally.&lt;/p&gt;

&lt;p&gt;This post is the eight prompts I actually ran during that week. Each case shows which tool gets called and what the answer looks like — full screen.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;4 Apsity MCP tools — &lt;code&gt;keyword_search&lt;/code&gt; · &lt;code&gt;app_lookup&lt;/code&gt; · &lt;code&gt;list_supported_countries&lt;/code&gt; · &lt;code&gt;keyword_search_history&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;A week in: chat-window queries handle more work than I expected&lt;/li&gt;
&lt;li&gt;8 cases — name conflict check, multi-region entry pick, ASO keyword gap, tracked competitor watch, launch-week baseline, meta design guide, research planning, 20-country matrix&lt;/li&gt;
&lt;li&gt;Why it helps: deciding and querying happen at the same time, so the friction drops&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  1. "Validate a new app name — conflict + visibility"
&lt;/h2&gt;

&lt;p&gt;The lightest but most frequent question right before launch. "Is this name available? Does anyone else use it? Will it surface in search?" These decisions are hard to undo post-launch, so verifying first is cheap insurance.&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.amazonaws.com%2Fuploads%2Farticles%2Fd3b22pj41kbm4c89r9mh.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.amazonaws.com%2Fuploads%2Farticles%2Fd3b22pj41kbm4c89r9mh.png" alt="Foco Pro name validation" width="800" height="544"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One &lt;code&gt;keyword_search&lt;/code&gt; call. Claude pattern-matches the name field for exact and partial hits. Zero exact = available; partial hits = subtitle has to differentiate — "Foco Pro is available, but it'll surface alongside two existing Foco apps, so pair it with a high-search keyword like Deep Focus Timer."&lt;/p&gt;

&lt;p&gt;A decision that's hard to reverse after launch finishes in one chat exchange. Light query, heavy consequence.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. "Compare 3 markets → pick one"
&lt;/h2&gt;

&lt;p&gt;Same keyword, three markets. Comparison alone is just data. Going one step further to "which market should a solo indie enter, and why" turns it into a decision. So I ask for the recommendation in the same prompt, not a follow-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.amazonaws.com%2Fuploads%2Farticles%2Fau9sve0jemo9aova2677.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.amazonaws.com%2Fuploads%2Farticles%2Fau9sve0jemo9aova2677.png" alt="budget tracker market comparison" width="800" height="544"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three &lt;code&gt;keyword_search&lt;/code&gt; calls (us, jp, kr). Claude builds a matrix — Top 5 avg reviews, dominant model, indie feasibility — then names &lt;strong&gt;US as the entry pick&lt;/strong&gt; with a concrete concept ("envelope + AI auto-categorization") on top. JP needs expensive bank-sync; KR has Toss/Bank Salad eating standalone budget apps; only US still has room for methodology-led differentiation.&lt;/p&gt;

&lt;p&gt;An analysis used for decisions has to ship with the recommendation. MCP fetches the data; the LLM adds judgment.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. "Competitor descriptions → keywords missing from mine"
&lt;/h2&gt;

&lt;p&gt;Core ASO question for any app already in the wild. Which keywords show up across the Top 3 competitor descriptions but are missing from yours? That gap drives search visibility differences. By hand it's an hour.&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.amazonaws.com%2Fuploads%2Farticles%2Ffw741atz1llhcspit7v3.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.amazonaws.com%2Fuploads%2Farticles%2Ffw741atz1llhcspit7v3.png" alt="habit tracker description comparison" width="800" height="544"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two tools chained. &lt;code&gt;keyword_search&lt;/code&gt; for Top 3, then &lt;code&gt;app_lookup&lt;/code&gt; × 3 to pull every description. Claude counts noun/verb keyword frequency, separates 3/3 vs 2/3 shared, then matches against my own description and surfaces what's missing — &lt;code&gt;streak&lt;/code&gt;, &lt;code&gt;daily&lt;/code&gt;, &lt;code&gt;motivation&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The most asked ASO question, answered in one chat exchange. Once the missing keywords land, rewriting the first paragraph of the description is the only follow-up.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. "Tracked competitors → threat &amp;amp; next move"
&lt;/h2&gt;

&lt;p&gt;Apsity's &lt;code&gt;isTracked&lt;/code&gt; flag picks the 5 competitors I follow. Stopping there is monitoring. Going one step further — threat scoring + next move as a solo indie in the same response — turns it into action.&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.amazonaws.com%2Fuploads%2Farticles%2Flks8axiqrgm1hmoe1v4v.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.amazonaws.com%2Fuploads%2Farticles%2Flks8axiqrgm1hmoe1v4v.png" alt="focus tracked competitor threat assessment" width="800" height="544"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One &lt;code&gt;keyword_search&lt;/code&gt; call. Claude scores threats from rating + reviews + price (color-tagged tiers) and lands on actionables: "don't fight Forest head-on (312K reviews)", "differentiate against Focus Keeper on ONE axis (themes / stats / Watch-first)."&lt;/p&gt;

&lt;p&gt;Competitor analysis matters less for "where they are now" and more for "what I do next." Both arriving in the same response is the channel's real strength.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. "Build me a launch-week baseline"
&lt;/h2&gt;

&lt;p&gt;The shakiest moment after launch is week-1 numbers. "Is this normal? Did I just bomb?" The only way to know is comparing against apps that recently launched into the same category. That's a baseline — and it's exactly what MCP is good at producing.&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.amazonaws.com%2Fuploads%2Farticles%2Feed7i7sppfym2qpevvvq.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.amazonaws.com%2Fuploads%2Farticles%2Feed7i7sppfym2qpevvvq.png" alt="launch week baseline for habit tracker" width="800" height="547"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two-tool chain. &lt;code&gt;keyword_search&lt;/code&gt; + &lt;code&gt;app_lookup&lt;/code&gt; filters 30-day-old launches and averages their week-1 reviews/rank/rating. The baseline lands as "~124 reviews, 60% Top 50 entry, 4.4 rating average." Plus an ops guide — "below 100 reviews? rewrite subtitle. rating dropping under 4.3? you'll bounce out of Top 50."&lt;/p&gt;

&lt;p&gt;Post-launch you need a comparison set, not raw data. The baseline tells you whether you're on track, and one chat exchange produces it.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. "Top 10 meta → my app's design guide"
&lt;/h2&gt;

&lt;p&gt;Subtitle, language, secondary-genre patterns from a Top 10 sweep are useful. Turning them into "a metadata design guide for my next app" in the same response is more useful — that's a checklist you can ship with that day.&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.amazonaws.com%2Fuploads%2Farticles%2Frw5z8sfdz38oodlo2yz0.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.amazonaws.com%2Fuploads%2Farticles%2Frw5z8sfdz38oodlo2yz0.png" alt="productivity Top 10 metadata patterns" width="800" height="544"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;keyword_search&lt;/code&gt; + &lt;code&gt;app_lookup&lt;/code&gt; × 10. Claude pulls description-first-sentences, genres, languages, pricing — "verb+noun subtitle, ~22 languages, Lifestyle as secondary, Free+IAP at 70%" — then converts the patterns directly into a checklist.&lt;/p&gt;

&lt;p&gt;An hour by hand, with gaps. In chat the analysis and the design guide arrive together — App Store listing fields can be filled the same day.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. "History → next-week research plan"
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;keyword_search_history&lt;/code&gt; shows footprints. Footprints alone are a postmortem. Asking for "blind spots + next-week priority keywords (5)" in the same response turns the postmortem into a queue.&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.amazonaws.com%2Fuploads%2Farticles%2Fa9z55h8w99orldsg9ig1.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.amazonaws.com%2Fuploads%2Farticles%2Fa9z55h8w99orldsg9ig1.png" alt="keyword search history analysis" width="800" height="544"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;keyword_search_history&lt;/code&gt; with limit 30. Claude clusters the list, spots US-bias and adjacent-keyword zeros, then drops 5 prioritized keywords — &lt;code&gt;screen time&lt;/code&gt;, &lt;code&gt;journal&lt;/code&gt;, &lt;code&gt;deep work&lt;/code&gt;, UK habit tracker, East Asia pomodoro.&lt;/p&gt;

&lt;p&gt;People are bad at noticing what they didn't see. The data sees it. Now my Monday queue auto-fills from one prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. "20-country sweep → top market + plan"
&lt;/h2&gt;

&lt;p&gt;Last case. &lt;code&gt;list_supported_countries&lt;/code&gt; hands back 20 markets, one keyword runs across all of them. The matrix is informative; pushing one step further to "#1 entry market + 3 localization musts" turns global-entry decisions into one response.&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.amazonaws.com%2Fuploads%2Farticles%2Fvwa2nz7rlzj77lo61v3y.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.amazonaws.com%2Fuploads%2Farticles%2Fvwa2nz7rlzj77lo61v3y.png" alt="20 country market matrix Vietnam pick" width="800" height="571"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two tools. &lt;code&gt;list_supported_countries&lt;/code&gt; + &lt;code&gt;keyword_search&lt;/code&gt; × 20. Claude bands markets by Top 5 reviews, picks Vietnam from the EMPTY band based on mobile pop + low entry barrier, then concretes it: lunar-calendar integration, MoMo/ZaloPay payments, Zalo-first share.&lt;/p&gt;

&lt;p&gt;A global-entry decision is normally a multi-day exercise. Burns 21/100 of the STARTER quota — fine for a once-a-month prompt that resets the next month's roadmap.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why four tools is enough
&lt;/h2&gt;

&lt;p&gt;After a week, four tools never felt limiting. Reason: market research is two questions on repeat. "What's ranking?" (&lt;code&gt;keyword_search&lt;/code&gt;) and "what does this app do?" (&lt;code&gt;app_lookup&lt;/code&gt;). Those two carry the load. The other two are just orchestration.&lt;/p&gt;

&lt;p&gt;If anything, having a small surface helps the LLM. Too many tools and Claude burns cycles deciding which to call. Four is small enough to memorize and call by name. "keyword_search US then JP, app_lookup the Top 5" — that level of explicit prompting comes naturally.&lt;/p&gt;

&lt;p&gt;And the LLM is better at recombining tools than I expected. Cases 5 (launch baseline) and 6 (meta patterns) use the same two tools but produce completely different analyses. The tool is the input; the LLM produces the analysis. Keeping them separate works best when the tools stay simple.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why MCP actually helps
&lt;/h2&gt;

&lt;p&gt;It's not the data speed. iTunes was already fast. What's actually different is that &lt;strong&gt;deciding and querying happen at the same time.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;"What should I look at right now?" and "open that view" are usually two steps. In a chat window they collapse into one. Typing "meditation US Top 50" decides and queries simultaneously. So I end up checking data I'd normally skip.&lt;/p&gt;

&lt;p&gt;The answer stays in chat history too. A dashboard is great for visual exploration — sort, filter, charts on one screen — and chat is great for fast ask-and-answer. They sit naturally side by side. Big-picture work goes to the dashboard; pinpoint questions go to chat.&lt;/p&gt;

&lt;p&gt;Last thing — and I only got this from building it: MCP's value isn't "hand data to Claude." It's "make data access cheaper for a person." The AI isn't doing my work; it's making my work easier to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next episode
&lt;/h2&gt;

&lt;p&gt;This episode was the catalog of "what you can ask." EP.23 goes a level deeper: gap analysis, idea validation, market-entry feasibility — how to compose four tools into a real decision-making workflow.&lt;/p&gt;

&lt;p&gt;And the episodes after that get into combining other MCPs. With Notion, Slack, or Email MCP, "summarize this week's new competitors into a Notion page and ping Slack" becomes one line. That's where this whole thing is actually heading.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://gocodelab.com/en/blog/en-apsity-mcp-natural-queries-ep22" rel="noopener noreferrer"&gt;GoCodeLab&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>indiehackers</category>
      <category>mcp</category>
    </item>
    <item>
      <title>I Shipped 3 Major Features in 3 Days — Keyword Search, MCP Server, Monthly Magazine</title>
      <dc:creator>LazyDev_OH</dc:creator>
      <pubDate>Thu, 30 Apr 2026 16:53:05 +0000</pubDate>
      <link>https://forem.com/lazydev_oh/i-shipped-3-major-features-in-3-days-keyword-search-mcp-server-monthly-magazine-5dm5</link>
      <guid>https://forem.com/lazydev_oh/i-shipped-3-major-features-in-3-days-keyword-search-mcp-server-monthly-magazine-5dm5</guid>
      <description>&lt;p&gt;One major feature normally eats a week. Design, implementation, UI, i18n, marketing pages, docs. One at a time. That's the rule I learned. From April 28 to 30, I shipped three of them. Keyword Search, an MCP server, and a monthly magazine. 41 commits, +15,380 lines.&lt;/p&gt;

&lt;p&gt;Up front: I didn't build all three with the same hands at the same time. I finished one per day. They landed in the same three days. The reason it worked was simple — I sliced each feature into phases, and the unit I gave Claude was small. This post is a record of those three days.&lt;/p&gt;

&lt;p&gt;EP.02 was where I built the Apsity dashboard. EP.03 added AI insights on top. That's the base. The three things I shipped this week sit on top of that — keyword discovery, calling data from Claude directly, and an automated monthly magazine.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;April 28-30. 41 commits, +15,380 lines. Three major Apsity features at once&lt;/li&gt;
&lt;li&gt;Day 1: Keyword Search (14 tasks). 18 countries, Top 50, AI summary, watchlist&lt;/li&gt;
&lt;li&gt;Day 2: MCP server. Apsity data callable from Claude. FREE plan blocked at key issuance&lt;/li&gt;
&lt;li&gt;Day 3: Monthly magazine. Phases 1-7 in one day. Auto-sent on the 1st of each month&lt;/li&gt;
&lt;li&gt;What made it possible: phase slicing per feature + small Claude prompts&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why these three at once
&lt;/h2&gt;

&lt;p&gt;They weren't separate items. They were a single flow. Apsity already shows registered apps well — revenue, downloads, keyword ranks, competitor changes. What was missing: "Where do I find new keywords?", "How do I use this data in other tools?", and "I want a one-page view of what happened this month."&lt;/p&gt;

&lt;p&gt;Keyword Search is discovery. ASO ultimately comes down to picking which keywords to rank for. Looking only at registered keywords is a fishbowl. You need to see how other apps are showing up. So I pull iTunes Top 50 across 18 countries, let AI summarize, and let the user save promising keywords to a watchlist.&lt;/p&gt;

&lt;p&gt;The MCP server is the exit door. Sometimes you want to ask the data in natural language from Claude instead of opening Apsity. "How was my revenue yesterday?" — Claude asks Apsity and answers. I'd been thinking about this since I built npm-subscriber-mcp in EP.15.&lt;/p&gt;

&lt;p&gt;The monthly magazine is the look-back. Daily alerts came in EP.03. But daily is noisy. After a month, you want to look back and see what happened — and that data is scattered. Aggregate it on the 1st, send it as email, done.&lt;/p&gt;

&lt;p&gt;Together: discovery → use → look-back. Both ends of a workflow that were missing. That's why they shipped together.&lt;/p&gt;

&lt;h2&gt;
  
  
  How concurrency was possible — slicing phases
&lt;/h2&gt;

&lt;p&gt;Three week-long features in three days. The reason it worked is simple. I never looked at a feature as one big lump. I sliced it into small phases. Each phase ends with a working artifact and a commit.&lt;/p&gt;

&lt;p&gt;The monthly magazine, for example, was sliced like this.&lt;br&gt;
&lt;/p&gt;

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

Phase 1 — Language setting (ko/en in Settings)
Phase 2 — Monthly aggregation function
Phase 3 — Claude generates the magazine body
Phase 4 — 4 card components (metrics/chart/reviews/suggestions)
Phase 5 — Magazine page render
Phase 6 — Email send (4 cards inline)
Phase 7 — CLI test tool
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Phases 1 and 2 are independent. Phase 3 takes the output of 2. Phases 4 and 5 are built on Phase 3's result. The dependency graph looks serial, but Phase 2 and 4 can run in parallel. Define the data shape early, then build the aggregation query and the card UI separately.&lt;/p&gt;

&lt;p&gt;Two benefits. First, the unit I throw at Claude shrinks. "Build me a magazine system" is too big. "Phase 4: just the metrics card component. Input is this object, output is a React component" is precise. Second, when something looks off, I can stop at that phase. I rarely lose a whole day.&lt;/p&gt;

&lt;p&gt;Keyword Search was 14 tasks. MCP was 5 stages — server code, auth, gating, UI, docs. The big picture stays in my head, but execution moves in small steps. That's the whole trick.&lt;/p&gt;

&lt;h2&gt;
  
  
  Day 1 (4/28) — Keyword Search
&lt;/h2&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.amazonaws.com%2Fuploads%2Farticles%2F464mvqea602l40vo0lqp.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.amazonaws.com%2Fuploads%2Farticles%2F464mvqea602l40vo0lqp.png" alt="Apsity Keyword Search input + AI Summary card" width="800" height="390"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Day 1 — Search input + AI Summary / GoCodeLab&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0zelsniusrx9htzrrag5.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.amazonaws.com%2Fuploads%2Farticles%2F0zelsniusrx9htzrrag5.png" alt="Apsity Keyword Search top-50 results list" width="800" height="393"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Day 1 — Top 50 results list / GoCodeLab&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;April 28 went entirely to keyword search. I shipped 14 tasks at once. It's a tool that searches iTunes Top 50 across 18 countries — not just registered apps, but any app worldwide, by keyword.&lt;/p&gt;

&lt;p&gt;I started with requirements. Search form, results list, side panel on row click for app detail, AI summary, search history, watchlist. Free/paid filter, daily limit, input validation. Korean and English i18n. And marketing — Pricing page mention, landing demo, blog announcement in both languages.&lt;/p&gt;

&lt;p&gt;Here are the 14 tasks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// Keyword Search 14 tasks

Backend: iTunes Top 50 helper, daily limit, search history, validation
API: POST /api/keywords/search, history GET/DELETE, summary
DB: KeywordSearchHistory + plan-limits extension
Components: SearchForm, SearchResults, SearchHistory, AISummary, SearchTab
UI: /dashboard/keywords tab, side panel, free/paid filter, watchlist
i18n: full ko/en split
Marketing: Pricing/Landing/Blog ko·en
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The core was gating. Daily limits sit in plan-limits. FREE gets N per day, STARTER and up get more. Side panel detail is STARTER+. Watchlist plus daily snapshots are PRO-gated. The UI blocks, the API blocks again. That's the pattern I learned in EP.07 with LemonSqueezy. If you only block at the UI layer, someone routes around it.&lt;/p&gt;

&lt;p&gt;Three follow-up fixes after launch. AI summary came back as markdown so asterisks were rendering literally — added a markdown renderer. SWR cache flickered on every search — keepPreviousData option. Side panel scrolled the page background — body overflow lock. None of these show up unless you actually use the thing. The gap between "code that runs" and "product I'd use" is exactly here. Ship 80% fast, fix the last 20% when real problems show up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Day 2 (4/29) — MCP server
&lt;/h2&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.amazonaws.com%2Fuploads%2Farticles%2Fwld3e22vgo1doh1vohlk.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.amazonaws.com%2Fuploads%2Farticles%2Fwld3e22vgo1doh1vohlk.png" alt="Apsity Settings — MCP API key issuance and Claude Desktop config snippet" width="800" height="392"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Day 2 — MCP API key screen / GoCodeLab&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;April 29 went to MCP. EP.15 already taught me the pattern with npm-subscriber-mcp. That one exposed npm download data to Claude. This one exposes Apsity data.&lt;/p&gt;

&lt;p&gt;The server itself is a two-day job at most. Use &lt;code&gt;@modelcontextprotocol/sdk&lt;/code&gt; to build a stdio server, define tools, and have handlers call the Apsity API. Where I actually spent time was gating.&lt;/p&gt;

&lt;p&gt;The problem: MCP is called from external clients like Claude. The key runs in an environment the user doesn't directly control. If a FREE-plan user calls it without limits, costs leak. So how do we block it?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;// MCP gating — defense in depth

Layer 1 — Settings UI: issue button disabled on FREE
Layer 2 — POST /api/mcp/keys: server checks plan, blocks
Layer 3 — On MCP call: validate key + re-check plan
Layer 4 — Per-tool gating (PRO-only tools separated)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you bypass the UI and hit the API directly, blocked. If you grab a key while on STARTER, downgrade, and try to call it, blocked. Security can't sit in one place. EP.06 and EP.07 taught me that.&lt;/p&gt;

&lt;p&gt;UI is a new tab in Settings. Issue API key, list issued keys, revoke. Key is shown once. Afterwards only the last 4 chars. That's the GitHub Personal Access Token pattern.&lt;/p&gt;

&lt;p&gt;Marketing got an MCP demo section on the landing page and a guide at &lt;code&gt;/docs/mcp&lt;/code&gt; in both languages. Claude Desktop config JSON, example conversations, issuance walkthrough. Ship without marketing and nobody knows it exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  Day 3 (4/30) — monthly magazine
&lt;/h2&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.amazonaws.com%2Fuploads%2Farticles%2Fv76qdg58l7qjjkl7kimo.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.amazonaws.com%2Fuploads%2Farticles%2Fv76qdg58l7qjjkl7kimo.png" alt="Apsity Monthly Magazine page 1 — April revenue / downloads / rating / market summary cards" width="800" height="1000"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Day 3 — Magazine page 1 (Wrap, sample data) / GoCodeLab&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;April 30 — monthly magazine. Phases 1-7 from above, all in one day. It worked because the magazine doesn't generate new data; it organizes existing data. Revenue, downloads, reviews, keywords — already there. Aggregate, summarize with AI, render as cards, email it.&lt;/p&gt;

&lt;p&gt;Phases 1-3 were the data pipeline. Add a magazine language setting, build the monthly aggregation, generate the body with Claude. This is where currency conversion came in for the first time. KRW and USD revenue mixed; if magazine display currency is KRW, USD has to be converted. Exchange rate cached at the 1st-of-month value. Plus subscription deduplication — same payment recorded twice in some edge cases.&lt;/p&gt;

&lt;p&gt;Phases 4-7 were the output. Four card components — key metrics, trend chart, review highlights, next-month suggestions. Magazine page render. Email send via Resend with all four cards inline. CLI test tool to preview an arbitrary month.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;May 1 single-shot test cron&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The real cron runs at 5am KST on the 1st of each month. But I shipped on the 30th and couldn't wait until the 1st to verify. So I added a one-time cron that runs only on May 1st, sending myself the magazine in both Korean and English. After verification, only the regular monthly cron stays.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The magazine is data aggregation plus auto-send, but to a user it's just "an email shows up on the 1st." The four cards inside, the FX handling, the dedup — all invisible. Time goes into invisible details. EP.04 was the first time I felt this with FeedMission. Going from MVP to product takes longer than the MVP itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that snuck in
&lt;/h2&gt;

&lt;p&gt;Three things crept in around the major work.&lt;/p&gt;

&lt;p&gt;One — auto-recovery for paid-but-not-signed-up edge case. Sometimes payment goes through before signup completes. Used to be manual. Built a PendingSubscription model — store payment temporarily, match on signup, auto-activate. I knew about this case from EP.07 but had pushed it off.&lt;/p&gt;

&lt;p&gt;Two — VAT/tax disclaimer near pricing. Tiny addition on the Pricing page. Skipping it means post-purchase emails asking "why did the price go up?"&lt;/p&gt;

&lt;p&gt;Three — Korean translations of 9 English posts. Marketing blog had been English-first, but Korean users need Korean. Translated 9. Plus fixed Korean detection by reading the entire &lt;code&gt;navigator.languages&lt;/code&gt; array — some browsers don't put ko-KR first.&lt;/p&gt;

&lt;p&gt;These slip in between major work. One hour at a time. "Doing one thing at a time" is a fantasy; in reality, small things move alongside the big ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retrospective — what really made it work
&lt;/h2&gt;

&lt;p&gt;Reasons three in three days worked:&lt;/p&gt;

&lt;p&gt;One — no new stack. Next.js + Supabase + Vercel + Prisma + Resend. All running since EP.02. No learning tax. MCP I'd already done in EP.15, so the pattern was familiar.&lt;/p&gt;

&lt;p&gt;Two — small phases. Each phase finishes in 30 minutes to 2 hours. That keeps the prompt to Claude small and the verification fast. "Build a magazine system" becomes "write a function that generates magazine body via Claude. Input is this object, output is markdown."&lt;/p&gt;

&lt;p&gt;Three — verify every phase. Don't run end to end and pray. EP.04 was the lesson: 52-minute MVP, then 6 more days of work. Fast generation isn't fast completion. Fast verification is fast completion.&lt;/p&gt;

&lt;p&gt;Four — marketing in the same loop. Usually you build the feature, then make marketing pages, then more days disappear. This time Pricing/Landing/Blog ko·en were folded into the feature task list. Shipping = feature + marketing + docs. That's a real ship.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coming next
&lt;/h2&gt;

&lt;p&gt;How keyword search actually works, the MCP server architecture, the magazine data pipeline — each is its own deep-dive. EP.22 onward will go into one at a time. This post is just the record of how I ran three at once.&lt;/p&gt;

&lt;p&gt;That's how the last week of April ended. May 1, 5am KST — the magazine sends itself for the first time. If that goes through, it's truly shipped.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Q. Did you really build three features at the same time?
&lt;/h3&gt;

&lt;p&gt;Not literally at the same time. I finished one major feature per day, and inside each feature I sliced things into phases that ran in order. Concurrency here means three week-long projects landing in the same week, not two hands typing two features.&lt;/p&gt;

&lt;h3&gt;
  
  
  Q. How did you build the MCP server?
&lt;/h3&gt;

&lt;p&gt;Stdio server on &lt;code&gt;@modelcontextprotocol/sdk&lt;/code&gt;. The hard part was auth and gating. Users get an API key from the Settings UI, and FREE plan key issuance is blocked at the API layer too — defense in depth. UI gate plus server gate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Q. How do you slice phases?
&lt;/h3&gt;

&lt;p&gt;Independently runnable units. For the magazine, phases 1-3 were the data pipeline and 4-7 were rendering, email, and CLI. Each phase ends with a commit and a quick check. I never run end to end before validating an intermediate output.&lt;/p&gt;

&lt;h3&gt;
  
  
  Q. Is Claude doing all of this?
&lt;/h3&gt;

&lt;p&gt;No. I write the requirements, define inputs and outputs per phase, throw the implementation to Claude, verify, and move on. The speedup isn't code generation — it's the entire decision loop. Vague requirements produce vague code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Related posts
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://gocodelab.com/en/blog/en-npm-subscriber-mcp-ep15" rel="noopener noreferrer"&gt;I got tired of checking npm downloads, so I made an MCP (EP.15)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://gocodelab.com/en/blog/en-feedmission-saas-7days-mvp-ep04" rel="noopener noreferrer"&gt;I built a SaaS in 7 days (EP.04)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://gocodelab.com/en/blog/en-apsity-app-store-analytics-dashboard-ep02" rel="noopener noreferrer"&gt;I got tired of checking 12 apps' revenue, so I built a dashboard (EP.02)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>indiehackers</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Supabase RLS — 5 Common Mistakes I Broke and Fixed Myself</title>
      <dc:creator>LazyDev_OH</dc:creator>
      <pubDate>Thu, 30 Apr 2026 04:53:33 +0000</pubDate>
      <link>https://forem.com/lazydev_oh/supabase-rls-5-common-mistakes-i-broke-and-fixed-myself-38bl</link>
      <guid>https://forem.com/lazydev_oh/supabase-rls-5-common-mistakes-i-broke-and-fixed-myself-38bl</guid>
      <description>&lt;p&gt;I had RLS enabled on a Supabase project and data still leaked. A single anon API key read another user's entire notes table. No error message. The problem was that I'd only configured half of it. It took two hours to find what was missing.&lt;/p&gt;

&lt;p&gt;RLS (Row Level Security) is PostgreSQL's row-level security feature. The simple way to picture it: a lock on every row of a table. Supabase ships it by default, but if the setup is half-done, it gets quietly broken. This is the 5 mistake patterns I found while reproducing real attack scenarios myself.&lt;/p&gt;

&lt;p&gt;Block these five and you stop most data leaks. The difference is one line of code, one policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Look
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mistake 1&lt;/strong&gt; — RLS itself wasn't enabled → table fully public. &lt;code&gt;ALTER TABLE ... ENABLE ROW LEVEL SECURITY&lt;/code&gt; required&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mistake 2&lt;/strong&gt; — RLS on, no policies → returns 0 rows (silent failure). At least 1 policy required&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mistake 3&lt;/strong&gt; — &lt;code&gt;auth.uid()&lt;/code&gt; called directly → re-runs per row, slow. Replace with &lt;code&gt;(SELECT auth.uid())&lt;/code&gt; pattern&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mistake 4&lt;/strong&gt; — UPDATE WITH CHECK missing → user_id can be tampered with. USING + WITH CHECK together always&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mistake 5&lt;/strong&gt; — INSERT with no role specified → anon can write. &lt;code&gt;TO authenticated&lt;/code&gt; must be explicit&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bonus&lt;/strong&gt; — service_role key exposed to client → RLS fully bypassed. Isolate to server-only env vars&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What RLS Is — Why Mistakes Happen
&lt;/h2&gt;

&lt;p&gt;RLS is PostgreSQL's row-level access control. Unlike GRANT, which controls read/write at the whole-table level, RLS attaches a condition to each row so only specific rows can be seen. The "you can only see your own data" rule is enforced at the DB layer, server-side. No matter how sloppy the app code is, the DB layer blocks access.&lt;/p&gt;

&lt;p&gt;Supabase Auth puts the logged-in user's UUID into a JWT token and passes it along. Calling &lt;code&gt;auth.uid()&lt;/code&gt; inside an RLS policy retrieves that UUID. Compare it to the table's user_id column to restrict access to your own rows. Break that link and data leaks anywhere.&lt;/p&gt;

&lt;p&gt;Supabase has three roles. &lt;code&gt;anon&lt;/code&gt; is the anonymous user accessing without login. &lt;code&gt;authenticated&lt;/code&gt; is a user logged in via Supabase Auth. &lt;code&gt;service_role&lt;/code&gt; is an admin key that bypasses RLS. If a policy doesn't specify a role, it applies to all three. Not knowing this means anon users become subject to the policy and end up with unintended access.&lt;/p&gt;

&lt;p&gt;Why do mistakes happen so often? Because RLS setup is split across stages. First you enable RLS on the table, then create policies, then specify roles and conditions inside each policy. Drop any of these and the table is silently breached or silently locked. No error message — that makes it harder to find.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 1 — RLS Itself Wasn't Enabled
&lt;/h2&gt;

&lt;p&gt;When you create a new table in Supabase, RLS is off by default. &lt;code&gt;CREATE TABLE&lt;/code&gt; alone makes the entire table queryable with the anon API key. The Dashboard Table Editor shows a "RLS disabled" warning, but it gets ignored often. Deploy in this state and anyone can read the table.&lt;/p&gt;

&lt;p&gt;I tested this directly. A curl with the anon key returned all the data as-is. No authentication, content field exposed too. One missing line of RLS does it. Some devs turn RLS off during development for convenience, but it must be on before deploy.&lt;/p&gt;

&lt;p&gt;The safest pattern is to put the ENABLE line right next to &lt;code&gt;CREATE TABLE&lt;/code&gt; in migration files.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Vulnerable: no RLS → fully public&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;      &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="n"&gt;UUID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="nb"&gt;TEXT&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Fix: run immediately after CREATE TABLE&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Find all tables with RLS off (result should be 0 rows)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rowsecurity&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_tables&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;rowsecurity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I made a query to find tables with RLS off. Adding this query to a CI/CD pipeline catches it automatically before deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 2 — RLS Enabled but No Policies
&lt;/h2&gt;

&lt;p&gt;Sometimes you enable RLS and no data shows up at all. No error. Just an empty array &lt;code&gt;[]&lt;/code&gt;. At first I couldn't tell whether it was a data bug or a security setup issue. After staring at the wrong query for a while, I finally realized there were no policies.&lt;/p&gt;

&lt;p&gt;If RLS is enabled and zero policies exist, PostgreSQL blocks all access by default. This is called &lt;strong&gt;implicit deny&lt;/strong&gt;. It returns 0 rows with no error message, so it looks like a bug. You need at least one allow policy for data to show up.&lt;/p&gt;

&lt;p&gt;The default (PERMISSIVE) policies combine with OR when there are multiple on the same operation. Passing one is enough for access. RESTRICTIVE policies combine with AND and must all pass. Most cases use PERMISSIVE; use RESTRICTIVE only when you need additional restrictions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Vulnerable: RLS on, no policies → returns 0 rows (silent failure)&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- No policies → all blocked, returns [] with no error&lt;/span&gt;

&lt;span class="c1"&gt;-- Correct: RLS + policy together&lt;/span&gt;
&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"Users see own notes"&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- List policies on the current table&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;policyname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qual&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_policies&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'notes'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Mistake 3 — auth.uid() Called Directly
&lt;/h2&gt;

&lt;p&gt;When writing policies, many people put &lt;code&gt;auth.uid()&lt;/code&gt; directly in the USING clause. It works. But there's a performance trap. &lt;strong&gt;This pattern calls &lt;code&gt;auth.uid()&lt;/code&gt; once per row of the table.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;10,000 rows means 10,000 &lt;code&gt;auth.uid()&lt;/code&gt; calls. Wrap it in a subquery as &lt;code&gt;(SELECT auth.uid())&lt;/code&gt; and it runs once per query. The &lt;a href="https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select" rel="noopener noreferrer"&gt;Supabase official docs&lt;/a&gt; recommend this pattern. The bigger the table, the wider the gap.&lt;/p&gt;

&lt;p&gt;I compared the two with &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;. On a 50,000-row table, the direct call did a Seq Scan with 50,000 function executions. The &lt;code&gt;(SELECT auth.uid())&lt;/code&gt; version had 1 function execution and used an index scan. Query time differed by more than 4x.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Slow: re-calls auth.uid() per row&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"slow policy"&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Fast: called once per query (recommended)&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"fast policy"&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Replace existing policy&lt;/span&gt;
&lt;span class="k"&gt;DROP&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="nv"&gt;"slow policy"&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"fast policy"&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Add user_id index (if missing, add it)&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;notes_user_id_idx&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Mistake 4 — UPDATE WITH CHECK Missing
&lt;/h2&gt;

&lt;p&gt;UPDATE policies have two clauses: USING and WITH CHECK. &lt;strong&gt;USING applies when picking which rows to modify. WITH CHECK validates that the post-modification result still satisfies the condition.&lt;/strong&gt; Drop WITH CHECK and a row's ownership can be transferred to another user.&lt;/p&gt;

&lt;p&gt;The scenario is UPDATE-ing my note's user_id to another user's UUID. With USING only, the pre-modification row is mine, so the condition passes. With no post-modification check, it saves as-is. My note is now owned by another user.&lt;/p&gt;

&lt;p&gt;This attack is real and reproducible. From the JavaScript client, send &lt;code&gt;.update({ user_id: 'other-user-uuid' })&lt;/code&gt;. With a USING-only policy, the request succeeds. The target then SELECTs their own data and gets the tampered note. Data integrity broken.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Vulnerable: no WITH CHECK → user_id can be tampered&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"update own notes (vulnerable)"&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Correct: USING + WITH CHECK both specified&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"update own notes"&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt;      &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;UPDATE policies must always include both USING and WITH CHECK. Even if the two conditions are identical expressions, both must be specified. PostgreSQL's docs explicitly state this behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistake 5 — INSERT Policy with No Role Specified
&lt;/h2&gt;

&lt;p&gt;If an INSERT policy doesn't include a TO clause, PostgreSQL applies the policy to all roles by default. &lt;strong&gt;That includes anon.&lt;/strong&gt; If WITH CHECK is loose, data can be written without login.&lt;/p&gt;

&lt;p&gt;Especially when &lt;code&gt;WITH CHECK (true)&lt;/code&gt; is used as a fully permissive condition with no role specified, anyone can INSERT. Spam data piles up and the table grows fast. Just specifying &lt;code&gt;TO authenticated&lt;/code&gt; blocks it.&lt;/p&gt;

&lt;p&gt;Even &lt;code&gt;WITH CHECK (auth.uid() = user_id)&lt;/code&gt; isn't fully safe. In the anon role, &lt;code&gt;auth.uid()&lt;/code&gt; returns NULL. NULL = UUID comparison is FALSE, so it looks blocked. But if user_id has no NOT NULL constraint and the client sends user_id as NULL, you get a NULL = NULL comparison. Depending on DB version or settings, that can pass.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Vulnerable: no role + loose condition → anon can INSERT&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"insert notes (vulnerable)"&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt;
  &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Incomplete: NULL comparison may accidentally pass&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"insert notes (incomplete)"&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt;
  &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- auth.uid() is NULL in anon → NULL = NULL comparison risk&lt;/span&gt;

&lt;span class="c1"&gt;-- Correct: TO authenticated + ownership check&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"insert own notes"&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The principle is to not depend on accidental condition pass-through. Specifying &lt;code&gt;TO authenticated&lt;/code&gt; blocks anon-role requests before policy evaluation. Specifying the role makes intent explicit and prevents unexpected bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus — service_role Key in the Client
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;service_role&lt;/code&gt; key bypasses RLS. Requests with this key can access every row. Put this key in a browser or mobile app and the entire RLS setup becomes meaningless. Anyone can open DevTools, extract the key, and access all data.&lt;/p&gt;

&lt;p&gt;It must be managed only as a server-side environment variable. In Next.js terms, manage it as a server-side variable without the &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; prefix. The client should only get the anon key. Any name like &lt;code&gt;NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY&lt;/code&gt; in code must be fixed immediately.&lt;/p&gt;

&lt;p&gt;If the key is already exposed, rotate it immediately. Project Settings → API → API Keys. The old key expires the moment a new one is issued. Same applies if the key was committed to GitHub. Even in git history, rotate immediately.&lt;/p&gt;

&lt;p&gt;The recommended Next.js pattern is to split the Supabase client into two. One for the browser (anon key) and one for the server (service_role key), each created separately. &lt;code&gt;createBrowserClient&lt;/code&gt; and &lt;code&gt;createServerClient&lt;/code&gt; handle that role.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Key&lt;/th&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;th&gt;RLS&lt;/th&gt;
&lt;th&gt;Public?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;anon&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;browser, app client&lt;/td&gt;
&lt;td&gt;Applied&lt;/td&gt;
&lt;td&gt;Safe to expose&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;service_role&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;server-only (Edge Functions, API Routes)&lt;/td&gt;
&lt;td&gt;Bypassed&lt;/td&gt;
&lt;td&gt;Never expose&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Real-World Scenario — Full RLS Setup for a Multi-User Notes App
&lt;/h2&gt;

&lt;p&gt;I built the full CRUD-protected RLS setup for a notes app from scratch. SELECT, INSERT, UPDATE, DELETE — all four needed. The structure ends up with 4 policies on one table.&lt;/p&gt;

&lt;p&gt;Order of setup matters. Run as: create table → enable RLS → policies. Naming policies clearly with intent helps when debugging later. "notes: select own" — table name + operation + target — is readable even six months later.&lt;/p&gt;

&lt;p&gt;Skip the DELETE policy and other users can delete your notes. DELETE also needs a USING condition. SELECT and DELETE need only USING, INSERT needs only WITH CHECK, UPDATE needs both.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Full RLS setup example for a multi-user notes app&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;id&lt;/span&gt;         &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;PRIMARY&lt;/span&gt; &lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt;    &lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="k"&gt;REFERENCES&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;content&lt;/span&gt;    &lt;span class="nb"&gt;TEXT&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt; &lt;span class="k"&gt;DEFAULT&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"notes: select own"&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"notes: insert own"&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"notes: update own"&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt;      &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"notes: delete own"&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Index that directly affects RLS performance&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;notes_user_id_idx&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After setup I tested three scenarios. Do my notes show up under my UUID? Are my notes hidden under a different UUID? Does the anon key see nothing? All three behaved as expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mixed Public/Private Posts
&lt;/h2&gt;

&lt;p&gt;For blog-like data where public and private posts share a table with an &lt;code&gt;is_public&lt;/code&gt; column. Two PERMISSIVE policies combine with OR — &lt;code&gt;anon&lt;/code&gt; users pass only the public post policy, &lt;code&gt;authenticated&lt;/code&gt; users pass either the public post policy or the own-post policy.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Policy 1: public posts readable by anyone (including anon)&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"posts: select public"&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;is_public&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Policy 2: own posts readable regardless of public/private status&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"posts: select own"&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Two policies combined with OR:&lt;/span&gt;
&lt;span class="c1"&gt;-- anon: sees only public posts&lt;/span&gt;
&lt;span class="c1"&gt;-- owner: sees all own posts (public + private)&lt;/span&gt;
&lt;span class="c1"&gt;-- other users: see only that user's public posts&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  RLS Policy Checklist
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Mistake Pattern&lt;/th&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Risk Level&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;RLS not enabled&lt;/td&gt;
&lt;td&gt;All data exposed&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Critical&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ENABLE ROW LEVEL SECURITY&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No policies&lt;/td&gt;
&lt;td&gt;Returns 0 rows (no error)&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Add at least 1 SELECT policy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;auth.uid()&lt;/code&gt; called directly&lt;/td&gt;
&lt;td&gt;Slow queries&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Replace with &lt;code&gt;(SELECT auth.uid())&lt;/code&gt; pattern&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UPDATE WITH CHECK missing&lt;/td&gt;
&lt;td&gt;user_id can be tampered&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Add WITH CHECK&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;INSERT with no role&lt;/td&gt;
&lt;td&gt;anon can write&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Specify &lt;code&gt;TO authenticated&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;service_role exposed to client&lt;/td&gt;
&lt;td&gt;RLS completely bypassed&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Critical&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Move to server-only env var&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Operation&lt;/th&gt;
&lt;th&gt;USING&lt;/th&gt;
&lt;th&gt;WITH CHECK&lt;/th&gt;
&lt;th&gt;Role (TO)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SELECT&lt;/td&gt;
&lt;td&gt;Required&lt;/td&gt;
&lt;td&gt;Not required&lt;/td&gt;
&lt;td&gt;authenticated (anon too for public content)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;INSERT&lt;/td&gt;
&lt;td&gt;Not required&lt;/td&gt;
&lt;td&gt;Required&lt;/td&gt;
&lt;td&gt;authenticated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UPDATE&lt;/td&gt;
&lt;td&gt;Required&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Required (often missed)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;authenticated&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DELETE&lt;/td&gt;
&lt;td&gt;Required&lt;/td&gt;
&lt;td&gt;Not required&lt;/td&gt;
&lt;td&gt;authenticated&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Adding RLS to an Existing Project
&lt;/h2&gt;

&lt;p&gt;Order matters when adding RLS late. Either create the policies before enabling RLS, or wrap them in a transaction and run them together. Enabling RLS alone without policies puts the service in a 0-row state, even if only briefly. Wrapping with BEGIN/COMMIT applies it atomically.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Migrating an existing project: wrap RLS + policies in a transaction&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt; &lt;span class="n"&gt;ENABLE&lt;/span&gt; &lt;span class="k"&gt;ROW&lt;/span&gt; &lt;span class="k"&gt;LEVEL&lt;/span&gt; &lt;span class="k"&gt;SECURITY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"notes: select own"&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"notes: insert own"&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt; &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"notes: update own"&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"notes: delete own"&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;DELETE&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- RLS enablement and policy creation applied atomically&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How to Properly Verify RLS
&lt;/h2&gt;

&lt;p&gt;After creating policies, actual testing is necessary. The Supabase SQL Editor lets you set the role and JWT claims directly.&lt;/p&gt;

&lt;p&gt;Two things must be confirmed. &lt;strong&gt;Does my data appear with my UUID? Does my data not appear with another UUID?&lt;/strong&gt; The second is more important. In many cases own data shows up fine, but blocking access to others' data is missing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Testing RLS in Supabase SQL Editor&lt;/span&gt;

&lt;span class="c1"&gt;-- 1. Test authenticated role with my UUID&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="s1"&gt;'authenticated'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="nv"&gt;"request.jwt.claims"&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="s1"&gt;'{"sub": "my-user-uuid-here"}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- Only my notes should appear&lt;/span&gt;

&lt;span class="c1"&gt;-- 2. Access with another UUID (should return 0 rows)&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="nv"&gt;"request.jwt.claims"&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="s1"&gt;'{"sub": "other-user-uuid"}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- Should return 0 rows&lt;/span&gt;

&lt;span class="c1"&gt;-- 3. Test anon role&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;LOCAL&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="k"&gt;TO&lt;/span&gt; &lt;span class="s1"&gt;'anon'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- Should return 0 rows&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Firing a curl with the anon key directly is also essential. Hitting the actual API endpoint, not the SQL Editor, gives the real picture.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Direct API call test with anon key&lt;/span&gt;
curl &lt;span class="s1"&gt;'https://&amp;lt;project-ref&amp;gt;.supabase.co/rest/v1/notes?select=*'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'apikey: &amp;lt;anon-key&amp;gt;'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Authorization: Bearer &amp;lt;anon-key&amp;gt;'&lt;/span&gt;

&lt;span class="c"&gt;# Result should be []&lt;/span&gt;
&lt;span class="c"&gt;# If data is visible, RLS configuration error&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Testing with the JavaScript client is also possible. Log in a test account with &lt;code&gt;supabase.auth.signInWithPassword&lt;/code&gt; and SELECT a row that belongs to a different account's UUID. An empty array result is correct. This approach has the advantage of testing through the exact same path as the actual app code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;Half-configured RLS gets breached. Enabling it matters, and writing policies correctly matters too. The most common of the five mistakes are not enabling RLS and missing policies. Fixing just those two prevents most data leaks.&lt;/p&gt;

&lt;p&gt;Run through the checklist above every time a new table is created. It does not have to be perfect. One SELECT policy and one INSERT policy with WITH CHECK is enough to cover the basics. The DELETE policy and UPDATE WITH CHECK can be added as the next step.&lt;/p&gt;

&lt;p&gt;Adding &lt;code&gt;service_role&lt;/code&gt; key management to the code review checklist is a good idea. If any &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt;-prefixed environment variable holds a service_role key, move it immediately. RLS configuration is never a one-time task.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://supabase.com/docs/guides/database/postgres/row-level-security" rel="noopener noreferrer"&gt;Supabase Official Docs — Row Level Security&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://supabase.com/docs/guides/auth/row-level-security" rel="noopener noreferrer"&gt;Supabase Auth — RLS Guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/ddl-rowsecurity.html" rel="noopener noreferrer"&gt;PostgreSQL Official Docs — Row Security Policies&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://supabase.com/docs/guides/database/postgres/row-level-security#call-functions-with-select" rel="noopener noreferrer"&gt;Supabase — auth.uid() Performance Optimization Pattern&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/sql-createpolicy.html" rel="noopener noreferrer"&gt;PostgreSQL Official Docs — CREATE POLICY Reference&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Actually Write a CLAUDE.md — A Solo Indie Dev's Guide From Running 16 Apps</title>
      <dc:creator>LazyDev_OH</dc:creator>
      <pubDate>Thu, 30 Apr 2026 04:52:56 +0000</pubDate>
      <link>https://forem.com/lazydev_oh/how-to-actually-write-a-claudemd-a-solo-indie-devs-guide-from-running-16-apps-1b3c</link>
      <guid>https://forem.com/lazydev_oh/how-to-actually-write-a-claudemd-a-solo-indie-devs-guide-from-running-16-apps-1b3c</guid>
      <description>&lt;p&gt;Every time I opened a new session, I was repeating the same things. "Reply in Korean." "Use the App Router, not Pages Router." "Explain in one line before throwing code at me." I'm running 16 apps as a solo indie, and every session felt like a new-hire interview.&lt;/p&gt;

&lt;p&gt;I put up with this for a month, then drew a conclusion. Stop teaching inside the session — pin it outside the session. That's what &lt;code&gt;CLAUDE.md&lt;/code&gt; does. It's an internal wiki you hand to the AI.&lt;/p&gt;

&lt;p&gt;This is my guide to writing &lt;code&gt;CLAUDE.md&lt;/code&gt;, distilled from a year of running it across 16 iOS apps and SaaS projects as a one-person operation. Bottom line: written well, it eliminates 30 minutes of context-setting per session. Written poorly, it gets ignored.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;CLAUDE.md&lt;/code&gt; is a markdown file Claude Code auto-injects at session start. It's read from two locations: global and project.&lt;/li&gt;
&lt;li&gt;A well-written 80-line page kills 30 minutes of context-setting per session.&lt;/li&gt;
&lt;li&gt;The 5 core patterns: identity / tone / prohibitions / workflow / danger. Split into five zones.&lt;/li&gt;
&lt;li&gt;"Use App Router" is weak. "No Pages Router → use App Router" is strong. Pin prohibition + alternative on one line.&lt;/li&gt;
&lt;li&gt;Cap length at 60–300 lines. Karpathy's public CLAUDE.md is 65 lines. Past that, priorities collapse.&lt;/li&gt;
&lt;li&gt;Don't mix rules with different scopes (Web vs iOS) in one global file.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;@import&lt;/code&gt; to break up long rule files. Past 200 lines in one file, the model loses priority signal.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What CLAUDE.md actually is
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt; is a markdown file Anthropic's Claude Code auto-injects when work starts. It's read from two locations.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;~/.claude/CLAUDE.md&lt;/code&gt; is the global rules file. It applies to every project. This is where I pin my identity, my tone preferences, and my safety rules. &lt;code&gt;&amp;lt;project-root&amp;gt;/CLAUDE.md&lt;/code&gt; is the project rules file. It only applies inside that folder. This is where I pin codebase structure, conventions, and domain knowledge.&lt;/p&gt;

&lt;p&gt;I use both. Identity and tone go global. Codebase-specific structure and risks go in the project file. Combined, every session starts already knowing me.&lt;/p&gt;

&lt;p&gt;The analogy: instead of giving every new hire a fresh company tour, you hand them a wiki link. If the wiki is good, they're productive on day one. If it's not, they keep walking over to the founder's desk to ask questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it differs from other config files
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Location&lt;/th&gt;
&lt;th&gt;How it applies&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;td&gt;global + project&lt;/td&gt;
&lt;td&gt;auto-injected at session start&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.cursorrules&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cursor (legacy)&lt;/td&gt;
&lt;td&gt;project root&lt;/td&gt;
&lt;td&gt;injected as system prompt every chat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;.cursor/rules/*.mdc&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cursor (new)&lt;/td&gt;
&lt;td&gt;folder inside project&lt;/td&gt;
&lt;td&gt;conditional injection via globs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AGENTS.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Codex CLI etc.&lt;/td&gt;
&lt;td&gt;project root&lt;/td&gt;
&lt;td&gt;injected at session start&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GEMINI.md&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Gemini CLI&lt;/td&gt;
&lt;td&gt;global + project&lt;/td&gt;
&lt;td&gt;nearly identical to CLAUDE.md&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two I use most are &lt;code&gt;CLAUDE.md&lt;/code&gt; and &lt;code&gt;.cursor/rules/*.mdc&lt;/code&gt;. I do inline edits in Cursor and run bigger jobs in Claude Code, so the same rules end up scattered across two files. I covered how to keep that in sync separately in &lt;a href="https://gocodelab.com/en/blog/en-cursor-rules-vs-claude-md-comparison" rel="noopener noreferrer"&gt;Cursor Rules vs CLAUDE.md compared&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The short version: &lt;code&gt;CLAUDE.md&lt;/code&gt; is always in. &lt;code&gt;.cursor/rules/*.mdc&lt;/code&gt; can be conditionally injected via globs. So the token cost of &lt;code&gt;CLAUDE.md&lt;/code&gt; is higher. But it's simpler — one file to look at.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to split global vs project
&lt;/h2&gt;

&lt;p&gt;My rule is simple. &lt;strong&gt;If it doesn't change with the human, global. If it changes per codebase, project.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Global gets the fact that I'm a solo dev, the "reply in Korean" tone, and the "never touch &lt;code&gt;.env&lt;/code&gt;" safety rule. These are the same whether I'm building a SaaS or an iOS app. Project gets that codebase's folder structure, conventions, and domain vocabulary. The MVVM layout of a SwiftUI project and the App Router layout of a Next.js project have no reason to live in the same file.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pin zone&lt;/th&gt;
&lt;th&gt;Global&lt;/th&gt;
&lt;th&gt;Project&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;My identity&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;X&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Reply language / tone&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;X&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Safety / prohibition rules&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;reinforce&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;High-level stack&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;td&gt;X&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Folder structure&lt;/td&gt;
&lt;td&gt;X&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Domain vocabulary&lt;/td&gt;
&lt;td&gt;X&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frequently used npm scripts&lt;/td&gt;
&lt;td&gt;X&lt;/td&gt;
&lt;td&gt;O&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Without this split, the global file bloats. My global is 80 lines and I never let it grow past that. There was a phase where it ballooned to 200 lines, and at some point the model stopped picking up priorities. I cut it back to 80 and rule adherence climbed again.&lt;/p&gt;

&lt;p&gt;For reference, Andrej Karpathy's public CLAUDE.md is 65 lines. Operations-heavy companies like HumanLayer recommend keeping global under 60 lines and splitting overflow into &lt;code&gt;agent_docs/&lt;/code&gt;. 80–300 lines is a safe ceiling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't mix differently-scoped rules in one global file.&lt;/strong&gt; I keep only my reply language and the "solo indie" identity in global. Web code conventions and iOS code conventions both moved into their respective project &lt;code&gt;CLAUDE.md&lt;/code&gt; files. If both stacks' rules sit in the global file, iOS rules get pinned into the context even during Web work, and the model gets confused.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 1 — Pin identity and stack on the first line
&lt;/h2&gt;

&lt;p&gt;The first line should say who you are and what you use. The most common AI question is "what stack are you on?" Answer it ahead of time and it doesn't ask.&lt;/p&gt;

&lt;p&gt;My first line: "Solo indie dev (iOS + Web SaaS)". That single line locks in a lot of assumptions. Say "build me a sign-up flow" and Claude automatically assumes a one-person operation. It doesn't recommend pricey services like Auth0.&lt;/p&gt;

&lt;p&gt;For the stack, a noun list works best. "Supabase, Vercel, Tailwind." Now PostgreSQL, edge functions, and App Router are all default assumptions. It stops asking "is this Firebase?".&lt;/p&gt;

&lt;p&gt;One caveat. &lt;strong&gt;Don't pin versions.&lt;/strong&gt; If you write "Next.js 16", you'll forget to update it when 17 ships. Just write "Next.js" and let the model assume current.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 2 — Pin tone rules with detail
&lt;/h2&gt;

&lt;p&gt;This is where I got the most leverage. Everyone writes "reply in Korean", but what comes after that matters more.&lt;/p&gt;

&lt;p&gt;One important distinction. &lt;strong&gt;Pinning tone is not the same as pinning code style.&lt;/strong&gt; Indentation, semicolons, quote style — that's ESLint and Prettier territory, not CLAUDE.md territory. Telling an LLM to enforce linter rules just burns tokens with weak results. Tone-pinning should stay limited to actually-human stuff: &lt;strong&gt;reply language, first-person, greeting patterns, banned words.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I pinned "Keep explanations concise, skip unnecessary preamble." That one line killed translated-from-English greetings like "Of course! Great question." I also pinned "Explain in one line before changing code." So instead of dumping code, it now writes "I'm going to extract this function — for reuse" first.&lt;/p&gt;

&lt;p&gt;Tone responds to detail. "Be friendly" is vague, but "no emojis" is concrete. Tone is the easy thing to forget. Code violations are obvious; tone violations sneak past you for a while. That's exactly why pinning matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 3 — Write coding rules as "prohibition + alternative"
&lt;/h2&gt;

&lt;p&gt;This one I learned the hard way. "Use the App Router" is weak. "No Pages Router" is stronger. But the truly strong form combines both. &lt;strong&gt;"No Pages Router → use App Router"&lt;/strong&gt;, on a single line.&lt;/p&gt;

&lt;p&gt;There's a reason the difference is large. Training data has way more Pages Router examples than App Router. If you only say "use App Router", the model defaults to majority-class Pages Router under pressure. If you pin "No Pages Router", that phrase enters the context and the model token-by-token avoids it. But pure negative rules aren't enough — sometimes the model halts at "okay then what" and slips into a different legacy pattern. So &lt;strong&gt;always attach the alternative right after the prohibition.&lt;/strong&gt; Anthropic's official guidance also recommends "constructive rules over purely negative ones" for consistency.&lt;/p&gt;

&lt;p&gt;My global has five negative + alternative rules: "No Pages Router → App Router", "No class components → functional components", "No completion handlers → async/await" (iOS), "No git push --force", and "Minimize separate CSS files → Tailwind utilities first". Each one was added after I got bitten.&lt;/p&gt;

&lt;p&gt;Negative rules also protect me from myself. At 3 a.m., Pages Router can start to look faster. Claude steps in: "this is Pages Router, but CLAUDE.md prohibits it." It's a pin for future me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 4 — Write workflow as steps
&lt;/h2&gt;

&lt;p&gt;"Plan → approve → implement → verify." I pinned that 4-step flow globally. Without it, "build me a sign-up flow" will get you 5 files dumped at once.&lt;/p&gt;

&lt;p&gt;My pinned phrasing: "For complex tasks, present a plan before coding" and "Wait for plan approval before starting implementation." With those two lines, Claude now writes "1) add the route, 2) add the Supabase table, 3) UI. Should I proceed?" first.&lt;/p&gt;

&lt;p&gt;Verification is pinned too. "After writing code, run the build/typecheck if possible." That single line means it doesn't drop code and walk away — it builds its own code, sees the type error, and fixes it.&lt;/p&gt;

&lt;p&gt;I also pin the commands I use frequently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Commands I use&lt;/span&gt;

&lt;span class="c"&gt;## Web (Next.js + Supabase)&lt;/span&gt;
npm run dev          &lt;span class="c"&gt;# dev server (localhost:3000)&lt;/span&gt;
npm run build        &lt;span class="c"&gt;# production build&lt;/span&gt;
npm run typecheck    &lt;span class="c"&gt;# TypeScript type check&lt;/span&gt;
npx supabase db push &lt;span class="c"&gt;# apply Supabase migration&lt;/span&gt;

&lt;span class="c"&gt;## iOS (Xcode + Swift)&lt;/span&gt;
xcodebuild &lt;span class="nt"&gt;-scheme&lt;/span&gt; MyApp &lt;span class="nt"&gt;-destination&lt;/span&gt; &lt;span class="s1"&gt;'platform=iOS Simulator,name=iPhone 15'&lt;/span&gt; build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With this pinned, Claude doesn't guess which npm script to run. The point is to pre-authorize what the AI can do autonomously. When permissions are vague, it goes conservative and keeps asking. Pinned permissions, it just goes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pattern 5 — Pin dangers explicitly
&lt;/h2&gt;

&lt;p&gt;The last pattern matters most. Pin the things the AI must never do, separately.&lt;/p&gt;

&lt;p&gt;I have three danger pins. "Never read or modify &lt;code&gt;.env&lt;/code&gt; files", "Always ask before modifying production files", "No git push --force". These are the worst-case patterns for a solo dev.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;.env&lt;/code&gt; rule was added after I got burned directly. Mid-debug, Claude printed a chunk of &lt;code&gt;.env&lt;/code&gt; and it ended up in the session log. It was a dev key, thankfully — if it had been production I'd have had to rotate everything. Pinned it globally the next day.&lt;/p&gt;

&lt;p&gt;The "ask before production" rule was added after I almost shipped a production migration via a two-line PR. Solo operations often skip staging. "Obviously dangerous" isn't enough — you need to pin "this is dangerous" explicitly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Use &lt;a class="mentioned-user" href="https://dev.to/import"&gt;@import&lt;/a&gt; to break up long rules
&lt;/h2&gt;

&lt;p&gt;When rules cross 80 lines, I switch to &lt;code&gt;@import&lt;/code&gt;. Claude Code supports this officially. Write &lt;code&gt;@docs/conventions.md&lt;/code&gt; inside &lt;code&gt;CLAUDE.md&lt;/code&gt; and it pulls that file in and merges it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Project conventions&lt;/span&gt;

@docs/db-schema.md
@docs/api-conventions.md
@docs/danger-rules.md

&lt;span class="gu"&gt;## Extra rules&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Billing-related code can only be modified inside &lt;span class="sb"&gt;`lib/billing/`&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Supabase RLS policies must only change via migrations
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two upsides. First, splitting rules by topic looks better on GitHub. Second, when multiple projects share rules, I only edit one file. My two SaaS projects both import &lt;code&gt;docs/danger-rules.md&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The downside: imported files still hit the context. This isn't a token-saving feature. Use it for organization only.&lt;/p&gt;

&lt;h2&gt;
  
  
  A small script to keep CLAUDE.md updated
&lt;/h2&gt;

&lt;p&gt;At the end of a session, I tell Claude "if you learned a new rule today, add it to global." But I forget to type that every time, so I keep a tiny helper around.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# scripts/append-claude-rule.sh — safely append a new rule to ~/.claude/CLAUDE.md&lt;/span&gt;

&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;CLAUDE_MD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;HOME&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/.claude/CLAUDE.md"&lt;/span&gt;
&lt;span class="nv"&gt;TODAY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y-%m-%d&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-z&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="k"&gt;:-}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Usage: ./append-claude-rule.sh &lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;new rule on one line&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
  &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# Auto-backup, then append&lt;/span&gt;
&lt;span class="nb"&gt;cp&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLAUDE_MD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_MD&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.bak.&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TODAY&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;- %s  # added %s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$TODAY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CLAUDE_MD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"added → &lt;/span&gt;&lt;span class="nv"&gt;$CLAUDE_MD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"backup → &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_MD&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.bak.&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;TODAY&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No more opening vim, and the auto-backup means if I delete something accidentally I can restore it. Lower the friction of pinning. The closer friction gets to zero, the more pins accumulate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limitations — what pinning can't fix
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt; isn't magic. After a year of running it, three real limitations:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First, long contexts start to override it.&lt;/strong&gt; If you code for hours in one session, the accumulated context outweighs &lt;code&gt;CLAUDE.md&lt;/code&gt;. Late in the session it might suddenly answer in English or suggest Pages Router. When this happens, just open a new session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second, write too much and the details get drowned.&lt;/strong&gt; My global is 80 lines. Early on it ballooned to 200 and the longer the rules got, the worse the model's prioritization became. Even human employees don't follow 200 rules.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Third, if I break my own rule, the AI won't stop me.&lt;/strong&gt; If I explicitly say "do this with Pages Router", Claude does it. Pinning sets defaults, not guardrails on the human. At the end of the day, controlling myself is still my job.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to pin first, by situation
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;First pin&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Writing CLAUDE.md for the first time&lt;/td&gt;
&lt;td&gt;reply language + identity&lt;/td&gt;
&lt;td&gt;effect lands on the very first response&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You've nagged the same thing 3+ times&lt;/td&gt;
&lt;td&gt;that nag itself&lt;/td&gt;
&lt;td&gt;you already know it's a rule&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Right after an incident&lt;/td&gt;
&lt;td&gt;danger rules&lt;/td&gt;
&lt;td&gt;pin it or it happens again&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Just joined a project&lt;/td&gt;
&lt;td&gt;folder structure&lt;/td&gt;
&lt;td&gt;stop file-location guessing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Blog/docs writing&lt;/td&gt;
&lt;td&gt;tone + banned words&lt;/td&gt;
&lt;td&gt;output tone stabilizes immediately&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This single table is a year's worth of trial-and-error. The two highest-leverage pins were always the top two: "reply in Korean" and "the rule I've now repeated three times". The latter is the strongest one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt; takes 5 minutes to start. 3 lines of identity, 3 lines of tone, 5 lines of coding rules, 4 lines of workflow, 3 lines of danger. An 18-line v1 is enough. Add what bites you over the next month. A year in, 80% of how you work will be pinned.&lt;/p&gt;

&lt;p&gt;One tip when you write the first version. Don't just freestyle it — do a quick retrospective first. When I wrote my v1, I first listed "the 5 things I nagged Claude about most this past week." That list became my global rules verbatim. Whatever I keep telling it is exactly what should be pinned first.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt; is the wiki you hand to the AI. Written well, it works like a coworker from day one. Written poorly, you remain founder, new hire, and everything in between. Running 16 apps as a solo, the latter doesn't scale. Better to start today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.anthropic.com/en/docs/claude-code/memory" rel="noopener noreferrer"&gt;Anthropic — Claude Code Memory and CLAUDE.md&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.anthropic.com/en/docs/claude-code/settings" rel="noopener noreferrer"&gt;Anthropic — Claude Code Settings&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.cursor.com/context/rules" rel="noopener noreferrer"&gt;Cursor — Rules for AI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/openai/codex" rel="noopener noreferrer"&gt;OpenAI — Codex CLI AGENTS.md spec&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>productivity</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>Cursor Rules vs CLAUDE.md — A Deep Dive into Context Injection Patterns for AI Coding Tools</title>
      <dc:creator>LazyDev_OH</dc:creator>
      <pubDate>Thu, 30 Apr 2026 04:52:50 +0000</pubDate>
      <link>https://forem.com/lazydev_oh/cursor-rules-vs-claudemd-a-deep-dive-into-context-injection-patterns-for-ai-coding-tools-3a6o</link>
      <guid>https://forem.com/lazydev_oh/cursor-rules-vs-claudemd-a-deep-dive-into-context-injection-patterns-for-ai-coding-tools-3a6o</guid>
      <description>&lt;p&gt;I run Cursor and Claude Code together on the same projects. Both tools see the same codebase, but they inject rules in fundamentally different ways. Early on I didn't know this and copy-pasted the same markdown to both sides. One side followed it well, the other slowly forgot. After a month of confusion I landed on the conclusion that "these two are different species when it comes to context injection."&lt;/p&gt;

&lt;p&gt;This post unpacks that difference from a solo dev's perspective. On the surface they're both just one markdown file, but crack them open and Cursor Rules has four modes, while CLAUDE.md has three layers plus an import system. Use them without knowing this and the same rule ends up injected twice per message eating tokens, or the file exists but the model never reads it.&lt;/p&gt;

&lt;p&gt;I run 16 iOS apps and a few SaaS products as a solo indie. Mixing two or more tools is daily life for me. I hate writing the same rule twice, and I hate even more spending time debugging rules that don't get followed. That's the motivation for this post — how AI coding tool context injection patterns differ, how to split things when running both, and five common traps.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Cursor Rules is injected per inline edit. CLAUDE.md is loaded once at session start, with child-dir files added on demand.&lt;/li&gt;
&lt;li&gt;Cursor's &lt;code&gt;.mdc&lt;/code&gt; has 4 modes (Always Apply / Apply Intelligently / Apply to Specific Files / Apply Manually). CLAUDE.md has 5 locations (&lt;code&gt;~/.claude/&lt;/code&gt;, project root, &lt;code&gt;CLAUDE.local.md&lt;/code&gt;, parent dirs, child dirs) + &lt;code&gt;@import&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;For the same rule across both, short imperative for Cursor and descriptive context for Claude makes them stick.&lt;/li&gt;
&lt;li&gt;Never commit globals to git. Project &lt;code&gt;CLAUDE.md&lt;/code&gt; gets committed, &lt;code&gt;CLAUDE.local.md&lt;/code&gt; goes in gitignore.&lt;/li&gt;
&lt;li&gt;The 2026 trend is &lt;strong&gt;AGENTS.md as the single source of truth&lt;/strong&gt; + Cursor/Claude/Codex/Copilot all importing from it.&lt;/li&gt;
&lt;li&gt;When rules don't stick, it's almost always either "the file wasn't actually read" or "two rules contradict each other."&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  At a glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Item&lt;/th&gt;
&lt;th&gt;Cursor Rules&lt;/th&gt;
&lt;th&gt;CLAUDE.md&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Location&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.cursor/rules/*.mdc&lt;/code&gt; or &lt;code&gt;.cursorrules&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;~/.claude/CLAUDE.md&lt;/code&gt; + project root&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Injection timing&lt;/td&gt;
&lt;td&gt;Per inline edit / chat&lt;/td&gt;
&lt;td&gt;Once at session start&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Splitting&lt;/td&gt;
&lt;td&gt;File-level + conditional &lt;code&gt;globs&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Chained via &lt;code&gt;@import&lt;/code&gt; syntax&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Modes&lt;/td&gt;
&lt;td&gt;Always / Auto / Agent / Manual&lt;/td&gt;
&lt;td&gt;Always injected (only layered)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Global area&lt;/td&gt;
&lt;td&gt;App settings screen (no text file)&lt;/td&gt;
&lt;td&gt;Separate markdown file&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Why Cursor Rules and CLAUDE.md are different species
&lt;/h2&gt;

&lt;p&gt;Surface-level, both are markdown. But the tools' usage patterns differ. Cursor's main mode is inline edit. You fix short blocks of code on the spot. Claude Code's main mode is the agent session. Once you start, it autonomously runs dozens of tool calls. That difference is reflected directly in how each rule file is structured.&lt;/p&gt;

&lt;p&gt;Cursor builds a fresh system prompt for every inline edit request. So it injects the rules every time. Token cost every time. That's why Cursor Rules works best with short, forceful imperatives. One-line rules like "use Tailwind only" or "App Router only" — the model re-reads them every request.&lt;/p&gt;

&lt;p&gt;Claude Code sessions are long. Once you start, it carries the same system prompt all the way through. So CLAUDE.md works better with background context than short commands. Things like "this is why the codebase is structured this way," "here are the danger zones," "here's who I am" — descriptive prose. Material the model references when making its own judgment calls.&lt;/p&gt;

&lt;p&gt;When I didn't know this and copy-pasted the same rules to both sides, Claude got annoyed by the imperative tone and started routing around the rules. Cursor was too long, so it only listened to the first 30 lines and skimmed the rest. Just splitting the tone alone visibly improved rule compliance on both tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 4 modes of Cursor Rules
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;.cursor/rules/*.mdc&lt;/code&gt; files are markdown with frontmatter. The frontmatter field combination determines the mode. Early on I didn't read this and just slapped &lt;code&gt;alwaysApply: true&lt;/code&gt; on everything, wasting all the efficiency.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SwiftUI iOS work rules&lt;/span&gt;
&lt;span class="na"&gt;globs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;**/*.swift"&lt;/span&gt;
&lt;span class="na"&gt;alwaysApply&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Swift 6, async/await&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Prefer @Observable macro, no ObservableObject&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;No completion handlers&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Split long Views with ViewBuilder&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Always mode&lt;/strong&gt; (&lt;code&gt;alwaysApply: true&lt;/code&gt;) is forcibly injected on every request. Same as the old &lt;code&gt;.cursorrules&lt;/code&gt; file. I put danger rules here (no production touches, no force push). Only items where forgetting causes accidents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auto mode&lt;/strong&gt; (&lt;code&gt;alwaysApply: false&lt;/code&gt; + &lt;code&gt;globs&lt;/code&gt; filled in) auto-injects only when globs match. The example above only injects when touching a &lt;code&gt;.swift&lt;/code&gt; file. I split stack-specific conventions into this mode.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agent-requested mode&lt;/strong&gt; (only &lt;code&gt;description&lt;/code&gt; filled, both globs and alwaysApply empty) lets the model decide whether to pull it in. If the description looks relevant, the model fetches it. I keep things like occasional code review checklists or migration procedures here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Manual mode&lt;/strong&gt; (just the name, all auto-triggers off) only loads when explicitly called via &lt;code&gt;@rulename&lt;/code&gt; in chat. For templates I don't want forced but want to summon on demand.&lt;/p&gt;

&lt;p&gt;After a year of running this, my settled ratio is Always 20%, Auto 60%, Agent 15%, Manual 5%. Stuff too much into Always and per-request token cost balloons while the model's attention drops.&lt;/p&gt;

&lt;h2&gt;
  
  
  CLAUDE.md's 3 layers and &lt;a class="mentioned-user" href="https://dev.to/import"&gt;@import&lt;/a&gt; system
&lt;/h2&gt;

&lt;p&gt;CLAUDE.md doesn't have modes — it has layers. It reads from three places. &lt;code&gt;~/.claude/CLAUDE.md&lt;/code&gt; (global), &lt;code&gt;&amp;lt;project root&amp;gt;/CLAUDE.md&lt;/code&gt; (project), and &lt;code&gt;CLAUDE.md&lt;/code&gt; inside subfolders. All three get pasted whole at the front of the system prompt at session start.&lt;/p&gt;

&lt;p&gt;Global is my personal identity. Name, tone, my regular stack, hard-banned actions. My global is about 50 lines.&lt;/p&gt;

&lt;p&gt;Project root is for that codebase only. Stack structure, folder conventions, danger zones. If something contradicts the global, this is where I explicitly override. For example if global says "TypeScript strict" but some legacy project says "any allowed for now," I write in the project CLAUDE.md: "&lt;strong&gt;override the global strict rule for this project&lt;/strong&gt;."&lt;/p&gt;

&lt;p&gt;Sub-directory applies only inside that folder. SwiftUI conventions go in &lt;code&gt;apps/ios/CLAUDE.md&lt;/code&gt;, Next.js conventions in &lt;code&gt;apps/web/CLAUDE.md&lt;/code&gt;. Works well for monorepos.&lt;/p&gt;

&lt;p&gt;There's also &lt;code&gt;@import&lt;/code&gt; syntax. Write &lt;code&gt;@docs/conventions.md&lt;/code&gt; inside CLAUDE.md and it pulls that file in too. My project CLAUDE.md is about 50 lines and imports two files: &lt;code&gt;@docs/stack.md&lt;/code&gt; and &lt;code&gt;@docs/danger.md&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Context injection timing — every time vs once
&lt;/h2&gt;

&lt;p&gt;This is the most important difference. Cursor and Claude inject rules at fundamentally different moments.&lt;/p&gt;

&lt;p&gt;Cursor rebuilds the system prompt every request. Rules get injected every time. Token cost every time. Upside: add a new rule and the next request applies it instantly.&lt;/p&gt;

&lt;p&gt;Claude Code reads once at session start. It carries the same system prompt until that session ends. Token cost is small thanks to prompt cache. Downside: changing a rule doesn't apply to the existing session. You need a new session for the new rule to land.&lt;/p&gt;

&lt;p&gt;Add a rule in Cursor and the next inline edit picks it up. Same scenario in Claude requires &lt;code&gt;/clear&lt;/code&gt; to reset the session, or opening a new conversation. I once spent 30 minutes confused about "why isn't it picking up the new rule" before I figured this out.&lt;/p&gt;

&lt;h2&gt;
  
  
  globs matching vs &lt;a class="mentioned-user" href="https://dev.to/import"&gt;@import&lt;/a&gt; — file-splitting pattern
&lt;/h2&gt;

&lt;p&gt;Cursor Rules splits by file via globs. Touch a &lt;code&gt;.swift&lt;/code&gt; and only iOS rules come in; touch a &lt;code&gt;.tsx&lt;/code&gt; and only Next.js rules come in. No noise in the model context.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.cursor/rules/
├── always-danger.mdc        # alwaysApply: true
├── nextjs-stack.mdc         # globs: "**/*.{tsx,ts}"
├── ios-swiftui.mdc          # globs: "**/*.swift"
├── tailwind.mdc             # globs: "**/*.{tsx,jsx,html}"
└── pr-template.mdc          # manual
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CLAUDE.md splits via &lt;code&gt;@import&lt;/code&gt;. The main file acts as the entrypoint and imported files trail along. Downside: no globs matching, so everything loads regardless of work context. Every import behaves like &lt;code&gt;alwaysApply: true&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Project: apsity&lt;/span&gt;

@docs/stack.md
@docs/conventions.md
@docs/danger.md

&lt;span class="gu"&gt;## Extra notes&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Add new routes under app/
&lt;span class="p"&gt;-&lt;/span&gt; DB schema changes always via migration files
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Cursor I split fine by stack, for Claude I bundle into bigger chunks. Since everything loads on Claude anyway, fine-grained splitting is meaningless. But I never let an imported file go past 200 lines — model attention drops past that line.&lt;/p&gt;

&lt;h2&gt;
  
  
  Token cost and noise control
&lt;/h2&gt;

&lt;p&gt;Stuffing too many rules in is the trap indie devs fall into most. I crammed 200 lines into a single page. The result was the model dutifully followed the first 30 lines and "is that even there?" the rest.&lt;/p&gt;

&lt;p&gt;On Cursor this also hits as direct token cost. Rules go in every request, so a 200-line ruleset is 200 lines of tokens per inline edit. If I do 50 inline edits a day, that's 10,000 lines of tokens per day. Split with globs and usually only 30-50 lines load — 1/4 the tokens.&lt;/p&gt;

&lt;p&gt;The rule-writing guide I've settled on has three parts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Write in measurable form. "Write clean code" doesn't land — "split functions over 50 lines" does.&lt;/li&gt;
&lt;li&gt;One rule per line, one command per rule.&lt;/li&gt;
&lt;li&gt;Attach a short "why." Claude generalizes when it knows the reason.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Strategy for running both tools together
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Location&lt;/th&gt;
&lt;th&gt;Tone&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Danger rules&lt;/td&gt;
&lt;td&gt;Both&lt;/td&gt;
&lt;td&gt;Short imperative&lt;/td&gt;
&lt;td&gt;"no .env, no force push"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stack conventions&lt;/td&gt;
&lt;td&gt;Cursor (per glob)&lt;/td&gt;
&lt;td&gt;Imperative&lt;/td&gt;
&lt;td&gt;"Tailwind utils only, no styled-components"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Codebase background&lt;/td&gt;
&lt;td&gt;Claude (CLAUDE.md)&lt;/td&gt;
&lt;td&gt;Descriptive&lt;/td&gt;
&lt;td&gt;"App Router migration done, new code goes under app/"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Personal identity&lt;/td&gt;
&lt;td&gt;Claude global only&lt;/td&gt;
&lt;td&gt;Descriptive&lt;/td&gt;
&lt;td&gt;"solo indie, Korean, ship-fast first"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PR/review checklist&lt;/td&gt;
&lt;td&gt;Cursor (manual)&lt;/td&gt;
&lt;td&gt;Steps&lt;/td&gt;
&lt;td&gt;"Call via @pr-template"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Only danger rules duplicate across both. Stack conventions go heavily on the Cursor side. Codebase background goes heavily on the Claude side. Personal identity only goes in Claude global.&lt;/p&gt;

&lt;h2&gt;
  
  
  Debugging order when rules don't stick
&lt;/h2&gt;

&lt;p&gt;I debug "this rule isn't sticking" more often than I write new rules. Almost every case falls into these 4 steps.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1, confirm the file actually got read.&lt;/strong&gt; On Claude Code, in the first message of a session, ask "list 3 of the danger rules I put in CLAUDE.md." If the answer is accurate, it read it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2, recheck the rule mode.&lt;/strong&gt; In Cursor, if &lt;code&gt;alwaysApply: false&lt;/code&gt; and both globs and description are empty, that rule will never auto-inject. Always check the frontmatter first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3, check for rule contradictions.&lt;/strong&gt; When global vs project, or &lt;code&gt;.cursorrules&lt;/code&gt; vs &lt;code&gt;.cursor/rules/*.mdc&lt;/code&gt; conflict, the model follows whichever is more strongly worded.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4, check whether the rule is too abstract.&lt;/strong&gt; "Write clean code" doesn't land. "Split functions over 50 lines" lands. Has to be measurable.&lt;/p&gt;

&lt;p&gt;One more thing. Cursor's &lt;code&gt;.cursorrules&lt;/code&gt; (old format) and &lt;code&gt;.cursor/rules/*.mdc&lt;/code&gt; (new format) both apply when both exist. Keeping both around duplicates rules and confuses the model. Migrate and delete the old file immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security and git commit policy
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Project-level files always get committed, globals never.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&amp;lt;project&amp;gt;/CLAUDE.md&lt;/code&gt;, &lt;code&gt;.cursorrules&lt;/code&gt;, &lt;code&gt;.cursor/rules/*.mdc&lt;/code&gt; are part of the codebase. New collaborators or future-me need the same rules. Don't put them in &lt;code&gt;.gitignore&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The opposite for &lt;code&gt;~/.claude/CLAUDE.md&lt;/code&gt;, &lt;code&gt;~/.gemini/GEMINI.md&lt;/code&gt; and other globals — those are personal identity. Email, tone preferences, even names of apps I run. Pushing this to git causes accidents. I keep them in a separate private dotfiles repo.&lt;/p&gt;

&lt;p&gt;Watch for secrets inside rule files. I once accidentally put a Supabase project ID in CLAUDE.md. After that I don't put env var names or identifier-style things in rule files. I stop at "env vars live in &lt;code&gt;.env.local&lt;/code&gt;."&lt;/p&gt;

&lt;h2&gt;
  
  
  Real example — my SaaS setup
&lt;/h2&gt;

&lt;p&gt;Project root &lt;code&gt;CLAUDE.md&lt;/code&gt; (descriptive context):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gh"&gt;# Project: apsity&lt;/span&gt;

&lt;span class="gu"&gt;## Stack&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Next.js 15 App Router, TypeScript strict
&lt;span class="p"&gt;-&lt;/span&gt; Supabase (PostgreSQL + Auth + Storage)
&lt;span class="p"&gt;-&lt;/span&gt; Vercel deploy, Tailwind v4

&lt;span class="gu"&gt;## Structural decisions&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Prefer Server Components, 'use client' only for interactions
&lt;span class="p"&gt;-&lt;/span&gt; Data fetching: call Supabase directly from Server Components
&lt;span class="p"&gt;-&lt;/span&gt; API routes only for webhooks and external calls
&lt;span class="p"&gt;-&lt;/span&gt; Auth via RLS, no backend if-statements

@docs/danger.md
@docs/conventions.md
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same folder's &lt;code&gt;.cursor/rules/nextjs-stack.mdc&lt;/code&gt; (imperative, auto via globs):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Next.js + TypeScript work rules&lt;/span&gt;
&lt;span class="na"&gt;globs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;**/*.{ts,tsx}"&lt;/span&gt;
&lt;span class="na"&gt;alwaysApply&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;App Router only. Don't write Pages Router code.&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Minimize 'use client'. Branch at the top of the component tree.&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Tailwind utils only. No styled-components.&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;No `as` casting. If unsure, unknown then narrow.&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Split functions over 50 lines.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;.cursor/rules/always-danger.mdc&lt;/code&gt; (every-request injection):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Danger zone guards&lt;/span&gt;
&lt;span class="na"&gt;alwaysApply&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Confirm with user before editing supabase/migrations/.&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Never print production env vars.&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;DB schema changes always via migration files.&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;No git push --force.&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Never read or modify .env files.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same ruleset, but Cursor gets short commands and Claude gets background + reasoning. They don't conflict.&lt;/p&gt;

&lt;h2&gt;
  
  
  AGENTS.md — the 2026 single-source-of-truth trend
&lt;/h2&gt;

&lt;p&gt;Pinning the same rule in two files is obviously inefficient. So starting late 2025, &lt;strong&gt;AGENTS.md&lt;/strong&gt; rose as a standard. OpenAI Codex defined it first, and in December 2025 it was donated to Linux Foundation/AAIF. As of 2026, Cursor, Claude Code, Codex CLI, GitHub Copilot, Devin, Windsurf, and Gemini CLI all read &lt;code&gt;AGENTS.md&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;My ops pattern got simpler. &lt;strong&gt;Keep &lt;code&gt;AGENTS.md&lt;/code&gt; as the source of truth&lt;/strong&gt;, then &lt;code&gt;@AGENTS.md&lt;/code&gt; import via a single line in &lt;code&gt;.cursor/rules/index.mdc&lt;/code&gt;, &lt;code&gt;@AGENTS.md&lt;/code&gt; import in &lt;code&gt;CLAUDE.md&lt;/code&gt;, same for other tools. Edit one file and every tool reflects it.&lt;/p&gt;

&lt;p&gt;That said, Claude Code's global (&lt;code&gt;~/.claude/CLAUDE.md&lt;/code&gt;) and Cursor's user rules (app settings screen) can't be sync'd outside the tool — personal identity, voice — so they stay out of the AGENTS.md source. The source holds project conventions, codebase rules, and danger rules only.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;Cursor Rules and CLAUDE.md look similar on the surface — both markdown — but inside they're entirely different systems. Cursor is short rulesets injected per inline edit; CLAUDE.md is descriptive context pinned at session level. When writing the same rule in both places, you have to match the tone too for both to listen.&lt;/p&gt;

&lt;p&gt;My one-year conclusion is simple. Don't pin both files from the start — start with one tool, one file. When that file goes past 80 lines, then split via globs or &lt;code&gt;@import&lt;/code&gt;. When you adopt the second tool, that's when you extract AGENTS.md as the source. Build everything up front and you'll just have rules that go unfollowed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Official sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.cursor.com/context/rules" rel="noopener noreferrer"&gt;Cursor Rules official docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.claude.com/en/docs/claude-code/memory" rel="noopener noreferrer"&gt;Anthropic Claude Code memory guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/best-practices" rel="noopener noreferrer"&gt;Anthropic Claude Code Best Practices&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/agentsmd/agents.md" rel="noopener noreferrer"&gt;AGENTS.md standard (Linux Foundation/AAIF)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.openai.com/codex/guides/agents-md" rel="noopener noreferrer"&gt;AGENTS.md — OpenAI Codex guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching" rel="noopener noreferrer"&gt;Anthropic prompt caching official docs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This post is current as of 2026-04-30. Both Cursor and Claude Code update their rule systems quickly — six months out, this may need a recheck. The SaaS I run, &lt;a href="https://apsity.com" rel="noopener noreferrer"&gt;apsity&lt;/a&gt;, is built for solo developers.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>cursor</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Teaching Claude to Play Tetris with 100 App Store Characters</title>
      <dc:creator>LazyDev_OH</dc:creator>
      <pubDate>Wed, 15 Apr 2026 17:30:08 +0000</pubDate>
      <link>https://forem.com/lazydev_oh/teaching-claude-to-play-tetris-with-100-app-store-characters-3o9k</link>
      <guid>https://forem.com/lazydev_oh/teaching-claude-to-play-tetris-with-100-app-store-characters-3o9k</guid>
      <description>&lt;p&gt;The App Store keyword field is exactly 100 characters. Commas only, no spaces, no duplicates. You need to pack 15–20 keywords inside.&lt;/p&gt;

&lt;p&gt;I tried writing those by hand for a dozen apps. Every time I'd leave characters on the table — a rogue space after a comma, a singular/plural duplicate Apple would auto-match anyway. Manual packing is tedious enough that most indie developers just don't iterate on ASO.&lt;/p&gt;

&lt;p&gt;So I built an AI that does it. This post is the actual implementation — prompts, JSON schemas, validation, and the gotchas that killed my first three attempts. I ship this in my ASO tool for iOS developers (&lt;a href="https://apsity.com" rel="noopener noreferrer"&gt;Apsity&lt;/a&gt;), but the approach works for any tight-constraint text-generation problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Constraints That Break Generic LLMs
&lt;/h2&gt;

&lt;p&gt;When you ask any LLM "generate App Store keywords for my budget app," you get something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;budget tracker, expense manager, spending analysis,
money manager, personal finance, bill tracker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Readable. Useless. Two characters wasted on every &lt;code&gt;,&lt;/code&gt; (space after comma). Four characters wasted on &lt;code&gt;personal finance&lt;/code&gt; because Apple auto-matches &lt;code&gt;personal&lt;/code&gt; + &lt;code&gt;finance&lt;/code&gt; separately. Total wasted: roughly 30% of your 100.&lt;/p&gt;

&lt;p&gt;The rules that matter:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Exactly ≤100 characters&lt;/strong&gt; (including commas)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Single comma separators, no spaces&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No duplicate tokens&lt;/strong&gt; (Apple ignores them anyway)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No singular+plural pairs&lt;/strong&gt; (Apple auto-matches)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shorter tokens &amp;gt; compound words&lt;/strong&gt; (Apple combines them for you)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No competitor brand names&lt;/strong&gt; (trademark rejection)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No category names, and no &lt;code&gt;app&lt;/code&gt;, &lt;code&gt;free&lt;/code&gt;, &lt;code&gt;new&lt;/code&gt;, &lt;code&gt;best&lt;/code&gt;, &lt;code&gt;iPhone&lt;/code&gt;, &lt;code&gt;iPad&lt;/code&gt;&lt;/strong&gt; (Apple auto-indexes all of these)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mix function + situation + alternative keywords&lt;/strong&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;An LLM without these constraints spelled out won't enforce them. Generic "write keywords" prompts fail rules 1–4 consistently.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Claude Sonnet
&lt;/h2&gt;

&lt;p&gt;I tested GPT-5, Gemini 2.0 Pro, and Claude Sonnet 4.6 on the same task. Three metrics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Character compliance&lt;/strong&gt; — stays under 100 chars without excess whitespace&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JSON schema adherence&lt;/strong&gt; — returns exactly the structured output I asked for&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge case handling&lt;/strong&gt; — catches duplicates, plural forms, category name leaks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Claude Sonnet won on all three, but the meaningful gap was edge case handling. When I explicitly said "no duplicates including singular/plural pairs," Claude filtered them out. The others listed &lt;code&gt;budget&lt;/code&gt; and &lt;code&gt;budgets&lt;/code&gt; and called it done — which is wrong, because Apple's algorithm auto-indexes plurals from the singular form anyway. A keyword duplicated across singular/plural just wastes characters.&lt;/p&gt;

&lt;p&gt;I'm also passing a lot of context — competitor review snippets, current rankings, market-specific search trends. Sonnet 4.6's 1M-token context window handles it without trimming.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Prompt Structure
&lt;/h2&gt;

&lt;p&gt;The prompt is in three layers: system prompt (the rules), user prompt (the app context), and a JSON schema Claude must match.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/keyword-generator.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Anthropic&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@anthropic-ai/sdk&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Anthropic&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;SYSTEM_PROMPT&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`
You are an ASO (App Store Optimization) keyword specialist.
Generate keywords for the app store "Keywords" field, which has
a STRICT 100-character limit. Characters include commas.

Rules (apply in order):
1. Total output length MUST be ≤100 characters
2. Use ONLY commas as separators, no spaces after commas
3. No duplicate tokens
4. No singular+plural pairs (Apple auto-matches both)
5. Prefer short atomic tokens over compound words
   (Apple combines A + B into "A B" automatically)
6. No competitor brand names (trademark violation)
7. No category names and no words Apple already indexes automatically:
   app, free, new, best, iPhone, iPad, or any category label
8. Blend three keyword types:
   - Function (what the app does)
   - Situation (when users need it)
   - Alternative (different names for the same thing)

Return JSON with this schema:
{
  "keywords": string[],         // individual tokens, no commas inside
  "joined": string,             // comma-joined, must be ≤100 chars
  "char_count": number,         // .length of "joined"
  "coverage_notes": string[]    // which search queries this covers
}
`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;KeywordOutput&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;joined&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;char_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;coverage_notes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The JSON schema isn't just for structure. &lt;code&gt;char_count&lt;/code&gt; forces Claude to count the output itself — models aren't great at counting, but self-reporting forces a pass where the model checks its own work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Generating Keywords
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateKeywords&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;app_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;competitors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;existing_keywords&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;target_market&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;KeywordOutput&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;userPrompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`
App: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app_name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
Description: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
Target market: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target_market&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
Competitor apps (do NOT use these names): &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;competitors&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;
&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;existing_keywords&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="s2"&gt;`Currently underperforming keywords to replace: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;existing_keywords&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;

Generate an optimal 100-character keyword field.
Before finalizing, count your characters and confirm it fits.
`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude-sonnet-4-6&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;SYSTEM_PROMPT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userPrompt&lt;/span&gt; &lt;span class="p"&gt;}],&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\{[\s\S]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;\}&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;No JSON in response&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;KeywordOutput&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Straightforward Anthropic SDK call. Two things worth noting:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;max_tokens: 1024&lt;/code&gt;&lt;/strong&gt; — keywords are short, so we don't need more. Capping reduces cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JSON extraction via regex&lt;/strong&gt; — Claude sometimes wraps JSON in explanation text. Grabbing the first &lt;code&gt;{...}&lt;/code&gt; block is more reliable than asking for raw JSON.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Validation Is Where Production Code Lives
&lt;/h2&gt;

&lt;p&gt;Claude gets the constraints right ~85% of the time. Production code has to handle the other 15%.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// lib/validate-keywords.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;zod&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;KeywordSchema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
  &lt;span class="na"&gt;joined&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;char_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;coverage_notes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;validateKeywords&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nl"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="nl"&gt;data&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;KeywordOutput&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;KeywordSchema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safeParse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;success&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invalid JSON shape&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="na"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;joined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;char_count&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// 1. Length check&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;joined&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`joined is &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;joined&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; chars, exceeds 100`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. Trust but verify char_count&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;joined&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;char_count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`char_count mismatch: claimed &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;char_count&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;, actual &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;joined&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 3. Commas only, no spaces&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;joined&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;contains ', ' — spaces after commas waste characters&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 4. Reconstruct and compare&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;reconstructed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reconstructed&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;joined&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;keywords array doesn't match joined string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 5. Duplicate detection (case-insensitive)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nb"&gt;Set&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;lower&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`duplicate token: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 6. Singular/plural detection (basic)&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;plural&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;s&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;singular&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/s$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;plural&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;plural&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`singular/plural pair: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; / &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;s`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;issues&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;parsed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When validation fails, I retry with the specific issue appended to the prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateWithRetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;KeywordContext&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;KeywordOutput&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Failed after 3 attempts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generateKeywords&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;check&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;validateKeywords&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;check&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;check&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="c1"&gt;// Feed issues back to Claude for a targeted retry&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;generateWithRetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="nx"&gt;context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;existing_keywords&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;keywords&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="c1"&gt;// Add validation issues into a correction prompt here&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="nx"&gt;attempt&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In practice, 94% succeed on the first attempt, 5% on the second, 1% fall through (usually when the concept genuinely can't fit in 100 chars — time to simplify the app description, not the prompt).&lt;/p&gt;

&lt;h2&gt;
  
  
  The Output Nobody Asks For But Everyone Needs
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;coverage_notes&lt;/code&gt; field in the schema looks optional. It's the most useful part.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"keywords"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"budget"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"expense"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"payday"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"wallet"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"debt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"bills"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"money"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"savings"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"joined"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"budget,expense,payday,wallet,debt,bills,money,savings"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"char_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;51&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"coverage_notes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"Matches: 'budget', 'expense tracker', 'payday planner', 'wallet app'"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"Covers 'money management' via money + bills combo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"Skipped 'finance' because it's the category — App Store auto-indexes that"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"Skipped 'mint' (Mint.com trademark)"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the app developer can audit &lt;em&gt;why&lt;/em&gt; each keyword was picked. When someone asks "why isn't my app showing up for X?" you have a record. Without &lt;code&gt;coverage_notes&lt;/code&gt;, the output is a black box.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prompt Failures I Hit Along the Way
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Attempt 1&lt;/strong&gt;: "Generate 15-20 keywords under 100 characters." Result: the model wrote a nice list, counted wrong, and delivered 112 characters. No self-verification step.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 2&lt;/strong&gt;: Added &lt;code&gt;"Do not exceed 100 characters"&lt;/code&gt; — model now refused to output more than 10 keywords to stay safe. Under-coverage.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 3&lt;/strong&gt;: JSON schema with &lt;code&gt;char_count&lt;/code&gt; field. Model started counting. Characters dropped into range but duplicates appeared.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attempt 4 (shipped)&lt;/strong&gt;: Enumerated every rule with "apply in order," asked for &lt;code&gt;coverage_notes&lt;/code&gt; to force reasoning, and added validation with retry.&lt;/p&gt;

&lt;p&gt;Each failure mode came from underspecifying the rules. The LLM isn't "wrong" — it's doing exactly what the prompt asked. Getting production-grade output means writing the prompt like a spec, not a request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where This Lives Now
&lt;/h2&gt;

&lt;p&gt;I packaged this into &lt;a href="https://apsity.com" rel="noopener noreferrer"&gt;Apsity&lt;/a&gt;'s AI Growth Agent — it runs on every keyword field update across the apps it tracks, compares against real-time search rankings, and flags underperforming tokens for replacement. Free tier covers 1 app and 5 keywords if you want to poke at it.&lt;/p&gt;

&lt;p&gt;More importantly, the pattern generalizes. Any time you have "generate text inside tight constraints" — tweet drafts with character limits, SMS messages, ad headlines, product names — the structure is the same:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Enumerate every constraint as a numbered rule&lt;/li&gt;
&lt;li&gt;Force a JSON schema with self-reported metrics&lt;/li&gt;
&lt;li&gt;Ask for a reasoning field so you can audit&lt;/li&gt;
&lt;li&gt;Validate in code, feed failures back for retry&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Writing the spec as a prompt beats writing it as docs — because you can actually run it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally written for &lt;a href="https://gocodelab.com/en/blog/en-apsity-ai-growth-agent-keyword-optimization" rel="noopener noreferrer"&gt;GoCodeLab&lt;/a&gt;. Deeper writeups on building indie SaaS with Claude are in the &lt;a href="https://gocodelab.com/en/blog" rel="noopener noreferrer"&gt;Lazy Developer series&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claude</category>
      <category>typescript</category>
      <category>showdev</category>
    </item>
    <item>
      <title>The Claude Code Skill Set I Actually Run — Mapped by Dev Task</title>
      <dc:creator>LazyDev_OH</dc:creator>
      <pubDate>Wed, 15 Apr 2026 16:11:05 +0000</pubDate>
      <link>https://forem.com/lazydev_oh/the-claude-code-skill-set-i-actually-run-mapped-by-dev-task-5afm</link>
      <guid>https://forem.com/lazydev_oh/the-claude-code-skill-set-i-actually-run-mapped-by-dev-task-5afm</guid>
      <description>&lt;p&gt;A type error detonated five minutes before deploy. Claude had just declared "completed." I trusted the line and hit &lt;code&gt;vercel --prod&lt;/code&gt;. Preview was green; the Production build failed. Four hotfix commits later, the evening was gone.&lt;/p&gt;

&lt;p&gt;Without that incident, I wouldn't have bothered organizing my Skills. The next day I pinned &lt;code&gt;/verification-before-completion&lt;/code&gt; as an always-on gate. Task by task I added similar guardrails. I ended up with seven Skills in active use.&lt;/p&gt;

&lt;p&gt;This post is the Skill and plugin set I actually run — grouped by dev task. UI / Backend · API / Data · DB / Deploy · Infra / Planning · Research / Review · Debug / Process · Docs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Skill vs Plugin
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;Skill&lt;/strong&gt; is a single markdown file — an SOP that says "this task runs in this order." One file per Skill.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;plugin&lt;/strong&gt; is a bundle of Skills. Anthropic launched the official marketplace in January 2026, and since then &lt;code&gt;/plugin install &amp;lt;name&amp;gt;&lt;/code&gt; pulls in a whole set. Updates ride the same command.&lt;/p&gt;

&lt;p&gt;With 3–4 Skills, manual copy is easier. Past that, plugins are the sensible path.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 4 Plugins I Run
&lt;/h2&gt;

&lt;p&gt;Most of my dev routine lives inside these four. The rest gets filled by two or three project-specific Skills I wrote myself.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Base install&lt;/span&gt;
/plugin &lt;span class="nb"&gt;install &lt;/span&gt;superpowers@claude-plugins-official
/plugin &lt;span class="nb"&gt;install &lt;/span&gt;vercel@claude-plugins-official
/plugin &lt;span class="nb"&gt;install &lt;/span&gt;frontend-design@claude-plugins-official
/plugin &lt;span class="nb"&gt;install &lt;/span&gt;bkit@claude-plugins-official

&lt;span class="c"&gt;# Check&lt;/span&gt;
/plugin list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;superpowers&lt;/strong&gt; — methodology Skill bundle. 20+ Skills: brainstorming, TDD, systematic-debugging, verification-before-completion. obra's open-source project, now on the official marketplace. 94k+ stars. Most battle-tested set.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;vercel&lt;/strong&gt; — infra and framework. Next.js App Router, Server Components, Vercel Functions, AI SDK, deploy CLI. Keeps you on the latest syntax without memorizing release notes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;frontend-design&lt;/strong&gt; — UI drafts + React code quality. Steers away from generic AI-looking UIs. After editing multiple TSX files, react-best-practices auto-kicks a quality checklist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;bkit&lt;/strong&gt; — PDCA and docs layer. If superpowers is "how to work," bkit is "what to record and in what stage." Overkill for solo work; essential when client or team docs matter. Plan → Design → Do → Check → Act each has a Skill. &lt;code&gt;gap-detector&lt;/code&gt; catches design-vs-implementation gaps; &lt;code&gt;pdca-iterator&lt;/code&gt; runs auto-improvement loops.&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.amazonaws.com%2Fuploads%2Farticles%2Fapd3a38eq0y93r94a6zn.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.amazonaws.com%2Fuploads%2Farticles%2Fapd3a38eq0y93r94a6zn.png" alt="4-layer plugin stack + custom Skills — each layer has a clear job" width="800" height="582"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Task-to-Skill Map (7 Tasks × Set)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task&lt;/th&gt;
&lt;th&gt;Primary Skills&lt;/th&gt;
&lt;th&gt;Plugin&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;UI / Frontend&lt;/td&gt;
&lt;td&gt;frontend-design · shadcn · react-best-practices&lt;/td&gt;
&lt;td&gt;frontend-design · vercel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend · API&lt;/td&gt;
&lt;td&gt;nextjs · vercel-functions · ai-sdk · phase-4-api&lt;/td&gt;
&lt;td&gt;vercel · bkit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Data · DB&lt;/td&gt;
&lt;td&gt;vercel-storage · runtime-cache · next-cache-components&lt;/td&gt;
&lt;td&gt;vercel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Deploy · Infra&lt;/td&gt;
&lt;td&gt;deployments-cicd · env-vars · verification&lt;/td&gt;
&lt;td&gt;vercel · superpowers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Planning · Research&lt;/td&gt;
&lt;td&gt;brainstorming · writing-plans · pdca (plan/design) · bkit-templates&lt;/td&gt;
&lt;td&gt;superpowers · bkit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Review · Debug&lt;/td&gt;
&lt;td&gt;systematic-debugging · verification-before-completion · requesting-code-review · code-analyzer · gap-detector&lt;/td&gt;
&lt;td&gt;superpowers · bkit&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Process · Docs&lt;/td&gt;
&lt;td&gt;pdca (do/check/act) · report-generator · pdca-iterator&lt;/td&gt;
&lt;td&gt;bkit&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Some Skills overlap. &lt;code&gt;verification-before-completion&lt;/code&gt; runs across deploy, review, and test tasks. I keep it globally on and call the rest per task.&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.amazonaws.com%2Fuploads%2Farticles%2Fvztd59vfmiirvbr797t7.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.amazonaws.com%2Fuploads%2Farticles%2Fvztd59vfmiirvbr797t7.png" alt="7 tasks × 4 plugins matrix — Skills called per crossing cell" width="800" height="578"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  UI / Frontend — Draft to Review
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/brainstorming → /frontend-design draft → shadcn components
  → react-best-practices auto-trigger on TSX save
  → /verification-before-completion
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For the draft stage, &lt;code&gt;frontend-design&lt;/code&gt; produces production-grade drafts without the generic AI-tailwind look. If the project uses shadcn, &lt;code&gt;vercel:shadcn&lt;/code&gt; attaches — component installs, theming, custom registries.&lt;/p&gt;

&lt;p&gt;After implementation, &lt;code&gt;vercel:react-best-practices&lt;/code&gt; detects multiple TSX edits and runs a review checklist — hooks usage, accessibility, performance, TypeScript patterns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Backend · API — Next.js Fullstack
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;vercel:nextjs&lt;/code&gt; holds the freshest patterns — Server Component fetch, Server Actions for forms, Middleware for request interception. Write Next.js 16 code and it won't silently regress to 15-era syntax.&lt;/p&gt;

&lt;p&gt;When serverless functions enter, &lt;code&gt;vercel:vercel-functions&lt;/code&gt; attaches — Edge vs Node runtime, Fluid Compute streaming, Cron Jobs.&lt;/p&gt;

&lt;p&gt;AI features bring in &lt;code&gt;vercel:ai-sdk&lt;/code&gt;: chat UI, structured output, tool calls, agents, MCP integration. When working against the Anthropic SDK directly, &lt;code&gt;claude-api&lt;/code&gt; takes priority.&lt;/p&gt;

&lt;p&gt;At the early API-design stage, &lt;code&gt;bkit:phase-4-api&lt;/code&gt; helps — endpoint conventions, error payload shapes, Zero Script QA (validate via structured JSON logs, not test scripts).&lt;/p&gt;

&lt;h2&gt;
  
  
  Deploy · Infra — On Top of Vercel
&lt;/h2&gt;

&lt;p&gt;Two Skills always attached:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;vercel:deployments-cicd&lt;/code&gt;&lt;/strong&gt; — deploys, rollbacks, promotions, prebuilt builds. Even writes GitHub Actions workflow files.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;vercel:env-vars&lt;/code&gt;&lt;/strong&gt; — syncing &lt;code&gt;.env&lt;/code&gt; with Vercel env vars, OIDC tokens, per-env separation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The last gate is always &lt;strong&gt;&lt;code&gt;superpowers:verification-before-completion&lt;/code&gt;&lt;/strong&gt;. Confirms build, type, and tests actually pass before deploy. This Skill prevented most of my production incidents — including the "5 minutes before deploy" one from the intro.&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.amazonaws.com%2Fuploads%2Farticles%2Fr9u90rp5yabn0kjcoqru.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.amazonaws.com%2Fuploads%2Farticles%2Fr9u90rp5yabn0kjcoqru.png" alt="Feature flow — 7 steps with Skills per step and artifacts" width="800" height="732"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Planning · Research — Before the Code
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;superpowers:brainstorming&lt;/code&gt; for "build vs. skip." Requirement structuring, edge-case discovery, decision trees.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;superpowers:writing-plans&lt;/code&gt; converts brainstorming into an execution plan — split by file, by step. When the plan file is ready, &lt;code&gt;/execute-plan&lt;/code&gt; picks it up.&lt;/p&gt;

&lt;p&gt;When planning docs must live in the team repo, &lt;code&gt;bkit:pdca&lt;/code&gt;'s plan/design stages run alongside. Writes to &lt;code&gt;docs/plans/{feature}.md&lt;/code&gt; in template form. &lt;code&gt;bkit:bkit-templates&lt;/code&gt; brings planning and design doc templates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Review · Debug — The Last Gate
&lt;/h2&gt;

&lt;p&gt;Three review/debug Skills hold the last gate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;systematic-debugging&lt;/code&gt;&lt;/strong&gt; kicks in on bugs and failing tests. Forces a sequence — reproduce → three hypotheses → eliminate two with evidence → minimal fix. Cuts the impulse to "just fix it."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;verification-before-completion&lt;/code&gt;&lt;/strong&gt; runs before "done." Confirms type, build, tests before allowing completion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;requesting-code-review&lt;/code&gt;&lt;/strong&gt; is for handing off to another session for review.&lt;/p&gt;

&lt;p&gt;Two bkit Skills attach here. &lt;code&gt;bkit:code-analyzer&lt;/code&gt; produces pre-commit quality, security, and performance reports. &lt;code&gt;bkit:gap-detector&lt;/code&gt; catches gaps between design docs and actual implementation — PDCA's Check stage. If Match Rate drops below 90%, &lt;code&gt;pdca-iterator&lt;/code&gt; launches an auto-improvement loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Process · Docs — When Docs Become Necessary
&lt;/h2&gt;

&lt;p&gt;On solo work, process and docs Skills stay off. When a teammate joins, when progress reports go to a client, or when future-me asks "why did I build it this way?" — bkit pays off.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;bkit:pdca&lt;/code&gt; splits Plan → Design → Do → Check → Act into slash commands. &lt;code&gt;/pdca plan {feature}&lt;/code&gt; writes the planning doc, &lt;code&gt;/pdca analyze&lt;/code&gt; writes the post-implementation analysis.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;bkit:report-generator&lt;/code&gt; fires after one PDCA cycle. Pulls Plan/Design/Do/Check docs and actual code into a single-page completion report — ready to hand to stakeholders.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;bkit:pdca-iterator&lt;/code&gt; is the Evaluator-Optimizer pattern. Max 5 iterations, hands off to report-generator past 90% match rate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mapped Onto the EP.19 5-Agent Team
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;planner   → brainstorming · writing-plans · pdca plan/design
coder     → executing-plans · nextjs · frontend-design
reviewer  → requesting-code-review · react-best-practices · code-analyzer
tester    → test-driven-development · verification-before-completion
debugger  → systematic-debugging · gap-detector
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the codebase isn't Next.js, swap &lt;code&gt;nextjs&lt;/code&gt; for whatever framework Skill fits. Skills are bundled per plugin, so changing stack doesn't require redesigning the team.&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.amazonaws.com%2Fuploads%2Farticles%2Fjyz0bdntxeb2s77bjjv3.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.amazonaws.com%2Fuploads%2Farticles%2Fjyz0bdntxeb2s77bjjv3.png" alt="Skill cards per agent — swap only the framework Skill to port across stacks" width="800" height="463"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Skills I Tried and Dropped
&lt;/h2&gt;

&lt;p&gt;Not every Skill is worth keeping.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;using-git-worktrees&lt;/strong&gt; — useful in theory, didn't fit my workflow. I switch branches fast and don't have enough parallel work to justify worktrees.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;dispatching-parallel-agents&lt;/strong&gt; — set it aside as I moved to running the 5-agent team directly. The external DB state-sharing structure is more stable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;bkit:enterprise&lt;/strong&gt; / &lt;strong&gt;bkit:infra-architect&lt;/strong&gt; — built for microservices + k8s + Terraform. Doesn't match my stack (Vercel + Supabase). Off unless enterprise-grade infra design actually applies.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When to Build Your Own Skill
&lt;/h2&gt;

&lt;p&gt;Start with what plugins ship. Only build your own when "I keep typing the same thing" repeats three times.&lt;/p&gt;

&lt;p&gt;My three custom Skills: &lt;code&gt;publish-post&lt;/code&gt; (blog publish pipeline), &lt;code&gt;screenshot-ppt&lt;/code&gt; (Puppeteer capture template), &lt;code&gt;wp-media-upload&lt;/code&gt; (WordPress media API call). All blog-operations only.&lt;/p&gt;

&lt;p&gt;When writing your own, &lt;code&gt;superpowers:writing-skills&lt;/code&gt; helps — authoring conventions, examples, metadata format.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;Skills don't make Claude smarter. They show Claude how I work, repeatedly. Four plugins cover most of it; the remainder goes to two or three custom Skills. First setup is under 10 minutes.&lt;/p&gt;

&lt;p&gt;80% first, fix the remaining 20% as real problems show up. Skills grow the same way. Start with the four base plugins. When "this repeats" happens three times, that's the moment to write a new Skill. Don't force it earlier.&lt;/p&gt;

&lt;p&gt;The last gate is always a human. &lt;code&gt;verification-before-completion&lt;/code&gt; passing doesn't guarantee the feature works — build, type, and tests get the automated pass; business logic still needs a human check. Hold that line, and a Skill set demonstrably speeds up development.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://gocodelab.com/en/blog/en-claude-code-skills-plugin-set-by-task-ep20" rel="noopener noreferrer"&gt;GoCodeLab&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>productivity</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Same Claude, Different Roles — My 5-Agent Dev Team</title>
      <dc:creator>LazyDev_OH</dc:creator>
      <pubDate>Wed, 15 Apr 2026 16:11:00 +0000</pubDate>
      <link>https://forem.com/lazydev_oh/same-claude-different-roles-my-5-agent-dev-team-3jlc</link>
      <guid>https://forem.com/lazydev_oh/same-claude-different-roles-my-5-agent-dev-team-3jlc</guid>
      <description>&lt;p&gt;I pushed a PR. The Claude I'd spun up as Reviewer flagged 3 edge cases and 1 memory leak. In the previous session, the Claude I'd spun up as Coder had called the same code "complete."&lt;/p&gt;

&lt;p&gt;Same Claude 4.6. Only the role was different.&lt;/p&gt;

&lt;p&gt;That gap is why I built an agent team from scratch. Writing and validating solo meant obvious issues slipped through. A session set up as Reviewer doesn't hand out "looks fine" easily. Tell it to nitpick and it nitpicks.&lt;/p&gt;

&lt;p&gt;EP.17 laid down harness engineering (Rules, Commands, Hooks). Sitting on top of it now is a 5-person team — &lt;strong&gt;Planner, Coder, Reviewer, Tester, Debugger&lt;/strong&gt;. Claude Code subagents isolate context by design, so they don't share cumulative state across sessions. My routine needed that sharing, so I layered MCP servers for Agent-to-Agent communication and Supabase for task state on top.&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.amazonaws.com%2Fuploads%2Farticles%2Fub957pqm0ywhv84o2590.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.amazonaws.com%2Fuploads%2Farticles%2Fub957pqm0ywhv84o2590.png" alt="5-agent team structure — role, Skills, and MCP tools all separated" width="800" height="385"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Quality Shifts With the Role
&lt;/h2&gt;

&lt;p&gt;The principle is simple. Claude answers in line with the role you give it.&lt;/p&gt;

&lt;p&gt;Tell it "you're the developer who wrote this code," and it answers defensively. Switch to "you're a reviewer, your job is to find mistakes," and it goes straight for edge cases. Same model, different output distribution.&lt;/p&gt;

&lt;p&gt;The problem was writing and validating in the same session. Ask Claude "did this go well?" and it tends to protect what it just wrote. Humans do the same. Reviewing your own code has blind spots.&lt;/p&gt;

&lt;p&gt;Role separation doesn't end with one line of system prompt. Each role reads different Skills, has different tool permissions, and produces different output formats.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 5 Agents (Copy-Paste Ready)
&lt;/h2&gt;

&lt;p&gt;Each Agent is a single markdown file following the official Claude Code subagent format — YAML frontmatter with &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt;, &lt;code&gt;tools&lt;/code&gt;, &lt;code&gt;model&lt;/code&gt;, and the system prompt in the body.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;~/.claude/agents/planner.md&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;planner&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Designs&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;specs,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;implementation&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;plans,&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;risks.&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;Writes&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;code&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;—&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;outputs&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;spec.json&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;only."&lt;/span&gt;
&lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Read, Grep, Glob, mcp__supabase__query, mcp__docs_search__search&lt;/span&gt;
&lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inherit&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

You are the Planner. You do not write code. You write the spec.
Input: user's natural-language request.
Output: spec.json (schema below).

spec.json required fields:
&lt;span class="p"&gt;  -&lt;/span&gt; goal: one-line summary
&lt;span class="p"&gt;  -&lt;/span&gt; user_stories: array
&lt;span class="p"&gt;  -&lt;/span&gt; api_endpoints: method, path, I/O
&lt;span class="p"&gt;  -&lt;/span&gt; components: new / modified components
&lt;span class="p"&gt;  -&lt;/span&gt; data_model: new tables / columns
&lt;span class="p"&gt;  -&lt;/span&gt; risks: N+1, missing caching, race conditions
&lt;span class="p"&gt;  -&lt;/span&gt; test_outline: scenarios the Tester will use

Forbidden:
&lt;span class="p"&gt;  -&lt;/span&gt; modifying files, running git, running migrations
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;~/.claude/agents/coder.md&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;coder&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Reads Planner's spec.json and implements it. Stops before commit.&lt;/span&gt;
&lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Read, Write, Edit, Bash, Grep, Glob, mcp__supabase__query&lt;/span&gt;
&lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inherit&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

You are the Coder. Read spec.json and implement it.
Do not add features outside the spec.

Sequence:
&lt;span class="p"&gt;  1.&lt;/span&gt; Read spec.json in full
&lt;span class="p"&gt;  2.&lt;/span&gt; List files affected
&lt;span class="p"&gt;  3.&lt;/span&gt; Implement — record rationale in implementation_notes.json
&lt;span class="p"&gt;  4.&lt;/span&gt; Run build / type check (exit into debugger state on failure)

Forbidden:
&lt;span class="p"&gt;  -&lt;/span&gt; committing, git push
&lt;span class="p"&gt;  -&lt;/span&gt; running migrations (if not in spec)
&lt;span class="p"&gt;  -&lt;/span&gt; accessing .env or production secrets
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;~/.claude/agents/reviewer.md&lt;/code&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;reviewer&lt;/span&gt;
&lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Full review of changed code. Edge cases, security, performance. No modifications.&lt;/span&gt;
&lt;span class="na"&gt;tools&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Read, Grep, Glob, Bash(git diff:*), mcp__docs_search__search&lt;/span&gt;
&lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;inherit&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;

You are the Reviewer. You do not modify code. You nitpick.
"Looks fine" is only allowed after three review passes.

Review checklist:
&lt;span class="p"&gt;  -&lt;/span&gt; edge cases (null, empty, overflow)
&lt;span class="p"&gt;  -&lt;/span&gt; race conditions, concurrency
&lt;span class="p"&gt;  -&lt;/span&gt; memory leaks, resource cleanup
&lt;span class="p"&gt;  -&lt;/span&gt; security (XSS, SQL injection, missing permissions)
&lt;span class="p"&gt;  -&lt;/span&gt; naming, consistency
&lt;span class="p"&gt;  -&lt;/span&gt; missing error handling

Output: review_findings.json
&lt;span class="p"&gt;  -&lt;/span&gt; severity: critical / major / minor
&lt;span class="p"&gt;  -&lt;/span&gt; file, line, description, suggested_fix

Forbidden — modifying files, auto-formatting, refactoring suggestions outside spec
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;tester&lt;/code&gt; and &lt;code&gt;debugger&lt;/code&gt; follow the same pattern — each strictly scoped, each with explicit "do not" rules. What you do &lt;strong&gt;not&lt;/strong&gt; do is stated explicitly. Reviewer — no edits. Debugger — no edits. Coder — no commits. Keeping each role inside its lane is half of the quality story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cross-Session State via Supabase
&lt;/h2&gt;

&lt;p&gt;The moment I split roles, I hit a wall. Coder sessions didn't know about the spec Planner had written. Different sessions don't share memory.&lt;/p&gt;

&lt;p&gt;Solution: an &lt;code&gt;agent_state&lt;/code&gt; table in Supabase. Each Agent writes only to its own slot.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;agent_state&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;task_id&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;project&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;current_owner&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;-- planner | coder | ...&lt;/span&gt;
  &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="s1"&gt;'planning'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;spec&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;implementation_notes&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;review_findings&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;test_results&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;debug_trace&lt;/span&gt; &lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- planning → coding → reviewing → testing → debugging? → done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Columns are split so it's obvious which stage broke. If a session dies mid-turn, &lt;code&gt;status=reviewing, review_findings is null&lt;/code&gt; tells me the Reviewer turn died.&lt;/p&gt;

&lt;p&gt;State-transition guards live in a Supabase Edge Function. Moving from "planning" to "coding" requires that the spec column is populated. The DB enforces it.&lt;/p&gt;

&lt;p&gt;Files get left behind too. &lt;code&gt;docs/tasks/{task_id}.md&lt;/code&gt; holds human-readable output per Agent. DB is the state record, files are the reading record.&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.amazonaws.com%2Fuploads%2Farticles%2Fpr0hjp2462qacsjxomzd.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.amazonaws.com%2Fuploads%2Farticles%2Fpr0hjp2462qacsjxomzd.png" alt="CLAUDE.md layers — merged top-down through global → project → agent scope" width="800" height="649"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  MCP Servers for Per-Role Permissions
&lt;/h2&gt;

&lt;p&gt;Five agents working on the same project share tools but need different permission levels. MCP servers handle this — each Agent hits an MCP endpoint, the server checks the role, and serves only allowed operations.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// ~/.claude/mcp/supabase-server.ts (core excerpt)&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;planner&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;coder&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;migrate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;reviewer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;tester&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;tables&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;test_*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;debugger&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;read&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;write&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;

&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;supabase.query&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agent_role&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unknown role&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;op&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;insert&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;write&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; has no write`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;op&lt;/span&gt;&lt;span class="p"&gt;](&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No need to repeat prohibition rules in every system prompt — the server rejects anything outside role permissions. Agent prompts stay readable; security lives on the server side.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Actual Routine
&lt;/h2&gt;

&lt;p&gt;Turns are driven by one shell script. Claude Code auto-loads subagent files at &lt;code&gt;~/.claude/agents/&amp;lt;name&amp;gt;.md&lt;/code&gt;. The script tells the main session "read the current state for this task_id and delegate to that subagent."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# ~/.claude/bin/agent-run.sh&lt;/span&gt;

&lt;span class="nv"&gt;TASK_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;
&lt;span class="nv"&gt;AGENT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$2&lt;/span&gt;

&lt;span class="nv"&gt;STATE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SUPABASE_URL&lt;/span&gt;&lt;span class="s2"&gt;/rest/v1/agent_state?task_id=eq.&lt;/span&gt;&lt;span class="nv"&gt;$TASK_ID&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"apikey: &lt;/span&gt;&lt;span class="nv"&gt;$SUPABASE_ANON_KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

claude &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--mcp-config&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.claude/mcp.json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--append-system-prompt&lt;/span&gt; &lt;span class="s2"&gt;"Task: &lt;/span&gt;&lt;span class="nv"&gt;$TASK_ID&lt;/span&gt;&lt;span class="s2"&gt;. State: &lt;/span&gt;&lt;span class="nv"&gt;$STATE&lt;/span&gt;&lt;span class="s2"&gt;. Delegate to the '&lt;/span&gt;&lt;span class="nv"&gt;$AGENT&lt;/span&gt;&lt;span class="s2"&gt;' subagent only."&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"Use the &lt;/span&gt;&lt;span class="nv"&gt;$AGENT&lt;/span&gt;&lt;span class="s2"&gt; subagent to handle task &lt;/span&gt;&lt;span class="nv"&gt;$TASK_ID&lt;/span&gt;&lt;span class="s2"&gt;. Read the current agent_state, perform only your role, write back to your own column, and exit."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Turn trace for a single feature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[me]       → agent-run.sh task-001 planner
              "add a usage chart to the dashboard"

planner    → spec.json → status=coding
coder      → implement → status=reviewing
reviewer   → 1 critical, 2 major → status=coding (rework)
coder      → apply findings → status=reviewing (round 2)
reviewer   → OK → status=testing
tester     → 2 failures → status=debugging
debugger   → root cause → status=coding
coder      → minimal fix → status=testing
tester     → all pass → status=done

[me]       → review diff → commit myself
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key point: turns are closed. A single Agent session does only its job, leaves output in files/DB, and exits. The next Agent doesn't inherit the previous session — it reads artifacts.&lt;/p&gt;

&lt;p&gt;A human gates between Agents. I don't auto-launch the next Agent. Tried full auto once; it wandered off course. Manual gating is the current setup. Commits stay with me — no Agent has commit permissions.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Changed, What Didn't (Honestly)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What changed:&lt;/strong&gt; edge cases get caught earlier. Reviewer is built to nitpick, so "looks good" doesn't come cheap. Same code Coder called fine, Reviewer finds three problems in.&lt;/p&gt;

&lt;p&gt;Debugging sped up. Debugger is a separate session, so the context is clean. No "I just wrote this, it should be fine" bias from Coder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What didn't change:&lt;/strong&gt; final review is still mine. Five Agents pass the code, I still skim the diff. Tester sometimes writes meaningless tests. Reviewer sometimes demands a bar nothing can pass. Debugger sometimes names the wrong cause. Team or not, the last gate is a human.&lt;/p&gt;

&lt;p&gt;Cost went up too. About 2–3x the tokens of a single-session run. On the other hand, rework rounds dropped, so total wall-clock time actually went down. A token-for-time trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;Role separation is less prompt engineering than workflow engineering. Same model, same codebase — split the session, and the result shifts. Ask the Coder to review, it shields its own work. Split off a Reviewer session, and it nitpicks.&lt;/p&gt;

&lt;p&gt;80% first, then fix the remaining 20% as real problems show up. This team grew that way. Planner and Coder first. Adding Reviewer showed clearly how quality changed. Test automation was thin, so Tester joined. Debugger got split out last to kill debugging bias. Teams should grow by need.&lt;/p&gt;

&lt;p&gt;Final review is still mine. Even after five Agents, I skim the code myself. Take that as a given, and role separation alone changes code quality — measurably.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://gocodelab.com/en/blog/en-dev-agent-team-role-separation-code-quality-ep19" rel="noopener noreferrer"&gt;GoCodeLab&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>claudecode</category>
      <category>productivity</category>
      <category>programming</category>
    </item>
    <item>
      <title>axios npm Supply Chain Attack (March 31, 2026) — What Happened and How to Check Your Lock File Right Now</title>
      <dc:creator>LazyDev_OH</dc:creator>
      <pubDate>Tue, 14 Apr 2026 04:44:20 +0000</pubDate>
      <link>https://forem.com/lazydev_oh/axios-npm-supply-chain-attack-march-31-2026-what-happened-and-how-to-check-your-lock-file-coh</link>
      <guid>https://forem.com/lazydev_oh/axios-npm-supply-chain-attack-march-31-2026-what-happened-and-how-to-check-your-lock-file-coh</guid>
      <description>&lt;p&gt;On &lt;strong&gt;March 31, 2026&lt;/strong&gt;, malicious versions of &lt;code&gt;axios&lt;/code&gt; — a package with &lt;strong&gt;70M+ weekly downloads&lt;/strong&gt; — were published to npm after the maintainer's account was hijacked via social engineering. Versions &lt;code&gt;1.14.1&lt;/code&gt; and &lt;code&gt;0.30.4&lt;/code&gt; were pushed back-to-back, both carrying a &lt;code&gt;plain-crypto-js@^4.2.1&lt;/code&gt; dependency that deploys a &lt;strong&gt;cross-platform RAT&lt;/strong&gt; through a postinstall hook.&lt;/p&gt;

&lt;p&gt;The malicious releases sat on the registry for roughly &lt;strong&gt;3 hours&lt;/strong&gt;. In that window, an estimated &lt;strong&gt;600,000 installs&lt;/strong&gt; occurred.&lt;/p&gt;

&lt;p&gt;If you use axios, check your lock file. Now.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Malicious: &lt;code&gt;axios@1.14.1&lt;/code&gt;, &lt;code&gt;axios@0.30.4&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Safe: &lt;code&gt;axios@1.14.0&lt;/code&gt;, &lt;code&gt;axios@0.30.3&lt;/code&gt; (pre-incident), &lt;code&gt;1.15.0+&lt;/code&gt; / &lt;code&gt;0.30.5+&lt;/code&gt; (post-incident)&lt;/li&gt;
&lt;li&gt;Attribution: North Korea — Sapphire Sleet (Microsoft) / UNC1069 (Google)&lt;/li&gt;
&lt;li&gt;Action: wipe &lt;code&gt;node_modules&lt;/code&gt;, reinstall, &lt;strong&gt;rotate all credentials&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&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.amazonaws.com%2Fuploads%2Farticles%2F3i3ozbu7ofnuvaxmgir8.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.amazonaws.com%2Fuploads%2Farticles%2F3i3ozbu7ofnuvaxmgir8.png" alt="axios supply chain attack timeline" width="800" height="501"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  How to Check Right Now
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Installed axios version&lt;/span&gt;
npm list axios

&lt;span class="c"&gt;# Check lock file for malicious versions&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"axios@(1&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;14&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;1|0&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;30&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;4)|plain-crypto-js"&lt;/span&gt; package-lock.json

&lt;span class="c"&gt;# Monorepo-wide scan&lt;/span&gt;
find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"package-lock.json"&lt;/span&gt; &lt;span class="nt"&gt;-not&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; &lt;span class="s2"&gt;"*/node_modules/*"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | xargs &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt; &lt;span class="s2"&gt;"plain-crypto-js&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;1.14.1&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;0.30.4"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If grep returns a match, remediate immediately. No output means you're probably fine — but also check git history. If the malicious version was ever installed in the past, the postinstall hook has already run.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Did the malicious version ever land in lock file history?&lt;/span&gt;
git log &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; package-lock.json | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s2"&gt;"1&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;14&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;1|0&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;30&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;4|plain-crypto-js"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;pnpm&lt;/code&gt;, use &lt;code&gt;pnpm list axios&lt;/code&gt;; with &lt;code&gt;yarn&lt;/code&gt;, &lt;code&gt;yarn list --pattern axios&lt;/code&gt;. The lock-file grep pattern applies regardless of package manager.&lt;/p&gt;




&lt;h2&gt;
  
  
  The 3-Hour Timeline
&lt;/h2&gt;

&lt;p&gt;Independent reconstructions from &lt;a href="https://www.aikido.dev/blog/axios-npm-compromised-maintainer-hijacked-rat" rel="noopener noreferrer"&gt;Aikido Security&lt;/a&gt;, Arctic Wolf, and &lt;a href="https://www.elastic.co/security-labs/axios-one-rat-to-rule-them-all" rel="noopener noreferrer"&gt;Elastic Security Labs&lt;/a&gt; largely agree:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Time (UTC)&lt;/th&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2026-03-31 00:21&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;axios@1.14.1&lt;/code&gt; published — targets 1.x line, adds &lt;code&gt;plain-crypto-js@^4.2.1&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;+39 min&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Attacker stages the 0.x legacy release&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;01:00&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;axios@0.30.4&lt;/code&gt; published — 0.x branch compromised&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;~03:00&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Socket.dev / Aikido detect anomalous postinstall hook, community alerts begin&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;~04:00&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;npm force-unpublishes both versions, exposure totals ~3 hours&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"Only 3 hours" is a dangerous framing. Vercel, GitHub Actions, CircleCI, and similar CI environments pull fresh versions on cache misses every 10~30 seconds. Globally, tens of thousands of builds ran in that window. Several regions also reported CDN cache serving the malicious version briefly after the unpublish.&lt;/p&gt;




&lt;h2&gt;
  
  
  How the Malicious Code Works
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;plain-crypto-js&lt;/code&gt; disguises itself as a crypto utility. &lt;strong&gt;It is never imported anywhere in axios source&lt;/strong&gt; — it exists solely to execute its postinstall hook.&lt;/p&gt;

&lt;p&gt;During install, npm runs &lt;code&gt;postinstall&lt;/code&gt; automatically. That hook contacts the attacker's C2 server and pulls a second-stage payload. The payload detects the host OS (macOS / Windows / Linux) and drops a matching RAT (Remote Access Trojan).&lt;/p&gt;

&lt;p&gt;Per Elastic Security Labs, the C2 protocol rides on HTTPS with a custom command set designed to blend into normal API traffic, making network-level detection difficult.&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.amazonaws.com%2Fuploads%2Farticles%2Fp4el6pqvzo5r572px6o1.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.amazonaws.com%2Fuploads%2Farticles%2Fp4el6pqvzo5r572px6o1.png" alt="axios attack impact stats" width="800" height="458"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Attack Vector — Maintainer Account Hijack
&lt;/h2&gt;

&lt;p&gt;Per SANS Institute and &lt;a href="https://thehackernews.com/2026/04/unc1069-social-engineering-of-axios.html" rel="noopener noreferrer"&gt;The Hacker News&lt;/a&gt;, the axios maintainer account was hijacked through a &lt;strong&gt;targeted social engineering campaign&lt;/strong&gt;. The attacker changed the account email to &lt;code&gt;ifstap@proton.me&lt;/code&gt;, then abused publish permissions to push the two malicious releases.&lt;/p&gt;

&lt;h3&gt;
  
  
  Attribution
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Microsoft Threat Intelligence&lt;/strong&gt;: &lt;a href="https://www.microsoft.com/en-us/security/blog/2026/04/01/mitigating-the-axios-npm-supply-chain-compromise/" rel="noopener noreferrer"&gt;Sapphire Sleet&lt;/a&gt; — North Korea state actor&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google GTIG&lt;/strong&gt;: &lt;a href="https://cloud.google.com/blog/topics/threat-intelligence/north-korea-threat-actor-targets-axios-npm-package" rel="noopener noreferrer"&gt;UNC1069&lt;/a&gt; — same actor, tracked independently&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Joint attribution confirmed&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;UNC1069 / Sapphire Sleet has a track record of targeting developers through:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fake job offers with malicious coding-test files&lt;/li&gt;
&lt;li&gt;Fake recruiter outreach via LinkedIn or Telegram&lt;/li&gt;
&lt;li&gt;Phishing open-source maintainers directly&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This axios case appears to fall into the third pattern.&lt;/p&gt;




&lt;h2&gt;
  
  
  Remediation
&lt;/h2&gt;

&lt;p&gt;Don't just upgrade — wipe and rebuild.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Wipe node_modules + lock file&lt;/span&gt;
&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; node_modules package-lock.json

&lt;span class="c"&gt;# 2. Clean cache&lt;/span&gt;
npm cache clean &lt;span class="nt"&gt;--force&lt;/span&gt;

&lt;span class="c"&gt;# 3. Reinstall latest safe version&lt;/span&gt;
npm &lt;span class="nb"&gt;install &lt;/span&gt;axios@latest

&lt;span class="c"&gt;# 4. Verify&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s2"&gt;"plain-crypto-js"&lt;/span&gt; package-lock.json
&lt;span class="c"&gt;# → No output = clean&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply the same to deployment environments (Vercel / Netlify / GitHub Actions caches). A stale cache can still serve the compromised artifact.&lt;/p&gt;




&lt;h2&gt;
  
  
  Rotate All Credentials — Not Just Env Vars
&lt;/h2&gt;

&lt;p&gt;If a malicious version ever reached your machines, the RAT may still be resident. The attacker has system-level access, not just &lt;code&gt;process.env&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rotation checklist:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] AWS / GCP / Azure access keys&lt;/li&gt;
&lt;li&gt;[ ] AI API keys — OpenAI / Anthropic / Gemini&lt;/li&gt;
&lt;li&gt;[ ] Database passwords — PostgreSQL, MySQL, MongoDB&lt;/li&gt;
&lt;li&gt;[ ] Payment API keys — Stripe, LemonSqueezy, Paddle&lt;/li&gt;
&lt;li&gt;[ ] GitHub Personal Access Token + SSH keys&lt;/li&gt;
&lt;li&gt;[ ] App secrets — &lt;code&gt;NEXTAUTH_SECRET&lt;/code&gt;, &lt;code&gt;SESSION_SECRET&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;[ ] Webhook secrets for external services&lt;/li&gt;
&lt;li&gt;[ ] Infected-machine SSH public keys — remove from &lt;code&gt;~/.ssh/authorized_keys&lt;/code&gt; on any servers they reached&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Revoke old keys immediately after issuing new ones. Keeping the old key alive defeats the rotation.&lt;/p&gt;

&lt;p&gt;For machines with high suspicion of compromise, an OS reinstall is the safest option. CI runner images should be rebuilt clean. Local dev machines should at minimum clear browser sessions, SSH keys, and saved AWS CLI profiles, then reconfigure.&lt;/p&gt;




&lt;h2&gt;
  
  
  Prevention Routines
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Commit lock files.&lt;/strong&gt; Without a lock file, every build can pull a different version. If &lt;code&gt;package-lock.json&lt;/code&gt; is in &lt;code&gt;.gitignore&lt;/code&gt;, remove it now.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Put &lt;code&gt;npm audit&lt;/code&gt; in CI.&lt;/strong&gt; Run it on every PR. &lt;code&gt;npm audit --audit-level=high&lt;/code&gt; catches high-severity issues at minimum. Caveat: audit only sees what's public in the CVE database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Tighten version range specifiers.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;❌&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Too&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;loose&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;—&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;opens&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;door&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;to&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;auto-updates&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"axios"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"^1.13.0"&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;✅&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Exact&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;pin&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"axios"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1.14.0"&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;✅&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;Patch-only&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"axios"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"~1.14.0"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. Monitor beyond CVE.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Strength&lt;/th&gt;
&lt;th&gt;Note&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Dependabot&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Built into GitHub&lt;/td&gt;
&lt;td&gt;CVE-based, limited against fresh attacks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Socket.dev&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Behavioral analysis&lt;/td&gt;
&lt;td&gt;Flagged this axios incident early&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Aikido Security&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Real-time behavioral&lt;/td&gt;
&lt;td&gt;Published first public analysis&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Snyk&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Scan + remediation&lt;/td&gt;
&lt;td&gt;Free tier available&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;npm audit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Built-in&lt;/td&gt;
&lt;td&gt;CVE-based limits&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Realistic combo: Dependabot + Socket.dev. Single-tool reliance leaves blind spots.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why This Keeps Happening
&lt;/h2&gt;

&lt;p&gt;The npm ecosystem has a low publishing bar. A single account compromise can poison a package used by hundreds of millions of developers. That structural fact isn't changing fast.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;XZ Utils&lt;/strong&gt; (2024-03) — compromised Linux distribution backdoor&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;event-stream&lt;/strong&gt; (2018) — crypto wallet stealer hidden in dependency&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ua-parser-js&lt;/strong&gt; (2021) — malicious versions with credential stealer&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;axios&lt;/strong&gt; (2026-03) — this incident&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;axios isn't the first and won't be the last.&lt;/p&gt;

&lt;p&gt;Following this incident, npm is reportedly considering mandatory 2FA expansion and a 24-hour cooldown on maintainer email changes. GitHub already required 2FA for top npm maintainers since 2024, but &lt;strong&gt;this hijack went through the email recovery flow&lt;/strong&gt;. Security chains only hold as strong as the weakest link.&lt;/p&gt;




&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Check your lock file right now&lt;/strong&gt; — don't assume you're fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wipe, don't just upgrade&lt;/strong&gt; — stale caches and remnant RATs are real risks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotate credentials broadly&lt;/strong&gt; — system-level access means everything is suspect.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put behavioral analysis in your CI&lt;/strong&gt; — CVE-based tools can't catch fresh attacks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin exact versions for critical packages&lt;/strong&gt; — range specifiers are attack surface.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Trusting a popular package and verifying it are different things. If you use axios, put a version check in your routine starting today.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Sources:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/axios/axios/issues/10636" rel="noopener noreferrer"&gt;axios Official Post-Mortem (GitHub #10636)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.microsoft.com/en-us/security/blog/2026/04/01/mitigating-the-axios-npm-supply-chain-compromise/" rel="noopener noreferrer"&gt;Microsoft Security Blog — Mitigating the Axios npm supply chain compromise&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://cloud.google.com/blog/topics/threat-intelligence/north-korea-threat-actor-targets-axios-npm-package" rel="noopener noreferrer"&gt;Google Cloud Threat Intelligence — UNC1069 analysis&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.aikido.dev/blog/axios-npm-compromised-maintainer-hijacked-rat" rel="noopener noreferrer"&gt;Aikido Security — first public analysis&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.elastic.co/security-labs/axios-one-rat-to-rule-them-all" rel="noopener noreferrer"&gt;Elastic Security Labs — RAT technical analysis&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://socket.dev/blog/axios-npm-package-compromised" rel="noopener noreferrer"&gt;Socket.dev — plain-crypto-js analysis&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://snyk.io/blog/axios-npm-package-compromised-supply-chain-attack-delivers-cross-platform/" rel="noopener noreferrer"&gt;Snyk Security Blog&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://gocodelab.com/en/blog/en-axios-npm-supply-chain-attack-2026" rel="noopener noreferrer"&gt;GoCodeLab&lt;/a&gt; — April 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>npm</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Vercel vs Netlify vs Cloudflare Pages 2026 — Deep Comparison with Real Numbers</title>
      <dc:creator>LazyDev_OH</dc:creator>
      <pubDate>Tue, 14 Apr 2026 04:25:43 +0000</pubDate>
      <link>https://forem.com/lazydev_oh/vercel-vs-netlify-vs-cloudflare-pages-2026-deep-comparison-with-real-numbers-8pl</link>
      <guid>https://forem.com/lazydev_oh/vercel-vs-netlify-vs-cloudflare-pages-2026-deep-comparison-with-real-numbers-8pl</guid>
      <description>&lt;p&gt;The web deployment landscape crystallized into a clear three-way split in 2026. Vercel for Next.js full-stack. Cloudflare Pages for static sites and edge workloads. Netlify for the Jamstack middle ground. All three ship with &lt;code&gt;git push&lt;/code&gt;-to-deploy out of the box.&lt;/p&gt;

&lt;p&gt;The real story is in billing and performance. In February 2026, Vercel shipped Fluid Compute to GA and announced &lt;strong&gt;up to 95% cost savings across 45 billion weekly requests&lt;/strong&gt;. Cloudflare Workers hold cold starts under 5ms. Netlify migrated to credit-based billing in September 2025. The same app gets billed differently, responds at different speeds, and feels different to operate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Short version&lt;/strong&gt;: Next.js ecosystem → Vercel. High-traffic static or edge-heavy → Cloudflare Pages. Forms and adapter ecosystem → Netlify.&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.amazonaws.com%2Fuploads%2Farticles%2Fmlnxs4r4cj8i789ctyc1.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.amazonaws.com%2Fuploads%2Farticles%2Fmlnxs4r4cj8i789ctyc1.png" alt="Vercel vs Netlify vs Cloudflare Pages comparison" width="800" height="477"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vercel&lt;/strong&gt;: Hobby free (100GB · 1M invocations), Pro $20/user/mo, Fluid Compute saves up to 95%&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Netlify&lt;/strong&gt;: Free 100GB · 300 build min, Pro $19/user/mo, credit-based since Sept 2025&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Pages&lt;/strong&gt;: unlimited bandwidth, 500 builds/mo free, Workers Paid $5/mo bundles ecosystem&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cold starts&lt;/strong&gt;: Cloudflare &amp;lt; 5ms &amp;gt; Vercel Fluid ~0ms (warm) &amp;gt; Netlify 150~3,000ms&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Next.js support&lt;/strong&gt;: Vercel native &amp;gt; Netlify adapter (30~60% slower builds) &amp;gt; Cloudflare OpenNext (constraints)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge PoPs&lt;/strong&gt;: Cloudflare 330+ / Vercel 40+ / Netlify 8-region multi-cloud&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare ecosystem&lt;/strong&gt;: KV · D1 · R2 · Durable Objects · Hyperdrive bundled at $5&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What Each Platform Actually Is
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Vercel&lt;/strong&gt; was founded by Guillermo Rauch in 2015 — the same person behind Next.js. As of 2026, the company sits around $3.2B valuation. The core edge: native Next.js integration. ISR, Image Optimization, Middleware, Server Actions, Cache Components — all of it works with zero config. Hobby plan is personal / non-commercial only.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Netlify&lt;/strong&gt; was founded in 2014 and coined the term "Jamstack." Framework adapters span Astro, Next.js, SvelteKit, Nuxt, Gatsby, Hugo — the widest ecosystem of the three. Forms, serverless functions, and Edge Functions come built in. In September 2025, they migrated to credit-based billing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloudflare Pages&lt;/strong&gt; runs on Cloudflare's global edge network. The headline features are 330+ PoPs and unlimited bandwidth. Workers Paid ($5/month) alone bundles Workers, Pages Functions, KV, D1, R2, Durable Objects, and Hyperdrive. Next.js runs through OpenNext and inherits edge runtime constraints — some Node.js modules unavailable, ISR limited.&lt;/p&gt;




&lt;h2&gt;
  
  
  Free Tier Deep Dive
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Vercel Hobby&lt;/th&gt;
&lt;th&gt;Netlify Free&lt;/th&gt;
&lt;th&gt;Cloudflare Pages&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Bandwidth&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;100GB&lt;/td&gt;
&lt;td&gt;100GB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Unlimited&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Build time&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Unlimited deploys&lt;/td&gt;
&lt;td&gt;300 min/mo&lt;/td&gt;
&lt;td&gt;500 builds/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Function invocations&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1M/mo&lt;/td&gt;
&lt;td&gt;125K/mo&lt;/td&gt;
&lt;td&gt;100K/day (~3M/mo)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CPU time&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;4h Active CPU&lt;/td&gt;
&lt;td&gt;Credit-based&lt;/td&gt;
&lt;td&gt;10ms/request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Storage&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1GB Blob&lt;/td&gt;
&lt;td&gt;10GB&lt;/td&gt;
&lt;td&gt;R2 10GB / KV 1GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Usage restriction&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Personal / non-commercial&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Commercial OK&lt;/td&gt;
&lt;td&gt;Commercial OK&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cloudflare dominates on raw bandwidth — traffic spikes don't trigger overage invoices. Vercel Hobby's decisive constraint is the no-commercial clause. A single advertisement can put you in violation. Netlify's 300 build-minute cap is the actual bottleneck — a medium Next.js project often builds in 5~8 minutes, hitting the ceiling at 40~60 deploys/month.&lt;/p&gt;




&lt;h2&gt;
  
  
  Paid Plans and Overage Simulation
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vercel Pro&lt;/strong&gt;: $20/user/month + 16 CPU-hours, 1,440 GB-hours memory — overage Active CPU $0.128/hour&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Netlify Pro&lt;/strong&gt;: $19/user/month + 1TB bandwidth, 25K build minutes — $7 per 500 extra build min, $20 per 100GB extra bandwidth, $25 per 1M extra invocations&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Workers Paid&lt;/strong&gt;: $5/month + 10M requests, 30M CPU-ms — $0.30 per extra 1M requests&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Scenario A — Small blog (100K monthly visits, 50GB bandwidth, 500K function calls)&lt;/strong&gt;&lt;br&gt;
Vercel Hobby $0 / Pro $20. Netlify Free possible. Cloudflare Pages $0. → &lt;strong&gt;Cloudflare Pages wins&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scenario B — Next.js SaaS (500K monthly visits, 5M function calls, DB-heavy)&lt;/strong&gt;&lt;br&gt;
Vercel Pro ~$20~30 (Fluid keeps CPU overage near zero). Netlify Pro $19 + function overage $100 = &lt;strong&gt;$119&lt;/strong&gt;. Cloudflare Workers Paid $5 + extra requests $1.50 = &lt;strong&gt;$6.50&lt;/strong&gt;. → Order: Cloudflare, Vercel, Netlify. If Next.js compatibility is non-negotiable, Vercel.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Scenario C — Image hosting (2TB monthly downloads)&lt;/strong&gt;&lt;br&gt;
Vercel Pro $20 + 1.9TB overage $380 = &lt;strong&gt;$400&lt;/strong&gt;. Netlify Pro $19 + 1TB overage $200 = &lt;strong&gt;$219&lt;/strong&gt;. Cloudflare R2 $0 egress + $30 storage = &lt;strong&gt;$30&lt;/strong&gt;. → For egress-heavy workloads, Cloudflare is effectively the only option.&lt;/p&gt;


&lt;h2&gt;
  
  
  Vercel Fluid Compute — Real Savings
&lt;/h2&gt;

&lt;p&gt;Fluid Compute hit GA in February 2026. Per Vercel's figures: 45B weekly requests, customers seeing up to 95% savings, 75%+ of all functions now on Fluid. The old model billed the entire function duration. Fluid only bills &lt;strong&gt;Active CPU windows&lt;/strong&gt; — when your code is actually executing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Example: Next.js API handler (I/O-bound)&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 100ms — JSON parsing, validation (Active CPU)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="c1"&gt;// 400ms — Supabase query wait (I/O, Fluid bills nothing)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="c1"&gt;// 30ms — response serialization (Active CPU)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// Total wall time: 530ms&lt;/span&gt;
&lt;span class="c1"&gt;// Legacy billing: 530ms (all of it)&lt;/span&gt;
&lt;span class="c1"&gt;// Fluid billing: 130ms (Active CPU only) → 75% saved&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From Vercel's case studies: &lt;em&gt;"Many of our API endpoints were lightweight and involved external requests, resulting in idle compute time. By leveraging in-function concurrency, we were able to share compute resources between invocations, cutting costs by over 50% with zero code changes."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For typical Next.js apps, expect function invocation counts to drop 30~50% with proportional cost reduction. The benefit is limited for CPU-heavy workloads (ML inference, image resizing) where Active CPU dominates.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cold Start Benchmarks — 5ms vs 250ms vs 3 Seconds
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Cold start&lt;/th&gt;
&lt;th&gt;Warm response&lt;/th&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Cloudflare Workers&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&amp;lt; 5ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~1ms&lt;/td&gt;
&lt;td&gt;V8 Isolates + Shard-and-Conquer (99.99% warm)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Vercel Fluid&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~0ms (warm)&lt;/td&gt;
&lt;td&gt;20~50ms&lt;/td&gt;
&lt;td&gt;Instance pre-warming + in-function concurrency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vercel legacy serverless&lt;/td&gt;
&lt;td&gt;~250ms&lt;/td&gt;
&lt;td&gt;50~80ms&lt;/td&gt;
&lt;td&gt;AWS Lambda&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Netlify Functions&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;150~3,000ms&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;80~150ms&lt;/td&gt;
&lt;td&gt;AWS Lambda (high variance)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cloudflare Workers' sub-5ms comes from V8 Isolates. Instead of spinning up a container, the platform runs your function directly inside the JavaScript engine. Initialization overhead is near zero. Shard-and-Conquer consistent hashing routes same-request traffic to the same node, keeping warm-hit rate at 99.99%.&lt;/p&gt;

&lt;p&gt;Vercel Fluid keeps instances warm with in-function concurrency — a single instance handles multiple concurrent requests. Near-zero cold starts for active functions.&lt;/p&gt;

&lt;p&gt;Netlify, running on AWS Lambda, is the slowest. Cold starts up to 3 seconds in benchmarks. For low-traffic sites or early-morning first requests, users feel the wait.&lt;/p&gt;




&lt;h2&gt;
  
  
  Next.js Feature Compatibility Matrix
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Next.js feature&lt;/th&gt;
&lt;th&gt;Vercel&lt;/th&gt;
&lt;th&gt;Netlify&lt;/th&gt;
&lt;th&gt;Cloudflare (OpenNext)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Server Components (RSC)&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server Actions&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ISR (revalidate)&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;On-Demand only&lt;/td&gt;
&lt;td&gt;Limited&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image Optimization&lt;/td&gt;
&lt;td&gt;Native&lt;/td&gt;
&lt;td&gt;Adapter&lt;/td&gt;
&lt;td&gt;Cloudflare Images&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Middleware&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;Full (edge)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cache Components&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;Planned&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Partial Prerendering (PPR)&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;Partial&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edge Runtime&lt;/td&gt;
&lt;td&gt;Full&lt;/td&gt;
&lt;td&gt;Edge Functions&lt;/td&gt;
&lt;td&gt;Native&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full Node.js modules&lt;/td&gt;
&lt;td&gt;All&lt;/td&gt;
&lt;td&gt;All&lt;/td&gt;
&lt;td&gt;Some blocked&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build speed (same project)&lt;/td&gt;
&lt;td&gt;baseline&lt;/td&gt;
&lt;td&gt;30~60% slower&lt;/td&gt;
&lt;td&gt;20% slower&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Next.js' latest features (Cache Components, PPR) only ship fully on Vercel. Netlify covers most of it via adapter, but ISR semantics differ and builds run noticeably longer. Cloudflare Pages inherits edge-runtime constraints — can't use &lt;code&gt;fs&lt;/code&gt;, &lt;code&gt;net&lt;/code&gt;, or &lt;code&gt;child_process&lt;/code&gt;, and ISR requires wiring Incremental Cache into KV separately.&lt;/p&gt;

&lt;p&gt;On the flip side, Cloudflare's Image Optimization routes through Cloudflare Images (faster CDN), and Edge Runtime is native. For edge-friendly codebases, Cloudflare Pages can actually win.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cloudflare Ecosystem — KV · D1 · R2 · Durable Objects
&lt;/h2&gt;

&lt;p&gt;Cloudflare's real edge: $5/month Workers Paid bundles 6+ data services. Each as a standalone SaaS would run into hundreds of dollars.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Use case&lt;/th&gt;
&lt;th&gt;Price (Paid)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Workers KV&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Global key-value, config/session/personalization&lt;/td&gt;
&lt;td&gt;Reads 10M $0.50, writes 1M $5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;D1&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Managed SQLite, lightweight relational DB&lt;/td&gt;
&lt;td&gt;Reads 25M $1, writes 50K $1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;R2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;S3-compatible object storage, &lt;strong&gt;zero egress&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;$0.015/GB storage, Class A 1M $4.50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Durable Objects&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;WebSockets, collaboration, locks, rate limiters&lt;/td&gt;
&lt;td&gt;1M requests $0.15, $0.20/GB/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Queues&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Message queue, async work&lt;/td&gt;
&lt;td&gt;1M operations $0.40&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Hyperdrive&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;External PostgreSQL pooling&lt;/td&gt;
&lt;td&gt;Included in Workers Paid&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Practical combo: sessions/config on KV, user data on D1, images/files on R2, chat rooms on Durable Objects, background jobs via Queues. Everything at the same $5.&lt;/p&gt;

&lt;p&gt;AWS equivalent stack: RDS ($15) + DynamoDB ($10) + S3 ($5) + &lt;strong&gt;egress ($100+)&lt;/strong&gt; + SQS ($2) = &lt;strong&gt;$130+/month minimum&lt;/strong&gt;. R2's zero-egress policy alone makes file-heavy services land in a completely different cost range.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Durable Objects is the only practical choice for stateful edge computing.&lt;/strong&gt; WebSocket chat rooms, Google Docs-style real-time collaboration, distributed locks, rate limiters. Vercel and Netlify have no equivalent, forcing external services (Pusher, Ably) to fill the gap.&lt;/p&gt;




&lt;h2&gt;
  
  
  Edge Network and Global TTFB
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare&lt;/strong&gt;: 330+ PoPs across 120+ countries&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vercel&lt;/strong&gt;: own edge network (40+ regions) + AWS/GCP&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Netlify&lt;/strong&gt;: multi-cloud AWS/GCP/Azure (8 main regions)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;TTFB benchmarks from Korea (static content): Cloudflare Seoul PoP 30~50ms, Vercel Tokyo/Seoul region 80~120ms, Netlify US-West default 250~400ms. For global apps with APAC users, Cloudflare is overwhelmingly the fastest experience.&lt;/p&gt;

&lt;p&gt;Vercel's Tokyo/Singapore regions can reach ~100ms in Korea when explicitly configured. Hobby has limited region pinning; Pro enables per-project region selection. Setting &lt;code&gt;regions&lt;/code&gt; in &lt;code&gt;vercel.json&lt;/code&gt; is important — defaults often point to US regions.&lt;/p&gt;




&lt;h2&gt;
  
  
  Netlify Credit-Based Pricing
&lt;/h2&gt;

&lt;p&gt;Since September 2025, Netlify uses a unified credit pool. Approximate conversions: 1 build minute = 1 credit, 1,000 function invocations = 1 credit, 1 GB bandwidth = 1 credit. Pro includes 500 credits/month — in theory 100 deploys if builds are 5 minutes each, but practical ceiling drops to 50~70 after other usage.&lt;/p&gt;

&lt;p&gt;The complaint is predictability. &lt;em&gt;"My build ran long and drained my credits"&lt;/em&gt; posts keep showing up in dev forums. Accounts created before September 4, 2025 can stay on the legacy plan.&lt;/p&gt;

&lt;p&gt;Netlify's strengths still hold — Forms built in (100 submissions/mo free), Identity, Large Media, Split Testing. Features Vercel and Cloudflare don't match natively.&lt;/p&gt;




&lt;h2&gt;
  
  
  Full Comparison Table
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Vercel&lt;/th&gt;
&lt;th&gt;Netlify&lt;/th&gt;
&lt;th&gt;Cloudflare Pages&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Free bandwidth&lt;/td&gt;
&lt;td&gt;100GB&lt;/td&gt;
&lt;td&gt;100GB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Unlimited&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Paid starting&lt;/td&gt;
&lt;td&gt;$20/user/mo&lt;/td&gt;
&lt;td&gt;$19/user/mo&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;$5/mo&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cold start&lt;/td&gt;
&lt;td&gt;~0ms (warm)&lt;/td&gt;
&lt;td&gt;150~3,000ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;&amp;lt; 5ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Next.js support&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Native (full)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Adapter (mostly)&lt;/td&gt;
&lt;td&gt;OpenNext (constrained)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Serverless billing&lt;/td&gt;
&lt;td&gt;Active CPU (Fluid)&lt;/td&gt;
&lt;td&gt;Credit-based&lt;/td&gt;
&lt;td&gt;Per-request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Global PoPs&lt;/td&gt;
&lt;td&gt;40+ edge&lt;/td&gt;
&lt;td&gt;8 regions&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;330+ PoPs&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Commercial free use&lt;/td&gt;
&lt;td&gt;Not allowed&lt;/td&gt;
&lt;td&gt;Allowed&lt;/td&gt;
&lt;td&gt;Allowed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ecosystem&lt;/td&gt;
&lt;td&gt;Next.js + Postgres/KV/Blob&lt;/td&gt;
&lt;td&gt;Forms, Identity, Split Testing&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;KV, D1, R2, DO, Queues&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Build speed (Next.js)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Fastest&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;30~60% slower&lt;/td&gt;
&lt;td&gt;20% slower&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DX / dashboard&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Best&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Clean&lt;/td&gt;
&lt;td&gt;Deep but learning curve&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Egress cost&lt;/td&gt;
&lt;td&gt;Deducts from bandwidth&lt;/td&gt;
&lt;td&gt;Deducts&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;R2 $0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Combining Platforms — Real-World Patterns
&lt;/h2&gt;

&lt;p&gt;No reason to pick one and stick with it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Pattern A — Subdomain split (most common)
static.example.com  → Cloudflare Pages (images, docs, heavy assets)
app.example.com     → Vercel Pro (Next.js full-stack)
forms.example.com   → Netlify (form intake)

# Pattern B — Cloudflare as front CDN, Vercel as origin
Cloudflare (CDN/WAF/DDoS) → Vercel (serverless origin)
# Cloudflare absorbs egress, Vercel handles execution only

# Pattern C — Full Cloudflare stack (AWS alternative)
Cloudflare Pages + Workers + D1 + R2 + Durable Objects
# Full-stack infra starting at $5/month
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Recommendations by Scenario
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Next.js full-stack SaaS&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Vercel Pro&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Fluid Compute 95% savings, Cache Components/PPR native&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image / video hosting&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Cloudflare + R2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Zero egress, 330+ PoPs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Astro / SvelteKit&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Netlify&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Adapter ecosystem, built-in forms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Real-time / WebSocket&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Cloudflare + DO&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Only edge stateful solution&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Global TTFB matters&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Cloudflare&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Largest edge network&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Intermittent traffic&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Vercel Fluid / Cloudflare&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Low cold start&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Personal (no revenue)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Vercel Hobby&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;All Next.js features free&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Form-heavy marketing&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Netlify&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Forms built in&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Closing Thoughts
&lt;/h2&gt;

&lt;p&gt;All three platforms are mature as of 2026. "Which is better" is the wrong frame — "which fits your stack" is the real question.&lt;/p&gt;

&lt;p&gt;Three trends worth watching as of April 2026:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Vercel Fluid Compute&lt;/strong&gt; now powers 75%+ of all Vercel Functions and has measurably dropped Next.js full-stack bills.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare D1&lt;/strong&gt; moved past GA with real production references, making AWS RDS replacement a concrete option.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Netlify's credit-based pricing&lt;/strong&gt; is driving heavy users to reconsider.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The right choice shifts each year. Review your workload periodically.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Official sources:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://vercel.com/pricing" rel="noopener noreferrer"&gt;Vercel Pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://vercel.com/docs/fluid-compute" rel="noopener noreferrer"&gt;Vercel Fluid Compute docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://vercel.com/blog/introducing-active-cpu-pricing-for-fluid-compute" rel="noopener noreferrer"&gt;Vercel Active CPU pricing blog&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.netlify.com/pricing/" rel="noopener noreferrer"&gt;Netlify Pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.netlify.com/manage/accounts-and-billing/billing/billing-for-credit-based-plans/credit-based-pricing-plans/" rel="noopener noreferrer"&gt;Netlify credit-based pricing docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/workers/platform/pricing/" rel="noopener noreferrer"&gt;Cloudflare Workers pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/durable-objects/platform/pricing/" rel="noopener noreferrer"&gt;Cloudflare Durable Objects pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://blog.cloudflare.com/unpacking-cloudflare-workers-cpu-performance-benchmarks/" rel="noopener noreferrer"&gt;Cloudflare Workers CPU benchmarks&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://gocodelab.com/en/blog/en-vercel-vs-netlify-vs-cloudflare-pages-2026" rel="noopener noreferrer"&gt;GoCodeLab&lt;/a&gt; — April 2026 pricing. Plans and policies change frequently; verify with official docs before committing.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>vercel</category>
      <category>cloudflare</category>
      <category>devops</category>
    </item>
    <item>
      <title>I Catalogued the Security Patterns That Keep Showing Up in AI Code</title>
      <dc:creator>LazyDev_OH</dc:creator>
      <pubDate>Mon, 13 Apr 2026 04:03:48 +0000</pubDate>
      <link>https://forem.com/lazydev_oh/i-catalogued-the-security-patterns-that-keep-showing-up-in-ai-code-2jla</link>
      <guid>https://forem.com/lazydev_oh/i-catalogued-the-security-patterns-that-keep-showing-up-in-ai-code-2jla</guid>
      <description>&lt;p&gt;Across the Apsity App Store dashboard, the FeedMission SaaS, and a dozen side projects, more than half the code I touch is AI-generated. After &lt;a href="https://gocodelab.com/en/blog/en-feedmission-saas-7days-mvp-ep04" rel="noopener noreferrer"&gt;shipping a SaaS in 7 days&lt;/a&gt;, vibe coding has been the default workflow.&lt;/p&gt;

&lt;p&gt;Run it long enough and the patterns show up. AI-generated code keeps producing the same classes of security holes. One FeedMission review surfaced &lt;a href="https://gocodelab.com/en/blog/en-feedmission-nextjs-security-email-debug-ep06" rel="noopener noreferrer"&gt;seven criticals at the same time&lt;/a&gt; — a Slack webhook URL bundled into the frontend, an unsubscribe endpoint that any email address could trigger, an admin reply leaking through a public API, routes missing team-member auth checks. None of that was bad luck. Industry research lists these as the highest-frequency patterns, and they had effectively reproduced themselves in our codebase.&lt;/p&gt;

&lt;p&gt;So now I run the same seven checks before every deploy, the same way each time. This post is the pattern catalogue plus the routine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers, first
&lt;/h2&gt;

&lt;p&gt;This isn't a vibe check. Multiple groups in 2026 (Georgia Tech, Cloud Security Alliance, Checkmarx) analyzed AI-generated code and found:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;40–62%&lt;/strong&gt; of samples contain security issues&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2.74×&lt;/strong&gt; more vulnerable than human-written code on equivalent tasks&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;86%&lt;/strong&gt; failed XSS defenses&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;88%&lt;/strong&gt; vulnerable to log injection&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;35 new CVEs&lt;/strong&gt; tied to AI-generated code in March 2026 alone&lt;/li&gt;
&lt;li&gt;One AI app leaked &lt;strong&gt;1.5M API keys&lt;/strong&gt; post-launch — shipped without security review&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Nobody's quitting vibe coding because of these numbers. I'm not. But the 10 minutes you spend before deploy is what decides production's fate.&lt;/p&gt;

&lt;h2&gt;
  
  
  How AI skips security
&lt;/h2&gt;

&lt;p&gt;Beginners get this wrong. The AI didn't make a mistake — it built what you asked for. "Make a user profile API" → it makes one. Auth wasn't requested, so it's not there. It leaves &lt;code&gt;// TODO: add auth here&lt;/code&gt; and moves on.&lt;/p&gt;

&lt;p&gt;The fix: &lt;strong&gt;put security in the prompt from the start.&lt;/strong&gt; "Include JWT auth middleware, read secrets only from env, no raw SQL, no TODO comments, ship complete code." One line changes the output quality.&lt;/p&gt;

&lt;h2&gt;
  
  
  Top 7 mistakes — in the order I hit them
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;#&lt;/th&gt;
&lt;th&gt;Mistake&lt;/th&gt;
&lt;th&gt;What happens&lt;/th&gt;
&lt;th&gt;Red flag&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;Hardcoded API keys&lt;/td&gt;
&lt;td&gt;Scraped by bots within seconds&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sk_&lt;/code&gt;, &lt;code&gt;api_key=&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;Auth-less API routes&lt;/td&gt;
&lt;td&gt;URL-only access to your DB&lt;/td&gt;
&lt;td&gt;no session/auth/token references&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; misuse&lt;/td&gt;
&lt;td&gt;Service-role key in browser bundle&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NEXT_PUBLIC_*_SECRET/KEY&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;Raw SQL interpolation&lt;/td&gt;
&lt;td&gt;SQL injection → full DB exfil&lt;/td&gt;
&lt;td&gt;&lt;code&gt;`SELECT ... ${}`&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;CORS wildcards&lt;/td&gt;
&lt;td&gt;Any domain hits your API&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Allow-Origin: *&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Missing XSS / log-injection defense&lt;/td&gt;
&lt;td&gt;User input straight into HTML/logs&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;dangerouslySetInnerHTML&lt;/code&gt;, raw-string logs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Phantom packages (slopsquatting)&lt;/td&gt;
&lt;td&gt;Malicious package under hallucinated name&lt;/td&gt;
&lt;td&gt;unfamiliar packages, low downloads&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h1&gt;
  
  
  1 and #3 hit fastest. The moment you push to GitHub, scraper bots scoop the key and burn your API quota. If you've never been hit, you've only been lucky.
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Slopsquatting warning&lt;/strong&gt; — when AI says &lt;code&gt;npm install some-plausible-package&lt;/code&gt;, check npmjs.com first. About &lt;strong&gt;20% of AI-generated code references nonexistent packages&lt;/strong&gt;. Attackers register those names with malicious payloads, and you install them instantly.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What could have happened at FeedMission
&lt;/h2&gt;

&lt;p&gt;From the 7 above, FeedMission had #2, #3, #6, plus a few app-specific issues:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Slack webhook URL&lt;/strong&gt; rode on ProjectContext into the frontend bundle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unsubscribe API&lt;/strong&gt; took just an email address. Anyone's email → instant unsubscribe. Switched to an &lt;code&gt;unsubscribeToken&lt;/code&gt; flow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/api/feedback/mine&lt;/code&gt;&lt;/strong&gt; returned the full admin reply text. Now &lt;code&gt;hasReply: boolean&lt;/code&gt; only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Team member auth checks&lt;/strong&gt; missing across several APIs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;.env&lt;/code&gt;&lt;/strong&gt; wasn't in &lt;code&gt;.vercelignore&lt;/code&gt; — almost shipped via symlink in a Vercel build.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All fixed in one commit (&lt;code&gt;52efb89&lt;/code&gt;). None of these are "too edge-case to happen to me."&lt;/p&gt;

&lt;h2&gt;
  
  
  My 10-minute pre-deploy routine
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Three grep lines — 5 seconds&lt;/span&gt;
&lt;span class="c"&gt;# Unfinished security code&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"TODO&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;FIXME&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;implement.*later&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;add.*auth"&lt;/span&gt; ./src

&lt;span class="c"&gt;# Hardcoded secrets&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"sk_&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;api_key&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;password&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="s2"&gt;*="&lt;/span&gt; ./src

&lt;span class="c"&gt;# Client-exposed env vars&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rE&lt;/span&gt; &lt;span class="s2"&gt;"NEXT_PUBLIC_.*(SECRET|KEY|TOKEN)"&lt;/span&gt; ./src

&lt;span class="c"&gt;# 2. SQL interpolation and CORS wildcards&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="s2"&gt;SELECT&lt;/span&gt;&lt;span class="se"&gt;\|\`&lt;/span&gt;&lt;span class="s2"&gt;INSERT&lt;/span&gt;&lt;span class="se"&gt;\|\`&lt;/span&gt;&lt;span class="s2"&gt;UPDATE&lt;/span&gt;&lt;span class="se"&gt;\|\`&lt;/span&gt;&lt;span class="s2"&gt;DELETE"&lt;/span&gt; ./src
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"Allow-Origin.*&lt;/span&gt;&lt;span class="se"&gt;\*&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; ./src
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If all pass, paste the generated code back to the AI and ask: &lt;em&gt;"Review this code against OWASP Top 10 for vulnerabilities."&lt;/em&gt; Imperfect but a fine first-pass filter.&lt;/p&gt;

&lt;p&gt;GitHub side, turn on three things: &lt;strong&gt;Secret Scanning, Push Protection, CodeQL Code Scanning&lt;/strong&gt;. Plus Dependabot/npm audit in CI for package vulns.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;My prompt tail (every code-generation request):&lt;/strong&gt; &lt;em&gt;"Include auth middleware; read secrets only from process.env and use NEXT_PUBLIC only for public values; always validate user input; no raw SQL; ship complete code without TODO/FIXME."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Bonus — Using Supabase? RLS is its own chapter
&lt;/h2&gt;

&lt;p&gt;Next.js + Supabase is the default vibe-coder stack, so RLS gets a dedicated section. RLS (Row Level Security) is PostgreSQL's row-level access control. &lt;em&gt;"This row is readable only by the user whose user_id matches"&lt;/em&gt; — enforced at the database layer.&lt;/p&gt;

&lt;p&gt;Why this matters: &lt;strong&gt;when you create a table in Supabase Studio, RLS is OFF by default.&lt;/strong&gt; Ship &lt;code&gt;NEXT_PUBLIC_SUPABASE_ANON_KEY&lt;/code&gt; to the client in that state and anyone with that key can read or write every row in every table. The anon key effectively becomes a service-role key. Whatever assurance "client-side anon key is safe" gave you, it's gone.&lt;/p&gt;

&lt;p&gt;Turning RLS on isn't enough either. &lt;strong&gt;Without policies, every access is denied.&lt;/strong&gt; You write separate policies per action: &lt;code&gt;SELECT&lt;/code&gt;, &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt;. The most frequent mistake is writing &lt;code&gt;USING&lt;/code&gt; (the read/delete-time filter) but forgetting &lt;code&gt;WITH CHECK&lt;/code&gt; (the post-write validation):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- ✗ Risky — USING only&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"own rows"&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;
&lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;-- WITH CHECK forgotten!&lt;/span&gt;

&lt;span class="c1"&gt;-- ✓ Safe — both&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="nv"&gt;"own rows"&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;posts&lt;/span&gt; &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt;
&lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without WITH CHECK, user_a can INSERT or UPDATE rows claiming user_b's user_id — planting rows or hijacking existing ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Three review queries to save in your Supabase SQL Editor:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- 1. Tables with RLS still off&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rowsecurity&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_tables&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;rowsecurity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 2. RLS on but no policies — everything is rejected&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tablename&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_tables&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_policies&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
  &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tablename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tablename&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;policyname&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- 3. INSERT/UPDATE policies missing WITH CHECK&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policyname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;with_check&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_policies&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'INSERT'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'UPDATE'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;with_check&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run these after every migration. Empty results on all three = you're clear.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Top-4 BaaS-specific mistakes:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;RLS off&lt;/strong&gt; — anon key becomes a master key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing WITH CHECK&lt;/strong&gt; — attackers plant rows under someone else's user_id.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;service_role key shipped to client&lt;/strong&gt; — &lt;code&gt;SUPABASE_SERVICE_ROLE_KEY&lt;/code&gt; must never be &lt;code&gt;NEXT_PUBLIC&lt;/code&gt;. Server routes / Edge Functions only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permissive anon-role policies&lt;/strong&gt; — &lt;code&gt;auth.uid() = user_id&lt;/code&gt; missing means unauthenticated callers reach every row.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Same principle applies to Firebase Security Rules, Appwrite Permissions, PocketBase Collection rules: &lt;strong&gt;if the client talks to the database directly, the database is the last line of defense.&lt;/strong&gt; Leave that line empty and no upstream security matters.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;Vibe coding didn't make security worse. The habit of deploying without review did. AI raised the speed. Raise your review speed with it. Three grep lines, one AI review, three GitHub settings, the RLS check if you're on Supabase. Ten minutes.&lt;/p&gt;

&lt;p&gt;Skip those ten minutes and "1.5M API keys leaked" stops being someone else's story.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://owasp.org/www-project-top-ten/" rel="noopener noreferrer"&gt;OWASP Top 10&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://checkmarx.com/blog/security-in-vibe-coding/" rel="noopener noreferrer"&gt;Checkmarx — Security in Vibe Coding&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://labs.cloudsecurityalliance.org/research/csa-research-note-ai-generated-code-vulnerability-surge-2026/" rel="noopener noreferrer"&gt;Cloud Security Alliance — AI-Generated CVE Surge 2026&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://supabase.com/docs/guides/database/postgres/row-level-security" rel="noopener noreferrer"&gt;Supabase RLS docs&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.github.com/en/code-security/secret-scanning/introduction/about-secret-scanning" rel="noopener noreferrer"&gt;GitHub Secret Scanning docs&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://gocodelab.com/en/blog/en-vibecoding-security-checklist-for-beginners-ep18" rel="noopener noreferrer"&gt;GoCodeLab&lt;/a&gt;. Lazy Developer EP.18.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>ai</category>
      <category>webdev</category>
      <category>supabase</category>
    </item>
    <item>
      <title>Upgraded to Tailwind v4 — Config Files Are Gone</title>
      <dc:creator>LazyDev_OH</dc:creator>
      <pubDate>Mon, 13 Apr 2026 02:23:23 +0000</pubDate>
      <link>https://forem.com/lazydev_oh/upgraded-to-tailwind-v4-config-files-are-gone-1o09</link>
      <guid>https://forem.com/lazydev_oh/upgraded-to-tailwind-v4-config-files-are-gone-1o09</guid>
      <description>&lt;p&gt;Tailwind CSS v4 shipped in January 2025 and &lt;code&gt;tailwind.config.js&lt;/code&gt; is gone. Configuration now lives inside the CSS file itself. I migrated a Next.js project — unfamiliar at first, but simpler once you're through it.&lt;/p&gt;

&lt;p&gt;The actual transition is faster than expected. &lt;strong&gt;The official CLI handles about 80% of it.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What Changes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;tailwind.config.js&lt;/code&gt; → replaced by a CSS &lt;code&gt;@theme&lt;/code&gt; block&lt;/li&gt;
&lt;li&gt;Rust-based &lt;strong&gt;Oxide compiler&lt;/strong&gt; — up to &lt;strong&gt;5x faster&lt;/strong&gt; full builds, up to &lt;strong&gt;100x faster&lt;/strong&gt; incremental&lt;/li&gt;
&lt;li&gt;Automatic content detection — no more manual &lt;code&gt;content&lt;/code&gt; array&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;@tailwind base/components/utilities&lt;/code&gt; → single &lt;code&gt;@import "tailwindcss"&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Plugins declared in CSS via &lt;code&gt;@plugin "..."&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Real-world number from Tailwind's own benchmark: a design system with 15,000 utility classes saw cold builds drop from 840ms to 170ms.&lt;/p&gt;

&lt;h2&gt;
  
  
  Config Moved into CSS
&lt;/h2&gt;

&lt;p&gt;v3 kept everything in JS. v4 does it all in one CSS file.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* v4 — configure directly in CSS */&lt;/span&gt;
&lt;span class="k"&gt;@import&lt;/span&gt; &lt;span class="s1"&gt;"tailwindcss"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;@theme&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--breakpoint-3xl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1920px&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="py"&gt;--color-brand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;oklch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;68%&lt;/span&gt; &lt;span class="m"&gt;0.19&lt;/span&gt; &lt;span class="m"&gt;245&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="py"&gt;--font-display&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;"Inter Variable"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;sans-serif&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@theme&lt;/code&gt; uses CSS variables. Design tokens are visible in DevTools at runtime. One less JS dependency.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;a class="mentioned-user" href="https://dev.to/theme"&gt;@theme&lt;/a&gt; Naming Convention
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;--color-{name}&lt;/code&gt;, &lt;code&gt;--font-{name}&lt;/code&gt;, &lt;code&gt;--spacing-{name}&lt;/code&gt;. Tailwind reads the namespace and generates utility classes automatically. Define &lt;code&gt;--color-brand&lt;/code&gt; and &lt;code&gt;text-brand&lt;/code&gt;, &lt;code&gt;bg-brand&lt;/code&gt;, &lt;code&gt;border-brand&lt;/code&gt; light up immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Oxide Compiler
&lt;/h2&gt;

&lt;p&gt;Rust, not Node. Replaces the old PostCSS plugin. Content path detection is automatic — no more &lt;code&gt;content: ['./src/**/*.tsx']&lt;/code&gt;. Oxide ships inside the &lt;code&gt;tailwindcss&lt;/code&gt; v4 package, no separate install. Integrates with Vite and PostCSS pipelines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migration Steps
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Option A — one command
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @tailwindcss/upgrade
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Handles config conversion and class renames for projects without custom plugins.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option B — manual (Next.js / PostCSS)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;tailwindcss@latest @tailwindcss/postcss
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// postcss.config.js (v4)&lt;/span&gt;
&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@tailwindcss/postcss&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="c"&gt;/* globals.css (v4) */&lt;/span&gt;
&lt;span class="k"&gt;@import&lt;/span&gt; &lt;span class="s1"&gt;"tailwindcss"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;@theme&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--color-brand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#6366f1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;tailwind.config.js&lt;/code&gt; can be deleted or kept — v4 doesn't read it. Deleting it is cleaner for team repos.&lt;/p&gt;

&lt;h2&gt;
  
  
  Plugins Now Live in CSS
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight css"&gt;&lt;code&gt;&lt;span class="k"&gt;@import&lt;/span&gt; &lt;span class="s1"&gt;"tailwindcss"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;@plugin&lt;/span&gt; &lt;span class="s1"&gt;"@tailwindcss/typography"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;@plugin&lt;/span&gt; &lt;span class="s1"&gt;"@tailwindcss/forms"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;@plugin&lt;/span&gt; &lt;span class="s1"&gt;"./plugins/my-plugin.js"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;@theme&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="py"&gt;--color-brand&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="m"&gt;#6366f1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;plugins&lt;/code&gt; array in &lt;code&gt;tailwind.config.js&lt;/code&gt; is gone. Pass a package name or a file path to &lt;code&gt;@plugin&lt;/code&gt; and it works. Existing &lt;code&gt;addUtilities&lt;/code&gt; and &lt;code&gt;addComponents&lt;/code&gt; APIs mostly still apply, but parts of the plugin API changed — verify behavior after migrating.&lt;/p&gt;

&lt;h2&gt;
  
  
  The &lt;code&gt;outline-none&lt;/code&gt; Gotcha
&lt;/h2&gt;

&lt;p&gt;v3: &lt;code&gt;outline-none&lt;/code&gt; rendered as &lt;code&gt;outline: 2px solid transparent&lt;/code&gt; — still accessible.&lt;br&gt;
v4: &lt;code&gt;outline-none&lt;/code&gt; renders as &lt;code&gt;outline: none&lt;/code&gt; — actually removes the outline.&lt;/p&gt;

&lt;p&gt;If you used &lt;code&gt;outline-none&lt;/code&gt; to hide focus rings on buttons or inputs, swap in &lt;code&gt;outline-hidden&lt;/code&gt;. Expect this to surface during accessibility checks.&lt;/p&gt;

&lt;h2&gt;
  
  
  v3 vs v4 at a Glance
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;v3&lt;/th&gt;
&lt;th&gt;v4&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Config&lt;/td&gt;
&lt;td&gt;&lt;code&gt;tailwind.config.js&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CSS &lt;code&gt;@theme&lt;/code&gt; block&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Import&lt;/td&gt;
&lt;td&gt;three &lt;code&gt;@tailwind&lt;/code&gt; lines&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@import "tailwindcss"&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content detection&lt;/td&gt;
&lt;td&gt;manual array&lt;/td&gt;
&lt;td&gt;automatic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compiler&lt;/td&gt;
&lt;td&gt;PostCSS (Node)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Oxide (Rust)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Plugins&lt;/td&gt;
&lt;td&gt;&lt;code&gt;plugins: [...]&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;@plugin "..."&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;outline-none&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;transparent outline&lt;/td&gt;
&lt;td&gt;actual &lt;code&gt;none&lt;/code&gt; (use &lt;code&gt;outline-hidden&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Should You Upgrade Now?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;New project&lt;/strong&gt; → v4. No reason not to.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Existing v3 project&lt;/strong&gt; → no rush. v3 is still supported.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Heavy custom-plugin stack&lt;/strong&gt; → stay on v3 until you've tested each plugin against the v4 API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build times biting&lt;/strong&gt; → v4 is worth the migration cost just for the Oxide numbers.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q. Do I need to delete &lt;code&gt;tailwind.config.js&lt;/code&gt;?&lt;/strong&gt;&lt;br&gt;
No — v4 doesn't read it. The upgrade CLI handles conversion. Delete for cleanliness.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q. Separate Oxide install?&lt;/strong&gt;&lt;br&gt;
No. Included in the &lt;code&gt;tailwindcss&lt;/code&gt; v4 package.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q. How long does migration take?&lt;/strong&gt;&lt;br&gt;
Small Next.js projects: 30 minutes including manual review. Larger ones with custom plugins and dynamic class composition (&lt;code&gt;bg-${color}-500&lt;/code&gt; patterns): a couple hours, because those aren't auto-migrated.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://tailwindcss.com/blog/tailwindcss-v4-alpha" rel="noopener noreferrer"&gt;Open-sourcing progress on Tailwind CSS v4.0&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://tailwindcss.com/blog/tailwindcss-v4" rel="noopener noreferrer"&gt;Tailwind CSS v4.0 release post&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://gocodelab.com/en/blog/en-tailwind-css-v4-migration-guide-2026" rel="noopener noreferrer"&gt;GoCodeLab&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>tailwindcss</category>
      <category>webdev</category>
      <category>frontend</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
