<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Jakub</title>
    <description>The latest articles on DEV Community by Jakub (@jakub_inithouse).</description>
    <link>https://dev.to/jakub_inithouse</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%2F3847884%2Fd5cc2611-0246-4150-95e0-c1145fa35d05.png</url>
      <title>DEV Community: Jakub</title>
      <link>https://dev.to/jakub_inithouse</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jakub_inithouse"/>
    <language>en</language>
    <item>
      <title>One React SPA, Five Domains, Five Languages: How We Route by Domain</title>
      <dc:creator>Jakub</dc:creator>
      <pubDate>Fri, 29 May 2026 20:17:57 +0000</pubDate>
      <link>https://dev.to/jakub_inithouse/one-react-spa-five-domains-five-languages-how-we-route-by-domain-2mkl</link>
      <guid>https://dev.to/jakub_inithouse/one-react-spa-five-domains-five-languages-how-we-route-by-domain-2mkl</guid>
      <description>&lt;p&gt;At Inithouse we build and ship products fast. One of them, &lt;a href="https://zivafotka.cz" rel="noopener noreferrer"&gt;Ziva Fotka&lt;/a&gt;, turns a still photo into a short animated video. No signup, no stored data, photos deleted after processing.&lt;/p&gt;

&lt;p&gt;The product started on a single Czech domain. Then we wanted to reach Slovak, Polish, German, and English-speaking users. Five TLDs, five languages, one React codebase. Here is how we wired the routing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Subpaths like &lt;code&gt;/en/&lt;/code&gt; or &lt;code&gt;/de/&lt;/code&gt; are the textbook approach, but we wanted separate country domains: &lt;code&gt;zivafotka.cz&lt;/code&gt;, &lt;code&gt;zivafotka.sk&lt;/code&gt;, &lt;code&gt;zywafotka.pl&lt;/code&gt;, &lt;code&gt;lebendigfoto.de&lt;/code&gt;, and &lt;code&gt;alivephoto.online&lt;/code&gt;. Each domain should feel native to its audience. A Polish user landing on &lt;code&gt;zywafotka.pl&lt;/code&gt; should never see a Czech string.&lt;/p&gt;

&lt;p&gt;Separate codebases per domain would mean five deploys, five bug-fix cycles, five feature rollouts. That was not going to scale for a small team running about 14 products in parallel.&lt;/p&gt;

&lt;h2&gt;
  
  
  Domain detection at boot
&lt;/h2&gt;

&lt;p&gt;The SPA reads &lt;code&gt;window.location.hostname&lt;/code&gt; on mount and maps it to a locale:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DOMAIN_LOCALE_MAP&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&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="p"&gt;,&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="o"&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;zivafotka.cz&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zivafotka.sk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zywafotka.pl&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pl&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lebendigfoto.de&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;de&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;alivephoto.online&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;detectLocale&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hostname&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;/^www&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="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;DOMAIN_LOCALE_MAP&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;host&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="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This runs once during app initialization. The resolved locale feeds into a React context that every component reads from. No user-facing language picker, no cookie. The domain &lt;em&gt;is&lt;/em&gt; the language selector.&lt;/p&gt;

&lt;h2&gt;
  
  
  Lazy locale loading
&lt;/h2&gt;

&lt;p&gt;Bundling all five translation files into the main chunk would bloat the initial load for every user. We split translations into per-locale JSON files and load only the one that matches:&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;loadMessages&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`./locales/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;locale&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.json`&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;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;default&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;Vite handles the code splitting. The Czech user downloads &lt;code&gt;cs.json&lt;/code&gt;, the Polish user downloads &lt;code&gt;pl.json&lt;/code&gt;. The rest never leave the server.&lt;/p&gt;

&lt;p&gt;For us, the translation files hold about 120 keys each. Small enough that a single JSON import per locale keeps things simple without needing a heavier i18n library.&lt;/p&gt;

&lt;h2&gt;
  
  
  hreflang for SEO
&lt;/h2&gt;

&lt;p&gt;Google needs to know that &lt;code&gt;zivafotka.cz&lt;/code&gt; and &lt;code&gt;zywafotka.pl&lt;/code&gt; are the same page in different languages. Without proper &lt;code&gt;hreflang&lt;/code&gt; tags, the crawler might treat them as duplicate content or pick the wrong version for a given user's search locale.&lt;/p&gt;

&lt;p&gt;We inject &lt;code&gt;&amp;lt;link rel="alternate"&amp;gt;&lt;/code&gt; tags in the document head for every page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;HREFLANG_ENTRIES&lt;/span&gt; &lt;span class="o"&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;lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://zivafotka.cz&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;lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sk&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://zivafotka.sk&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;lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;pl&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://zywafotka.pl&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;lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;de&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://lebendigfoto.de&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;lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;en&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://alivephoto.online&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;lang&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-default&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://alivephoto.online&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;x-default&lt;/code&gt; entry points to the English domain as the fallback for unmatched locales. These tags go into the &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; at render time so crawlers pick them up without executing JavaScript. For an SPA, that meant handling them in the static HTML shell or via a pre-rendering step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sitemap per domain
&lt;/h2&gt;

&lt;p&gt;Each domain serves its own &lt;code&gt;sitemap.xml&lt;/code&gt; with URLs scoped to that domain. We generate them from a shared route list and swap in the correct base URL. This keeps Google Search Console clean: each GSC property sees only the URLs it owns.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we learned shipping this
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Start with two, not five.&lt;/strong&gt; We launched Czech first, added Slovak (close language, easy to test), and only then tackled Polish and German. Each new locale surfaced edge cases: date formats, number separators, text that broke layouts because German words run long.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test search indexation early.&lt;/strong&gt; We use Google Search Console for each domain (five GSC properties). After launch, several Polish pages sat in "Discovered, not indexed" for weeks. The fix was adding the hreflang cluster and submitting sitemaps, but we should have done that on day one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Monitor per-domain separately.&lt;/strong&gt; GA4 streams and Clarity projects are set up per domain. Aggregating everything into one dashboard hides which locale actually converts. Our Czech domain has the best CTR in the portfolio; Polish traffic patterns look completely different.&lt;/p&gt;

&lt;h2&gt;
  
  
  Other products where this matters
&lt;/h2&gt;

&lt;p&gt;The multi-domain pattern is specific to &lt;a href="https://zivafotka.cz" rel="noopener noreferrer"&gt;Ziva Fotka&lt;/a&gt;, but the general principle (one codebase, locale from context, lazy loading translations) shows up in a lighter form across our portfolio. &lt;a href="https://berecommended.com" rel="noopener noreferrer"&gt;Be Recommended&lt;/a&gt;, our AI visibility reporting tool, currently runs on a single English domain, but the architecture could support localization the same way if we ever expand into non-English markets. &lt;a href="https://tarotas.com" rel="noopener noreferrer"&gt;Tarotas&lt;/a&gt;, a tarot reflection app, already ships content in five languages (cs/en/pl/sk/de) on one domain using a similar locale-detection approach, just with path-based routing instead of separate TLDs.&lt;/p&gt;

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

&lt;p&gt;The pattern boils down to three moving parts: domain-to-locale mapping at boot, lazy-loaded translation bundles, and a proper hreflang setup for search engines. The deployment stays single: push once, all five domains update.&lt;/p&gt;

&lt;p&gt;If you are running a product aimed at multiple language markets and want each market to feel native, separate domains with a shared SPA keep the maintenance burden low. The SEO setup takes some care, but once the hreflang cluster and per-domain sitemaps are in place, Google handles the rest.&lt;/p&gt;

&lt;p&gt;We have been running this setup across five domains for months now with no major issues. The biggest ongoing cost is translation maintenance, not infrastructure.&lt;/p&gt;

</description>
      <category>react</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How LLMs Decide Which Brands to Mention: A Technical Look at GEO</title>
      <dc:creator>Jakub</dc:creator>
      <pubDate>Sat, 23 May 2026 21:16:30 +0000</pubDate>
      <link>https://dev.to/jakub_inithouse/how-llms-decide-which-brands-to-mention-a-technical-look-at-geo-3d44</link>
      <guid>https://dev.to/jakub_inithouse/how-llms-decide-which-brands-to-mention-a-technical-look-at-geo-3d44</guid>
      <description>&lt;p&gt;When you ask ChatGPT "what's a good project management tool?", it doesn't randomly pick Asana or Linear. There's a pipeline behind every brand mention, and understanding it is the first step toward what the industry now calls GEO (Generative Engine Optimization).&lt;/p&gt;

&lt;p&gt;I'm Jakub, builder at Inithouse. We run 14 products across different verticals, and one of them, &lt;a href="https://berecommended.com" rel="noopener noreferrer"&gt;Be Recommended&lt;/a&gt;, was born from trying to reverse-engineer exactly this: how do LLMs decide which brands to cite?&lt;/p&gt;

&lt;p&gt;Here's what we learned, technically.&lt;/p&gt;

&lt;h2&gt;
  
  
  The RAG Pipeline: Where Brand Mentions Actually Come From
&lt;/h2&gt;

&lt;p&gt;Most production LLM systems (Perplexity, ChatGPT with browsing, Gemini with grounding) don't rely purely on parametric knowledge. They use Retrieval-Augmented Generation, a two-stage architecture:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Retrieval&lt;/strong&gt;: the system queries an index (web search, vector store, or both) using the user's prompt as input. This returns a set of candidate documents ranked by relevance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generation&lt;/strong&gt;: the LLM reads the retrieved documents and synthesizes an answer, pulling facts, brand names, and citations from the retrieved context.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This means brand visibility in AI answers is not just about what the model "knows" from pretraining. It's about what the retrieval layer finds and ranks highly enough to pass into the context window.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User prompt
    |
    v
