<?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>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>Psychology frameworks are basically prompts — here's how I structured them for production</title>
      <dc:creator>Jakub</dc:creator>
      <pubDate>Tue, 05 May 2026 20:40:44 +0000</pubDate>
      <link>https://dev.to/jakub_inithouse/psychology-frameworks-are-basically-prompts-heres-how-i-structured-them-for-production-25ac</link>
      <guid>https://dev.to/jakub_inithouse/psychology-frameworks-are-basically-prompts-heres-how-i-structured-them-for-production-25ac</guid>
      <description>&lt;p&gt;Turns out the hardest part of building an AI conflict resolver wasn't the AI. It was figuring out which psychology framework to apply — and when.&lt;/p&gt;

&lt;p&gt;I'm building &lt;a href="https://verdictbuddy.com" rel="noopener noreferrer"&gt;VerdictBuddy&lt;/a&gt;, a tool that helps people navigate relationship conflicts using AI-powered analysis grounded in real psychological frameworks. Not generic "communicate better" advice — structured verdicts based on Gottman, NVC, attachment theory, and cognitive-behavioral models.&lt;/p&gt;

&lt;p&gt;Here's the technical breakdown of how I got there.&lt;/p&gt;

&lt;h2&gt;
  
  
  The core insight: frameworks are prompt templates
&lt;/h2&gt;

&lt;p&gt;Every established psychology framework follows a predictable structure. Gottman's Four Horsemen model, for example, identifies four destructive communication patterns (criticism, contempt, stonewalling, defensiveness) and maps each to a repair strategy.&lt;/p&gt;

&lt;p&gt;That structure translates almost directly into a prompt template:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Input classification&lt;/strong&gt; — which pattern is present in the user's conflict description&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Framework logic&lt;/strong&gt; — what does the theory say about this specific pattern&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Output generation&lt;/strong&gt; — concrete verdict + reasoning + actionable next steps&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same applies to Nonviolent Communication (NVC), which breaks every conflict into observations, feelings, needs, and requests. That four-part structure becomes a four-stage prompt pipeline.&lt;/p&gt;

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

&lt;p&gt;Not every framework fits every conflict. A workplace disagreement about project ownership doesn't need attachment theory. A recurring argument with a partner probably doesn't need a pure CBT reframe.&lt;/p&gt;

&lt;p&gt;I built a classification layer that analyzes the conflict description and selects the most appropriate framework based on three signals:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Relationship type&lt;/strong&gt; — partner conflicts lean toward Gottman and attachment theory. Workplace conflicts map better to NVC and interest-based negotiation. Family dynamics often need a blend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Conflict pattern&lt;/strong&gt; — is this about communication style (Gottman), unmet needs (NVC), cognitive distortions (CBT), or deep-rooted attachment patterns? The pattern determines the lens.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Escalation level&lt;/strong&gt; — high-escalation conflicts get de-escalation-first approaches. Low-stakes disagreements can jump straight to resolution frameworks.&lt;/p&gt;

&lt;p&gt;This isn't a simple if-else tree. The classifier weighs multiple signals and sometimes combines frameworks. A partner conflict about household responsibilities might get Gottman for the communication breakdown analysis and NVC for the resolution path.&lt;/p&gt;

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

&lt;p&gt;Each framework has its own prompt template, but they share a common output structure. This was a deliberate design choice — users get consistent verdicts regardless of which framework runs under the hood.&lt;/p&gt;

&lt;p&gt;The template structure looks like this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Context injection&lt;/strong&gt; — the user's conflict description, parsed into structured elements (who said what, what happened, what's the recurring pattern).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Framework application&lt;/strong&gt; — the specific psychological model's lens applied to those elements. For Gottman, this means identifying which of the Four Horsemen are present. For NVC, it means extracting the unspoken needs behind each person's position.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verdict generation&lt;/strong&gt; — a clear assessment of what's happening, why it's happening, and what each person can do about it. Not "you're both right" — an actual analysis with reasoning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next steps&lt;/strong&gt; — three to five specific, actionable things each person can try. Not vague advice like "communicate more." Concrete moves like "next time you notice yourself withdrawing from the conversation, say 'I need five minutes to collect my thoughts' instead of going silent."&lt;/p&gt;

&lt;h2&gt;
  
  
  Where AI fails — and how to catch it
&lt;/h2&gt;

&lt;p&gt;The biggest quality issue isn't hallucination in the traditional sense. It's &lt;strong&gt;false confidence in framework application&lt;/strong&gt;. The model will confidently apply Gottman's contempt pattern to something that's actually just frustration, because the surface-level language looks similar.&lt;/p&gt;

&lt;p&gt;I built validation checks at three points:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pre-generation&lt;/strong&gt; — does the conflict description contain enough information to apply the selected framework? If someone writes "we argued about dinner," that's not enough signal. The system asks follow-up questions instead of guessing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Post-generation&lt;/strong&gt; — does the verdict actually use the framework correctly? I check for common misapplications: confusing criticism with contempt (Gottman), confusing observations with evaluations (NVC), or applying attachment labels without behavioral evidence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;User feedback loop&lt;/strong&gt; — after each verdict, users can flag whether the analysis felt accurate. This doesn't retrain the model, but it feeds into my understanding of where each framework template needs refinement.&lt;/p&gt;

&lt;p&gt;The hardest lesson: AI is surprisingly good at &lt;em&gt;describing&lt;/em&gt; psychological concepts but mediocre at &lt;em&gt;applying&lt;/em&gt; them to specific situations. The gap between "can explain Gottman" and "can correctly identify contempt in this specific conversation" is wider than you'd expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;If I started over, I'd begin with fewer frameworks. I launched with five, and the interaction effects between them created edge cases I'm still sorting out. Start with one, nail the prompt template, validate the output quality, then add the next.&lt;/p&gt;