+----------------+
|  Query         | &amp;lt;- reformulated search query
|  Expansion     |
+-------+--------+
        |
        v
+----------------+
|  Retrieval     | &amp;lt;- web search / vector DB / hybrid
|  (top-k)       |
+-------+--------+
        |
        v
+----------------+
|  Reranking     | &amp;lt;- cross-encoder or LLM-based reranking
+-------+--------+
        |
        v
+----------------+
|  Generation    | &amp;lt;- LLM synthesizes answer from context
|  + Citation    |
+----------------+
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Embeddings and Retrieval Ranking
&lt;/h2&gt;

&lt;p&gt;The retrieval step typically uses dense embeddings. Your page content gets embedded into a vector, and the system computes cosine similarity between the query embedding and your content embedding.&lt;/p&gt;

&lt;p&gt;What matters here:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Topical density beats keyword stuffing.&lt;/strong&gt; Dense retrievers reward pages that semantically cluster around a topic. A page titled "AI Visibility Tools for Brands" that covers monitoring, scoring, and optimization will rank higher than a generic marketing page mentioning "AI" once in a list of features.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Structured data helps retrieval.&lt;/strong&gt; Schema.org markup, clean H2/H3 hierarchies, FAQ sections: these create clear semantic boundaries that chunking algorithms can split cleanly. When a retriever chunks your page, each chunk should be a self-contained answer to a plausible question.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Freshness signals exist.&lt;/strong&gt; Perplexity in particular uses recency as a ranking signal. A blog post from this week about "best AI tools for X" will often outrank an older listicle with the same content. We've measured this across 50+ queries on Be Recommended: content published within the last 30 days gets retrieved 2.3x more often than identical content older than 90 days.&lt;/p&gt;

&lt;h2&gt;
  
  
  Citation Extraction: How the LLM Decides What to Name
&lt;/h2&gt;

&lt;p&gt;Once the retrieved documents land in the context window, the LLM has to decide which brands to mention by name. This is where it gets interesting, because the model isn't following a ranking algorithm anymore. It's doing language modeling.&lt;/p&gt;

&lt;p&gt;From our testing across four major AI platforms (ChatGPT, Perplexity, Claude, Gemini), we've identified three patterns that drive explicit brand citations:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pattern 1: Authority signals in retrieved text.&lt;/strong&gt; If the retrieved document frames a brand as a category leader ("X is widely used for Y"), the model tends to propagate that framing. Third-party comparison pages, review aggregators, and "best of" listicles carry this signal strongly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pattern 2: Specificity over generality.&lt;/strong&gt; The model prefers to cite brands that are described with specific capabilities. "Notion offers database views, kanban boards, and API access" gets cited; "Notion is a great tool" doesn't. Specificity gives the model something concrete to use in its synthesis.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pattern 3: Source diversity.&lt;/strong&gt; When a brand appears in multiple retrieved documents from different domains, the model treats it as more credible. One mention on your own site is weak. Mentions across Product Hunt, G2, a tech blog, and a Reddit thread create a reinforcement pattern the model picks up on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building a Monitoring System: High-Level Architecture
&lt;/h2&gt;

&lt;p&gt;If you want to track how AI systems mention your brand, the architecture is straightforward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Simplified monitoring loop
&lt;/span&gt;&lt;span class="n"&gt;queries&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;load_test_queries&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# 50+ prompts per brand
&lt;/span&gt;&lt;span class="n"&gt;engines&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chatgpt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;perplexity&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gemini&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;engine&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;engines&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;queries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;query_engine&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# Extract brand mentions
&lt;/span&gt;        &lt;span class="n"&gt;mentions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_mentions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;brand_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# Score: sentiment, position, context
&lt;/span&gt;        &lt;span class="n"&gt;score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;analyze_mention&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mentions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# Track citation sources
&lt;/span&gt;        &lt;span class="n"&gt;sources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;extract_citations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="nf"&gt;store_result&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sources&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tricky parts:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Query design matters more than volume.&lt;/strong&gt; You need queries that a real user would type, not keyword-stuffed test prompts. "What's the best tool for monitoring AI brand visibility?" is useful. "AI brand visibility monitoring tool list 2026" is not, because real users don't query like that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Each engine behaves differently.&lt;/strong&gt; Perplexity cites sources explicitly with URLs. ChatGPT mentions brands in prose but doesn't always link. Claude tends to be conservative with brand recommendations unless the retrieved context is strong. Gemini sometimes attributes products to specific people or companies, creating interesting cross-reference patterns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Response parsing is non-trivial.&lt;/strong&gt; ChatGPT's temporary chat mode sometimes returns just citation chips with no prose (especially for niche products). Perplexity's citation format changes between search modes. You need robust extraction that handles all these edge cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Learned Building Be Recommended
&lt;/h2&gt;

&lt;p&gt;We built &lt;a href="https://berecommended.com" rel="noopener noreferrer"&gt;Be Recommended&lt;/a&gt; using exactly this approach. The tool runs 50+ real AI prompts against major platforms and produces a scored report (0 to 100) showing where your brand appears, where it doesn't, and what to do about it.&lt;/p&gt;

&lt;p&gt;A few things that surprised us:&lt;/p&gt;

&lt;p&gt;Content published on third-party platforms (Dev.to, Medium, Reddit, Product Hunt) consistently outperforms on-site blog content for driving AI citations. The retrieval layer treats these as independent authority signals.&lt;/p&gt;

&lt;p&gt;Schema.org &lt;code&gt;SoftwareApplication&lt;/code&gt; and &lt;code&gt;Product&lt;/code&gt; markup had a measurable impact on Gemini's brand attribution specifically. Other engines showed less sensitivity to structured data.&lt;/p&gt;

&lt;p&gt;The gap between "the AI knows about you" (parametric knowledge) and "the AI recommends you" (retrieval-driven) is where most brands lose visibility. Your company might exist in GPT-4's training data, but if current web content doesn't surface in retrieval, you won't get mentioned.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting Started
&lt;/h2&gt;

&lt;p&gt;If you want to check your own brand's AI visibility, you can run a free analysis at &lt;a href="https://berecommended.com" rel="noopener noreferrer"&gt;berecommended.com&lt;/a&gt;. The free tier covers one brand across all major AI platforms.&lt;/p&gt;

&lt;p&gt;For the technically inclined: start by manually querying ChatGPT, Perplexity, and Claude with 10 prompts your customers would actually use. Note which brands get mentioned. If yours isn't among them, the fix is almost always on the retrieval side, not the model side.&lt;/p&gt;

&lt;p&gt;GEO is still early. The teams that instrument it now will have a significant head start when every marketing department starts asking "why doesn't ChatGPT recommend us?"&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Jakub, builder at &lt;a href="https://inithouse.com" rel="noopener noreferrer"&gt;Inithouse&lt;/a&gt;. We build products that help brands navigate AI-driven discovery.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>llm</category>
      <category>seo</category>
    </item>
    <item>
      <title>78 tarot cards x 5 languages: the content pipeline nobody warns you about</title>
      <dc:creator>Jakub</dc:creator>
      <pubDate>Wed, 20 May 2026 04:13:24 +0000</pubDate>
      <link>https://dev.to/jakub_inithouse/78-tarot-cards-x-5-languages-the-content-pipeline-nobody-warns-you-about-17n3</link>
      <guid>https://dev.to/jakub_inithouse/78-tarot-cards-x-5-languages-the-content-pipeline-nobody-warns-you-about-17n3</guid>
      <description>&lt;p&gt;Last week I shipped &lt;a href="https://tarotas.com" rel="noopener noreferrer"&gt;Tarotas&lt;/a&gt;, a tarot reading app that supports five languages from day one. Czech, English, Polish, Slovak, German. 78 cards in each. That's 390 card objects, and each card carries a name, upright meaning, reversed meaning, a set of keywords, and a one-paragraph description. Multiply it out and you're looking at roughly 2,000 individual text fields.&lt;/p&gt;

&lt;p&gt;I didn't translate them by hand. Obviously.&lt;/p&gt;

&lt;h2&gt;
  
  
  The source language problem
&lt;/h2&gt;

&lt;p&gt;My first instinct was to write everything in English and translate outward. Wrong call. Tarot has deep roots in specific traditions, and the English terms aren't always the "canonical" ones across languages. The Czech name for The High Priestess is "Velekněžka," and if you translate back from English you get something slightly off.&lt;/p&gt;