&lt;p&gt;I'd also invest earlier in the classification layer. Getting the right framework matters more than perfecting any single framework's prompt. A mediocre NVC analysis of a situation that actually needs NVC beats a perfect Gottman analysis applied to the wrong conflict type.&lt;/p&gt;




&lt;p&gt;This is part of what I'm building at &lt;a href="https://inithouse.com" rel="noopener noreferrer"&gt;Inithouse&lt;/a&gt; — a portfolio of AI-powered tools where each product tackles a specific problem domain. &lt;a href="https://verdictbuddy.com" rel="noopener noreferrer"&gt;VerdictBuddy&lt;/a&gt; handles conflict resolution, &lt;a href="https://berecommended.com" rel="noopener noreferrer"&gt;Be Recommended&lt;/a&gt; tracks AI visibility for brands, and &lt;a href="https://hereweask.com" rel="noopener noreferrer"&gt;Here We Ask&lt;/a&gt; explores community-driven Q&amp;amp;A.&lt;/p&gt;

&lt;p&gt;The psychology-to-prompt pipeline is the kind of problem that looks simple until you're three frameworks deep and your classifier is arguing with itself. But when it works — when someone gets a verdict that genuinely helps them understand their conflict — the architecture disappears and the output speaks for itself.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>psychology</category>
      <category>webdev</category>
      <category>productivity</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>
    <item>
      <title>Supabase dual-DB gotcha — test vs live, and how I stopped shipping broken data</title>
      <dc:creator>Jakub</dc:creator>
      <pubDate>Tue, 05 May 2026 20:13:14 +0000</pubDate>
      <link>https://dev.to/jakub_inithouse/supabase-dual-db-gotcha-test-vs-live-and-how-i-stopped-shipping-broken-data-10ao</link>
      <guid>https://dev.to/jakub_inithouse/supabase-dual-db-gotcha-test-vs-live-and-how-i-stopped-shipping-broken-data-10ao</guid>
      <description>&lt;p&gt;I spent two hours debugging an empty production list. The data was in the test database the whole time.&lt;/p&gt;

&lt;p&gt;If you're building with &lt;a href="https://lovable.dev" rel="noopener noreferrer"&gt;Lovable&lt;/a&gt; and Supabase, there's a gotcha that will bite you eventually — and when it does, you'll wonder why nobody warned you. Consider this your warning.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup nobody explains
&lt;/h2&gt;

&lt;p&gt;When you spin up a Supabase project through Lovable, you get two database environments: &lt;strong&gt;test&lt;/strong&gt; and &lt;strong&gt;live&lt;/strong&gt;. This makes sense in theory — you don't want your AI-assisted edits touching production data while you're experimenting.&lt;/p&gt;

&lt;p&gt;The problem? The boundary between these two is almost invisible.&lt;/p&gt;

&lt;p&gt;When you use the AI chat in Lovable to insert data — say, seed some blog posts, add sample users, or populate a lookup table — that data goes into the &lt;strong&gt;test database&lt;/strong&gt;. Your production app, the one your users actually visit, reads from the &lt;strong&gt;live database&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;You insert ten records. You check the app. Zero records. You start debugging.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the confusion hits
&lt;/h2&gt;

&lt;p&gt;Here's the typical debugging spiral:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;You ask the AI to insert data. It confirms success.&lt;/li&gt;
&lt;li&gt;You open your app. The list is empty.&lt;/li&gt;
&lt;li&gt;You check your query. It looks correct.&lt;/li&gt;
&lt;li&gt;You add console logs. The query runs fine, returns nothing.&lt;/li&gt;
&lt;li&gt;You start questioning your RLS policies, your auth setup, your entire understanding of PostgreSQL.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The data is there. It's just in the wrong database.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is one toggle
&lt;/h2&gt;

&lt;p&gt;In Lovable's Supabase integration, there's a &lt;strong&gt;SQL Editor&lt;/strong&gt; with a toggle to switch between test and live environments. When you need data in production, you switch to &lt;strong&gt;Live&lt;/strong&gt; and run your inserts there.&lt;/p&gt;

&lt;p&gt;Simple — once you know about it. Brutal when you don't.&lt;/p&gt;

&lt;h2&gt;
  
  
  My pre-deploy checklist
&lt;/h2&gt;

&lt;p&gt;After getting burned on this across multiple projects at &lt;a href="https://inithouse.com" rel="noopener noreferrer"&gt;Inithouse&lt;/a&gt; (we run about a dozen MVPs built on Lovable + Supabase), I started using a quick checklist before every deploy:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Check which database you're targeting.&lt;/strong&gt; Before any insert, look at the SQL Editor toggle. If it says "test" and you need production data, switch it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Don't trust AI chat inserts for production.&lt;/strong&gt; The AI chat is great for prototyping, but treat its database writes as test-only by default. For production data, use the SQL Editor switched to Live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Verify after insert.&lt;/strong&gt; After running your production inserts, open your deployed app and confirm the data shows up. Don't assume — check.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Document your seed data.&lt;/strong&gt; Keep your production seed SQL in a file. When you inevitably need to re-run it (new environment, data reset, migration), you want it ready.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this matters for real products
&lt;/h2&gt;

&lt;p&gt;This isn't just a development inconvenience. I've shipped &lt;a href="https://zivafotka.cz" rel="noopener noreferrer"&gt;Živá Fotka&lt;/a&gt; and &lt;a href="https://hereweask.com" rel="noopener noreferrer"&gt;HereWeAsk&lt;/a&gt; on the Lovable + Supabase stack. Both had moments where content was "missing" in production because someone inserted it through the AI chat.&lt;/p&gt;

&lt;p&gt;For an MVP you're validating with real users, showing an empty page when there should be content is a conversion killer. Users don't debug — they leave.&lt;/p&gt;

&lt;h2&gt;
  
  
  The broader lesson
&lt;/h2&gt;

&lt;p&gt;AI-assisted builders are incredible for speed. But they abstract away infrastructure in ways that create new categories of bugs. The test-vs-live database split is Supabase doing the right thing by protecting your production data. The gap is in making that split visible enough.&lt;/p&gt;

&lt;p&gt;If you're building on Lovable + Supabase, bookmark this. You'll need it at 11 PM on a Sunday when your production page is empty and you can't figure out why.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Jakub, building a portfolio of micro-products at &lt;a href="https://inithouse.com" rel="noopener noreferrer"&gt;Inithouse&lt;/a&gt;. Follow along for more war stories from the AI-assisted building trenches.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>webdev</category>
      <category>lovable</category>
      <category>database</category>
    </item>
    <item>
      <title>SEO Fixes for Lovable Apps — Sitemap, Meta Tags, Canonical URLs, and the Full Checklist</title>
      <dc:creator>Jakub</dc:creator>
      <pubDate>Tue, 05 May 2026 19:34:59 +0000</pubDate>
      <link>https://dev.to/jakub_inithouse/seo-fixes-for-lovable-apps-sitemap-meta-tags-canonical-urls-and-the-full-checklist-2m40</link>
      <guid>https://dev.to/jakub_inithouse/seo-fixes-for-lovable-apps-sitemap-meta-tags-canonical-urls-and-the-full-checklist-2m40</guid>
      <description>&lt;p&gt;Your Lovable app is fast and pretty. Google still can't find it.&lt;/p&gt;

&lt;p&gt;I've shipped over a dozen MVPs with &lt;a href="https://lovable.dev" rel="noopener noreferrer"&gt;Lovable&lt;/a&gt; over the past year at &lt;a href="https://inithouse.com" rel="noopener noreferrer"&gt;Inithouse&lt;/a&gt;. The builder handles UI, routing, and deployment beautifully — but SEO is not part of the default stack. Every single app I launched needed manual fixes before Google would index it properly.&lt;/p&gt;

&lt;p&gt;Here's the checklist I now run on every project before going live.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem: SPAs Are Invisible by Default
&lt;/h2&gt;

&lt;p&gt;Lovable apps are React single-page applications. That means the HTML Google receives on first request is mostly empty — a root div and some script tags. Without server-side rendering, crawlers see nothing.&lt;/p&gt;

&lt;p&gt;This isn't unique to Lovable. Every SPA framework has this issue. But Lovable's target audience — solo builders shipping fast — often skips the SEO layer entirely. I did too, until I noticed zero organic traffic on projects that should have been ranking.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 1: Generate a Real Sitemap
&lt;/h2&gt;

&lt;p&gt;Lovable apps don't ship with a sitemap. If you have dynamic content (blog posts, listings, user profiles), you need one that updates automatically.&lt;/p&gt;