&lt;p&gt;So I wrote the Czech versions first (that's the language I think in), then generated the other four from Czech as source. This sounds backwards if you're used to EN-first localization, but it meant the source text had the most nuance and the translations could be validated against it.&lt;/p&gt;

&lt;h2&gt;
  
  
  AI translation with a trust boundary
&lt;/h2&gt;

&lt;p&gt;I used Claude for the bulk translations. The pipeline was dead simple: feed it the Czech card object as JSON with all fields, ask for the target language, get back a JSON object with the same structure. One card at a time, not batched, because batching 78 cards in one prompt led to skipped fields and hallucinated keywords.&lt;/p&gt;

&lt;p&gt;The prompt looked 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;Translate this tarot card from Czech to {lang}.
Keep the JSON structure identical.
For the card name, use the standard tarot name
in {lang} tradition.
For keywords, translate meaning not words.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line matters. "Nový začátek" in Czech literally means "new beginning," but the German tarot tradition might use "Aufbruch" (departure, setting out) for the same card. I wanted the AI to pick the term a German tarot reader would actually recognize.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the human still wins
&lt;/h2&gt;

&lt;p&gt;I didn't review all 2,000 fields. That would defeat the purpose. Instead I set up three checkpoints:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Card names.&lt;/strong&gt; I manually verified every Major Arcana name (22 cards x 5 languages = 110 fields). These are the ones people actually search for and would notice if wrong.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Reversed meanings spot-check.&lt;/strong&gt; Reversed card meanings are where AI gets creative. "Stagnation" becomes "creative blockage" becomes "spiritual paralysis" if you don't catch it early. I checked 10 random Minor Arcana per language.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Keyword deduplication.&lt;/strong&gt; The AI sometimes generates near-synonyms as separate keywords. "Love" and "romance" on the same card, that kind of thing. I wrote a quick script to flag cards where two keywords had high semantic similarity.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Total human review time: about 4 hours. For 2,000 fields across 5 languages, I can live with that.&lt;/p&gt;

&lt;p&gt;The storage side was simple. Postgres (via Supabase), one row per card-language pair:&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;cards&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;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;card_number&lt;/span&gt; &lt;span class="nb"&gt;int&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;arcana&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;suit&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;lang&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;name&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;meaning_upright&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;meaning_reversed&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;keywords&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="n"&gt;description&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;card_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lang&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;390 rows. No joins for a card lookup: &lt;code&gt;where card_number = X and lang = Y&lt;/code&gt;. The app reads the browser language or the user's explicit choice, passes it as a parameter, done. If I ever add language number six, it's 78 inserts and zero schema changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it actually cost
&lt;/h2&gt;

&lt;p&gt;The AI translation ran about $3 in API calls. 390 requests, a few hundred tokens each. The human review: 4 hours of my time spread over two evenings. The keyword dedup script: 30 minutes to write, ran in under a second.&lt;/p&gt;

&lt;p&gt;I got quotes for professional tarot translation. $0.08 to $0.12 per word, because it's a niche. At roughly 50 words per card across all fields, that's 78 x 50 x 5 x $0.10 = about $1,950. For a product that has 37 visitors so far. Nope.&lt;/p&gt;

&lt;p&gt;The AI route isn't flawless. I'm sure a native Polish tarot reader could find something to nitpick. But "mostly right with a clear correction path" beats "can't afford to ship multilingual at all" every single time.&lt;/p&gt;

&lt;p&gt;Content scaling with AI is maybe 10% prompting and 90% knowing where the AI will screw up. For tarot, that's proper nouns (card names vary between traditions) and nuanced spiritual terminology (reversed meanings). For your domain, it'll be something completely different. But the pattern holds: translate structured data one object at a time, validate the high-stakes fields manually, accept "good enough" for the rest.&lt;/p&gt;

&lt;p&gt;I shipped five languages in a week. The app is live at &lt;a href="https://tarotas.com" rel="noopener noreferrer"&gt;tarotas.com&lt;/a&gt;. It's early, it's small, and I still can't guarantee the German keywords are perfect. But 390 content units are out in the world instead of sitting in a "someday we'll translate" backlog forever.&lt;/p&gt;

&lt;p&gt;Jakub, builder @ &lt;a href="https://inithouse.com" rel="noopener noreferrer"&gt;Inithouse&lt;/a&gt;&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>contentops</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>I shipped a self-discovery app in a weekend. Here's the whole stack.</title>
      <dc:creator>Jakub</dc:creator>
      <pubDate>Mon, 18 May 2026 04:11:48 +0000</pubDate>
      <link>https://dev.to/jakub_inithouse/i-shipped-a-self-discovery-app-in-a-weekend-heres-the-whole-stack-23m3</link>
      <guid>https://dev.to/jakub_inithouse/i-shipped-a-self-discovery-app-in-a-weekend-heres-the-whole-stack-23m3</guid>
      <description>&lt;p&gt;Last Saturday morning I had an idea for a personality quiz app. Not one of those BuzzFeed "which bread are you" things. Something that actually combines multiple self-discovery frameworks into one written portrait. By Sunday evening it was live on a custom domain with analytics, a database, and real users hitting it.&lt;/p&gt;

&lt;p&gt;The product is &lt;a href="https://originofyou.com" rel="noopener noreferrer"&gt;Origin Of You&lt;/a&gt;: five personality systems, 120+ data points, one AI-generated portrait at the end. I built it with Lovable, React, and Supabase. Here's what that actually looked like.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Lovable handled without me touching code
&lt;/h2&gt;

&lt;p&gt;Lovable is an AI app builder. You describe what you want, it generates a full React + Vite + Tailwind app. I've shipped about a dozen products through it at &lt;a href="https://inithouse.com" rel="noopener noreferrer"&gt;Inithouse&lt;/a&gt; so I know what to expect.&lt;/p&gt;

&lt;p&gt;For Origin Of You, the stuff that came free:&lt;/p&gt;

&lt;p&gt;React Router with proper client-side routing. Each of the five "systems" (think: personality dimensions) got its own flow, and Lovable set up the routes without me asking. No manual route config.&lt;/p&gt;

&lt;p&gt;Responsive layout. I described the quiz UI once. Mobile, tablet, desktop all worked on first deploy. Tailwind utility classes, nothing custom.&lt;/p&gt;

&lt;p&gt;Google Fonts integration. I wanted a serif/sans pairing (Cormorant Garamond + Inter) for that "journal" feel. One prompt, done.&lt;/p&gt;

&lt;p&gt;OG image and meta tags. Social sharing cards were generated and wired into the HTML head. I didn't write a single meta tag by hand.&lt;/p&gt;

&lt;p&gt;Supabase connection. Lovable scaffolded the client, the auth flow, and the database queries. I described the schema in plain language ("I need a table for quiz responses with columns for each system score and the final portrait text") and it created the migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I had to do real work
&lt;/h2&gt;

&lt;p&gt;Custom domain setup. Lovable gives you a &lt;code&gt;*.lovable.app\&lt;/code&gt; preview URL. Getting &lt;code&gt;originofyou.com\&lt;/code&gt; pointed at it required DNS config, SSL verification, and about 45 minutes of waiting for propagation. Not hard, but not automated either.&lt;/p&gt;

&lt;p&gt;GA4 and Clarity. Lovable doesn't set up analytics for you. I had to manually add the GA4 snippet and Microsoft Clarity tracking code. Copy-paste into &lt;code&gt;index.html\&lt;/code&gt;, then verify both dashboards were receiving hits. Maybe 20 minutes total, but you can't skip it if you want any data on what users actually do.&lt;/p&gt;

&lt;p&gt;The AI portrait generation. This is the core feature: after the user completes all five systems, the app sends their scores to an AI endpoint that writes a personalized portrait. Lovable can't design that prompt for you. I spent most of Sunday afternoon iterating on the system prompt, testing edge cases (what if someone scores extreme on everything?), and tuning the output length. The prompt engineering was easily 60% of the weekend's work.&lt;/p&gt;

&lt;p&gt;Content and copy. Lovable generates placeholder text. Every heading, every description for each personality system, every CTA: I rewrote all of it. The app has maybe 3,000 words of copy and none of it came from the builder.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost, time, and what I'd change
&lt;/h2&gt;

&lt;p&gt;About 14 hours total across the weekend. Three on Lovable prompting, two on infrastructure (domain, analytics, Supabase schema), eight on content and the AI portrait prompt, one on testing. Cost: Lovable subscription I already had, Supabase free tier, $12 for the domain. That's it.&lt;/p&gt;

&lt;p&gt;First week brought 35 visitors. Basically me and friends testing. But the thing works end to end, and now I can point ads or content at it. That's the whole point of a weekend build.&lt;/p&gt;

&lt;p&gt;If I did it again: I wouldn't fight the AI builder. I wasted two hours early on trying to get Lovable to match my exact component hierarchy. Let it generate, adjust after. It's right 80% of the time. I'd also set up GA4 before the first deploy (lost early visitor data because I added it Sunday morning). And I'd timebox prompt engineering harder. Eight hours for an MVP prompt is too much. Ship "good enough," iterate on real feedback.&lt;/p&gt;

&lt;p&gt;Weekend MVPs aren't about getting it perfect. They're about finding out if anyone cares.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>react</category>
      <category>ai</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Why I Built a Personality Reader That Combines Five Ancient Systems</title>
      <dc:creator>Jakub</dc:creator>
      <pubDate>Sun, 17 May 2026 22:16:59 +0000</pubDate>
      <link>https://dev.to/jakub_inithouse/why-i-built-a-personality-reader-that-combines-five-ancient-systems-294e</link>
      <guid>https://dev.to/jakub_inithouse/why-i-built-a-personality-reader-that-combines-five-ancient-systems-294e</guid>
      <description>&lt;p&gt;Most personality tools give you one label. A zodiac sign. An MBTI type. A number. You read it, nod, forget it by Tuesday.&lt;/p&gt;

&lt;p&gt;I kept noticing something when people around me talked about this stuff. They'd read their horoscope and go "yeah, that's me." Then they'd take a numerology quiz somewhere else and go "wait, that's also me but different." The systems were saying overlapping things in completely different languages, and nobody was putting them side by side.&lt;/p&gt;

&lt;p&gt;So I built &lt;a href="https://originofyou.com" rel="noopener noreferrer"&gt;Origin Of You&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five Systems, One Portrait
&lt;/h2&gt;

&lt;p&gt;Origin Of You takes your birth date and runs it through five personality systems at once: Sun &amp;amp; Moon signs, full natal astrology (every planet, house, angle), numerology, tarot birth cards, and Chinese zodiac. Then it writes you a portrait in plain language. Not horoscope predictions, not vague fortune cookies. A description of how you actually work.&lt;/p&gt;

&lt;p&gt;The whole thing takes about 90 seconds. No account, nothing stored, free.&lt;/p&gt;

&lt;p&gt;I wanted to see what happens when you layer these systems on top of each other. Does the picture get muddier or sharper? From the early tests with friends and a few dozen strangers: sharper. The overlaps between systems tend to reinforce the same core traits, and the contradictions are usually where the interesting stuff hides.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a Quiz and Not a Full App
&lt;/h2&gt;

&lt;p&gt;I run about 14 products at &lt;a href="https://inithouse.com" rel="noopener noreferrer"&gt;Inithouse&lt;/a&gt; right now. All MVPs, all looking for traction. The last thing I needed was another app with user accounts, dashboards, saved readings, social sharing, and a six-week build timeline.&lt;/p&gt;

&lt;p&gt;A quiz is the leanest thing I could ship. One input (birth date), one output (written portrait). If people read the whole portrait and come back for more, that tells me something. If they bounce after the first paragraph, that also tells me something. I don't need a database of user profiles to figure out whether this idea has legs.&lt;/p&gt;

&lt;p&gt;Built it over a weekend with Lovable (React + Supabase under the hood). Custom domain, basic analytics, done. Total cost: a few hours and some coffee.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm Watching For
&lt;/h2&gt;

&lt;p&gt;The honest answer is I don't know if this will work. Personality content is massive online but also crowded. The bet is that combining systems into one readable portrait is different enough to hold attention.&lt;/p&gt;

&lt;p&gt;Here's what I'm measuring in the first few weeks: how far people scroll through their reading (do they actually read the whole thing or bail early?), return visits (does anyone come back to try a different date, maybe a partner's?), and organic shares. I didn't build any share buttons on purpose. If people share it, they'll paste the link themselves, and that signal is worth more than a like-count widget.&lt;/p&gt;

&lt;p&gt;35 visitors in the first couple of days, no ads, no launch campaign. Just the URL sitting there. If it gets to a few hundred organics in the next month without me pushing it, I'll know there's something worth expanding.&lt;/p&gt;

&lt;p&gt;If you're curious, try it: &lt;a href="https://originofyou.com" rel="noopener noreferrer"&gt;originofyou.com&lt;/a&gt;. Takes 90 seconds, costs nothing. I'd love to hear what people think of the multi-system approach.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Jakub, builder @ Inithouse&lt;/em&gt;&lt;/p&gt;

</description>
      <category>buildinpublic</category>
      <category>sideprojects</category>
      <category>webdev</category>
      <category>startup</category>
    </item>
    <item>
      <title>Why Distribution Belongs Inside Your MVP, Not After It</title>
      <dc:creator>Jakub</dc:creator>
      <pubDate>Sun, 10 May 2026 19:11:06 +0000</pubDate>
      <link>https://dev.to/jakub_inithouse/why-distribution-belongs-inside-your-mvp-not-after-it-3ghm</link>
      <guid>https://dev.to/jakub_inithouse/why-distribution-belongs-inside-your-mvp-not-after-it-3ghm</guid>
      <description>&lt;p&gt;I spent a year building fourteen products. Most of them are good. Some of them solve real problems. Almost none of them have users.&lt;/p&gt;

&lt;p&gt;The reason is embarrassingly simple: I treated distribution as a post-launch task. Something to figure out after the code works, after the design is polished, after the landing page is just right.&lt;/p&gt;

&lt;p&gt;That was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The build trap
&lt;/h2&gt;

&lt;p&gt;Here's what happened. I'd validate a niche, build an MVP in a few days, ship it, post it somewhere, and wait. The product worked. The landing page converted okay. Nobody came.&lt;/p&gt;

&lt;p&gt;Four of my products had solid problem-solution fit. Users who found them liked them. But "users who found them" was doing a lot of heavy lifting in that sentence, because almost nobody found them.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually moved the needle
&lt;/h2&gt;

&lt;p&gt;After months of near-zero traction across the portfolio, three things started working:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SEO comparison pages.&lt;/strong&gt; Instead of trying to rank for head terms, I built pages comparing my products to established competitors. These pages rank faster because the query intent is specific and the competition is lower. One product (&lt;a href="https://berecommended.com" rel="noopener noreferrer"&gt;BeRecommended&lt;/a&gt;) went from invisible to getting organic clicks within weeks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Building in public cross-posting.&lt;/strong&gt; Writing about what I'm doing across Dev.to, Medium, Hashnode, and a few other platforms. Each post is a small distribution event. The compounding effect is slow but real.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Niche communities over broadcast channels.&lt;/strong&gt; Posting in specific groups where my target users hang out beats shouting into the void on Twitter. IndieHackers threads, relevant subreddits, Quora answers with genuine context.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did not work
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Product Hunt without a community.&lt;/strong&gt; Zero pre-launch audience means zero launch momentum. The algorithm rewards early velocity, and I had none.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Twitter without followers.&lt;/strong&gt; Posting into an echo chamber of zero. Building an audience there takes months of consistent engagement before distribution pays off.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Paid ads without PMF signal.&lt;/strong&gt; Running Google Ads before knowing if people even want the thing is burning money to learn what organic feedback would tell you for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shift
&lt;/h2&gt;

&lt;p&gt;I now treat distribution as a core feature of the MVP itself. The question isn't "how do I get users after launch" but "how does this product reach people as part of its design."&lt;/p&gt;

&lt;p&gt;That means building comparison pages before the launch. It means writing the first building-in-public post the same week I ship. It means choosing niches where distribution channels already exist.&lt;/p&gt;

&lt;p&gt;The product is not done when it works. The product is done when people can find it.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Jakub, building &lt;a href="https://inithouse.cz" rel="noopener noreferrer"&gt;Inithouse&lt;/a&gt;, a portfolio of small products looking for traction. If you're in the same boat, I'd love to hear what distribution channels work for you.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>startup</category>
      <category>distribution</category>
      <category>indiehacker</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>The boring stack behind 14 live products</title>
      <dc:creator>Jakub</dc:creator>
      <pubDate>Thu, 07 May 2026 18:13:28 +0000</pubDate>
      <link>https://dev.to/jakub_inithouse/the-boring-stack-behind-14-live-products-2cbb</link>
      <guid>https://dev.to/jakub_inithouse/the-boring-stack-behind-14-live-products-2cbb</guid>
      <description>&lt;p&gt;Running 14 products on the same stack sounds like a scaling nightmare. It's not. It's actually the only thing keeping me sane.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;p&gt;Every product at &lt;a href="https://inithouse.com" rel="noopener noreferrer"&gt;Inithouse&lt;/a&gt; runs on three layers:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lovable&lt;/strong&gt; for the frontend. React SPAs, component library, built-in Cloudflare Pages deployment. I don't write boilerplate. I describe what I want, iterate in real time, and hit Publish. A new product goes from idea to live URL in hours, not weeks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Supabase&lt;/strong&gt; for the backend. Postgres with Row Level Security, edge functions for anything custom, real-time subscriptions where needed. The SQL editor alone saves me from building admin panels for half the products.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloudflare Pages&lt;/strong&gt; for hosting. Every Lovable publish pushes to CF automatically. I add a custom domain, set up &lt;code&gt;_headers&lt;/code&gt; for cache control and content types, and that's it. Global CDN, no config.&lt;/p&gt;

&lt;p&gt;Analytics: GA4 for traffic, Microsoft Clarity for session replays and heatmaps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this works for solo building
&lt;/h2&gt;

&lt;p&gt;The whole point is &lt;strong&gt;speed of iteration&lt;/strong&gt;. When you're validating 14 MVPs, you don't need the perfect architecture. You need to ship, measure, and decide fast.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://magicalsong.com" rel="noopener noreferrer"&gt;Magical Song&lt;/a&gt; went from concept to paying customers in a single weekend. Same stack as &lt;a href="https://berecommended.com" rel="noopener noreferrer"&gt;Be Recommended&lt;/a&gt;, which targets a completely different market (AI visibility for brands). The shared foundation means bug fixes in one product often improve another.&lt;/p&gt;

&lt;p&gt;Supabase edge functions handle the weird stuff: AI song generation triggers, LLM API calls for visibility scoring, payment webhooks. All in TypeScript, deployed in seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it breaks
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;No SSR.&lt;/strong&gt; Lovable builds React SPAs. That means client-side rendering only. For SEO-heavy products, this hurts. I work around it with Cloudflare Workers for meta tags and pre-rendering, but it's a patch, not a solution.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Supabase edge functions have cold starts.&lt;/strong&gt; Not terrible (200-500ms), but noticeable on first load for latency-sensitive features.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shared patterns create coupling.&lt;/strong&gt; When I update the blog system on one product, I'm tempted to update all 14. Sometimes that's efficient. Sometimes it introduces bugs in products I haven't touched in weeks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lovable's AI chat sometimes hallucinates component structures.&lt;/strong&gt; When it works, it's magic. When it doesn't, you spend more time debugging generated code than you'd spend writing it from scratch.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trade-off I made
&lt;/h2&gt;

&lt;p&gt;The obvious alternative: Next.js + Vercel. Better SSR, better SEO out of the box, mature ecosystem.&lt;/p&gt;

&lt;p&gt;I chose Lovable because iteration speed beats architectural purity when you're searching for product-market fit. Once a product finds traction, migrating the frontend is a known problem. Not finding PMF because you spent three months on infrastructure is a dead-end problem.&lt;/p&gt;

&lt;p&gt;Fourteen products, three tools, zero regret about the boring parts.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Jakub, builder @ &lt;a href="https://inithouse.com" rel="noopener noreferrer"&gt;Inithouse&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>react</category>
      <category>supabase</category>
      <category>cloudflare</category>
    </item>
    <item>
      <title>The useSEO hook pattern: why I dropped React Helmet across 14 projects</title>
      <dc:creator>Jakub</dc:creator>
      <pubDate>Thu, 07 May 2026 06:10:29 +0000</pubDate>
      <link>https://dev.to/jakub_inithouse/the-useseo-hook-pattern-why-i-dropped-react-helmet-across-14-projects-39j2</link>
      <guid>https://dev.to/jakub_inithouse/the-useseo-hook-pattern-why-i-dropped-react-helmet-across-14-projects-39j2</guid>
      <description>&lt;p&gt;React Helmet is great. It's also too much for a SPA with twelve dynamic blog routes. Here's what I shipped instead.&lt;/p&gt;

&lt;p&gt;I run &lt;a href="https://inithouse.com" rel="noopener noreferrer"&gt;Inithouse&lt;/a&gt;, a portfolio of about 14 web products. All built as React SPAs. Every single one needs proper meta tags for SEO: title, description, Open Graph, Twitter cards, JSON-LD. The usual stuff.&lt;/p&gt;

&lt;p&gt;I started with React Helmet. Worked fine for the first app. By app three, I was copy-pasting the same boilerplate helmet component across repos, fighting with nested helmet instances, and wondering why my bundle had an extra 12KB just to set a page title.&lt;/p&gt;

&lt;p&gt;So I wrote a hook. About 20 lines. It replaced Helmet across every project.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hook
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useEffect&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="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;SEOProps&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nl"&gt;title&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;image&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;url&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;type&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;jsonLd&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nb"&gt;Record&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="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;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;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;useSEO&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;title&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="nx"&gt;image&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;url&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="s1"&gt;website&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;jsonLd&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="nx"&gt;SEOProps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;title&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;setMeta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="na"&gt;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="na"&gt;content&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;=&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="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;querySelector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;`meta[name="&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;{name}"], meta[property="&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;{name}"]&lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="s2"&gt;);
      if (!el) {
        el = document.createElement('meta');
        el.setAttribute(name.startsWith('og:') || name.startsWith('article:') ? 'property' : 'name', name);
        document.head.appendChild(el);
      }
      el.setAttribute('content', content);
    };

    if (description) {
      setMeta('description', description);
      setMeta('og:description', description);
      setMeta('twitter:description', description);
    }
    setMeta('og:title', title);
    setMeta('twitter:title', title);
    setMeta('og:type', type);
    if (image) {
      setMeta('og:image', image);
      setMeta('twitter:image', image);
      setMeta('twitter:card', 'summary_large_image');
    }
    if (url) {
      setMeta('og:url', url);
      const link = document.querySelector('link[rel="canonical"]') || document.createElement('link');
      link.setAttribute('rel', 'canonical');
      link.setAttribute('href', url);
      if (!link.parentNode) document.head.appendChild(link);
    }

    if (jsonLd) {
      let script = document.getElementById('json-ld-seo');
      if (!script) {
        script = document.createElement('script');
        script.id = 'json-ld-seo';
        script.type = 'application/ld+json';
        document.head.appendChild(script);
      }
      script.textContent = JSON.stringify(jsonLd);
    }

    return () =&amp;gt; {
      document.getElementById('json-ld-seo')?.remove();
    };
  }, [title, description, image, url, type, jsonLd]);
}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. No provider, no context, no side-channel for server rendering. Just a hook that touches the DOM.&lt;/p&gt;