&lt;p&gt;The approach I use: a Supabase Edge Function that queries the database and returns XML.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// supabase/functions/sitemap/index.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@supabase/supabase-js&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SUPABASE_URL&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SUPABASE_ANON_KEY&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;posts&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;blog_posts&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="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;slug, updated_at&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="nf"&gt;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;published&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&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;urls&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;posts&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[]).&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;url&amp;gt;
    &amp;lt;loc&amp;gt;https://yourdomain.com/blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/loc&amp;gt;
    &amp;lt;lastmod&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updated_at&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/lastmod&amp;gt;
  &amp;lt;/url&amp;gt;`&lt;/span&gt;
  &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;xml&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;?xml version="1.0" encoding="UTF-8"?&amp;gt;
&amp;lt;urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"&amp;gt;
  &amp;lt;url&amp;gt;&amp;lt;loc&amp;gt;https://yourdomain.com/&amp;lt;/loc&amp;gt;&amp;lt;/url&amp;gt;
  &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;urls&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;
&amp;lt;/urlset&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;xml&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/xml&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then submit the function URL to Google Search Console under Sitemaps. I do this for every project — including &lt;a href="https://zivafotka.cz" rel="noopener noreferrer"&gt;Ziva Fotka&lt;/a&gt;, which runs the same codebase across five country domains.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key detail:&lt;/strong&gt; After deploying new content, manually resubmit the sitemap in GSC. Google can go weeks without re-fetching it on its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2: Dynamic Meta Tags
&lt;/h2&gt;

&lt;p&gt;React Helmet or a custom &lt;code&gt;useSEO&lt;/code&gt; hook — pick one, but you need dynamic &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;meta name="description"&amp;gt;&lt;/code&gt; tags per page. The default Lovable setup gives you a single static title across the entire app.&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="c1"&gt;// hooks/useSEO.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;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="s2"&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;canonical&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;ogImage&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="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;canonical&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ogImage&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="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="s2"&gt;`meta[name="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"]`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; 
        &lt;span class="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="s2"&gt;`meta[property="&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"]`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="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;el&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&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;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;meta&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;og:&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;property&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;name&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;head&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;};&lt;/span&gt;

    &lt;span class="nf"&gt;setMeta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;description&lt;/span&gt;&lt;span class="dl"&gt;"&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="nf"&gt;setMeta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;og:title&lt;/span&gt;&lt;span class="dl"&gt;"&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="nf"&gt;setMeta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;og:description&lt;/span&gt;&lt;span class="dl"&gt;"&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ogImage&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;setMeta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;og:image&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ogImage&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;)&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;link&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;link[rel="canonical"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;link&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;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;link&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;canonical&lt;/span&gt;&lt;span class="dl"&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;head&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
      &lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;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;canonical&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;ogImage&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;Call it at the top of every page component:&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="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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;How to Convert Live Photos to GIF&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Turn your iPhone Live Photos into shareable GIFs in seconds.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://yourdomain.com/blog/live-photo-to-gif&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is client-side only, so it won't help with crawlers that don't execute JavaScript. But Googlebot does execute JS in most cases, and it's infinitely better than having the same generic title on every page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 3: Canonical URLs for Multi-Domain
&lt;/h2&gt;

&lt;p&gt;If you run the same app on multiple domains (like I do with &lt;a href="https://zivafotka.cz" rel="noopener noreferrer"&gt;Ziva Fotka&lt;/a&gt; across .cz, .sk, .pl, .de, and .online), you need canonical tags pointing to the primary version. Without them, Google sees duplicate content and picks one arbitrarily — usually not the one you want.&lt;/p&gt;

&lt;p&gt;Set the canonical based on the current domain:&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;const&lt;/span&gt; &lt;span class="nx"&gt;domain&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;canonicalBase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://yourdomain.sk&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; 
  &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;endsWith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://yourdomain.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="s2"&gt;https://yourdomain.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// default primary&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="nx"&gt;pageTitle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;pageDesc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;canonical&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;canonicalBase&lt;/span&gt;&lt;span class="p"&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;pathname&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each domain should have its own GSC property. Submit sitemaps separately per domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 4: JSON-LD Structured Data
&lt;/h2&gt;

&lt;p&gt;Google uses structured data to generate rich snippets. For a SaaS landing page, &lt;code&gt;SoftwareApplication&lt;/code&gt; schema works. For blog posts, use &lt;code&gt;Article&lt;/code&gt;. For tools like &lt;a href="https://berecommended.com" rel="noopener noreferrer"&gt;Be Recommended&lt;/a&gt; (an AI visibility checker I built), &lt;code&gt;WebApplication&lt;/code&gt; fits.&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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;JsonLd&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="p"&gt;}:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;data&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;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;script&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"application/ld+json"&lt;/span&gt;
      &lt;span class="na"&gt;dangerouslySetInnerHTML&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;__html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;
    &lt;span class="p"&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="c1"&gt;// Usage on a blog post page&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;JsonLd&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@context&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://schema.org&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Article&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;headline&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;author&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Person&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Jakub&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;datePublished&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dateModified&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;post&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;updated_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Fix 5: Pre-Render Critical Pages
&lt;/h2&gt;

&lt;p&gt;For pages that absolutely must be indexed (landing page, pricing, key blog posts), consider pre-rendering. You can use a service like Prerender.io or build a simple Cloudflare Worker that serves a cached HTML snapshot to crawlers.&lt;/p&gt;

&lt;p&gt;I haven't needed this for most Lovable apps — Googlebot handles client-side rendering reasonably well in 2026. But if you're seeing "Discovered — currently not indexed" in GSC for important pages, pre-rendering is the nuclear option.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Pre-Launch SEO Checklist
&lt;/h2&gt;

&lt;p&gt;Before every Lovable app goes live, I run through this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Sitemap&lt;/strong&gt; — Edge Function generating XML from DB, submitted to GSC&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Meta tags&lt;/strong&gt; — Dynamic title + description per page via useSEO hook&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canonical URLs&lt;/strong&gt; — Set on every page, especially for multi-domain setups&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JSON-LD&lt;/strong&gt; — At minimum on the homepage and blog posts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;robots.txt&lt;/strong&gt; — Confirm it's not blocking anything important (Lovable default is fine)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open Graph&lt;/strong&gt; — og:title, og:description, og:image for social sharing&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GSC verification&lt;/strong&gt; — DNS TXT record, property confirmed, sitemap submitted&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Manual index request&lt;/strong&gt; — For the homepage and top 5 pages, use Request Indexing in GSC immediately after launch&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This takes maybe 30 minutes per project. The difference between doing it and not doing it is the difference between organic traffic and zero organic traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;Lovable is great for shipping fast. SEO is the part you bolt on after. None of these fixes are complex — they're just not included out of the box, and if you don't know to look for them, your app stays invisible.&lt;/p&gt;

&lt;p&gt;I've been building and measuring MVPs at &lt;a href="https://inithouse.com" rel="noopener noreferrer"&gt;Inithouse&lt;/a&gt; using this exact playbook. If you're shipping Lovable apps and wondering why Google isn't picking them up, start with the sitemap and meta tags. Those two alone will get you most of the way there.&lt;/p&gt;

&lt;p&gt;Questions or your own Lovable SEO tricks? Drop them in the comments.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>seo</category>
      <category>react</category>
      <category>beginners</category>
    </item>
    <item>
      <title>SEO fixes for Lovable apps — sitemap, meta, canonical, and the stuff Google actually needs</title>
      <dc:creator>Jakub</dc:creator>
      <pubDate>Tue, 05 May 2026 19:34:50 +0000</pubDate>
      <link>https://dev.to/jakub_inithouse/seo-fixes-for-lovable-apps-sitemap-meta-canonical-and-the-stuff-google-actually-needs-23og</link>
      <guid>https://dev.to/jakub_inithouse/seo-fixes-for-lovable-apps-sitemap-meta-canonical-and-the-stuff-google-actually-needs-23og</guid>
      <description>&lt;p&gt;Your Lovable app is fast and pretty. Google still can't find it. Here's the fix list.&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 micro-SaaS products — all built with Lovable. After shipping the first few, I noticed the same pattern: great product, zero organic traffic. The apps worked fine for users who had the link, but Google was basically ignoring them.&lt;/p&gt;

&lt;p&gt;It took some digging to figure out why. Lovable gives you a solid React SPA out of the box, but SPAs and search engines have a complicated relationship. Here's everything I learned fixing SEO across projects like &lt;a href="https://zivafotka.cz" rel="noopener noreferrer"&gt;Živá Fotka&lt;/a&gt; (AI photo animation), &lt;a href="https://berecommended.com" rel="noopener noreferrer"&gt;Be Recommended&lt;/a&gt; (AI visibility tool), and &lt;a href="https://vibecoderi.cz" rel="noopener noreferrer"&gt;Vibe Codéři&lt;/a&gt; (a Czech vibecoding portal).&lt;/p&gt;

&lt;h2&gt;
  
  
  The core problem: React SPAs are invisible by default
&lt;/h2&gt;

&lt;p&gt;When Googlebot hits a Lovable app, it sees a mostly-empty HTML shell. The content loads via JavaScript, and while Google claims to render JS, the reality is inconsistent. Some pages get indexed, others don't, and you have no idea why.&lt;/p&gt;

&lt;p&gt;The fix isn't one thing — it's a checklist of small wins that add up.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Generate a real sitemap from your database
&lt;/h2&gt;

&lt;p&gt;Lovable apps typically store content in Supabase. Your blog posts, product pages, landing pages — they all live in the database, but there's no sitemap telling Google about them.&lt;/p&gt;

&lt;p&gt;The fix: create a Supabase Edge Function (or a Lovable Cloud function) that queries your content tables and returns a proper XML sitemap.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Query all published pages/posts from your Supabase tables&lt;/li&gt;
&lt;li&gt;Build XML with &lt;code&gt;&amp;lt;url&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;loc&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;lastmod&amp;gt;&lt;/code&gt;, and &lt;code&gt;&amp;lt;changefreq&amp;gt;&lt;/code&gt; tags&lt;/li&gt;
&lt;li&gt;Set the response Content-Type to &lt;code&gt;application/xml&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Add the sitemap URL to your &lt;code&gt;robots.txt&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One gotcha I ran into: if you're using Supabase RLS (Row Level Security), make sure your Edge Function has the right permissions to read published content. I've had sitemaps return empty because the function was hitting RLS policies meant for authenticated users.&lt;/p&gt;

&lt;p&gt;After deploying the sitemap, submit it in Google Search Console. Don't just wait for Google to discover it — go to Sitemaps in GSC and submit the full URL manually.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Dynamic meta tags with a useSEO hook
&lt;/h2&gt;

&lt;p&gt;Out of the box, Lovable apps ship with a single set of meta tags in &lt;code&gt;index.html&lt;/code&gt;. Every page has the same title, description, and OG image. Google sees that as thin content — dozens of URLs with identical metadata.&lt;/p&gt;

&lt;p&gt;The fix: create a &lt;code&gt;useSEO&lt;/code&gt; custom hook that updates document metadata per page.&lt;/p&gt;

&lt;p&gt;What the hook should handle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;document.title&lt;/code&gt; — unique per page&lt;/li&gt;
&lt;li&gt;Meta description — via &lt;code&gt;document.querySelector('meta[name="description"]')&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Open Graph tags (&lt;code&gt;og:title&lt;/code&gt;, &lt;code&gt;og:description&lt;/code&gt;, &lt;code&gt;og:image&lt;/code&gt;, &lt;code&gt;og:url&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Twitter card tags&lt;/li&gt;
&lt;li&gt;Canonical URL (more on this below)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Call the hook at the top of every page component with page-specific values. For blog posts, pull the title and description from your Supabase data.&lt;/p&gt;

&lt;p&gt;The important thing: these changes happen client-side, so they only help with Google's JS rendering. For social media previews (Twitter, LinkedIn, Slack), you need server-side rendering or a prerender service. I've had mixed luck with prerender.io — it works, but adds latency. For most MVPs, client-side meta tags plus a good default OG image is enough to start.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Canonical URLs for multi-domain setups
&lt;/h2&gt;

&lt;p&gt;This one bit me hard. &lt;a href="https://zivafotka.cz" rel="noopener noreferrer"&gt;Živá Fotka&lt;/a&gt; runs on five domains (CZ, SK, PL, EN, DE) from a single codebase. Without canonical tags, Google was treating them as duplicate content and picking winners randomly.&lt;/p&gt;

&lt;p&gt;The fix: set a &lt;code&gt;&amp;lt;link rel="canonical"&amp;gt;&lt;/code&gt; tag that points to the primary URL for each piece of content.&lt;/p&gt;

&lt;p&gt;Rules I follow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Each domain's homepage canonical points to itself&lt;/li&gt;
&lt;li&gt;Shared content (like the main app page) canonicals point to the primary domain&lt;/li&gt;
&lt;li&gt;Blog posts canonical to whichever domain they were originally published on&lt;/li&gt;
&lt;li&gt;Use absolute URLs, never relative&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the &lt;code&gt;useSEO&lt;/code&gt; hook, I add canonical handling:&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="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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;link&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;link[rel="canonical"]&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;link&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;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;link&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rel&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;canonical&lt;/span&gt;&lt;span class="dl"&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;head&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;link&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setAttribute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;href&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canonicalUrl&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;canonicalUrl&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For multi-domain setups, also add &lt;code&gt;hreflang&lt;/code&gt; tags pointing to each language version. This tells Google which version to show in which country.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. JSON-LD structured data
&lt;/h2&gt;

&lt;p&gt;Google loves structured data. For a micro-SaaS, the most useful schemas are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;WebApplication&lt;/code&gt; — for the main product page&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Article&lt;/code&gt; or &lt;code&gt;BlogPosting&lt;/code&gt; — for blog posts&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;FAQPage&lt;/code&gt; — if you have an FAQ section&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Organization&lt;/code&gt; — for your company info&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I inject JSON-LD in the same &lt;code&gt;useSEO&lt;/code&gt; hook using a script tag with &lt;code&gt;type="application/ld+json"&lt;/code&gt;. The trick is to clean up old script tags when the component unmounts, otherwise you end up with duplicate structured data.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Pre-launch SEO checklist
&lt;/h2&gt;

&lt;p&gt;Before submitting any Lovable app to Google Search Console, I run through this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;robots.txt&lt;/code&gt; exists and doesn't block important paths&lt;/li&gt;
&lt;li&gt;Sitemap is generated, accessible, and submitted to GSC&lt;/li&gt;
&lt;li&gt;Every page has a unique title and meta description&lt;/li&gt;
&lt;li&gt;Canonical URLs are set on all pages&lt;/li&gt;
&lt;li&gt;OG tags are present (test with &lt;a href="https://opengraph.xyz" rel="noopener noreferrer"&gt;opengraph.xyz&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;JSON-LD validates (test with Google's &lt;a href="https://search.google.com/test/rich-results" rel="noopener noreferrer"&gt;Rich Results Test&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;No broken internal links&lt;/li&gt;
&lt;li&gt;Images have alt text&lt;/li&gt;
&lt;li&gt;Page loads under 3 seconds on mobile (Lovable apps are usually fast here)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;noindex&lt;/code&gt; isn't accidentally set anywhere&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The results
&lt;/h2&gt;

&lt;p&gt;After applying these fixes across our portfolio at &lt;a href="https://inithouse.com" rel="noopener noreferrer"&gt;Inithouse&lt;/a&gt;, the difference was measurable within weeks. Pages that were completely invisible started showing up in search. &lt;a href="https://hereweask.com" rel="noopener noreferrer"&gt;Here We Ask&lt;/a&gt; went from zero indexed pages to full coverage after the sitemap fix alone.&lt;/p&gt;

&lt;p&gt;The reality is that Lovable handles the hard parts of building a product — UI, database, auth, hosting. But SEO is still something you need to wire up yourself. The good news: once you've done it for one project, the pattern is the same for every new one.&lt;/p&gt;

&lt;p&gt;If you're building with Lovable and struggling with organic traffic, start with the sitemap and meta tags. Those two fixes alone will get you 80% of the way there.&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 AI-powered micro-SaaS products, all shipped with Lovable. Follow along for more builder notes.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>seo</category>
      <category>react</category>
      <category>webdev</category>
      <category>lovable</category>
    </item>
    <item>
      <title>How We Built a Conflict Resolver That Uses Psychology Frameworks as Prompts</title>
      <dc:creator>Jakub</dc:creator>
      <pubDate>Tue, 05 May 2026 10:15:36 +0000</pubDate>
      <link>https://dev.to/jakub_inithouse/how-we-built-a-conflict-resolver-that-uses-psychology-frameworks-as-prompts-3fdi</link>
      <guid>https://dev.to/jakub_inithouse/how-we-built-a-conflict-resolver-that-uses-psychology-frameworks-as-prompts-3fdi</guid>
      <description>&lt;p&gt;We're building &lt;a href="https://verdictbuddy.com" rel="noopener noreferrer"&gt;Verdict Buddy&lt;/a&gt; — an AI-powered conflict resolution tool that translates established psychology frameworks into structured prompts. Here's the technical story of how we got there and why prompt engineering for empathy turned out to be the hardest challenge we've tackled at &lt;a href="https://inithouse.cz" rel="noopener noreferrer"&gt;Inithouse&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem With "Just Talk It Out"
&lt;/h2&gt;

&lt;p&gt;Most conflict resolution advice boils down to "communicate better." But when you're in the middle of a heated argument — whether it's with your partner about finances, your roommate about dishes, or your coworker about project scope — your brain isn't exactly in problem-solving mode.&lt;/p&gt;

&lt;p&gt;We wanted to build something that could actually guide people through structured conflict resolution, the same way a therapist or mediator would. Not by replacing human connection, but by providing the framework when emotions are running high and you can't remember what your couples therapist said last Tuesday.&lt;/p&gt;

&lt;h2&gt;
  
  
  Four Frameworks, Three Modes
&lt;/h2&gt;

&lt;p&gt;We studied four established approaches to conflict resolution and translated each into prompt architectures:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Gottman Method&lt;/strong&gt; — focuses on "soft startups" and repair attempts. The prompt structure here emphasizes identifying the underlying need behind the complaint. Instead of "you never clean up," the AI guides toward "I need to feel like we're a team."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Emotionally Focused Therapy (EFT)&lt;/strong&gt; — works with attachment patterns. We built prompts that identify whether someone is pursuing or withdrawing, then scaffold responses that address the attachment need rather than the surface issue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Harvard Negotiation Project&lt;/strong&gt; — separates positions from interests. The prompt chain here is almost algorithmic: identify stated positions, dig for underlying interests, generate options for mutual gain, apply objective criteria.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nonviolent Communication (NVC)&lt;/strong&gt; — observation, feeling, need, request. This was actually the easiest to implement as prompts because it's already quite structured, but the hardest to make sound natural rather than robotic.&lt;/p&gt;

&lt;p&gt;Each framework maps to different conflict types. Gottman and EFT work best for intimate relationships. Harvard Negotiation is ideal for workplace or business disputes. NVC is the Swiss Army knife that works everywhere but requires the most prompt finessing to avoid sounding like a therapy textbook.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Three Modes
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://verdictbuddy.com" rel="noopener noreferrer"&gt;Verdict Buddy&lt;/a&gt; operates in three distinct modes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Solo Mode&lt;/strong&gt; — you describe the conflict from your perspective. The AI asks clarifying questions using the selected framework, then provides insights about what might be driving the other person's behavior and suggests specific language for your next conversation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Couple Mode&lt;/strong&gt; — both parties describe their perspective through a shared link. The AI synthesizes both views, identifies where the real disconnect is (often it's not what either person thinks), and proposes a path forward that addresses both people's underlying needs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Group Mode&lt;/strong&gt; — for team conflicts, family disputes, or roommate situations with more than two parties. The prompt architecture here is significantly more complex because you're tracking multiple attachment styles, interests, and communication patterns simultaneously.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Empathy Is Harder Than Image Generation
&lt;/h2&gt;

&lt;p&gt;Here's something that surprised us: getting an LLM to generate empathetic, framework-appropriate responses is significantly harder than most other prompt engineering tasks.&lt;/p&gt;

&lt;p&gt;The failure modes are subtle. An image generator either produces something that looks right or doesn't. But a conflict resolution response can sound reasonable while actually being harmful — validating one person's perspective in a way that dismisses the other, or using NVC language in a way that comes across as passive-aggressive rather than connecting.&lt;/p&gt;

&lt;p&gt;We spent weeks on what we call "empathy calibration" — making sure the AI doesn't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Take sides while appearing neutral&lt;/li&gt;
&lt;li&gt;Use therapeutic language that feels condescending&lt;/li&gt;
&lt;li&gt;Suggest solutions before both parties feel heard&lt;/li&gt;
&lt;li&gt;Default to compromise when the real issue is a boundary violation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The prompt engineering approach that finally worked was layering: the base prompt sets the framework constraints, a second layer adds the relational context (are these romantic partners? colleagues? family?), and a third layer handles tone calibration based on the emotional temperature of the conversation.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Learned About Prompt Architecture
&lt;/h2&gt;

&lt;p&gt;Building this taught us something about AI product development that I haven't seen discussed much: &lt;strong&gt;the hardest prompt engineering problems aren't about getting accurate outputs — they're about getting emotionally appropriate ones.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You can't unit test empathy the way you test factual accuracy. We ended up building our own evaluation rubric based on therapy research about what makes interventions effective vs. harmful. Each response gets scored on: validation (does both parties feel heard?), reframing (does it shift from blame to understanding?), actionability (is there a clear next step?), and safety (does it avoid reinforcing harmful dynamics?).&lt;/p&gt;

&lt;h2&gt;
  
  
  Current State
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://verdictbuddy.com" rel="noopener noreferrer"&gt;Verdict Buddy&lt;/a&gt; is live and we're actively collecting feedback on how people use the different modes and frameworks. Early signals suggest that Solo Mode is the most-used feature — people want to understand their own conflicts better before engaging the other party.&lt;/p&gt;

&lt;p&gt;If you're interested in the intersection of psychology and AI, or if you're building products that need emotionally intelligent outputs, I'd love to hear how you're approaching similar challenges.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm Jakub, building a portfolio of AI-powered products at &lt;a href="https://inithouse.cz" rel="noopener noreferrer"&gt;Inithouse&lt;/a&gt;. Verdict Buddy is one of about a dozen MVPs we're running simultaneously to find product-market fit.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>psychology</category>
      <category>promptengineering</category>
      <category>buildinpublic</category>
    </item>
    <item>
      <title>Whisper + Custom Prompts: Turning Messy Voice Into Structured Data</title>
      <dc:creator>Jakub</dc:creator>
      <pubDate>Tue, 05 May 2026 04:11:29 +0000</pubDate>
      <link>https://dev.to/jakub_inithouse/whisper-custom-prompts-turning-messy-voice-into-structured-data-3bdg</link>
      <guid>https://dev.to/jakub_inithouse/whisper-custom-prompts-turning-messy-voice-into-structured-data-3bdg</guid>
      <description>&lt;p&gt;The hardest part of voice-to-data isn't the transcription. It's making sense of someone thinking out loud.&lt;/p&gt;

&lt;p&gt;I've been building &lt;a href="https://voicetables.com" rel="noopener noreferrer"&gt;Voice Tables&lt;/a&gt; — a tool that lets you speak naturally and get structured spreadsheet rows back. No forms, no typing, just talk. Under the hood, it's a two-stage pipeline: Whisper handles the transcription, then a custom prompt chain extracts structured fields from the raw text.&lt;/p&gt;

&lt;p&gt;Here's how it actually works, and where things get tricky.&lt;/p&gt;

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

&lt;p&gt;The architecture is deceptively simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Voice recording → Whisper transcription → Prompt-based extraction → Structured row
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stage one (Whisper) is mostly a solved problem. Stage two — turning a messy human monologue into clean, column-mapped data — is where the real engineering lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Whisper Configuration
&lt;/h2&gt;

&lt;p&gt;For Voice Tables, I run Whisper with these considerations:&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;// Whisper config essentials&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;whisperConfig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;whisper-1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;language&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;// auto-detect — users speak CZ, EN, DE...&lt;/span&gt;
  &lt;span class="na"&gt;temperature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;// deterministic output&lt;/span&gt;
  &lt;span class="na"&gt;response_format&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;    &lt;span class="c1"&gt;// plain text, no timestamps needed&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things I learned the hard way:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Auto-detect language&lt;/strong&gt; works surprisingly well. When you're running a product across multiple markets, hardcoding &lt;code&gt;language: "en"&lt;/code&gt; breaks the moment a Czech user starts speaking. Let Whisper figure it out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Temperature 0&lt;/strong&gt; matters more than you'd think. Even small variations in transcription can cascade into extraction errors downstream. "Twenty three" vs "23" vs "twenty-three" — each triggers different parsing paths.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Model size tradeoffs&lt;/strong&gt;: The large model catches more edge cases (mumbling, background noise, accented speech) but adds latency. For a real-time-ish UX, the base model with a retry fallback to large works well.&lt;/p&gt;

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

&lt;p&gt;This is where it gets interesting. Raw Whisper output looks 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;"So I had a meeting with, uh, John from Acme Corp yesterday, 
it was about the Q2 renewal, they want to bump it up to 
fifty thousand annually, told him I'd get back by Friday"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And you need to extract:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Contact&lt;/th&gt;
&lt;th&gt;Company&lt;/th&gt;
&lt;th&gt;Topic&lt;/th&gt;
&lt;th&gt;Amount&lt;/th&gt;
&lt;th&gt;Deadline&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;John&lt;/td&gt;
&lt;td&gt;Acme Corp&lt;/td&gt;
&lt;td&gt;Q2 renewal&lt;/td&gt;
&lt;td&gt;$50,000/yr&lt;/td&gt;
&lt;td&gt;Friday&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The prompt template is column-aware — it knows what fields exist in the user's table and adapts extraction accordingly:&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;buildExtractionPrompt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;transcript&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;columnDefs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;columns&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`- &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;): &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;no description&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&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="s2"&gt;`Extract structured data from this voice transcript.

Target columns:
&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;columnDefs&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;

Rules:
1. Extract ONLY the columns listed above
2. If a value is ambiguous, use your best interpretation
3. If a value is missing from the transcript, use null
4. For dates, interpret relative references relative to today
5. For amounts, normalize to numbers ("fifty thousand" → 50000)
6. Return valid JSON array of objects

Transcript:
"&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;transcript&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"

Respond with ONLY the JSON array.`&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 key insight: &lt;strong&gt;column descriptions are the secret weapon&lt;/strong&gt;. When a user sets up their table with a "Company" column described as "the organization, not the person," extraction accuracy jumps dramatically. The prompt doesn't have to guess — it has context.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Validation Layer
&lt;/h2&gt;

&lt;p&gt;Garbage in, garbage out. Voice input is inherently messy, so the validation layer does heavy lifting:&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;validateExtraction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;extracted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;extracted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;clean&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{};&lt;/span&gt;

    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;col&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;columns&lt;/span&gt;&lt;span class="p"&gt;)&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;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

      &lt;span class="c1"&gt;// Type coercion&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseFloat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&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;/&lt;/span&gt;&lt;span class="se"&gt;[^&lt;/span&gt;&lt;span class="sr"&gt;0-9.-&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;isNaN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="c1"&gt;// Date normalization&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;date&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseRelativeDate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="c1"&gt;// Flag missing required fields&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;required&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_warnings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_warnings&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
        &lt;span class="nx"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;_warnings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Missing required: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;

      &lt;span class="nx"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;col&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;_warnings&lt;/code&gt; array is surfaced in the UI so users can quickly spot and fix extractions that need human review. You don't want a tool that silently produces wrong data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Edge Cases That Will Break You
&lt;/h2&gt;

&lt;p&gt;After processing thousands of voice entries across &lt;a href="https://voicetables.com" rel="noopener noreferrer"&gt;Voice Tables&lt;/a&gt;, here are the edge cases that consumed the most debugging time:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Homophony.&lt;/strong&gt; "Their" vs "there" vs "they're" — Whisper usually gets it right, but when it doesn't and you're extracting a company name, you get phantom entries. The fix: fuzzy matching against known entities when the column has existing data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Partial sentences.&lt;/strong&gt; People pause, restart, contradict themselves. "The price is... actually no, make it twelve hundred... wait, fifteen hundred." The prompt needs explicit instructions: "If the speaker corrects themselves, use the final stated value."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Number ambiguity.&lt;/strong&gt; "I need to order one fifty." Is that 1.50? 150? One unit of item #50? Context from column type and description helps, but this is genuinely hard. We added a confirmation step for ambiguous numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Multi-row entries.&lt;/strong&gt; Sometimes one recording contains data for multiple rows. "I talked to John about project Alpha and then Maria about project Beta." The extraction prompt handles this — it returns an array — but users are often surprised when one voice note creates two rows.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;p&gt;If I were starting Voice Tables from scratch:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Start with constrained input.&lt;/strong&gt; Let users define expected patterns ("I met [person] from [company] about [topic]") before going fully freeform. Constrained extraction is 10x more reliable.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Build the feedback loop earlier.&lt;/strong&gt; Users correcting extractions is the best training signal. Every correction should tune the extraction prompt for that specific table.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Batch processing matters.&lt;/strong&gt; The initial version processed one recording at a time. Batching multiple short recordings with shared context (same meeting, same project) dramatically improves extraction quality.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;

&lt;p&gt;For anyone building something similar, here's what powers this at &lt;a href="https://inithouse.com" rel="noopener noreferrer"&gt;Inithouse&lt;/a&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Whisper API&lt;/strong&gt; (OpenAI) for transcription&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPT-4o&lt;/strong&gt; for extraction (structured outputs mode)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;React + Supabase&lt;/strong&gt; frontend and storage (built with &lt;a href="https://lovable.dev" rel="noopener noreferrer"&gt;Lovable&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge functions&lt;/strong&gt; for the processing pipeline&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The whole thing runs surprisingly lean. Most of the complexity is in prompt engineering, not infrastructure.&lt;/p&gt;




&lt;p&gt;Voice-to-structured-data is one of those problems that sounds simple until you actually build it. The transcription part is essentially commoditized. The real challenge is bridging the gap between how humans think out loud and how databases expect data to arrive.&lt;/p&gt;

&lt;p&gt;If you're working on something similar, I'd love to hear what approaches you've tried. Drop a comment or find me on &lt;a href="https://dev.to/jakub_inithouse"&gt;Dev.to&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>whisper</category>
      <category>javascript</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