&lt;h2&gt;
  
  
  How I use it
&lt;/h2&gt;

&lt;p&gt;Every page or route component calls it at the top:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;BlogPost&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;useSEO&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;{post.title} | My App&lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="s2"&gt;,
    description: post.excerpt,
    image: post.heroImage,
    url: &lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="s2"&gt;https://myapp.com/blog/&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;{post.slug}&lt;/span&gt;&lt;span class="se"&gt;\`&lt;/span&gt;&lt;span class="s2"&gt;,
    type: 'article',
    jsonLd: {
      '@context': 'https://schema.org',
      '@type': 'BlogPosting',
      headline: post.title,
      author: { '@type': 'Person', name: 'Jakub' },
      datePublished: post.publishedAt,
      image: post.heroImage,
    },
  });

  return &amp;lt;article&amp;gt;...&amp;lt;/article&amp;gt;;
}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Blog index, product landing, about page, FAQ. Same pattern everywhere. The hook handles the meta tag creation, update, and JSON-LD injection in one call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not React Helmet?
&lt;/h2&gt;

&lt;p&gt;Couple of reasons that mattered for my setup:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bundle size.&lt;/strong&gt; React Helmet pulls in react-side-effect and handles nested instances with a priority queue. For a SPA where only one component sets meta at a time, that's dead code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SSR complexity.&lt;/strong&gt; Helmet has a whole server rendering story with Helmet.renderStatic(). If you're doing SSR, great. My apps are client-rendered SPAs deployed to static hosting. I don't need server extraction. Google crawls JavaScript fine for most content.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Mental model.&lt;/strong&gt; Helmet feels like a component you render. The hook feels like a side effect you declare. For meta tags (which are side effects), the hook model clicks better with how React works now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge cases I hit
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Route changes.&lt;/strong&gt; React Router triggers a re-render, the hook runs again, meta updates. Clean. But if you navigate from a page with JSON-LD to one without, the old script tag stays unless you clean up. The return cleanup in the useEffect handles this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multiple hooks on one page.&lt;/strong&gt; If two components both call useSEO, the last one wins. That's usually fine. The page-level component should be the one setting meta. If you need nested overrides, you need Helmet. I never did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Missing description.&lt;/strong&gt; Some crawlers penalize pages without a meta description. The hook only sets it if you pass one. I added a lint rule: every route component must pass at least title + description.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dynamic content.&lt;/strong&gt; Blog posts loaded from an API. The hook runs on mount with empty strings, then re-runs when data arrives. Brief flash of wrong meta. Not great for social share previews if a bot hits it before data loads. I prerender critical pages with a simple build step for this.&lt;/p&gt;

&lt;h2&gt;
  
  
  JSON-LD per route
&lt;/h2&gt;

&lt;p&gt;This was the real win. Each product in the portfolio has different structured data needs. &lt;a href="https://berecommended.com" rel="noopener noreferrer"&gt;Be Recommended&lt;/a&gt; needs SoftwareApplication schema. &lt;a href="https://magicalsong.com" rel="noopener noreferrer"&gt;Magical Song&lt;/a&gt; needs Product + MusicComposition. Blog posts need BlogPosting.&lt;/p&gt;

&lt;p&gt;With Helmet, I'd nest JSON-LD scripts inside JSX which felt wrong. With the hook, JSON-LD is just another parameter. Type-safe, cleaned up on unmount, one script tag in the head.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you should NOT use this
&lt;/h2&gt;

&lt;p&gt;If you need SSR meta extraction, use Helmet or a framework-level solution (Next.js Head, Remix meta). If you have deeply nested components that all want to contribute meta tags, use Helmet's priority system.&lt;/p&gt;

&lt;p&gt;For client-rendered SPAs where one component per route owns the page meta? A hook is simpler, smaller, and easier to reason about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern scales
&lt;/h2&gt;

&lt;p&gt;I've shipped this across &lt;a href="https://auditvibecoding.com" rel="noopener noreferrer"&gt;Audit Vibe Coding&lt;/a&gt;, &lt;a href="https://watchingagents.com" rel="noopener noreferrer"&gt;Watching Agents&lt;/a&gt;, &lt;a href="https://voicetables.com" rel="noopener noreferrer"&gt;Voice Tables&lt;/a&gt;, and about ten other projects. Copy the hook file, import it, done. No package to install, no version to track.&lt;/p&gt;

&lt;p&gt;Sometimes the boring solution is the right one.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Building 14 products at &lt;a href="https://inithouse.com" rel="noopener noreferrer"&gt;Inithouse&lt;/a&gt;. Writing about what actually ships.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>react</category>
      <category>seo</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Content API Beats Clicking Publish: Why I Stopped Using the UI</title>
      <dc:creator>Jakub</dc:creator>
      <pubDate>Wed, 06 May 2026 22:10:45 +0000</pubDate>
      <link>https://dev.to/jakub_inithouse/content-api-beats-clicking-publish-why-i-stopped-using-the-ui-4ph5</link>
      <guid>https://dev.to/jakub_inithouse/content-api-beats-clicking-publish-why-i-stopped-using-the-ui-4ph5</guid>
      <description>&lt;p&gt;I haven't clicked Publish in three weeks. My blog has more posts than ever.&lt;/p&gt;

&lt;p&gt;When you run one product, the editor workflow makes sense. Open dashboard, write, click Publish, done. Maybe five minutes of overhead. No big deal.&lt;/p&gt;

&lt;p&gt;But I run &lt;a href="https://inithouse.com" rel="noopener noreferrer"&gt;14 products&lt;/a&gt;. Each has its own blog on its own domain. Each blog needs fresh content for SEO, for AI visibility, for keeping the product alive in search results. Multiply that five-minute overhead by 14 and suddenly you're spending an hour just navigating dashboards before you've written a single word.&lt;/p&gt;

&lt;p&gt;So I built a Content API instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem With UI Publishing at Scale
&lt;/h2&gt;

&lt;p&gt;Every blog platform has a slightly different editor. Different button placement, different preview behavior, different autosave quirks. When you're context-switching between 14 products in a day, that cognitive load adds up fast.&lt;/p&gt;

&lt;p&gt;Here's what a typical morning used to look like:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open project for Product A&lt;/li&gt;
&lt;li&gt;Navigate to blog section&lt;/li&gt;
&lt;li&gt;Write post in the rich text editor&lt;/li&gt;
&lt;li&gt;Preview, fix formatting issues&lt;/li&gt;
&lt;li&gt;Click Publish&lt;/li&gt;
&lt;li&gt;Verify the sitemap updated&lt;/li&gt;
&lt;li&gt;Submit to Google Search Console&lt;/li&gt;
&lt;li&gt;Repeat for Product B, C, D...&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;By product number four, I'd already lost 20 minutes to just navigating UIs. The actual writing was maybe 30% of the time spent.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a Content API Looks Like
&lt;/h2&gt;

&lt;p&gt;The idea is dead simple. Instead of clicking through a UI, you POST a JSON payload to an endpoint and the blog post appears on your site.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://yourproduct.com/api/blog/publish &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&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;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "title": "How We Reduced Load Time by 40%",
    "slug": "reduced-load-time-40-percent",
    "content": "## The bottleneck was...",
    "author": "Jakub",
    "status": "published",
    "tags": ["performance", "optimization"],
    "meta_description": "A quick walkthrough of..."
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. No clicking, no waiting for autosave, no "are you sure?" modals. The post is live.&lt;/p&gt;

&lt;p&gt;For my stack (Supabase + React), the API is an Edge Function that validates the payload, inserts into the &lt;code&gt;blog_posts&lt;/code&gt; table, and returns the slug. The frontend already knows how to render posts from the database, so there's zero deployment step.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Auth Part Nobody Talks About
&lt;/h2&gt;

&lt;p&gt;Most tutorials show you the happy path. Here's what actually matters:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token rotation.&lt;/strong&gt; Don't hardcode a token that lives forever. I rotate mine weekly. A cron job generates a new one, stores it encrypted, and the old one expires.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Idempotency by slug.&lt;/strong&gt; If you POST twice with the same slug, the second call should update, not duplicate. This saved me more times than I want to admit. Typo in the content? Just re-POST with the fix.&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;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;blog_posts&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;author&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;updated_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Rate limiting.&lt;/strong&gt; Even on your own API. I hit my own endpoint 47 times in one session while debugging a script. Without rate limiting, that would've been 47 published posts.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Script That Replaced My Morning Routine
&lt;/h2&gt;

&lt;p&gt;I wrote a small bash wrapper that chains the whole publish flow:&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;#!/bin/bash&lt;/span&gt;
&lt;span class="nv"&gt;PRODUCT&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;SLUG&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;CONTENT_FILE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$3&lt;/span&gt;

&lt;span class="c"&gt;# Publish&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://&lt;/span&gt;&lt;span class="nv"&gt;$PRODUCT&lt;/span&gt;&lt;span class="s2"&gt;/api/blog/publish"&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;"Authorization: Bearer &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;vault_token &lt;span class="nv"&gt;$PRODUCT&lt;/span&gt;&lt;span class="si"&gt;)&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;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;--arg&lt;/span&gt; t &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt; &lt;span class="nv"&gt;$CONTENT_FILE&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
            &lt;span class="nt"&gt;--arg&lt;/span&gt; s &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SLUG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
            &lt;span class="nt"&gt;--arg&lt;/span&gt; c &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;tail&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; +3 &lt;span class="nv"&gt;$CONTENT_FILE&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
            &lt;span class="s1"&gt;'{title:$t, slug:$s, content:$c, status:"published"}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# Verify sitemap&lt;/span&gt;
&lt;span class="nb"&gt;sleep &lt;/span&gt;5
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://&lt;/span&gt;&lt;span class="nv"&gt;$PRODUCT&lt;/span&gt;&lt;span class="s2"&gt;/sitemap.xml"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SLUG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Sitemap OK"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"WARN: slug not in sitemap"&lt;/span&gt;

&lt;span class="c"&gt;# Request indexing (GSC API)&lt;/span&gt;
gsc_index &lt;span class="s2"&gt;"https://&lt;/span&gt;&lt;span class="nv"&gt;$PRODUCT&lt;/span&gt;&lt;span class="s2"&gt;/blog/&lt;/span&gt;&lt;span class="nv"&gt;$SLUG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One command. Product name, slug, content file. Post goes live, sitemap verified, indexing requested. What used to take 10 minutes per product now takes 10 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  When NOT to Use a Content API
&lt;/h2&gt;

&lt;p&gt;Not everything should be API-published. Some posts need the UI:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Visual-heavy posts.&lt;/strong&gt; If the post has custom layouts, embedded widgets, or interactive elements, the WYSIWYG editor is still faster than hand-coding HTML in a JSON payload.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First post on a new product.&lt;/strong&gt; When you're still figuring out the blog's look and feel, clicking through the editor helps you see what the reader sees. Don't automate discovery.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Collaboration posts.&lt;/strong&gt; If someone else needs to review before publishing, a shared editor with commenting beats a git-based review flow. At least at our scale.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Unexpected Side Effect
&lt;/h2&gt;

&lt;p&gt;Once publishing became a one-liner, I started publishing more. Not because I forced myself to, but because the friction disappeared. The mental cost of "I should write a post about this" dropped from "ugh, 20 minutes of UI" to "30 seconds, done."&lt;/p&gt;

&lt;p&gt;In the last month, I published more blog posts across my products than in the previous three months combined. Not because I wrote more. Because I stopped losing energy to the publish step.&lt;/p&gt;

&lt;p&gt;If you're running multiple products, especially on a stack like Supabase + a React frontend, building a Content API is maybe a weekend project. The ROI hits within the first week.&lt;/p&gt;

&lt;p&gt;I'm building tools like &lt;a href="https://auditvibecoding.com" rel="noopener noreferrer"&gt;Audit Vibe Coding&lt;/a&gt; for code quality and &lt;a href="https://berecommended.com" rel="noopener noreferrer"&gt;Be Recommended&lt;/a&gt; for AI visibility tracking. Both have blogs powered by this same API pattern. Same script, different domain, different content. That's the whole point.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Jakub, builder @ &lt;a href="https://inithouse.com" rel="noopener noreferrer"&gt;Inithouse&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>api</category>
      <category>productivity</category>
      <category>startup</category>
    </item>
    <item>
      <title>Building a Psychology-Framework Conflict Resolver</title>
      <dc:creator>Jakub</dc:creator>
      <pubDate>Tue, 05 May 2026 20:42:21 +0000</pubDate>
      <link>https://dev.to/jakub_inithouse/building-a-psychology-framework-conflict-resolver-4cn0</link>
      <guid>https://dev.to/jakub_inithouse/building-a-psychology-framework-conflict-resolver-4cn0</guid>
      <description>&lt;p&gt;Turns out psychology frameworks are basically prompts. Here's how I structured them for production.&lt;/p&gt;

&lt;p&gt;I've been building &lt;a href="https://verdictbuddy.com" rel="noopener noreferrer"&gt;Verdict Buddy&lt;/a&gt; - a tool where you describe a conflict, and it breaks it down from multiple angles, gives you a verdict, and suggests concrete next steps. The interesting part isn't the AI. It's the psychology layer sitting between the user's messy input and the model's structured output.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with "just ask AI"
&lt;/h2&gt;

&lt;p&gt;If you paste a relationship argument into ChatGPT and say "help me resolve this," you get generic advice. Be empathetic. Communicate better. Listen actively. Thanks, robot.&lt;/p&gt;

&lt;p&gt;The output is useless because there's no framework guiding the analysis. A couples therapist doesn't wing it. They apply specific models. Gottman's Four Horsemen. Nonviolent Communication. Emotionally Focused Therapy. Each framework looks at the same conflict through a different lens and catches different things.&lt;/p&gt;

&lt;p&gt;So I stopped trying to make AI "understand" conflicts and started making it apply frameworks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Framework selection logic
&lt;/h2&gt;

&lt;p&gt;Not every framework fits every situation. A workplace disagreement about project ownership doesn't need Gottman (that's couples territory). A recurring argument between partners doesn't need Harvard Negotiation tactics.&lt;/p&gt;

&lt;p&gt;The selection works in two stages. First, classify the conflict type from the user's description: romantic, family, workplace, friendship, roommate, or group. Second, map that type to the frameworks most likely to produce useful output.&lt;/p&gt;

&lt;p&gt;Romantic conflicts get Gottman + EFT (Emotionally Focused Therapy) + NVC. Workplace gets Harvard Negotiation Project + NVC + conflict resolution basics. Family disputes pull from Bowen Family Systems + NVC. Friendships and roommates get a lighter stack focused on boundaries and communication patterns.&lt;/p&gt;

&lt;p&gt;The mapping isn't random. Each framework was designed for specific relationship dynamics. Using Gottman's repair attempts framework on a salary negotiation produces nonsense. Using interest-based negotiation on a couple arguing about emotional availability misses the point entirely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prompt architecture per framework
&lt;/h2&gt;

&lt;p&gt;Each framework becomes a structured prompt template. Not a wall of text, but a focused analytical lens.&lt;/p&gt;

&lt;p&gt;Take Gottman as an example. The prompt doesn't say "analyze this using Gottman." It says: scan for the Four Horsemen (criticism, contempt, stonewalling, defensiveness). Identify which partner is flooding. Check for failed repair attempts. Look for positive sentiment override or its absence.&lt;/p&gt;

&lt;p&gt;These are specific, observable things. The model can actually find them in a conflict description because they map to concrete language patterns. "You always..." is criticism. "Whatever, I don't care" is stonewalling. "I tried to make a joke but she wasn't having it" is a failed repair attempt.&lt;/p&gt;

&lt;p&gt;NVC gets a different template: separate observations from evaluations, identify the feelings behind positions, surface the unmet needs driving those feelings, generate request-form suggestions (not demands).&lt;/p&gt;

&lt;p&gt;The key insight was that psychology frameworks are already structured analytical procedures. They were designed for human therapists to follow step by step. That makes them surprisingly good prompt templates, because they tell the model exactly what to look for and how to organize what it finds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Output structuring
&lt;/h2&gt;

&lt;p&gt;Raw framework analysis is interesting but not actionable. Nobody wants to read "Gottman analysis reveals presence of Horseman #2 (contempt) in Partner A's communication pattern." That's a textbook, not help.&lt;/p&gt;

&lt;p&gt;The output pipeline runs in three stages:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Analysis&lt;/strong&gt; runs each selected framework against the conflict description. This is the heavy lifting, usually 2-3 frameworks producing separate analyses.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Synthesis&lt;/strong&gt; merges the framework outputs into a unified verdict. Where frameworks agree, confidence goes up. Where they disagree, that's actually useful information. A conflict that looks fine through NVC but terrible through Gottman tells you something specific: the surface communication is okay but the underlying relationship dynamics are damaged.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Action plan&lt;/strong&gt; converts the synthesis into concrete next steps. Not "communicate better" but "when you notice yourself starting a sentence with 'you always,' stop and rephrase as 'I feel X when Y happens.'" Specific, behavioral, doable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where AI fails (and what to do about it)
&lt;/h2&gt;

&lt;p&gt;The model hallucinates framework concepts. It'll invent a "Fifth Horseman" or attribute techniques to the wrong researcher. The fix: constrain the output space. Each framework prompt includes an explicit list of valid concepts. If the model references something not on the list, the validation layer catches it.&lt;/p&gt;

&lt;p&gt;Severity calibration is another problem. AI tends to either catastrophize ("this relationship shows signs of serious dysfunction") or minimize ("this seems like a minor misunderstanding"). Both are dangerous in conflict resolution. The calibration comes from including severity anchors in the prompt: examples of mild, moderate, and serious conflicts with expected severity ratings. The model uses these as reference points.&lt;/p&gt;

&lt;p&gt;The hardest failure mode is bias toward the narrator. In any conflict, you only hear one side. The model naturally sympathizes with the person telling the story. Every framework prompt includes an explicit instruction to steelman the absent party's likely perspective. It doesn't eliminate bias, but it reduces it enough to be useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;Psychology frameworks are underused in AI product design. Most builders treat AI as a general-purpose reasoning engine and hope good output emerges from vague instructions. But decades of clinical psychology have already solved the "how to analyze human problems" question. Those solutions are structured, validated, and ready to be turned into prompts.&lt;/p&gt;

&lt;p&gt;The technical work isn't in the AI. It's in understanding which framework applies when, translating framework concepts into prompt constraints, and building validation layers that catch the model's predictable failure modes.&lt;/p&gt;

&lt;p&gt;If you're building anything that touches human behavior (conflict resolution, coaching, feedback tools, team dynamics), look at the clinical literature before you look at prompt engineering blogs. The frameworks are already there. You just need to structure them for a different kind of practitioner.&lt;/p&gt;

&lt;p&gt;Check out &lt;a href="https://verdictbuddy.com" rel="noopener noreferrer"&gt;Verdict Buddy&lt;/a&gt; if you want to see this in action, or browse more of what we're building at &lt;a href="https://inithouse.com" rel="noopener noreferrer"&gt;Inithouse&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Jakub, builder @ Inithouse&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>psychology</category>
      <category>webdev</category>
      <category>startup</category>
    </item>
    <item>
      <title>GA4 Custom Dimensions: The Events That Actually Matter for Micro-SaaS</title>
      <dc:creator>Jakub</dc:creator>
      <pubDate>Tue, 05 May 2026 20:28:16 +0000</pubDate>
      <link>https://dev.to/jakub_inithouse/ga4-custom-dimensions-the-events-that-actually-matter-for-micro-saas-5373</link>
      <guid>https://dev.to/jakub_inithouse/ga4-custom-dimensions-the-events-that-actually-matter-for-micro-saas-5373</guid>
      <description>&lt;p&gt;GA4 default events tell you traffic. Custom events tell you behavior. Here's the difference that matters when you're running a micro-SaaS.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem With Default GA4
&lt;/h2&gt;

&lt;p&gt;If you've launched a product — whether it's built with Lovable, Next.js, or anything else — GA4 gives you page_view, session_start, first_visit, and a handful of engagement events out of the box. That's fine for a blog. For a product trying to find product-market fit, it's almost useless.&lt;/p&gt;

&lt;p&gt;Default events answer "how many people visited." They don't answer "how many people actually tried the core feature," "where do users drop off in the flow," or "which acquisition channel brings users who convert."&lt;/p&gt;

&lt;p&gt;I run &lt;a href="https://inithouse.com" rel="noopener noreferrer"&gt;Inithouse&lt;/a&gt;, a portfolio of ~14 micro-SaaS products in various stages of finding PMF. Every single one of them needed custom events from day one. Here's what I've learned.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which Custom Events Actually Matter
&lt;/h2&gt;

&lt;p&gt;Forget tracking everything. Track the moments that tell you whether someone got value from your product.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Activation events&lt;/strong&gt; — the user did the core thing your product exists for. For &lt;a href="https://berecommended.com" rel="noopener noreferrer"&gt;Be Recommended&lt;/a&gt; (an AI visibility tool), that's "ran first analysis." For a photo tool, it's "generated first image." For a challenge game, it's "completed first round." Name it something specific like &lt;code&gt;core_action_completed&lt;/code&gt; and fire it exactly once per session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Friction events&lt;/strong&gt; — the user hit a wall. Form validation errors, failed API calls, empty states with no guidance. Track these as &lt;code&gt;user_friction&lt;/code&gt; with a custom dimension for the friction type. You'll find patterns fast.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conversion micro-steps&lt;/strong&gt; — every step between landing and paying. Clicked pricing, opened signup modal, started checkout, completed payment. Each one is a separate event with a &lt;code&gt;funnel_step&lt;/code&gt; dimension.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Feature discovery&lt;/strong&gt; — did users find the features you built? Track &lt;code&gt;feature_used&lt;/code&gt; with a dimension for which feature. If nobody finds your best feature, that's a product problem, not an analytics problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Implementation: gtag Basics and Gotchas
&lt;/h2&gt;

&lt;p&gt;The basic pattern is straightforward:&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="nf"&gt;gtag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;event&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;core_action_completed&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;action_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;analysis_run&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;result_count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;time_to_complete&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4500&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But here are the gotchas that trip people up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Custom dimensions need registration.&lt;/strong&gt; Firing custom parameters in gtag does not automatically create dimensions in GA4. Go to Admin, then Custom definitions, then Create custom dimension. Register each parameter you want to filter on. Until you do, the data is collected but invisible in reports.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Event-scoped vs user-scoped.&lt;/strong&gt; Event-scoped dimensions describe what happened (friction_type, feature_name). User-scoped dimensions describe who it happened to (plan_tier, signup_source). A user plan tier is user-scoped since it persists. A specific error code is event-scoped since it is per occurrence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 50-event limit.&lt;/strong&gt; GA4 allows up to 50 custom events (500 for GA360). Plan your taxonomy upfront. Use generic event names with descriptive dimensions instead of unique event names for every action. &lt;code&gt;feature_used&lt;/code&gt; with a &lt;code&gt;feature_name&lt;/code&gt; dimension is better than &lt;code&gt;used_search&lt;/code&gt;, &lt;code&gt;used_filter&lt;/code&gt;, &lt;code&gt;used_export&lt;/code&gt; as separate events.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Debug mode is essential.&lt;/strong&gt; Use the DebugView in GA4 while developing. Enable debug mode in your config:&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="nf"&gt;gtag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;config&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;G-XXXXXXXX&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;debug_mode&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without this, you are flying blind. Events take 24-48 hours to show up in standard reports.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up Custom Dimensions Step by Step
&lt;/h2&gt;

&lt;p&gt;Start by planning your taxonomy. List every meaningful user action. Group by category (activation, friction, conversion, discovery). Aim for 10-15 events max to start.&lt;/p&gt;

&lt;p&gt;Then register in GA4: Admin, Custom definitions, Create custom dimension. Set the scope (event or user), map to the parameter name in your code.&lt;/p&gt;

&lt;p&gt;Implement in code and fire events at the right moment. For SPAs, handle route changes since GA4 will not auto-track virtual pageviews unless configured.&lt;/p&gt;

&lt;p&gt;Validate in DebugView. Open your app with debug mode on, trigger every event, confirm they appear with correct parameters.&lt;/p&gt;

&lt;p&gt;Finally, build Explorations in GA4 using your custom dimensions. Funnel explorations are particularly useful for conversion micro-steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reporting That Actually Helps
&lt;/h2&gt;

&lt;p&gt;Once your custom events are flowing, build three reports:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Activation funnel&lt;/strong&gt; — from first_visit through each micro-step to core_action_completed. This is your north star. At &lt;a href="https://inithouse.com" rel="noopener noreferrer"&gt;Inithouse&lt;/a&gt; we check this weekly for every active product. If activation rate drops, everything else is noise.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Feature adoption matrix&lt;/strong&gt; — how many users discover each feature, and which features correlate with retention. Build this as a free-form exploration with feature_used events broken down by feature_name dimension.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Friction heatmap&lt;/strong&gt; — which friction events fire most often, on which pages, for which user segments. Sort by frequency and fix top-down. This is the highest-ROI work you can do since reducing friction directly improves activation.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Custom Events Beat Mixpanel
&lt;/h2&gt;

&lt;p&gt;For a solo builder or small team, GA4 custom events often beat dedicated product analytics tools. Mixpanel, Amplitude, and PostHog are great, but they add another service to manage, another SDK to load, and another bill to pay. If you are in the zero-to-one phase validating whether anyone even wants your product, GA4 custom events give you 80% of the insight at 0% of the cost.&lt;/p&gt;

&lt;p&gt;The exception: if you need cohort analysis, real-time funnels, or complex behavioral segmentation, dedicated tools earn their keep. But for most micro-SaaS at the PMF-hunting stage, gtag with well-planned custom dimensions is more than enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Checklist
&lt;/h2&gt;

&lt;p&gt;Before you ship your next feature, make sure you can answer these with your analytics:&lt;/p&gt;

&lt;p&gt;Can you measure the activation rate? (percentage of visitors who complete the core action)&lt;/p&gt;

&lt;p&gt;Do you know where users drop off? (friction events with page context)&lt;/p&gt;

&lt;p&gt;Can you compare acquisition channels by activation, not just traffic? (UTM + custom events)&lt;/p&gt;

&lt;p&gt;Do you know which features get used? (feature discovery tracking)&lt;/p&gt;

&lt;p&gt;If the answer to any of those is no, your next task is not building a new feature. It is adding the right custom events.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I am Jakub, building &lt;a href="https://inithouse.com" rel="noopener noreferrer"&gt;Inithouse&lt;/a&gt; — a portfolio of micro-SaaS products where every week is an experiment in finding product-market fit. Tools like &lt;a href="https://watchingagents.com" rel="noopener noreferrer"&gt;Watching Agents&lt;/a&gt; and &lt;a href="https://berecommended.com" rel="noopener noreferrer"&gt;Be Recommended&lt;/a&gt; are where I test these analytics patterns in practice.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>ga4</category>
      <category>saas</category>
      <category>webdev</category>
    </item>
    <item>
      <title>GA4 Custom Dimensions — The Events That Actually Matter for Micro-SaaS</title>
      <dc:creator>Jakub</dc:creator>
      <pubDate>Tue, 05 May 2026 20:27:24 +0000</pubDate>
      <link>https://dev.to/jakub_inithouse/ga4-custom-dimensions-the-events-that-actually-matter-for-micro-saas-4hng</link>
      <guid>https://dev.to/jakub_inithouse/ga4-custom-dimensions-the-events-that-actually-matter-for-micro-saas-4hng</guid>
      <description>&lt;p&gt;GA4 default events tell you traffic. Custom events tell you behavior. Here's the difference that matters when you're running a micro-SaaS and every user action counts.&lt;/p&gt;

&lt;p&gt;I run &lt;a href="https://inithouse.com" rel="noopener noreferrer"&gt;Inithouse&lt;/a&gt;, a portfolio of about a dozen small products — everything from &lt;a href="https://zivafotka.cz" rel="noopener noreferrer"&gt;AI photo tools&lt;/a&gt; to &lt;a href="https://watchingagents.com" rel="noopener noreferrer"&gt;analytics platforms&lt;/a&gt;. Each product is an MVP testing product-market fit. GA4's built-in events (page_view, session_start, first_visit) tell me almost nothing useful about whether a product is working. Custom events changed that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What GA4 Default Events Actually Miss
&lt;/h2&gt;

&lt;p&gt;Out of the box, GA4 tracks surface-level interactions. You get page views, scroll depth (at 90%), outbound clicks, and file downloads. That's fine for a content site. For a SaaS product, it's nearly useless.&lt;/p&gt;

&lt;p&gt;Here's what I actually need to know:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Did the user complete the core action? (generate a photo, run an audit, submit a form)&lt;/li&gt;
&lt;li&gt;Where did they drop off in the funnel?&lt;/li&gt;
&lt;li&gt;Which feature drove them to convert?&lt;/li&gt;
&lt;li&gt;How many times did they return before paying?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of that comes free. You have to build it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up Custom Events — The Practical Way
&lt;/h2&gt;

&lt;p&gt;The gtag API is straightforward. Here's the pattern I use across all Inithouse products:&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;// Core action completed&lt;/span&gt;
&lt;span class="nf"&gt;gtag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;event&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;core_action_complete&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;product&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;zivafotka&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;action_type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;photo_generated&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;is_first_time&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="c1"&gt;// Funnel step tracking&lt;/span&gt;
&lt;span class="nf"&gt;gtag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;event&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;funnel_step&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;step_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;upload_photo&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;step_number&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;funnel_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;photo_generation&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Feature engagement&lt;/span&gt;
&lt;span class="nf"&gt;gtag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;event&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;feature_used&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;feature_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;style_selector&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;homepage&lt;/span&gt;&lt;span class="dl"&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 key insight: &lt;strong&gt;name your events by what they mean to your business&lt;/strong&gt;, not by what the UI element does. &lt;code&gt;button_click&lt;/code&gt; tells you nothing in a report. &lt;code&gt;core_action_complete&lt;/code&gt; tells you everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Custom Dimensions — Making Events Queryable
&lt;/h2&gt;

&lt;p&gt;Events alone aren't enough. You need custom dimensions to slice the data. In GA4 Admin, go to Custom definitions and create custom dimensions:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Event-scoped dimensions I set up on every product:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;product&lt;/code&gt; — which product in the portfolio (essential when sharing one GA4 property)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;action_type&lt;/code&gt; — the specific action within the core flow&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;funnel_name&lt;/code&gt; + &lt;code&gt;step_name&lt;/code&gt; — for funnel analysis&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;feature_name&lt;/code&gt; — which feature was used&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;traffic_source_detail&lt;/code&gt; — enriched beyond GA4's default attribution&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;User-scoped dimensions worth adding:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;user_tier&lt;/code&gt; — free vs paid (set on login)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;signup_date_cohort&lt;/code&gt; — weekly cohort for retention analysis&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;first_action&lt;/code&gt; — what they did first (predicts retention)&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Gotchas Nobody Warns You About
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Register dimensions before sending events.&lt;/strong&gt; If you fire custom events before creating the corresponding custom dimensions in GA4 Admin, the data is lost. GA4 doesn't retroactively apply dimension definitions. I learned this the hard way on &lt;a href="https://berecommended.com" rel="noopener noreferrer"&gt;Be Recommended&lt;/a&gt; — two weeks of event data with unregistered dimensions, gone.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The 50-event limit is real.&lt;/strong&gt; GA4 allows 50 custom event names per property (500 for GA4 360). Plan your naming taxonomy before you start. I use a flat hierarchy: &lt;code&gt;core_action_complete&lt;/code&gt;, &lt;code&gt;funnel_step&lt;/code&gt;, &lt;code&gt;feature_used&lt;/code&gt;, &lt;code&gt;error_encountered&lt;/code&gt;, &lt;code&gt;conversion_intent&lt;/code&gt;. Five event names, infinite granularity through parameters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Event parameters vs. custom dimensions are different things.&lt;/strong&gt; You can send any parameter with an event. But unless you register it as a custom dimension, you can't use it in reports, explorations, or segments. The parameter still exists in BigQuery export — but if you're not on BigQuery, it's invisible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Debug View is your best friend.&lt;/strong&gt; GA4 DebugView in Admin lets you watch events arrive in real time. Install the GA Debugger Chrome extension, enable debug mode, and verify every custom event before you consider it shipped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. The &lt;code&gt;not set&lt;/code&gt; trap.&lt;/strong&gt; If an event fires without a parameter that you've registered as a dimension, that row shows as &lt;code&gt;(not set)&lt;/code&gt; in reports. This pollutes your data. Always set a default value:&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="nf"&gt;gtag&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;event&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;feature_used&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;feature_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;featureName&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unknown&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pageContext&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;direct&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Building Reports That Actually Help
&lt;/h2&gt;

&lt;p&gt;Once your custom events and dimensions are flowing, build these three explorations:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Core Action Funnel&lt;/strong&gt;&lt;br&gt;
Free-form exploration with funnel steps as rows and completion rate as metric. This is your product's heartbeat. If completion rate drops, something broke.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Feature Adoption Matrix&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;feature_used&lt;/code&gt; events pivoted by &lt;code&gt;user_tier&lt;/code&gt;. Shows which features free users engage with (upgrade triggers) and which paid users ignore (candidates for removal).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. First-Session Behavior Cohort&lt;/strong&gt;&lt;br&gt;
Filter by &lt;code&gt;first_visit&lt;/code&gt; event, group by &lt;code&gt;first_action&lt;/code&gt; dimension. Users who generate a photo on first visit retain 3x better than users who browse the gallery. That kind of insight changes your entire onboarding flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Custom Events Aren't Enough
&lt;/h2&gt;

&lt;p&gt;GA4 custom events work for 80% of product analytics. For the remaining 20% — real-time cohort analysis, complex funnels with branching paths, revenue attribution across a product portfolio — you'll eventually need something more. But for an MVP testing product-market fit, GA4 custom events give you the signal you need without adding another SaaS bill.&lt;/p&gt;

&lt;p&gt;The pattern is simple: decide what matters to your business, fire events when those things happen, register dimensions so you can query them, and verify everything in Debug View before calling it done.&lt;/p&gt;

&lt;p&gt;Start with five event names. You can always add more. You can't get back the data you didn't track.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Jakub, building &lt;a href="https://inithouse.com" rel="noopener noreferrer"&gt;Inithouse&lt;/a&gt; — a portfolio of MVPs finding product-market fit. I write about the tools and workflows that keep the machine running.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>ga4</category>
      <category>saas</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
