<?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: Vishal Chauhan</title>
    <description>The latest articles on DEV Community by Vishal Chauhan (@siteory).</description>
    <link>https://dev.to/siteory</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4172824%2F52f514e0-5c5a-408e-b644-1df812518d0b.png</url>
      <title>DEV Community: Vishal Chauhan</title>
      <link>https://dev.to/siteory</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/siteory"/>
    <language>en</language>
    <item>
      <title>How to Find Keywords Your New SaaS Can Actually Rank For: A 7-Step Free Keyword Research Guide for Beginners</title>
      <dc:creator>Vishal Chauhan</dc:creator>
      <pubDate>Sun, 25 Oct 2026 11:29:30 +0000</pubDate>
      <link>https://dev.to/siteory/how-to-find-keywords-your-new-saas-can-actually-rank-for-a-7-step-free-keyword-research-guide-for-28ib</link>
      <guid>https://dev.to/siteory/how-to-find-keywords-your-new-saas-can-actually-rank-for-a-7-step-free-keyword-research-guide-for-28ib</guid>
      <description>&lt;p&gt;In September 2026, a founder posted on r/SaaS that Google was not ranking their new site even though they had done the "basic SEO" work: meta titles, descriptions, indexing. Search Console showed 159 impressions and 1 click in 28 days, with an average position of 48. One of the most useful replies pointed out that people only search an app's name once they already know it; the real demand sits one step earlier, in someone describing the job they want done.&lt;/p&gt;

&lt;p&gt;That is the core problem with keyword research for a first SaaS. If you built your app with Cursor, Claude Code, Lovable, Bolt, Replit or v0, you probably wrote pages about your product, in your words. Customers type their problem, in their words. This guide shows you how to find those words with free tools, check which ones a brand-new site can win, and turn them into a short list of pages to write.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick glossary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keyword (or query):&lt;/strong&gt; the exact words someone types into Google, like "how to send invoice reminders".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search intent:&lt;/strong&gt; what the person actually wants. To learn something, compare options, buy something, or go to a specific site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Search volume:&lt;/strong&gt; roughly how many times per month people search a keyword. Every tool estimates this differently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keyword difficulty (KD):&lt;/strong&gt; a score from a tool that guesses how hard it is to reach page one. It is a guess, not a rule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long-tail keyword:&lt;/strong&gt; a longer, more specific search with fewer searches, like "late payment reminder email template for freelancers". Easier for new sites.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SERP:&lt;/strong&gt; search engine results page. The page Google shows after you search.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Impressions:&lt;/strong&gt; how many times your site appeared in Google results, even if nobody clicked. You see this in Google Search Console.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Write down your customers' problems in their words
&lt;/h2&gt;

&lt;p&gt;Do not open a keyword tool yet. Start a text file of phrases real people use to describe the problem your app solves. Look in:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your support emails, chat messages and signup survey answers.&lt;/li&gt;
&lt;li&gt;Reddit threads where your users hang out. Search for the problem, not your product category.&lt;/li&gt;
&lt;li&gt;Posts and replies on X complaining about the problem.&lt;/li&gt;
&lt;li&gt;1-star to 3-star reviews of bigger competitors. Complaints are full of search phrases.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Copy phrases exactly. This guide uses a &lt;strong&gt;hypothetical&lt;/strong&gt; example, an invoice tool for freelancers, whose notes might contain "client keeps paying late", "how do I remind a client to pay without sounding rude" and "invoice template that adds tax automatically".&lt;/p&gt;

&lt;p&gt;An AI assistant can pull phrases out of messy notes. Just do not let it invent data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Below are raw notes from support emails, Reddit threads and reviews about
[PROBLEM YOUR APP SOLVES]. My customers are [WHO THEY ARE].

1. Extract every distinct phrase a customer used to describe their problem
   or goal. Keep their original wording.
2. Add 10 more phrases a beginner in this audience might type into Google,
   and mark these clearly as "AI guess".
3. Do NOT include search volumes, difficulty scores or any numbers.
   I will check demand myself with real tools.

Notes:
[PASTE NOTES HERE]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Aim for 20 to 40 phrases. These are your seed keywords.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Expand your seeds with free tools
&lt;/h2&gt;

&lt;p&gt;Now check what people actually type. All of these are free as of October 2026:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Google autocomplete:&lt;/strong&gt; type a seed into Google slowly and note the suggestions. Google's help page says predictions reflect real searches, but they also depend on your language, location and past searches. Use a private window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;People Also Ask and related searches:&lt;/strong&gt; the question box on the results page and the related searches near the bottom. Each question is a possible section or page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google Trends:&lt;/strong&gt; compare two or three phrases to see which is more popular and whether interest is growing. Google says Trends shows relative interest scaled from 0 to 100, not search counts, and very low-volume terms show as 0. So "0" does not always mean nobody searches it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Google Keyword Planner:&lt;/strong&gt; free inside a Google Ads account. Google's help says you must enter billing information to use "Discover new keywords", but you do not need to run a campaign. Expect volume ranges like "100-1K" instead of exact numbers if you are not spending; Google introduced this limited view for lower-spend accounts, and Google Ads community experts still describe it the same way. Ranges are fine for comparing ideas.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bing Webmaster Tools Keyword Research:&lt;/strong&gt; free with a Bing Webmaster Tools account. Enter a root keyword and get related keywords, question keywords and the top ranking URLs, filtered by country, language and device for any period in the last six months. The numbers are Bing data, so use them to compare phrases, not to predict Google traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ahrefs Free Keyword Generator:&lt;/strong&gt; free, no account. Enter a seed and get keyword ideas, including questions, with estimated volumes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Semrush free account:&lt;/strong&gt; 10 keyword research queries per day, no credit card, per Semrush's own free-account page. Enough to check a shortlist.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Add everything relevant. In the hypothetical invoice example, you might add "how to politely ask for payment" and "what to do when a client doesn't pay". A list of 50 to 150 phrases is normal; the next steps cut it down.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Check search intent by looking at the results page
&lt;/h2&gt;

&lt;p&gt;A keyword tool cannot tell you what Google thinks the searcher wants. The results page can. For each promising phrase, search it in a private window and look at the top 10 results:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Mostly blog posts and guides? The intent is &lt;strong&gt;learn&lt;/strong&gt;. You need a helpful article.&lt;/li&gt;
&lt;li&gt;Mostly "best X tools" lists? The intent is &lt;strong&gt;compare&lt;/strong&gt;. You need a list or comparison page.&lt;/li&gt;
&lt;li&gt;Mostly product homepages and pricing pages? The intent is &lt;strong&gt;buy&lt;/strong&gt;. Your landing or feature page fits.&lt;/li&gt;
&lt;li&gt;Mostly templates, calculators or generators? People want a &lt;strong&gt;tool or template&lt;/strong&gt;, not an essay.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Write the intent next to each keyword. If Google shows templates and you planned a long essay, the essay will struggle to rank.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Judge whether a new site can compete
&lt;/h2&gt;

&lt;p&gt;Most beginners skip this. A brand-new domain has no history and few links, so it cannot beat huge sites on broad terms. On the same results page, look for these signals.&lt;/p&gt;

&lt;p&gt;Good signs (opportunity):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reddit, Quora or forum threads in the top 10. Google could not find a great dedicated page.&lt;/li&gt;
&lt;li&gt;Small blogs, personal sites or tiny tools ranking.&lt;/li&gt;
&lt;li&gt;Results that only half answer the query or are outdated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Bad signs (skip for now):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every result is a big brand, a huge publisher or a well-known tool.&lt;/li&gt;
&lt;li&gt;The top results are recent, excellent guides that answer everything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Treat keyword difficulty as a hint; the SERP is the real test. Mark each keyword "go", "maybe" or "skip".&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Pick long-tail problem keywords and map one main keyword per page
&lt;/h2&gt;

&lt;p&gt;Now pick your first 5 to 10 pages. Choose keywords that are:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Marked "go" in Step 4.&lt;/li&gt;
&lt;li&gt;Close to your product. Someone searching it has the problem you solve.&lt;/li&gt;
&lt;li&gt;Specific. Long-tail problem phrases beat broad category words for a new site.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For the hypothetical invoice tool, "invoicing software" is a skip (big brands everywhere), while "payment reminder email template for freelancers" could be a go if forums and small blogs rank.&lt;/p&gt;

&lt;p&gt;Give each page &lt;strong&gt;one main keyword&lt;/strong&gt; and a few close variations. If two keywords show almost the same top 10 results, they belong on the same page. If the results are different, they need separate pages.&lt;/p&gt;

&lt;p&gt;AI can draft this grouping if you give it your real data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Here is my keyword list with the intent and the "go/maybe/skip" verdict I
noted from looking at Google's results myself:
[PASTE ROWS: keyword | intent | verdict | volume range if I have one]

Group these into page ideas:
- One main keyword per page, plus up to 5 close variations.
- Put keywords together only if they share the same intent.
- Suggest a page type (guide, template, comparison, landing page).
- Ignore anything marked "skip".
- Do NOT invent or estimate search volumes or difficulty. Use only the
  numbers I gave you; leave blanks blank.
Output a table: page idea | main keyword | variations | page type | why.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check its work against the real results pages, then keep everything in one sheet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;keyword,source,intent,verdict,volume_range,main_or_variation,target_page,status,date_published,gsc_impressions_28d,gsc_avg_position
payment reminder email template for freelancers,autocomplete,learn/template,go,(from your tool),main,/templates/payment-reminder-email,draft,,,
how to politely ask a client for payment,people also ask,learn,go,(from your tool),variation,/templates/payment-reminder-email,draft,,,
invoicing software,keyword planner,buy,skip,(from your tool),,,,,,
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rows above are hypothetical. Fill in volume ranges from Keyword Planner, Bing or another tool, and leave the Search Console columns empty until Step 7.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Write each page to answer the query
&lt;/h2&gt;

&lt;p&gt;You are not writing "SEO content". You are writing the best answer to one question. A simple checklist for each page:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PAGE CHECKLIST
[ ] Main keyword (or a natural version of it) in the title and the H1
[ ] First 2-3 sentences answer the question directly
[ ] Format matches what Google shows (template, steps, list, tool)
[ ] Covers the People Also Ask questions for this keyword
[ ] Includes something real: a screenshot, an example, your own steps
[ ] Clear, specific meta description written for humans
[ ] Links to 1-2 related pages on your site, and from your homepage or blog
[ ] Soft, relevant mention of your product where it actually helps
[ ] Page is indexable (not blocked, not noindex) and in your sitemap
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If AI drafts the page, give it your notes, real examples and the questions you collected, then edit hard. A page that sounds like every other AI article adds nothing new.&lt;/p&gt;

&lt;p&gt;For the hypothetical invoice tool, the reminder page would open with a ready-to-copy template, add first, second and final reminder versions, and only then mention the app can send them automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Measure in Google Search Console and iterate
&lt;/h2&gt;

&lt;p&gt;Wait a few weeks after publishing; new pages take time to be crawled. Then open Google Search Console, go to &lt;strong&gt;Performance&lt;/strong&gt;, and use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Queries tab:&lt;/strong&gt; the actual searches where your site appeared. Google's help explains you can switch metrics on for clicks, impressions, CTR and average position.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pages tab:&lt;/strong&gt; click a page, then open Queries to see which searches it shows up for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Date range:&lt;/strong&gt; the default is the last three months. Compare the last 28 days with the previous period.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What to look for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Queries with impressions but position 8 to 20: improve that page. Add the missing section, make the answer clearer, link to it from other pages.&lt;/li&gt;
&lt;li&gt;Queries you never planned for: these are new keyword ideas from real data. Add them to your sheet.&lt;/li&gt;
&lt;li&gt;A page with zero impressions after several weeks: check it is indexed, then check the intent again in Step 3.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Update the sheet once a month and pick your next batch of pages from what you learn.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Starting with your product category.&lt;/strong&gt; Broad category words are owned by big brands. Start with problem phrases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trusting one volume number.&lt;/strong&gt; Every tool estimates differently, and Keyword Planner often shows only ranges. Use numbers to compare, not to predict traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Skipping the results page.&lt;/strong&gt; Intent and competition are visible in the SERP, not in a spreadsheet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Letting AI invent volumes.&lt;/strong&gt; If an AI gives you exact search numbers without a data source, they are made up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One keyword per blog post, ten posts on the same topic.&lt;/strong&gt; Similar keywords with the same results belong on one strong page.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Giving up after two weeks.&lt;/strong&gt; New sites need time.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Your action summary
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Collect 20 to 40 customer phrases from support emails, Reddit, X and competitor reviews.&lt;/li&gt;
&lt;li&gt;Expand them with autocomplete, People Also Ask, Trends, Keyword Planner, Bing and one free keyword generator.&lt;/li&gt;
&lt;li&gt;Search every promising phrase and write down the intent.&lt;/li&gt;
&lt;li&gt;Mark each keyword "go", "maybe" or "skip" based on who ranks.&lt;/li&gt;
&lt;li&gt;Pick 5 to 10 long-tail problem keywords and give each page one main keyword.&lt;/li&gt;
&lt;li&gt;Write each page to answer the query in the format Google already shows.&lt;/li&gt;
&lt;li&gt;Check Search Console Performance after a few weeks and plan the next batch.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You do not need an expensive SEO tool to find your first keywords. You need your customers' words, an honest look at the results page and one small spreadsheet, so pick five problem phrases today and write the page that answers the first one this week.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>saas</category>
      <category>beginners</category>
      <category>marketing</category>
    </item>
    <item>
      <title>How to Add Security Headers to Your AI-Built App: A 7-Step Beginner Guide to HTTPS and CSP</title>
      <dc:creator>Vishal Chauhan</dc:creator>
      <pubDate>Sun, 25 Oct 2026 06:29:04 +0000</pubDate>
      <link>https://dev.to/siteory/how-to-add-security-headers-to-your-ai-built-app-a-7-step-beginner-guide-to-https-and-csp-381p</link>
      <guid>https://dev.to/siteory/how-to-add-security-headers-to-your-ai-built-app-a-7-step-beginner-guide-to-https-and-csp-381p</guid>
      <description>&lt;p&gt;Your app is live, people are signing up, and then someone runs a free security scanner on it and sends you a screenshot full of red: "Missing Content-Security-Policy. Missing X-Frame-Options. Grade: D."&lt;/p&gt;

&lt;p&gt;You are not alone. In October 2026, the team behind the ShipSniffer scanner ran a passive scan on 99 live apps launched on Show HN (mostly built with Lovable, Bolt, Replit and Cursor). By their count, 91% had no Content-Security-Policy and 88% had no protection against being framed by another site. They call their numbers indicative, since automated scans make mistakes, but the pattern is clear: AI builders get you to a live URL and skip this step.&lt;/p&gt;

&lt;p&gt;The fix is mostly copy-paste. Security headers are a few lines of config that tell the browser how strict to be with your pages. This guide gives you 7 steps, with configs for Vercel, Next.js and Netlify, a note for Lovable and Bolt, and a prompt for your AI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick glossary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Response header:&lt;/strong&gt; a small line of text your server sends along with each page, like &lt;code&gt;X-Content-Type-Options: nosniff&lt;/code&gt;. Visitors never see it, but the browser follows it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HSTS (Strict-Transport-Security):&lt;/strong&gt; tells the browser "only ever load this site over HTTPS, for the next N seconds."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CSP (Content-Security-Policy):&lt;/strong&gt; a list of where your page is allowed to load scripts, styles, images and connections from. If an attacker manages to inject a script, a good CSP can stop it from running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Report-only mode:&lt;/strong&gt; a way to test a CSP without blocking anything. The browser just complains in the console.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Clickjacking:&lt;/strong&gt; another site loads your app invisibly in an iframe and tricks users into clicking your buttons. &lt;code&gt;frame-ancestors&lt;/code&gt; and &lt;code&gt;X-Frame-Options&lt;/code&gt; stop this.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One honest note before we start: headers are a safety net, not a lock. They do not fix a database with no access rules or a secret key in your frontend. If you use Supabase, do the &lt;a href="https://dev.to/siteory/your-vibe-coded-app-might-be-leaking-user-data-heres-how-to-lock-down-supabase-in-30-minutes-327k"&gt;Row Level Security lockdown&lt;/a&gt; first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Check what your site sends today
&lt;/h2&gt;

&lt;p&gt;Many hosts already send some headers, so look first. Open a terminal and run this against your live URL (not the editor preview):&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;-sI&lt;/span&gt; https://yourapp.com | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s2"&gt;"strict-transport|content-security|x-frame|x-content-type|referrer-policy|permissions-policy"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;curl -I&lt;/code&gt; asks for the headers only. The &lt;code&gt;grep&lt;/code&gt; part filters the list down to the six headers this guide covers. No output means none of them are set.&lt;/p&gt;

&lt;p&gt;Some real defaults, so you know what to expect:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Vercel's docs say custom domains get &lt;code&gt;Strict-Transport-Security: max-age=63072000;&lt;/code&gt; automatically, and &lt;code&gt;.vercel.app&lt;/code&gt; URLs get a preloaded version.&lt;/li&gt;
&lt;li&gt;Netlify says it redirects HTTP to HTTPS and sends HSTS on its &lt;code&gt;netlify.app&lt;/code&gt; subdomains.&lt;/li&gt;
&lt;li&gt;When I ran curl on a &lt;code&gt;lovable.app&lt;/code&gt; response and a &lt;code&gt;bolt.host&lt;/code&gt; app on October 25, 2026, both already sent HSTS, &lt;code&gt;X-Content-Type-Options: nosniff&lt;/code&gt; and &lt;code&gt;Referrer-Policy: strict-origin-when-cross-origin&lt;/code&gt;. The Bolt app also sent a minimal CSP. Neither sent a full CSP or frame protection. Platform defaults change, so check your own app.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Write down what you found:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Header check (date: ____ )   URL: ____________________
Strict-Transport-Security:  yes / no   value: ________
Content-Security-Policy:    yes / no / report-only
X-Frame-Options or frame-ancestors: yes / no
X-Content-Type-Options:     yes / no
Referrer-Policy:            yes / no   value: ________
Permissions-Policy:         yes / no
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: Add the four easy headers
&lt;/h2&gt;

&lt;p&gt;These four almost never break anything, so ship them first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
X-Frame-Options: DENY
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What each one does, in plain words:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;nosniff&lt;/strong&gt; stops the browser from guessing a file's type. MDN notes it makes the browser block a script or stylesheet whose content type doesn't match. It is the only valid value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;strict-origin-when-cross-origin&lt;/strong&gt; sends other sites only your domain (not the full page URL) when someone clicks a link out. It's already the browser default, but setting it stops scanners flagging it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permissions-Policy&lt;/strong&gt; with &lt;code&gt;()&lt;/code&gt; turns a feature off for your page and every iframe in it. Only list features you don't use. If your app uses the camera, leave &lt;code&gt;camera&lt;/code&gt; out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;X-Frame-Options: DENY&lt;/strong&gt; blocks other sites from framing you. Use &lt;code&gt;SAMEORIGIN&lt;/code&gt; if your own site needs to iframe its own pages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Vercel (any framework)&lt;/strong&gt; - create &lt;code&gt;vercel.json&lt;/code&gt; in the project root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/(.*)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"X-Content-Type-Options"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"nosniff"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Referrer-Policy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"strict-origin-when-cross-origin"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Permissions-Policy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"camera=(), microphone=(), geolocation=()"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"X-Frame-Options"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DENY"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Next.js&lt;/strong&gt; - you can use &lt;code&gt;headers()&lt;/code&gt; in &lt;code&gt;next.config.js&lt;/code&gt; instead:&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="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;exports&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="nf"&gt;headers&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;{&lt;/span&gt;
        &lt;span class="na"&gt;source&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/:path*&lt;/span&gt;&lt;span class="dl"&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-Content-Type-Options&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nosniff&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;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Referrer-Policy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;strict-origin-when-cross-origin&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;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Permissions-Policy&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;camera=(), microphone=(), geolocation=()&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;key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-Frame-Options&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DENY&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
      &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Netlify&lt;/strong&gt; - add a plain text file named &lt;code&gt;_headers&lt;/code&gt; to your publish folder. For a Vite app, put it in &lt;code&gt;public/&lt;/code&gt; so the build copies it into &lt;code&gt;dist/&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/*
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
  Permissions-Policy: camera=(), microphone=(), geolocation=()
  X-Frame-Options: DENY
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One Netlify catch from its docs: these custom headers don't apply to responses from functions, edge functions or server-rendered pages. Those need to set headers in code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Get HSTS right (and skip "preload" for now)
&lt;/h2&gt;

&lt;p&gt;HSTS is hard to undo. Once a browser sees &lt;code&gt;max-age=63072000&lt;/code&gt; (two years, in seconds), it refuses plain HTTP for your domain for that long.&lt;/p&gt;

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

&lt;ol&gt;
&lt;li&gt;If your host already sends HSTS (Step 1), leave it alone. A Netlify forum thread shows that adding your own can produce two HSTS headers, which can confuse browsers and fails the preload check.&lt;/li&gt;
&lt;li&gt;If you must add it, only do so once every page and subdomain works on HTTPS:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Strict-Transport-Security: max-age=63072000; includeSubDomains
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Leave out &lt;code&gt;preload&lt;/code&gt; unless you've read hstspreload.org. Netlify's docs warn that preloading "is not easily reversible." &lt;code&gt;includeSubDomains&lt;/code&gt; also covers things like &lt;code&gt;blog.yourapp.com&lt;/code&gt;, so make sure they all have HTTPS too.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Step 4: Start your CSP in report-only mode
&lt;/h2&gt;

&lt;p&gt;A CSP is the most useful header and the easiest to get wrong. A strict policy pasted blindly will block your analytics, your fonts, your Supabase calls and maybe your whole app. So you test it first with &lt;code&gt;Content-Security-Policy-Report-Only&lt;/code&gt;, which MDN describes as monitoring violations "without enforcing" them. Vercel's own security headers guide recommends the same start.&lt;/p&gt;

&lt;p&gt;A beginner starter policy (one line in the real header):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://YOUR-PROJECT.supabase.co wss://YOUR-PROJECT.supabase.co; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Swap &lt;code&gt;YOUR-PROJECT&lt;/code&gt; for your Supabase project ID, or delete it if you don't use Supabase. The &lt;code&gt;wss://&lt;/code&gt; part is for realtime connections.&lt;/p&gt;

&lt;p&gt;Add it with the same config file as Step 2. Then:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Deploy, open your live app in Chrome, and open DevTools (right-click, Inspect, Console tab).&lt;/li&gt;
&lt;li&gt;Click through everything: sign up, log in, pay, upload, every main screen.&lt;/li&gt;
&lt;li&gt;Look for messages starting with &lt;code&gt;[Report Only] Refused to...&lt;/code&gt;. Each one names a blocked source and which directive blocked it.&lt;/li&gt;
&lt;li&gt;Add each legit source to the right directive. For example, if Google Fonts shows up, add &lt;code&gt;https://fonts.googleapis.com&lt;/code&gt; to &lt;code&gt;style-src&lt;/code&gt; and &lt;code&gt;https://fonts.gstatic.com&lt;/code&gt; to &lt;code&gt;font-src&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Hypothetical example: your console says a script from &lt;code&gt;https://plausible.io&lt;/code&gt; was refused under &lt;code&gt;script-src&lt;/code&gt;. You'd add &lt;code&gt;https://plausible.io&lt;/code&gt; to &lt;code&gt;script-src&lt;/code&gt;, and probably to &lt;code&gt;connect-src&lt;/code&gt; too, since analytics scripts send data back.&lt;/p&gt;

&lt;p&gt;Chrome may also warn that a report-only policy has no reporting endpoint. That's fine here; the console is your report. (MDN covers the &lt;code&gt;report-to&lt;/code&gt; setup if you want reports emailed or logged later.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next.js heads-up:&lt;/strong&gt; Next.js injects small inline scripts, so &lt;code&gt;script-src 'self'&lt;/code&gt; will report a lot. The official Next.js CSP guide offers two paths: &lt;code&gt;'unsafe-inline'&lt;/code&gt; in &lt;code&gt;next.config.js&lt;/code&gt; (simpler, weaker), or nonces generated per request in &lt;code&gt;proxy.ts&lt;/code&gt; (stronger, but forces dynamic rendering). In local dev, React also needs &lt;code&gt;'unsafe-eval'&lt;/code&gt;. Production does not.&lt;/p&gt;

&lt;p&gt;Also note: a CSP in a &lt;code&gt;&amp;lt;meta&amp;gt;&lt;/code&gt; tag can't do report-only mode or &lt;code&gt;frame-ancestors&lt;/code&gt;, according to MDN. Use real headers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Switch CSP to enforce mode
&lt;/h2&gt;

&lt;p&gt;After a few days with no new console messages during normal use, rename the header from &lt;code&gt;Content-Security-Policy-Report-Only&lt;/code&gt; to &lt;code&gt;Content-Security-Policy&lt;/code&gt;. Keep the same value.&lt;/p&gt;

&lt;p&gt;Then:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Deploy and click through the full flow again, watching for anything that breaks.&lt;/li&gt;
&lt;li&gt;If something breaks, add the missing source. Don't "fix" it with &lt;code&gt;*&lt;/code&gt; or &lt;code&gt;'unsafe-eval'&lt;/code&gt;; Vercel's guide warns against both.&lt;/li&gt;
&lt;li&gt;Keep &lt;code&gt;X-Frame-Options: DENY&lt;/code&gt; from Step 2. Modern browsers use &lt;code&gt;frame-ancestors 'none'&lt;/code&gt;, and the Next.js docs call X-Frame-Options superseded by it, but keeping both is harmless and covers old browsers.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you need your app embedded somewhere (say, a widget on a customer's site), change &lt;code&gt;frame-ancestors 'none'&lt;/code&gt; to &lt;code&gt;frame-ancestors 'self' https://customer-site.com&lt;/code&gt; and drop &lt;code&gt;X-Frame-Options: DENY&lt;/code&gt; for that path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Lovable, Bolt and other hosted builders
&lt;/h2&gt;

&lt;p&gt;I couldn't find a custom response header setting in Lovable's hosting docs, and third-party reviews of Bolt hosting report the same (checked October 2026). Your options:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Accept the defaults&lt;/strong&gt; for a prototype. Step 1 shows they already cover HTTPS, HSTS and nosniff.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy the code to Vercel or Netlify.&lt;/strong&gt; Lovable's docs explain how to deploy its projects to Vercel, Netlify or Cloudflare Pages, and Bolt still offers a Netlify option. Then use the Step 2 configs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put Cloudflare in front of your custom domain&lt;/strong&gt; and add headers with a Response Header Transform Rule.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's a prompt for Cursor, Claude Code or any AI coding tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Add HTTP security headers to this app. We deploy on [Vercel / Netlify / other].
1. Tell me which file to use (vercel.json, next.config.js, or public/_headers) and why.
2. Add: X-Content-Type-Options nosniff, Referrer-Policy strict-origin-when-cross-origin,
   Permissions-Policy disabling camera, microphone and geolocation (unless we use them -
   search the code first), X-Frame-Options DENY.
3. Do NOT add Strict-Transport-Security if our host already sends it.
4. Add a Content-Security-Policy-Report-Only header (not enforced). List every external
   domain the code loads scripts, styles, fonts, images or API calls from, and include
   them in the right directives. Do not use * or 'unsafe-eval'.
5. Change only the config file. Show me the diff and a curl command to verify.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 7: Verify, then re-check after big changes
&lt;/h2&gt;

&lt;p&gt;After each deploy:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Re-run the curl command from Step 1 on your homepage and one logged-in page.&lt;/li&gt;
&lt;li&gt;Run a free scanner: &lt;a href="https://securityheaders.com/" rel="noopener noreferrer"&gt;securityheaders.com&lt;/a&gt; or &lt;a href="https://developer.mozilla.org/en-US/observatory" rel="noopener noreferrer"&gt;Mozilla's HTTP Observatory&lt;/a&gt;. Both grade your headers and explain what's missing.&lt;/li&gt;
&lt;li&gt;Do one last full click-through.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Disclosure: I work on Siteory. Its full audit (you get one free with a verified account at siteory.co) checks these same headers on your HTML page, including weak CSP values like &lt;code&gt;unsafe-inline&lt;/code&gt;, and gives you a fix prompt to paste into your AI tool. As its own methodology page says, header work rarely moves rankings, and a scan is not a penetration test.&lt;/p&gt;

&lt;p&gt;Re-check whenever you add a new script, analytics tool, payment provider or CDN, since each one can trip a working CSP.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pasting a strict CSP straight into enforce mode.&lt;/strong&gt; Blank pages and broken logins. Always start report-only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testing the editor preview instead of the live URL.&lt;/strong&gt; Previews often run on a different domain with different headers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adding HSTS preload on day one.&lt;/strong&gt; Hard to undo. Skip &lt;code&gt;preload&lt;/code&gt; until you're sure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixing CSP errors with &lt;code&gt;*&lt;/code&gt; or &lt;code&gt;'unsafe-eval'&lt;/code&gt;.&lt;/strong&gt; That turns the policy back into a sign that says "anything goes."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Using a &lt;code&gt;&amp;lt;meta&amp;gt;&lt;/code&gt; CSP and thinking you're covered against clickjacking.&lt;/strong&gt; &lt;code&gt;frame-ancestors&lt;/code&gt; only works as a real header.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disabling features you use.&lt;/strong&gt; &lt;code&gt;camera=()&lt;/code&gt; will break your video-call feature. Search your code first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assuming headers replace real security.&lt;/strong&gt; They don't fix missing database rules or leaked keys.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Your action summary
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Run &lt;code&gt;curl -sI&lt;/code&gt; on your live URL and fill in the header check.&lt;/li&gt;
&lt;li&gt;Add nosniff, Referrer-Policy, Permissions-Policy and X-Frame-Options with your host's config.&lt;/li&gt;
&lt;li&gt;Leave HSTS to your host if it already sends it. No &lt;code&gt;preload&lt;/code&gt; yet.&lt;/li&gt;
&lt;li&gt;Add a CSP in report-only mode and fix every console warning.&lt;/li&gt;
&lt;li&gt;Switch to enforced CSP after a few clean days.&lt;/li&gt;
&lt;li&gt;On Lovable or Bolt, keep the defaults or deploy the code where you control headers.&lt;/li&gt;
&lt;li&gt;Verify with curl and a free scanner, and re-check after every new integration.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Most of this is one config file and one afternoon. Do the four easy headers today, start the report-only CSP tonight, and that red scanner screenshot turns into something you'd be happy to share.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>beginners</category>
      <category>vibecoding</category>
    </item>
    <item>
      <title>How to Find Out Where Your SaaS Signups Come From: A 7-Step Beginner Guide to Simple Analytics</title>
      <dc:creator>Vishal Chauhan</dc:creator>
      <pubDate>Sat, 24 Oct 2026 11:31:06 +0000</pubDate>
      <link>https://dev.to/siteory/how-to-find-out-where-your-saas-signups-come-from-a-7-step-beginner-guide-to-simple-analytics-4dkp</link>
      <guid>https://dev.to/siteory/how-to-find-out-where-your-saas-signups-come-from-a-7-step-beginner-guide-to-simple-analytics-4dkp</guid>
      <description>&lt;p&gt;In March 2026, a founder posted on r/SaaS that they were finally getting their first users but had no idea where those users came from. Google Analytics showed mostly "Google" and "direct", and they summed it up as growth "happening in the dark". If you built your app with Lovable, Bolt, Replit, v0, Cursor or Claude Code, there is a good chance you are in the same spot: people sign up, and you are guessing why.&lt;/p&gt;

&lt;p&gt;Guessing is expensive. You keep posting where it feels busy instead of where signups actually come from. This guide fixes that in 7 steps, with free or cheap tools, a little copy-paste code and AI prompts you can hand to your coding tool. It takes an afternoon, and the setup is one-time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick glossary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pageview:&lt;/strong&gt; one page loaded by one visitor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Event:&lt;/strong&gt; anything you choose to count, like "signup" or "created first project". A pageview is also an event in most tools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Referrer:&lt;/strong&gt; the site a visitor clicked from, sent by the browser. Sometimes it is missing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UTM parameters:&lt;/strong&gt; tags you add to the end of a link, like &lt;code&gt;?utm_source=reddit&lt;/code&gt;, so your analytics knows where the click came from even when the referrer is missing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Direct (or "Direct / None"):&lt;/strong&gt; visits with no referrer and no UTM. Plausible's docs explain this bucket includes clicks from email, messaging apps like WhatsApp, Slack or Discord, and bookmarks. People often call it "dark traffic".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First touch:&lt;/strong&gt; the very first place a person came from, before they ever signed up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Conversion:&lt;/strong&gt; a visitor doing the thing you care about, like signing up or paying.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Write down 3 questions and 3 events
&lt;/h2&gt;

&lt;p&gt;Most beginners install a dashboard, look at a pretty traffic chart, and learn nothing. Start with the questions instead. For an early SaaS, three are enough:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Which channel brings visitors who actually sign up?&lt;/li&gt;
&lt;li&gt;Which channel brings signups who get value (their "first win")?&lt;/li&gt;
&lt;li&gt;Which channel brings paying customers?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each question needs one event. Keep the names short, lowercase and boring, and never rename them later.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EVENT PLAN (copy into your notes)
signup        -&amp;gt; account created successfully
first_win     -&amp;gt; user did the core thing once (e.g. created first project)
paid          -&amp;gt; first successful payment
Rule: lowercase, underscores, max 3-5 events for now.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you already track an &lt;code&gt;activated_at&lt;/code&gt; date from an onboarding setup (like in our &lt;a href="https://dev.to/siteory/how-to-set-up-onboarding-emails-that-bring-new-users-back-a-7-step-beginner-guide-for-your-first-4om5"&gt;onboarding emails guide&lt;/a&gt;), your &lt;code&gt;first_win&lt;/code&gt; event is the same moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Pick one simple tool (and know its limits)
&lt;/h2&gt;

&lt;p&gt;You do not need Google Analytics 4 to answer three questions. A lightweight, privacy-friendly tool is easier to read. Here is what the main options include, as of October 2026, from each vendor's own pricing or docs page:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Umami Cloud Hobby:&lt;/strong&gt; $0, 100K events per month, 1 website, 6 months of data. Includes custom events, UTM reports, goals, funnels and an attribution report. Umami says it uses no cookies, so no cookie banner is needed for it. Note that every pageview and every stored event property counts toward the 100K.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plausible:&lt;/strong&gt; 30-day free trial, then Starter at $9/month for up to 10k monthly pageviews, with goals and custom events. No cookies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PostHog:&lt;/strong&gt; free tier with 1M analytics events per month, no card required. It sets cookies by default, but has a cookieless mode you can switch on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vercel Web Analytics (Hobby plan):&lt;/strong&gt; free, but custom events need the Pro plan and UTM parameters need the Web Analytics Plus add-on. So on Hobby you cannot track signups or UTMs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lovable's built-in Analytics:&lt;/strong&gt; free and cookieless, groups visits into channels like Social, Referral and Direct. But Lovable's own docs say that to track steps like sign-up and purchase you should connect a dedicated tool such as PostHog.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This guide uses Umami because the free plan covers everything here. The same ideas work in Plausible or PostHog; only the one-line tracking call changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Install it on your real domain only
&lt;/h2&gt;

&lt;p&gt;Create a free Umami Cloud account, add your website, and copy the tracking snippet it gives you. It looks like this. The &lt;code&gt;data-domains&lt;/code&gt; attribute tells Umami to only count visits on the domains you list, so your localhost and preview URLs do not pollute the numbers.&lt;/p&gt;

&lt;p&gt;For Vite apps (most Lovable and Bolt projects), paste it inside &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; in &lt;code&gt;index.html&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;defer&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"https://cloud.umami.is/script.js"&lt;/span&gt;
  &lt;span class="na"&gt;data-website-id=&lt;/span&gt;&lt;span class="s"&gt;"YOUR-WEBSITE-ID"&lt;/span&gt;
  &lt;span class="na"&gt;data-domains=&lt;/span&gt;&lt;span class="s"&gt;"yourapp.com,www.yourapp.com"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Next.js (common with v0 and Cursor), use &lt;code&gt;next/script&lt;/code&gt; in &lt;code&gt;app/layout.tsx&lt;/code&gt;:&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;import&lt;/span&gt; &lt;span class="nx"&gt;Script&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;next/script&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// inside &amp;lt;body&amp;gt; in your root layout&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Script&lt;/span&gt;
  &lt;span class="na"&gt;src&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"https://cloud.umami.is/script.js"&lt;/span&gt;
  &lt;span class="na"&gt;data-website-id&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"YOUR-WEBSITE-ID"&lt;/span&gt;
  &lt;span class="na"&gt;data-domains&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"yourapp.com"&lt;/span&gt;
  &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"afterInteractive"&lt;/span&gt;
&lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Umami's docs say single-page apps are tracked automatically when the URL changes, so you do not need extra code for page navigation. Or hand it to your AI tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Add the Umami analytics script to this app so it loads on every page.
Script: &amp;lt;paste the snippet from your Umami dashboard&amp;gt;
Rules: add it once only (index.html head for Vite, or next/script in
the root layout for Next.js). Keep data-domains so it only tracks
yourapp.com. Do not add any other analytics or cookies.
Show me the exact file you changed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then open your live site, open DevTools, go to the Network tab, and check that &lt;code&gt;script.js&lt;/code&gt; loads. If it does not show up, try with your ad blocker off; Umami's docs note ad blockers can block it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Track the signup at the moment it succeeds
&lt;/h2&gt;

&lt;p&gt;The most common mistake is firing "signup" when someone clicks the button. Clicks fail: wrong password format, email already used, network error. Fire the event only after your auth call returns without an error.&lt;/p&gt;

&lt;p&gt;With Supabase auth it looks like this:&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;const&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="nx"&gt;error&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="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signUp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;password&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;error&lt;/span&gt;&lt;span class="p"&gt;)&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;umami&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;track&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;signup&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;Do the same for &lt;code&gt;first_win&lt;/code&gt; right after the core action saves successfully, and for &lt;code&gt;paid&lt;/code&gt; on your payment success page or webhook-confirmed screen. If you use PostHog instead, the line is &lt;code&gt;posthog.capture("signup")&lt;/code&gt;. In Plausible it is &lt;code&gt;plausible("signup")&lt;/code&gt;, and you must also create a goal with the exact same name in the dashboard, because Plausible does not show events without a matching goal.&lt;/p&gt;

&lt;p&gt;Never put emails or names into event data. Umami's docs warn against sending personal information, and you do not need it to answer your three questions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Save each user's first source in your database
&lt;/h2&gt;

&lt;p&gt;Analytics tools show trends, but they cannot tell you "user 47 came from Reddit and paid". For that, store the source on the user record. Two parts:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Part A: remember where they first came from.&lt;/strong&gt; This runs on every page load and only saves the first visit's source:&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;// src/lib/firstTouch.js&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;KEY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;first_touch&lt;/span&gt;&lt;span class="dl"&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;saveFirstTouch&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;KEY&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;const&lt;/span&gt; &lt;span class="nx"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&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;search&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;ref&lt;/span&gt; &lt;span class="o"&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="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;referrer&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;referrer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="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="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;ref&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&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;referrer&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="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;KEY&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="na"&gt;signup_source&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="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;utm_source&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="nx"&gt;ref&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;direct&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;signup_medium&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="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;utm_medium&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="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;signup_campaign&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="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;utm_campaign&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="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;landing_page&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="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;getFirstTouch&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;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;localStorage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;{}&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;&lt;strong&gt;Part B: attach it at signup.&lt;/strong&gt; Supabase lets you pass extra user metadata in &lt;code&gt;signUp&lt;/code&gt;, and it is stored on the user:&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;const&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="nx"&gt;error&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="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signUp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&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="nf"&gt;getFirstTouch&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;For Google sign-in, call &lt;code&gt;supabase.auth.updateUser({ data: getFirstTouch() })&lt;/code&gt; once after the first login if &lt;code&gt;signup_source&lt;/code&gt; is missing. Users can edit their own metadata, so treat it as a marketing hint, never for permissions.&lt;/p&gt;

&lt;p&gt;Also add one optional dropdown to your signup form: "How did you hear about us?" with 5-6 options (Google, Reddit, X, a friend, a newsletter, other). It catches the people your links cannot: the friend who sent your URL on WhatsApp shows up as "direct" in analytics, but the user will happily tell you "a friend".&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AI PROMPT
Add an optional "How did you hear about us?" select to the signup
form with options: google, reddit, x, friend, newsletter, other.
Save the value as heard_from in the Supabase signUp options.data,
together with the object returned by getFirstTouch().
Call saveFirstTouch() once when the app loads.
Do not make the field required.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A privacy note: you are storing a small marketing value in the browser and on the user record. Mention it in your privacy policy, and if you have EU users, check whether your consent setup covers it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Tag every link you share with UTMs
&lt;/h2&gt;

&lt;p&gt;Referrers get lost all the time. UTMs fix that for links you control. Google's own help page says to always use &lt;code&gt;utm_source&lt;/code&gt;, &lt;code&gt;utm_medium&lt;/code&gt; and &lt;code&gt;utm_campaign&lt;/code&gt;, and warns that values are case-sensitive, so stick to lowercase.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LINK TEMPLATE
https://yourapp.com/?utm_source=SOURCE&amp;amp;utm_medium=MEDIUM&amp;amp;utm_campaign=CAMPAIGN

SOURCE   = the exact place: reddit, x, linkedin, newsletter, youtube
MEDIUM   = the type: social, community, email, referral, video
CAMPAIGN = what you were doing: launch_oct, tutorial_post, bio_link

Rules:
- lowercase only, underscores instead of spaces
- one name per platform forever (always "x", never "twitter" or "X")
- one row per link in a sheet: date, where posted, full link, notes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Put a tagged link in your X bio, your newsletter, your Dev.to profile and every community post. Keep a simple sheet of every link so the numbers make sense later. If you use a link shortener or redirect, check that the final URL still has the &lt;code&gt;?utm_...&lt;/code&gt; part, because Plausible's docs note UTMs are lost if a redirect drops the query string.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Test it, then review one table every week
&lt;/h2&gt;

&lt;p&gt;First, test end to end:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open your site in a private window with &lt;code&gt;?utm_source=test&amp;amp;utm_medium=test&amp;amp;utm_campaign=test&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Sign up with a throwaway email.&lt;/li&gt;
&lt;li&gt;Check Umami: you should see the visit under UTM and one &lt;code&gt;signup&lt;/code&gt; event.&lt;/li&gt;
&lt;li&gt;Check Supabase: the new user's metadata should show &lt;code&gt;signup_source: "test"&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then, once a week, run this in the Supabase SQL editor:&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;select&lt;/span&gt;
  &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_user_meta_data&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'signup_source'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'unknown'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;coalesce&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw_user_meta_data&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'heard_from'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'-'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;heard_from&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;count&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="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;signups&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'7 days'&lt;/span&gt;
&lt;span class="k"&gt;group&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;signups&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compare it with Umami's numbers. Your database count is often higher, because ad blockers can stop analytics scripts from loading. Trust the database for "how many", and use analytics for "which pages and links".&lt;/p&gt;

&lt;p&gt;Spend 15 minutes on it and make one decision. Hypothetical example: if Reddit sent 400 visitors and 3 signups while a single tutorial post sent 90 visitors and 6 signups, write one more tutorial this week instead of five more Reddit comments. One founder in r/SaaSMarketing shared self-reported numbers like this in May 2026, where a smaller SEO channel produced more paying users than a bigger Reddit one. Your numbers will differ; that is the point of measuring.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tracking the click, not the success.&lt;/strong&gt; Fire &lt;code&gt;signup&lt;/code&gt; only when the auth call returns no error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Counting your own visits.&lt;/strong&gt; Use &lt;code&gt;data-domains&lt;/code&gt; and test on a private window, not your daily browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Messy UTM names.&lt;/strong&gt; "Twitter", "twitter" and "x" become three sources. Lowercase, one name per platform.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expecting UTMs to catch everything.&lt;/strong&gt; DMs, emails and bookmarks still show as direct. That is what the "How did you hear about us?" field is for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Putting personal data in events.&lt;/strong&gt; No emails or names in analytics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Picking a free plan that cannot do the job.&lt;/strong&gt; Vercel Hobby has no custom events or UTMs; Lovable's built-in analytics does not track signups.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Looking daily and changing everything.&lt;/strong&gt; With small numbers, one week is the minimum before you decide anything.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Your action summary
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Write your 3 questions and 3 events: &lt;code&gt;signup&lt;/code&gt;, &lt;code&gt;first_win&lt;/code&gt;, &lt;code&gt;paid&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Pick one tool (Umami Hobby is free as of October 2026) and create your site.&lt;/li&gt;
&lt;li&gt;Install the script with &lt;code&gt;data-domains&lt;/code&gt; and confirm it loads in DevTools.&lt;/li&gt;
&lt;li&gt;Fire &lt;code&gt;signup&lt;/code&gt; only after Supabase returns no error, plus &lt;code&gt;first_win&lt;/code&gt; and &lt;code&gt;paid&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Save first-touch source and "How did you hear about us?" on the user record.&lt;/li&gt;
&lt;li&gt;Tag every shared link with lowercase UTMs and log it in a sheet.&lt;/li&gt;
&lt;li&gt;Test with &lt;code&gt;utm_source=test&lt;/code&gt;, then run the weekly SQL and make one decision.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You do not need a data team to stop guessing. Three events, one stored source and one weekly table will tell you more about your growth than any traffic chart, so set it up today and let next week's numbers pick your next move.&lt;/p&gt;

</description>
      <category>analytics</category>
      <category>saas</category>
      <category>beginners</category>
      <category>marketing</category>
    </item>
    <item>
      <title>How to Fix a Slow Vibe-Coded App: A 7-Step Beginner Guide to Faster Pages and Queries</title>
      <dc:creator>Vishal Chauhan</dc:creator>
      <pubDate>Sat, 24 Oct 2026 06:31:20 +0000</pubDate>
      <link>https://dev.to/siteory/how-to-fix-a-slow-vibe-coded-app-a-7-step-beginner-guide-to-faster-pages-and-queries-53a8</link>
      <guid>https://dev.to/siteory/how-to-fix-a-slow-vibe-coded-app-a-7-step-beginner-guide-to-faster-pages-and-queries-53a8</guid>
      <description>&lt;p&gt;Your app worked fine when it was just you clicking around. Then real people showed up, and now pages hang on a spinner, images pop in one by one, and someone tells you "it feels slow on my phone."&lt;/p&gt;

&lt;p&gt;This is one of the most common problems with apps built in Lovable, Bolt, Replit, v0, Cursor or Claude Code. AI builders are great at "make it work," but they don't think about how much data, how many images and how much code your app sends to a stranger on a phone. In a January 2026 r/lovable thread, a developer described a client app that slowed to 30+ second page loads at around 1,000 users. The causes were boring: one endpoint returning every record, a database call per row, and no pagination.&lt;/p&gt;

&lt;p&gt;The good news: boring problems have boring fixes. This guide walks you through 7 steps, in the order I'd do them, with copy-paste prompts and short code. You don't need to be a performance expert.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick glossary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LCP (Largest Contentful Paint):&lt;/strong&gt; how long until the biggest thing on screen (usually a hero image or headline) shows up. Good is 2.5 seconds or less.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;INP (Interaction to Next Paint):&lt;/strong&gt; how fast the page reacts when someone taps or clicks. Good is 200 milliseconds or less.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CLS (Cumulative Layout Shift):&lt;/strong&gt; how much stuff jumps around while loading. Good is 0.1 or less.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Index:&lt;/strong&gt; a lookup table for your database, like the index at the back of a book. Without one, the database reads every row to find what you asked for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bundle:&lt;/strong&gt; the JavaScript file(s) your app ships to the browser. Bigger bundle, slower start.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These three LCP/INP/CLS numbers are Google's Core Web Vitals. Google says a page passes when 75% of visits hit "good" on all three, measured separately on mobile and desktop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Measure before you change anything
&lt;/h2&gt;

&lt;p&gt;If you skip this, you'll ask your AI to "make it faster," it will change 40 files, and you won't know if anything improved.&lt;/p&gt;

&lt;p&gt;Do this once and write the numbers down:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open &lt;a href="https://pagespeed.web.dev/" rel="noopener noreferrer"&gt;PageSpeed Insights&lt;/a&gt;, paste your live URL (not the editor preview), and check the &lt;strong&gt;Mobile&lt;/strong&gt; tab. Note LCP, INP (if shown), CLS and the overall score.&lt;/li&gt;
&lt;li&gt;Run it on your 2-3 most important pages too: the landing page, the main logged-in screen, and any page with a list or feed.&lt;/li&gt;
&lt;li&gt;Open Chrome DevTools (right-click, Inspect), go to the &lt;strong&gt;Network&lt;/strong&gt; tab, tick &lt;strong&gt;Disable cache&lt;/strong&gt;, set throttling to a slower preset like &lt;strong&gt;Fast 4G&lt;/strong&gt;, and reload. Note how long until the page looks usable.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;One thing that confuses beginners: PageSpeed Insights shows two kinds of numbers. "Discover what your real users are experiencing" is field data from real Chrome users, and a new or low-traffic app usually won't have it yet. The lab numbers below it come from a single simulated test. Use the lab numbers as your before/after scoreboard.&lt;/p&gt;

&lt;p&gt;Save this as a note in your project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Speed baseline (date: ____ )
Page: /            Mobile score: __  LCP: __s  CLS: __
Page: /dashboard   Mobile score: __  LCP: __s  CLS: __
Page: /feed        Mobile score: __  LCP: __s  CLS: __
Slowest thing I noticed by eye: ____________________
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: Find out what's actually heavy
&lt;/h2&gt;

&lt;p&gt;Stay in the Network tab and look for the biggest offenders. Click the &lt;strong&gt;Size&lt;/strong&gt; column to sort largest first, then use the filter buttons:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Img:&lt;/strong&gt; are any images over a few hundred KB? Are 1-2 MB photos being squeezed into small cards?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JS:&lt;/strong&gt; is one JavaScript file huge?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fetch/XHR:&lt;/strong&gt; these are your database and API calls. Are there dozens of them on one page? Does one take seconds, or return a giant response?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In an April 2026 r/lovable thread about a card feed that got slower as you scrolled, a commenter suggested exactly this check: filter by Img and see if full-size photos are loading into small slots. That's usually the answer for image-heavy pages.&lt;/p&gt;

&lt;p&gt;Write down the top 3 things you see. They tell you which steps below matter most.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Fix your images (the fastest win)
&lt;/h2&gt;

&lt;p&gt;Images are usually the biggest bytes on a page. Four rules cover most of it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Resize and compress before upload.&lt;/strong&gt; A card that displays at 400px wide doesn't need a 4000px photo. Export at roughly 2x the display size and use WebP. Free tools like Squoosh work fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always set width and height.&lt;/strong&gt; This reserves space so the layout doesn't jump (that's your CLS score).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lazy-load images below the fold.&lt;/strong&gt; Add &lt;code&gt;loading="lazy"&lt;/code&gt; so the browser only fetches them when the user scrolls near them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never lazy-load the main image at the top.&lt;/strong&gt; Google's web.dev guidance is blunt: never lazy-load your LCP image. Give it &lt;code&gt;fetchpriority="high"&lt;/code&gt; instead, and only on 1-2 images.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's what that looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Hero image at the top: load it first --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/hero.webp"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"App dashboard preview"&lt;/span&gt; &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"1200"&lt;/span&gt; &lt;span class="na"&gt;height=&lt;/span&gt;&lt;span class="s"&gt;"630"&lt;/span&gt; &lt;span class="na"&gt;fetchpriority=&lt;/span&gt;&lt;span class="s"&gt;"high"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Cards further down: load them when needed --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/cards/item-42.webp"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"Item 42"&lt;/span&gt; &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"400"&lt;/span&gt; &lt;span class="na"&gt;height=&lt;/span&gt;&lt;span class="s"&gt;"300"&lt;/span&gt; &lt;span class="na"&gt;loading=&lt;/span&gt;&lt;span class="s"&gt;"lazy"&lt;/span&gt; &lt;span class="na"&gt;decoding=&lt;/span&gt;&lt;span class="s"&gt;"async"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your users upload photos to Supabase Storage, you can resize them on the fly with Supabase's image transformations. As of October 2026 that feature is on the Pro plan and above, with 100 origin images included per billing cycle and $5 per extra 1,000. On the free plan, resize images when they're uploaded instead.&lt;/p&gt;

&lt;p&gt;Copy-paste prompt for your AI builder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Audit every &amp;lt;img&amp;gt; and image component in this app.
1. The main image at the top of each page: add fetchpriority="high" and make sure it is NOT lazy-loaded.
2. Every other image: add loading="lazy" and decoding="async".
3. Every image must have explicit width and height (or a fixed aspect-ratio box) so the layout doesn't shift.
4. List any image files in the repo over 300 KB with their display size, but do not delete or replace them. I will compress them myself.
Show me the list of files you changed. Do not change any other behavior.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: Stop loading data you don't need
&lt;/h2&gt;

&lt;p&gt;This is the one that bites at a few hundred users. AI-generated code often does three expensive things:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fetches every column&lt;/strong&gt; with &lt;code&gt;select('*')&lt;/code&gt;, including big text or JSON you never show.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fetches every row&lt;/strong&gt; with no limit, so the response grows with your user base.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Makes one call per row&lt;/strong&gt; (the "N+1 problem"): get 100 orders, then 100 more calls to get each customer name.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fixes with the Supabase JavaScript client:&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;// Before: everything, forever&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&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="s1"&gt;orders&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// After: only the columns you show, only this user's rows,&lt;/span&gt;
&lt;span class="c1"&gt;// the customer name in the SAME request, 20 at a time&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&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="s1"&gt;orders&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="s1"&gt;id, total, created_at, customers(name)&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="s1"&gt;user_id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;order&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;created_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="na"&gt;ascending&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;range&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="mi"&gt;19&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 notes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;customers(name)&lt;/code&gt; pulls the related customer in the same request. It works when there's a foreign key from &lt;code&gt;orders&lt;/code&gt; to &lt;code&gt;customers&lt;/code&gt;. (Table names here are just examples.)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.range(0, 19)&lt;/code&gt; returns rows 1-20. Load the next 20 when the user scrolls or taps "Load more."&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;.eq('user_id', userId)&lt;/code&gt; filter looks redundant if your Row Level Security policy already limits rows to the user. Supabase's own performance docs recommend adding it anyway, because it helps the database plan a faster query.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Copy-paste prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Find every database query in this app (Supabase client calls, fetch calls to my API, server routes).
For each one, tell me: which page uses it, whether it selects all columns, whether it has a limit, and whether it runs inside a loop or once per list item.
Then fix only these issues: select only the columns the UI uses, add pagination of 20 items to any list that can grow, and replace per-item queries with a single query that fetches related data together.
Keep the same UI and behavior. Show me a before/after table of the queries you changed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 5: Add the database indexes your app is missing
&lt;/h2&gt;

&lt;p&gt;If a specific screen is slow even after Step 4, the database may be reading every row to answer you. Supabase has a free checker for this.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;In your Supabase dashboard, open &lt;strong&gt;Advisors&lt;/strong&gt; and look at the &lt;strong&gt;Performance Advisor&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Look for &lt;strong&gt;"Unindexed foreign keys."&lt;/strong&gt; It means a column like &lt;code&gt;orders.customer_id&lt;/code&gt; links to another table but has no index, so joins and filters on it are slower.&lt;/li&gt;
&lt;li&gt;Also index any column your RLS policies filter on, usually &lt;code&gt;user_id&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The fix is one line of SQL per column, run in the SQL Editor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="n"&gt;orders_user_id_idx&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="n"&gt;orders_customer_id_idx&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One more RLS tip straight from Supabase's docs: wrap &lt;code&gt;auth.uid()&lt;/code&gt; in a &lt;code&gt;select&lt;/code&gt; inside your policies, so Postgres can run it once per query instead of once per row:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- inside a policy&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Want proof it worked? Run &lt;code&gt;explain analyze&lt;/code&gt; on a slow query in the SQL Editor before and after. If you see "Seq Scan" on a big table and a large "Rows Removed by Filter," an index is likely the fix. Supabase's query optimization guide also says: if an index doesn't lower the cost, remove it. Indexes aren't free, so add them where the Advisor or a slow screen points, not everywhere.&lt;/p&gt;

&lt;p&gt;If you followed my earlier &lt;a href="https://dev.to/siteory/how-to-stop-ai-from-wiping-your-apps-database-a-7-step-backup-and-safe-changes-guide-for-vibe-23d7"&gt;guide to backups and safe database changes&lt;/a&gt;, add these as a migration file and test on your dev database first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Ship less JavaScript
&lt;/h2&gt;

&lt;p&gt;Most AI-built apps are single-page React apps, and by default every page's code is packed into one big bundle. A visitor on your landing page downloads your entire dashboard, settings page and admin panel before seeing anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;See what's in your bundle.&lt;/strong&gt; For Vite apps (most Lovable and Bolt projects), install a visualizer as a dev dependency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-D&lt;/span&gt; rollup-plugin-visualizer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// vite.config.js (or .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;defineConfig&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;vite&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;react&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;@vitejs/plugin-react&lt;/span&gt;&lt;span class="dl"&gt;'&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;visualizer&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;rollup-plugin-visualizer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;defineConfig&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;plugins&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;react&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="nf"&gt;visualizer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;open&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="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;npm run build&lt;/code&gt; and a map of your bundle opens. Big blocks are big libraries. Common surprises: a full icon pack, a charting library only used on one page, or two date libraries doing the same job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Load pages only when they're visited.&lt;/strong&gt; React's built-in &lt;code&gt;lazy&lt;/code&gt; splits each page into its own file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&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;lazy&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Suspense&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;Dashboard&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lazy&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./pages/Dashboard&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;Settings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;lazy&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;import&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./pages/Settings&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="c1"&gt;// wrap your routes once:&lt;/span&gt;
&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;Suspense&lt;/span&gt; &lt;span class="nx"&gt;fallback&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;div&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nx"&gt;Loading&lt;/span&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="sr"&gt;/div&amp;gt;}&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;
&lt;/span&gt;  &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="cm"&gt;/* your &amp;lt;Routes&amp;gt; here */&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="err"&gt;/&lt;/span&gt;&lt;span class="na"&gt;Suspense&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next.js apps (common from v0 and Cursor) already split code per route, so there your focus is mostly images, data and heavy libraries.&lt;/p&gt;

&lt;p&gt;Copy-paste prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;I want a smaller JavaScript bundle without changing how the app looks or works.
1. Convert each top-level page/route to load with React.lazy and a single Suspense fallback.
2. List the 5 largest dependencies in the build and where each one is used.
3. For any library used on only one page, make sure it is only imported by that page.
Do not remove any library or feature without asking me first.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 7: Re-measure, then stop it from coming back
&lt;/h2&gt;

&lt;p&gt;Run PageSpeed Insights and your Network-tab check again on the same pages, same settings, and fill in your baseline note with the new numbers. Change one area at a time (images, then queries, then bundle) so you know what helped. If something got worse, undo that change with Git.&lt;/p&gt;

&lt;p&gt;Then protect your gains. AI tools will happily re-add a 3 MB image or a &lt;code&gt;select('*')&lt;/code&gt; next week. Add rules to the file your tool reads every time (&lt;code&gt;AGENTS.md&lt;/code&gt;, &lt;code&gt;CLAUDE.md&lt;/code&gt;, Cursor rules, or Lovable's project knowledge):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Performance rules for this project:
- Never lazy-load the main image at the top of a page; give it fetchpriority="high".
- All other images use loading="lazy" and have explicit width and height.
- Never use select('*'). Select only the columns the UI shows.
- Every list that can grow is paginated (20 items) and never fetched in a loop.
- New foreign key or policy-filtered columns get an index in the same migration.
- Each page/route is lazy-loaded. Ask before adding any new dependency.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Re-check once a month and after any big feature.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Testing only on your laptop on Wi-Fi.&lt;/strong&gt; Your users are on phones on mobile data. Always check the Mobile tab and throttle the Network tab.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lazy-loading everything.&lt;/strong&gt; It feels like a speed fix, but lazy-loading the hero image makes your main number (LCP) worse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Asking the AI to "optimize performance" in one prompt.&lt;/strong&gt; You get a giant diff and no idea what helped. Give it one job at a time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chasing a 100 score.&lt;/strong&gt; A landing page that goes from 45 to 85 on mobile is a real win. The last few points rarely matter to users.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adding indexes everywhere.&lt;/strong&gt; Each index slows down writes a little. Add them where the Advisor or a slow screen tells you to.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Your action summary
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Record a baseline on your top 3 pages (PageSpeed Insights Mobile plus the Network tab on Fast 4G).&lt;/li&gt;
&lt;li&gt;Sort the Network tab by size and write down your top 3 heavy things.&lt;/li&gt;
&lt;li&gt;Fix images: resize to WebP, set width and height, lazy-load below the fold, &lt;code&gt;fetchpriority="high"&lt;/code&gt; on the hero.&lt;/li&gt;
&lt;li&gt;Fix queries: only needed columns, paginate lists, no per-row calls, filter by user.&lt;/li&gt;
&lt;li&gt;Check Supabase's Performance Advisor and index unindexed foreign keys and RLS columns.&lt;/li&gt;
&lt;li&gt;Visualize your bundle and lazy-load each page.&lt;/li&gt;
&lt;li&gt;Re-measure, keep what helped, and add the performance rules to your AI rules file.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A fast app isn't about clever tricks. It's about not sending people things they didn't ask for: giant images, every row in your database, and code for pages they'll never open. Do these seven steps once, write the rules down, and your next feature starts fast instead of slowing everything else down.&lt;/p&gt;

</description>
      <category>beginners</category>
      <category>vibecoding</category>
      <category>webdev</category>
      <category>performance</category>
    </item>
    <item>
      <title>How to Validate Your SaaS Idea Before You Build It: A 7-Step Pre-Sell Playbook for Beginners</title>
      <dc:creator>Vishal Chauhan</dc:creator>
      <pubDate>Fri, 23 Oct 2026 11:25:18 +0000</pubDate>
      <link>https://dev.to/siteory/how-to-validate-your-saas-idea-before-you-build-it-a-7-step-pre-sell-playbook-for-beginners-4d5h</link>
      <guid>https://dev.to/siteory/how-to-validate-your-saas-idea-before-you-build-it-a-7-step-pre-sell-playbook-for-beginners-4d5h</guid>
      <description>&lt;p&gt;You can build an app in a weekend now. That is the good news and the trap. When building is this cheap, it is very easy to spend a month polishing something nobody will pay for, and only find out on launch day.&lt;/p&gt;

&lt;p&gt;This guide shows you how to test a SaaS idea before you build the real product, using a one-page site, a short waitlist form and a small paid "founding member" offer. It takes about two weeks, costs close to nothing, and works whether you build with Cursor, Claude Code, Lovable, Bolt, Replit or v0.&lt;/p&gt;

&lt;p&gt;The core idea is simple: &lt;strong&gt;compliments and email signups measure curiosity. Money, time and effort measure intent.&lt;/strong&gt; Every step below moves people one rung closer to the second kind.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick glossary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Validation&lt;/strong&gt;: evidence that real people have the problem and will do something (pay, book a call, switch tools) to fix it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Waitlist&lt;/strong&gt;: a list of people who left their email to hear when you launch. Useful, but weak on its own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-sell&lt;/strong&gt;: taking a real payment (or deposit) before the product is finished, with a clear delivery date and refund promise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pass mark&lt;/strong&gt;: the number you decide in advance that counts as "yes, build it".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Concierge MVP&lt;/strong&gt;: delivering the result by hand (spreadsheets, email, you doing the work) instead of with software.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why a waitlist alone can fool you
&lt;/h2&gt;

&lt;p&gt;On X in October 2026, posts like "How do you validate an idea before wasting months building it?" and "How to filter the 'cool idea' and 'I will use this' fake validation?" drew dozens of replies. On r/SaaS, the most common answer in recent threads is the same: a waitlist shows interest, a payment shows intent.&lt;/p&gt;

&lt;p&gt;One r/SaaS founder shared their own numbers in March 2026: about 1,200 waitlist signups turned into 89 trials and 12 paying customers, and the product was shut down. A big list is not the same as buyers.&lt;/p&gt;

&lt;p&gt;So we will still build a waitlist. We will just make it ask harder questions, and put a real price next to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Write your bet and your pass mark before you start
&lt;/h2&gt;

&lt;p&gt;If you decide what "good" looks like after seeing the numbers, anything looks like success. Write the bet down first.&lt;/p&gt;

&lt;p&gt;Copy this template into a note and fill it in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;WHO has the problem (be narrow):
WHAT they do today to handle it (tool, spreadsheet, person, nothing):
WHAT it costs them today (time, money, mistakes):
MY OFFER in one sentence:
PRICE I will show on the page:

TEST WINDOW: 14 days, from ____ to ____

PASS (build it):  at least __ founding-member payments
                  OR at least __ people book a call AND describe the problem unprompted
MAYBE (change one thing and rerun): __ to __ qualified signups, 0-1 payments
FAIL (stop or pick a new customer): fewer than __ qualified signups
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hypothetical example: a cafe-owner tool that turns messy shift notes into a weekly summary. Bet: "Owners of 1-3 cafes waste about an hour a week reading WhatsApp shift notes." Pass: 5 founding-member payments at a one-time price in 14 days.&lt;/p&gt;

&lt;p&gt;If no result could make you stop, it is not a test.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Talk to 10 people who have the problem (without pitching)
&lt;/h2&gt;

&lt;p&gt;Before you build a page, have 10 short conversations with people who match your "WHO". The goal is to learn how they handle the problem today, not to hear that your idea is nice.&lt;/p&gt;

&lt;p&gt;The book The Mom Test by Rob Fitzpatrick is the classic guide here. Its main lesson: people are polite about your idea, so ask about their past behavior instead of your future product.&lt;/p&gt;

&lt;p&gt;Where to find people: communities they already use, your own network, people complaining about the problem in public posts, and people leaving 2-3 star reviews of existing tools. Follow each community's self-promotion rules and ask for a 15-minute chat, not a sale.&lt;/p&gt;

&lt;p&gt;Use questions like these:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Tell me about the last time you dealt with [problem]. What happened?
2. How do you handle it today? What tools or people are involved?
3. What is annoying or expensive about that?
4. Have you tried to fix it? What did you try, and why did you stop?
5. Are you paying for anything to deal with this right now? How much?
6. If you could wave a magic wand, what would be different?
7. Who else should I talk to about this?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Avoid "Would you use an app that...?" and "Would you pay for this?". Polite people say yes.&lt;/p&gt;

&lt;p&gt;After each call, write down the exact words they used to describe the problem. You will reuse them on your page in Step 3.&lt;/p&gt;

&lt;p&gt;Good signs: they have already tried to fix it, they pay for a half-working workaround, or they ask "when can I try it?" before you mention a product. A bad sign: the problem only appears when you bring it up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Build one honest page in an afternoon
&lt;/h2&gt;

&lt;p&gt;Now build a single page. Not the app. Just the promise, the price and the next step.&lt;/p&gt;

&lt;p&gt;Paste this prompt into Lovable, Bolt, v0, Replit, Cursor or Claude Code (edit the brackets first):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Build a single landing page for a product that is NOT built yet. Keep it honest.

Audience: [who, in their words]
Problem (use these exact customer phrases): [2-3 quotes from my calls]
Offer: [one sentence outcome, not features]

Sections, in this order:
1. Headline: the outcome for this audience. Subheadline: how it works in one line.
2. "Sound familiar?" with the 3 customer phrases as bullet points.
3. How it will work: 3 short steps.
4. Pricing: show the real planned price: [price]. Under it, a "Founding member" box:
   [founding price], limited to [number] spots, delivery by [date],
   full refund if I do not deliver or you are not happy in the first 30 days.
   Button: "Become a founding member" linking to [payment link URL].
5. Waitlist form for people not ready to pay (fields in the next prompt).
6. FAQ: "Is this built yet?" (answer honestly: in progress, ship date [date]),
   "What if it never launches?" (full refund), "Who is building this?" (me, [name]).

Rules: no fake testimonials, no fake user counts, no fake logos, no countdown timers.
Mobile first. One page only. Plain, friendly language.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things matter most. First, &lt;strong&gt;show a price&lt;/strong&gt;. If the first time people hear the price is launch day, you never tested the price. Second, &lt;strong&gt;say plainly that the product is not built yet&lt;/strong&gt;, so nobody feels tricked later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Make the waitlist form ask one hard question
&lt;/h2&gt;

&lt;p&gt;A plain email box tells you almost nothing. Add two or three short questions so you can tell buyers from tourists.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Email (required)
What do you use today to handle [problem]? (short text, required)
How often does this happen?  [Daily / Weekly / Monthly / Rarely]
Can I email you a few questions or book a 15-min call?  [Yes / No]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The "What do you use today?" answer is gold. "Nothing, it is not a big deal" is a tourist. "A spreadsheet my assistant updates every Monday" is a potential customer.&lt;/p&gt;

&lt;p&gt;No-code option: Tally's free plan includes unlimited forms and submissions under its fair-use guidelines, as of October 2026, and you can embed the form or link to it.&lt;/p&gt;

&lt;p&gt;Code option: ask your AI builder to save signups to a database table. If you use Supabase, ask for a table where anonymous visitors can only insert rows and never read them, so nobody can download your waitlist. Our earlier guide on locking down Supabase covers insert-only tables: &lt;a href="https://dev.to/siteory/your-vibe-coded-app-might-be-leaking-user-data-heres-how-to-lock-down-supabase-in-30-minutes-327k"&gt;https://dev.to/siteory/your-vibe-coded-app-might-be-leaking-user-data-heres-how-to-lock-down-supabase-in-30-minutes-327k&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Send every new signup a short personal email from your own address within a day. Ask one question: "What made you sign up?" The replies are often more useful than the form.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Add a real founding-member offer with a payment link
&lt;/h2&gt;

&lt;p&gt;This is the step that turns interest into evidence. You do not need checkout code. A Stripe Payment Link is a hosted payment page you create in the Stripe Dashboard and share as a URL.&lt;/p&gt;

&lt;p&gt;Useful settings from Stripe's docs, as of October 2026:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Product type&lt;/strong&gt;: a one-time payment is simplest for a pre-sale (for example, "Founding member: first 6 months"). Payment Links also support subscriptions if you prefer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Limit the number of payments&lt;/strong&gt;: Stripe can deactivate the link automatically after a set number of payments, and you can customize the message shown afterwards. Perfect for "20 founding spots".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom fields&lt;/strong&gt;: add one short text field such as "What do you most want this to do for you?". Stripe's docs say not to use custom fields for sensitive personal data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store policies&lt;/strong&gt;: you can show your refund policy and support contact on the payment page.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Founding-member copy you can adapt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Founding member - [Product name]
[Founding price], one-time. Covers your first [6 months / 1 year] once it launches.

- Delivery: first working version by [date]. I will email you progress every 2 weeks.
- Refund: full refund if I do not deliver by [date], or within 30 days
  of launch if it is not useful. Just reply to any email.
- You get: [main outcome], direct access to me, and a say in what gets built first.

Only [number] spots. The regular price at launch will be [price]/month.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep your refund promise fast and without arguments. And know that, per Stripe's refund docs, Stripe's processing fees from the original payment are not returned when you refund, so a refund costs you a little. That is a fine price for real evidence.&lt;/p&gt;

&lt;p&gt;Not ready to charge? A weaker but useful version is a booked call, or a concierge offer where you deliver the result by hand for a small fee. A January 2026 r/SaaS thread on validation ranked pre-selling and concierge offers above a plain waitlist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Send traffic from where your buyers already are
&lt;/h2&gt;

&lt;p&gt;A test page with zero visitors proves nothing. Aim for a small number of the right people rather than lots of random clicks.&lt;/p&gt;

&lt;p&gt;In order of usefulness:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The 10 people from Step 2.&lt;/strong&gt; Send each a personal note: "You told me about [problem]. I am building this. Here is the page and the founding price. Honest feedback welcome, including no."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Communities where the problem gets discussed.&lt;/strong&gt; Share what you learned from your calls, not a link dump. Follow each community's promotion rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your network and one-to-one messages&lt;/strong&gt; to people who match the "WHO" exactly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optional small ad test.&lt;/strong&gt; Only if you already know where the audience hangs out, with a small fixed budget you are happy to lose.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;To know which source works, give each one its own link with UTM tags, for example &lt;code&gt;?utm_source=reddit&amp;amp;utm_medium=community&amp;amp;utm_campaign=validation&lt;/code&gt;. Stripe's docs say Payment Links accept UTM codes, and that they are passed through to your redirect URL after payment if you set the confirmation behavior to "redirect". Keep a simple sheet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;date | source | visits | waitlist signups | "what do you use today" answers that sound real | calls booked | payments
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 7: Score the result and decide (build, change, or stop)
&lt;/h2&gt;

&lt;p&gt;At the end of your 14 days, open the note from Step 1 and compare. Do not move the pass mark now.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PASS  -&amp;gt; Build the smallest version that delivers the main outcome for your payers.
         Email them weekly. They are your first users and your best testers.
MAYBE -&amp;gt; Change ONE thing and rerun for 7 days:
         - lots of visits, few signups: the headline/problem is wrong
         - signups, no payments: price, offer, or trust is wrong (talk to 5 signups)
         - payments, then refund requests: the promise does not match the need
FAIL  -&amp;gt; Stop building this version. Keep the notes. Try a narrower customer
         or the adjacent problem people kept mentioning in your calls.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then email everyone who signed up but did not pay: "Thanks for joining. Honest question: what stopped you from grabbing a founding spot?" The answers tell you which "MAYBE" case you are in.&lt;/p&gt;

&lt;p&gt;Stopping is a good outcome too. Two weeks to learn "not this" beats six months of building.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Counting signups as validation.&lt;/strong&gt; A big list with no money and no calls is curiosity. Weigh payments and booked calls far above emails.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hiding the price.&lt;/strong&gt; People assume free. Show the planned price from day one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pitching in your interviews.&lt;/strong&gt; If you talk about your idea in the first ten minutes, you will only hear compliments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Asking friends and other developers.&lt;/strong&gt; They are kind and rarely your customers. Talk to people who have the problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Building the app "just to test".&lt;/strong&gt; If AI can build it in a weekend, you will be tempted. Build the page first. The app can wait two weeks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fake proof.&lt;/strong&gt; No invented testimonials, user counts or "only 2 spots left" when it is not true. It breaks trust and, in many places, consumer rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vague refund terms.&lt;/strong&gt; If you take money before delivery, state the date and the refund rule in plain words, and honor them quickly. (This is general guidance, not legal advice.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Moving the pass mark.&lt;/strong&gt; Decide the numbers before the test, not after.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Your action summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Day 1&lt;/strong&gt;: Fill in the bet and pass-mark template. Pick a narrow "WHO".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Days 2-5&lt;/strong&gt;: Have 10 no-pitch problem conversations. Save their exact words.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 6&lt;/strong&gt;: Build one honest page with the AI prompt. Show the real price.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 6&lt;/strong&gt;: Add a waitlist form with "What do you use today?".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 7&lt;/strong&gt;: Create a Stripe Payment Link for a limited founding-member offer with a clear delivery date and refund promise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Days 7-13&lt;/strong&gt;: Send personal notes to your 10 people, share learnings in 1-2 communities, track each source with its own UTM link.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Day 14&lt;/strong&gt;: Score against your pass mark. Build, change one thing, or stop.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal is not to prove your idea is good. It is to find out, cheaply and quickly, whether strangers care enough to pay, so the next thing you ask your AI to build already has customers waiting for it.&lt;/p&gt;

</description>
      <category>startup</category>
      <category>saas</category>
      <category>beginners</category>
      <category>indiehackers</category>
    </item>
    <item>
      <title>How to Stop AI From Wiping Your App's Database: A 7-Step Backup and Safe-Changes Guide for Vibe Coders</title>
      <dc:creator>Vishal Chauhan</dc:creator>
      <pubDate>Fri, 23 Oct 2026 06:33:47 +0000</pubDate>
      <link>https://dev.to/siteory/how-to-stop-ai-from-wiping-your-apps-database-a-7-step-backup-and-safe-changes-guide-for-vibe-23d7</link>
      <guid>https://dev.to/siteory/how-to-stop-ai-from-wiping-your-apps-database-a-7-step-backup-and-safe-changes-guide-for-vibe-23d7</guid>
      <description>&lt;p&gt;Your app has real users now. Their accounts, their saved work, maybe their payments all live in one database. And the same AI agent that builds your features can also run commands against that database.&lt;/p&gt;

&lt;p&gt;One bad command, like a migration that drops a table or a "reset" pointed at the wrong project, can erase data you cannot get back. Git can undo code mistakes. It cannot undo lost user data.&lt;/p&gt;

&lt;p&gt;This guide fixes that in an afternoon: a backup you control, proof that it restores, a separate database for the AI to experiment on, and settings that keep your agent away from production. The examples use Supabase, which many Lovable, Bolt, v0, Cursor and Claude Code apps run on, with notes for Lovable Cloud and Replit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick glossary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Production (prod) database:&lt;/strong&gt; the one your live app and real users use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dev database:&lt;/strong&gt; a separate copy for building and testing. If it breaks, nobody loses anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restore:&lt;/strong&gt; loading a backup back into a database. A backup you have never restored is a guess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Migration:&lt;/strong&gt; a small SQL file that changes your database structure, like "add a &lt;code&gt;plan&lt;/code&gt; column to &lt;code&gt;profiles&lt;/code&gt;".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Destructive change:&lt;/strong&gt; anything that deletes data or structure, like &lt;code&gt;drop table&lt;/code&gt; or &lt;code&gt;truncate&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Find out what safety net you already have
&lt;/h2&gt;

&lt;p&gt;Here is what the official docs say, as of October 2026:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Supabase Free plan:&lt;/strong&gt; no daily backups you can rely on. Supabase's backup docs tell free projects to "regularly export their data using the Supabase CLI &lt;code&gt;db dump&lt;/code&gt; command and maintain off-site backups."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supabase Pro / Team / Enterprise:&lt;/strong&gt; automatic daily backups, kept for 7 / 14 / up to 30 days, under Database &amp;gt; Backups. Point-in-Time Recovery (restore to an exact minute) is a paid add-on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lovable Cloud:&lt;/strong&gt; one backup per day, about 14 days kept, under More &amp;gt; Database &amp;gt; Manage &amp;gt; Backups. You can also export the whole database (once every 24 hours).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replit:&lt;/strong&gt; separate development and production databases. Replit's docs say Agent "can't touch" the production database, and the development database can be rolled back to a checkpoint.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three gotchas apply almost everywhere:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Files are separate.&lt;/strong&gt; Supabase and Lovable both say database backups do not include files in Storage (uploads, avatars, PDFs).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deleting the project deletes the backups.&lt;/strong&gt; Supabase says deleting a project permanently removes its data, "including any backups."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restores roll back everything.&lt;/strong&gt; You lose every change made after the backup, and Supabase notes the project is unavailable during the restore.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Ask yourself: "If my database disappeared right now, what is the newest copy I could get back?" If the answer is "none" or "not sure", do Step 2 today.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Take a backup you control, today
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;On Supabase&lt;/strong&gt;, use the CLI commands from Supabase's own backup-and-restore guide. You need the Supabase CLI and Docker Desktop installed (the CLI runs the dump inside Docker). Get the connection string from the &lt;strong&gt;Connect&lt;/strong&gt; button on your project dashboard. Supabase recommends the &lt;strong&gt;Session pooler&lt;/strong&gt; string by default.&lt;/p&gt;

&lt;p&gt;Save this as &lt;code&gt;backup.sh&lt;/code&gt; in your project folder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# Backs up roles, schema and data into a dated folder.&lt;/span&gt;
&lt;span class="c"&gt;# Usage: DB_URL="your-session-pooler-connection-string" ./backup.sh&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt;
&lt;span class="nv"&gt;STAMP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%Y-%m-%d_%H%M&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"backups/&lt;/span&gt;&lt;span class="nv"&gt;$STAMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
supabase db dump &lt;span class="nt"&gt;--db-url&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DB_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"backups/&lt;/span&gt;&lt;span class="nv"&gt;$STAMP&lt;/span&gt;&lt;span class="s2"&gt;/roles.sql"&lt;/span&gt; &lt;span class="nt"&gt;--role-only&lt;/span&gt;
supabase db dump &lt;span class="nt"&gt;--db-url&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DB_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"backups/&lt;/span&gt;&lt;span class="nv"&gt;$STAMP&lt;/span&gt;&lt;span class="s2"&gt;/schema.sql"&lt;/span&gt;
supabase db dump &lt;span class="nt"&gt;--db-url&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DB_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"backups/&lt;/span&gt;&lt;span class="nv"&gt;$STAMP&lt;/span&gt;&lt;span class="s2"&gt;/data.sql"&lt;/span&gt; &lt;span class="nt"&gt;--use-copy&lt;/span&gt; &lt;span class="nt"&gt;--data-only&lt;/span&gt; &lt;span class="nt"&gt;-x&lt;/span&gt; &lt;span class="s2"&gt;"storage.buckets_vectors"&lt;/span&gt; &lt;span class="nt"&gt;-x&lt;/span&gt; &lt;span class="s2"&gt;"storage.vector_indexes"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Backup saved in backups/&lt;/span&gt;&lt;span class="nv"&gt;$STAMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then keep it out of Git, because these files contain your users' data:&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="nb"&gt;chmod&lt;/span&gt; +x backup.sh
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"backups/"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; .gitignore
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The connection string contains your database password. Never paste it into a public repo, a screenshot, or an AI chat.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On Lovable Cloud&lt;/strong&gt;, go to More &amp;gt; Database &amp;gt; Manage &amp;gt; Settings, click Export data, then Export and Start export. Lovable emails you when it is ready and saves it to your project's Storage. Download it and keep it somewhere outside Lovable, since its docs warn that removing the database deletes the export too. Download important Storage files separately from More &amp;gt; Storage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Prove the backup works with a test restore
&lt;/h2&gt;

&lt;p&gt;On Supabase, create a new empty project to restore into. The Free plan allows 2 active projects, so this can be free. Install &lt;code&gt;psql&lt;/code&gt; (the Supabase guide has instructions for Mac and Windows), then run the restore command from that guide, using the &lt;strong&gt;new&lt;/strong&gt; project's connection string:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;psql &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--single-transaction&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--variable&lt;/span&gt; &lt;span class="nv"&gt;ON_ERROR_STOP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--file&lt;/span&gt; roles.sql &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--file&lt;/span&gt; schema.sql &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--command&lt;/span&gt; &lt;span class="s1"&gt;'SET session_replication_role = replica'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--file&lt;/span&gt; data.sql &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--dbname&lt;/span&gt; &lt;span class="s2"&gt;"PASTE-THE-NEW-PROJECT-CONNECTION-STRING"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it from inside the dated backup folder. Then compare row counts for your most important tables in both projects, using the SQL editor. Replace the table names with your own (&lt;code&gt;profiles&lt;/code&gt; and &lt;code&gt;projects&lt;/code&gt; are just examples):&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;select&lt;/span&gt; &lt;span class="s1"&gt;'profiles'&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;count&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="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;profiles&lt;/span&gt;
&lt;span class="k"&gt;union&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="s1"&gt;'projects'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;count&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="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;projects&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the numbers match, you have a real backup. If the restore errors out, the Supabase guide has a troubleshooting section (for example, for errors mentioning &lt;code&gt;supabase_admin&lt;/code&gt;), plus an extra key-copy step if you use Supabase Vault.&lt;/p&gt;

&lt;p&gt;Now delete this test project, because it holds a full copy of real user data. Create a fresh one and restore only &lt;code&gt;roles.sql&lt;/code&gt; and &lt;code&gt;schema.sql&lt;/code&gt; (leave out the &lt;code&gt;--command&lt;/code&gt; and &lt;code&gt;data.sql&lt;/code&gt; lines). That empty copy becomes your dev database in Step 4.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Give the AI its own database to break
&lt;/h2&gt;

&lt;p&gt;The safest rule in this whole guide: &lt;strong&gt;the AI builds against a dev database, never against production.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Supabase (with Cursor, Claude Code, Bolt, v0):&lt;/strong&gt; use two projects, for example &lt;code&gt;myapp-dev&lt;/code&gt; and &lt;code&gt;myapp-prod&lt;/code&gt;. Your local &lt;code&gt;.env&lt;/code&gt; points at dev. Production keys live only in your hosting dashboard (Vercel, Netlify, and so on). Supabase also offers a local database (&lt;code&gt;supabase start&lt;/code&gt;) and paid Branching.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replit:&lt;/strong&gt; already split for you. One catch from Replit's docs: when you publish, structure changes Agent made to the development database, including deleted columns or tables, are applied to production. So check what changed before you hit Publish.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lovable Cloud:&lt;/strong&gt; since October 5, 2026, Lovable runs every Cloud project on a single database. Its migration guide puts it plainly: "The data you see while building is the data your published app uses." Drafts preview code changes, not a second database. So lean on Steps 2, 3 and 7, test with obviously fake accounts, and keep the Step 5 setting. For a true dev/prod split, Lovable lets you connect your own Supabase project instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Habit: before anything touches data, check which project ref is in your connection string or &lt;code&gt;.env&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Make every database change a reviewed migration file
&lt;/h2&gt;

&lt;p&gt;Raw SQL fired off by an AI leaves no record. Migration files give you something to read before anything runs. With the Supabase CLI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Create a new, empty migration file&lt;/span&gt;
supabase migration new add_plan_to_profiles

&lt;span class="c"&gt;# 2. Let the AI (or you) write the SQL into that file, then test it locally.&lt;/span&gt;
&lt;span class="c"&gt;#    "db reset" here rebuilds your LOCAL database only.&lt;/span&gt;
supabase db reset

&lt;span class="c"&gt;# 3. Compare local and remote migration history&lt;/span&gt;
supabase migration list

&lt;span class="c"&gt;# 4. See exactly what would be applied, without applying it&lt;/span&gt;
supabase db push &lt;span class="nt"&gt;--dry-run&lt;/span&gt;

&lt;span class="c"&gt;# 5. Only after a fresh backup (Step 2), apply it&lt;/span&gt;
supabase db push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before step 5, read the migration file. You are not checking every line, just scanning for red-flag words:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;drop table        drop column        truncate
delete from ...   (with no where)    alter column ... type
rename            db reset --linked  db push --include-all
migration repair
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two deserve a special warning. Supabase's CLI docs say &lt;code&gt;db reset&lt;/code&gt; with &lt;code&gt;--linked&lt;/code&gt; or &lt;code&gt;--db-url&lt;/code&gt; runs a script that drops all user-created entities in the remote database. And if &lt;code&gt;migration list&lt;/code&gt; shows lots of local migrations with no remote match, stop: a push may try to apply old files you did not expect. One r/Supabase post from April 2026 describes exactly that, an agent running &lt;code&gt;db push&lt;/code&gt; that replayed old migrations, including &lt;code&gt;DROP TABLE&lt;/code&gt;, against a live project.&lt;/p&gt;

&lt;p&gt;For destructive changes, prefer an additive version: add the new column, copy the data, switch the app over, and remove the old column later, after a backup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On Lovable Cloud,&lt;/strong&gt; its docs say Lovable refuses changes like dropping or renaming a table or column, or changing a column's type, and adds to the database instead. That behavior is controlled by the agent permission &lt;strong&gt;Execute backward incompatible database migrations&lt;/strong&gt;. Leave it at the default, or set it to &lt;strong&gt;Ask each time&lt;/strong&gt; so you see the SQL first. Avoid &lt;strong&gt;Always allow&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Take away the AI's keys to production
&lt;/h2&gt;

&lt;p&gt;Prompt rules shape what the AI tries. Settings decide what it can do. Use both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Supabase MCP server.&lt;/strong&gt; Supabase's docs offer two URL options: &lt;code&gt;project_ref&lt;/code&gt; (limit it to one project) and &lt;code&gt;read_only=true&lt;/code&gt; (run queries as a read-only Postgres user). Scope it to dev:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://mcp.supabase.com/mcp?project_ref=YOUR_DEV_PROJECT_REF
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the AI must look at production, use a separate read-only connection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://mcp.supabase.com/mcp?project_ref=YOUR_PROD_PROJECT_REF&amp;amp;read_only=true
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Claude Code.&lt;/strong&gt; Add deny rules to &lt;code&gt;.claude/settings.json&lt;/code&gt; in your project. Claude Code enforces these itself, whatever the model decides:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"permissions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"deny"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(supabase db push *)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(supabase db reset *)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(supabase link *)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(npx supabase db push *)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(npx supabase db reset *)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="s2"&gt;"Bash(psql *)"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;npx&lt;/code&gt; lines matter: Claude Code's docs explain that a rule only matches the command as written. The same docs say deny rules are not a full security boundary, so the dev project and read-only MCP still matter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cursor.&lt;/strong&gt; In Settings &amp;gt; Agents &amp;gt; Approvals &amp;amp; Execution, avoid &lt;strong&gt;Run Everything&lt;/strong&gt;, which runs every command without approval. Auto-review or Allowlist are safer, but Cursor's docs say neither is a security boundary.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Everyone.&lt;/strong&gt; Add these rules to your &lt;code&gt;AGENTS.md&lt;/code&gt;, &lt;code&gt;CLAUDE.md&lt;/code&gt;, or Cursor rules file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Database safety rules:
- Never run commands against the production database.
- Never run: supabase db push, supabase db reset --linked, migration repair,
  or any DROP, TRUNCATE, or DELETE without a WHERE clause.
- All schema changes go in a new file in supabase/migrations/. Stop after
  writing it and tell me what it does in plain English.
- Flag anything that deletes data, drops or renames columns, or changes types.
- Never ask me to paste database passwords or connection strings into chat.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 7: Automate backups and schedule a restore drill
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Supabase Free:&lt;/strong&gt; Supabase has an official guide, "Automated backups using GitHub Actions", with a workflow that runs the same three &lt;code&gt;db dump&lt;/code&gt; commands every day at midnight (UTC, since GitHub schedules use UTC). It reads the connection string from a GitHub secret named &lt;code&gt;SUPABASE_DB_URL&lt;/code&gt; and commits the dump files. Supabase's guide warns: "Never backup your data to a public repository." Use a private repo dedicated to backups.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supabase Pro:&lt;/strong&gt; daily backups are automatic. Still export weekly, because deleting the project deletes its backups.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lovable Cloud:&lt;/strong&gt; daily backups are automatic. Export before any big change, and download it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then add a monthly calendar reminder, "Restore drill": restore the latest backup into a throwaway project (Step 3), compare row counts, then delete it. On the Free plan's 2-project limit, pause your dev project first. It is the only way to know your backups still work.&lt;/p&gt;

&lt;p&gt;To set up the workflow, paste this into your AI tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Read Supabase's guide "Automated backups using GitHub Actions"
(supabase.com/docs/guides/deployment/ci/backups). Create the periodic backup
workflow from that guide in .github/workflows/backup.yml, but trigger it only
on schedule (daily) and workflow_dispatch, not on push or pull_request.
Do not put any connection string in the file. Tell me exactly which GitHub
secret to create and confirm the repo must be private. Do not run anything.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Trusting a backup you never restored.&lt;/strong&gt; Do the Step 3 test once and the monthly drill after that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting Storage files.&lt;/strong&gt; Database backups do not include uploads. Download them separately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Running &lt;code&gt;db reset&lt;/code&gt; while linked to production.&lt;/strong&gt; Locally it is harmless. With &lt;code&gt;--linked&lt;/code&gt; it wipes the remote database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Saving backups in a public repo.&lt;/strong&gt; Backups contain emails and personal data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restoring to an old backup without exporting first.&lt;/strong&gt; A restore erases everything after that point, so export the current state before you roll back.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Your action summary
&lt;/h2&gt;

&lt;p&gt;Today:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Answer the Step 1 question: what is the newest copy of my database I could get back?&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;backup.sh&lt;/code&gt; (or Lovable's export) and add &lt;code&gt;backups/&lt;/code&gt; to &lt;code&gt;.gitignore&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Restore that backup into a second project and compare row counts.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This week:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Point your AI tools at the dev project only.&lt;/li&gt;
&lt;li&gt;Make database changes through migration files, read them, run &lt;code&gt;db push --dry-run&lt;/code&gt;, and back up before every push.&lt;/li&gt;
&lt;li&gt;Add the Claude Code deny rules, a scoped MCP URL, safe Cursor settings, and the rules block above.&lt;/li&gt;
&lt;li&gt;Automate nightly backups in a private repo and put a monthly restore drill on your calendar.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this slows you down. It just means that the next time an AI agent gets too confident with your database, you lose a few minutes instead of your users' data.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>beginners</category>
      <category>vibecoding</category>
      <category>database</category>
    </item>
    <item>
      <title>How to Fix Your App's Link Previews on X, LinkedIn, Slack and WhatsApp: A 7-Step Beginner Guide</title>
      <dc:creator>Vishal Chauhan</dc:creator>
      <pubDate>Thu, 22 Oct 2026 11:28:03 +0000</pubDate>
      <link>https://dev.to/siteory/how-to-fix-your-apps-link-previews-on-x-linkedin-slack-and-whatsapp-a-7-step-beginner-guide-5cll</link>
      <guid>https://dev.to/siteory/how-to-fix-your-apps-link-previews-on-x-linkedin-slack-and-whatsapp-a-7-step-beginner-guide-5cll</guid>
      <description>&lt;p&gt;You finally share your app on X, in a Slack community, or in a WhatsApp group. The preview card shows the wrong name, a blank gray box, or the logo of the tool you built it with. People scroll past.&lt;/p&gt;

&lt;p&gt;This is common in apps built with Lovable, Bolt, Replit, v0, Cursor or Claude Code, and it is fixable in an afternoon. The same few tags control your link previews and how your pages look in Google, so one round of fixes improves both. Here are 7 steps, with copy-paste tags, AI prompts, and the official tools to refresh old previews.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick glossary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Title tag:&lt;/strong&gt; the page name in the browser tab. Google often uses it as the clickable headline in search results.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Meta description:&lt;/strong&gt; a one or two sentence summary of the page. Google sometimes shows it under the headline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Open Graph (OG) tags:&lt;/strong&gt; &lt;code&gt;og:title&lt;/code&gt;, &lt;code&gt;og:description&lt;/code&gt;, &lt;code&gt;og:image&lt;/code&gt; and friends. Facebook created the standard, and LinkedIn, WhatsApp, Slack, iMessage and many other apps read it to build preview cards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Twitter card tags:&lt;/strong&gt; X's own tags. X falls back to your OG tags for title, description and image, but &lt;code&gt;twitter:card&lt;/code&gt; picks the big-image card style.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Link-preview bot:&lt;/strong&gt; the program a platform sends to read your page when someone pastes a link. Most of them do not run JavaScript.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cache:&lt;/strong&gt; platforms save a preview the first time they see your link and keep showing it after you fix your page.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: See what the bots see
&lt;/h2&gt;

&lt;p&gt;Before changing anything, find out what is broken. Paste your homepage URL into two free official tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LinkedIn Post Inspector&lt;/strong&gt; (linkedin.com/post-inspector): shows the title, description and image LinkedIn found.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Facebook Sharing Debugger&lt;/strong&gt; (developers.facebook.com/tools/debug): shows the tags Meta's crawler found, plus warnings.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then look at the raw HTML yourself. This asks for the page the way a simple bot would and prints only the tags that matter:&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;-sL&lt;/span&gt; &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"facebookexternalhit/1.1"&lt;/span&gt; https://yourapp.com/pricing &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-ioE&lt;/span&gt; &lt;span class="s1"&gt;'&amp;lt;title&amp;gt;[^&amp;lt;]*|&amp;lt;meta[^&amp;gt;]*(og:|twitter:|description)[^&amp;gt;]*&amp;gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the output is empty or shows the same title on every page, you found the problem. One caveat: some hosts serve pre-rendered HTML only to verified crawlers (Lovable's docs say its older React + Vite apps work this way), so curl may show an empty page while real bots get full tags. When in doubt, trust the platform debuggers.&lt;/p&gt;

&lt;p&gt;Write down what you find in a simple table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PAGE        TITLE              DESCRIPTION     OG:IMAGE        PROBLEM
/           "Vite + React"     missing         lovable.png     wrong brand
/pricing    "Vite + React"     missing         lovable.png     same as home
/blog/post  "Vite + React"     missing         none            no image
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2: Write a unique title and description for every page you share
&lt;/h2&gt;

&lt;p&gt;Google's guidance is clear: every page needs its own descriptive title, without boilerplate or keyword stuffing, and a description that accurately describes that specific page.&lt;/p&gt;

&lt;p&gt;Start with the pages people actually share: home, pricing, your main use case or feature pages, and blog posts. Use this pattern:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Title tag:       [What this page is] | [Brand]
og:title:        [What this page is]            (no brand; og:site_name holds it)
Description:     [Who it is for] + [what they get] + [one specific detail]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A hypothetical example for a meal-planning app called PlateWise:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Home     Title: Weekly meal plans for busy families | PlateWise
         Description: Plan a week of dinners in 10 minutes and get one
         shopping list. Free for your first 2 weeks.
Pricing  Title: Pricing | PlateWise
         Description: One plan, $6 a month, cancel anytime. See what is
         included and how the free 2 weeks work.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few rules from the platforms' own docs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep descriptions short.&lt;/strong&gt; WhatsApp's docs say about 80 characters is enough for its preview, and X lists 200 characters as the maximum for its description tag. Put the important words first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leave the brand out of og:title.&lt;/strong&gt; Apple's Messages guidance says to put the site name in &lt;code&gt;og:site_name&lt;/code&gt; instead, to avoid repeating it in the preview.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not let logged-in pages say "Sign In".&lt;/strong&gt; Apple recommends that pages behind a login still describe what the link gives access to, without revealing private content.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Google may still rewrite your title or snippet in results. Good, specific text just makes that less likely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Add the full tag set
&lt;/h2&gt;

&lt;p&gt;Here is the complete set for one page. Swap in your real values and use full &lt;code&gt;https://&lt;/code&gt; URLs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;title&amp;gt;&lt;/span&gt;Pricing | PlateWise&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"description"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"One plan, $6 a month, cancel anytime."&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"canonical"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://platewise.app/pricing"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:type"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"website"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:site_name"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"PlateWise"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:title"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"Pricing"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:description"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"One plan, $6 a month, cancel anytime."&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:url"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://platewise.app/pricing"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"https://platewise.app/og/pricing.png"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:width"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"1200"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:height"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"630"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;property=&lt;/span&gt;&lt;span class="s"&gt;"og:image:alt"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"PlateWise pricing: one plan, $6 a month"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;meta&lt;/span&gt; &lt;span class="na"&gt;name=&lt;/span&gt;&lt;span class="s"&gt;"twitter:card"&lt;/span&gt; &lt;span class="na"&gt;content=&lt;/span&gt;&lt;span class="s"&gt;"summary_large_image"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"icon"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/favicon.ico"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"apple-touch-icon"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"/apple-touch-icon.png"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why &lt;code&gt;og:image:width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt;? Facebook's docs say they let its crawler render the image immediately for the first person who shares your link.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If your app is Next.js&lt;/strong&gt; (common with v0, Cursor and Claude Code), do not hand-write tags. Use the Metadata API. Set a base URL and a title template once in your root layout:&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;// app/layout.tsx&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Metadata&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;next&lt;/span&gt;&lt;span class="dl"&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;const&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Metadata&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;metadataBase&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;URL&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://platewise.app&lt;/span&gt;&lt;span class="dl"&gt;"&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;%s | PlateWise&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PlateWise&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;Weekly meal plans for busy families.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;openGraph&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;siteName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;PlateWise&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;website&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;twitter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;card&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;summary_large_image&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;Then give each page its own values:&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;// app/pricing/page.tsx&lt;/span&gt;
&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;metadata&lt;/span&gt; &lt;span class="o"&gt;=&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;Pricing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// becomes "Pricing | PlateWise"&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;One plan, $6 a month, cancel anytime.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;openGraph&lt;/span&gt;&lt;span class="p"&gt;:&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;Pricing&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/pricing&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;&lt;code&gt;metadataBase&lt;/code&gt; turns relative paths like &lt;code&gt;/pricing&lt;/code&gt; into full URLs. Watch one gotcha from the Next.js docs: if a page sets &lt;code&gt;openGraph&lt;/code&gt;, it replaces the layout's whole &lt;code&gt;openGraph&lt;/code&gt; object, so repeat any fields you still need. For pages built from your database, such as blog posts, use &lt;code&gt;generateMetadata&lt;/code&gt; instead. For icons, drop &lt;code&gt;favicon.ico&lt;/code&gt;, &lt;code&gt;icon.png&lt;/code&gt; and &lt;code&gt;apple-icon.png&lt;/code&gt; into the &lt;code&gt;app/&lt;/code&gt; folder and Next.js adds the tags.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If your app is Lovable&lt;/strong&gt;, ask in chat. Its docs say it generates titles and descriptions per page, and the page selector above the preview shows a Social card and a Search card for each page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If your app is a plain React + Vite app&lt;/strong&gt; (common with Bolt and Replit), the tags go in &lt;code&gt;index.html&lt;/code&gt;, and image files go in the &lt;code&gt;public/&lt;/code&gt; folder, which Vite serves from the root of your site. Read Step 5 before you rely on this for more than one page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Make one share image that works everywhere
&lt;/h2&gt;

&lt;p&gt;Each platform has its own rules. Here is what their docs say:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PLATFORM   SIZE / RATIO                          FILE LIMIT     NOTES
Facebook   1200x630 best, 1.91:1                 8 MB           under 600x315 = small thumbnail
LinkedIn   at least 1200x627, 1.91:1             5 MB           under 401 px wide = thumbnail
X          2:1, min 300x157                      5 MB           JPG, PNG, WEBP, GIF; no SVG
WhatsApp   300 px wide or more, max 4:1          under 600 KB   tags in first 300 KB of HTML
iMessage   900 px wide or more                   10 MB total    avoid small text in the image
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The safe choice that fits all of them: &lt;strong&gt;1200 x 630 pixels, PNG or JPG, under 300 KB.&lt;/strong&gt; Lovable's docs warn that WhatsApp shows no preview for larger files.&lt;/p&gt;

&lt;p&gt;Design tips:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Keep the important content in the middle. X uses 2:1 and the others use 1.91:1, so the edges may get cropped.&lt;/li&gt;
&lt;li&gt;Use your product name and one short promise in big text, or a clean screenshot. Apple warns that small text can become unreadable.&lt;/li&gt;
&lt;li&gt;Never reuse your logo or favicon as the share image. Small square images become tiny thumbnails.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ask your AI tool to build it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Create a 1200x630 PNG share image for my app [name]. Show the product
name and this promise in large text: "[one short promise]". Keep all
text inside the middle 1000x500 area. Use my brand colors [colors].
Save it as public/og/home.png, keep it under 300 KB, and set og:image
and the image width, height and alt tags to its full https URL.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next.js users can also place an &lt;code&gt;opengraph-image.png&lt;/code&gt; file inside any route folder and Next.js will add the image tags for that route automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Make sure the tags exist before JavaScript runs
&lt;/h2&gt;

&lt;p&gt;This is the step that breaks most vibe-coded apps. Many AI-built apps are single-page apps: the server sends one nearly empty HTML file, and JavaScript builds the page in the browser. Link-preview bots usually do not wait for that. Apple's docs state plainly that link previews in Messages do not run JavaScript. Slack's link bot fetches as little of the page as it can. WhatsApp needs the tags within the first 300 KB of HTML.&lt;/p&gt;

&lt;p&gt;So if your AI tool added tags with a library like react-helmet, browsers see them but bots do not. Every page previews with whatever is in &lt;code&gt;index.html&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;How to fix it depends on your stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js:&lt;/strong&gt; the Metadata API from Step 3 already puts tags in the server HTML. Keep &lt;code&gt;metadata&lt;/code&gt; and &lt;code&gt;generateMetadata&lt;/code&gt; in Server Components, as the docs require.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lovable:&lt;/strong&gt; its docs say new apps use server-side rendering, and older React + Vite apps get pre-rendered pages for verified crawlers, including social-preview bots. You mostly need good per-page tags.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plain Vite SPA (Bolt, Replit, others):&lt;/strong&gt; set strong site-wide defaults in &lt;code&gt;index.html&lt;/code&gt; so every link at least looks right. For pages you want to share with their own preview, such as pricing, features and blog posts, serve them as real HTML: a static marketing site, a server-rendered framework, or a build-time pre-render step.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A prompt to plan this safely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;My app is a client-side React app. Social link previews only read the
first HTML response and do not run JavaScript. List every public page
I would want to share. For each, tell me whether its title, description
and og:image are in the raw HTML today. Then propose the smallest change
that puts unique tags in the server response for those pages only.
Do not change logged-in app pages. Show me the plan before editing.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 6: Clear the old previews
&lt;/h2&gt;

&lt;p&gt;After you deploy, refresh each platform:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Facebook (and Meta apps):&lt;/strong&gt; open the Sharing Debugger and click Scrape Again. Facebook caches images by URL, so give a changed image a new file name or URL. Old posts keep their old preview.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LinkedIn:&lt;/strong&gt; paste the URL into Post Inspector. It shows when LinkedIn last updated its data and lets you request a re-scrape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;X:&lt;/strong&gt; the Card Validator lost its preview feature in 2022, and X's docs said cards are re-read roughly every seven days. Check a card by pasting the link into the post composer. X's troubleshooting docs suggested adding a parameter to the image URL, such as &lt;code&gt;og.png?v=2&lt;/code&gt;, so the new image is fetched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slack:&lt;/strong&gt; Slack's docs say responses are cached for around 30 minutes, so just wait and post again.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WhatsApp:&lt;/strong&gt; there is no official refresh tool. Its docs suggest typing the link into a chat and waiting for the preview above the text box. If it is stale, testing with a harmless parameter like &lt;code&gt;?v=2&lt;/code&gt; is a common workaround.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 7: Check every page, then keep it from breaking
&lt;/h2&gt;

&lt;p&gt;One fixed homepage is not enough. Check every public page for duplicate or missing titles, descriptions and images, using a crawler instead of one URL at a time. Disclosure: I work on Siteory. Siteory (siteory.co) is one free option: it reads your public HTML and flags missing titles and meta descriptions, and duplicate titles, across the pages it crawls. It does not draw social cards for you, so pair it with the platform debuggers from Step 1.&lt;/p&gt;

&lt;p&gt;Also check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Favicon:&lt;/strong&gt; Google needs a square icon (larger than 48x48 px recommended) at a stable URL, linked from your homepage. It must not be blocked from crawling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;robots.txt:&lt;/strong&gt; do not block Twitterbot or the folder that holds your images.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Staging links:&lt;/strong&gt; previews only work for public pages. A password-protected preview URL will always show a blank card.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Paste this audit prompt into your AI tool after every big change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Check every public route in this project. For each route, list the
title, meta description, og:title, og:description, og:image (full URL)
and twitter:card. Flag: duplicate titles, missing descriptions,
relative or missing image URLs, images that are not 1200x630, and tags
that are only added by client-side JavaScript. Do not edit anything yet.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Leaving the template defaults.&lt;/strong&gt; "Vite + React", "Lovable App" or "Your SaaS Name" in the title or preview tells people the app is unfinished.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Relative image URLs.&lt;/strong&gt; &lt;code&gt;/og.png&lt;/code&gt; works in your browser but not for bots. Use &lt;code&gt;https://yourapp.com/og.png&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Same title everywhere.&lt;/strong&gt; Your pages look identical in Google and in chats.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tags added by JavaScript only.&lt;/strong&gt; They are invisible to most preview bots.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Huge or SVG images.&lt;/strong&gt; X does not support SVG, and WhatsApp needs under 600 KB.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forgetting the cache.&lt;/strong&gt; Run the debuggers after every change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testing only on one platform.&lt;/strong&gt; A card that works on LinkedIn can still fail on WhatsApp.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Your action summary
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Run Post Inspector, the Sharing Debugger and the curl check on your top pages.&lt;/li&gt;
&lt;li&gt;Write a unique title and description for home, pricing, feature pages and posts.&lt;/li&gt;
&lt;li&gt;Add the full tag set, or use the Next.js Metadata API or Lovable's Social card.&lt;/li&gt;
&lt;li&gt;Create a 1200x630 PNG or JPG share image under 300 KB.&lt;/li&gt;
&lt;li&gt;Make sure the tags are in the first HTML response, not added by JavaScript.&lt;/li&gt;
&lt;li&gt;Refresh caches on Facebook, LinkedIn and X, and retest Slack and WhatsApp.&lt;/li&gt;
&lt;li&gt;Crawl every public page for duplicates and gaps, and rerun the audit prompt after big changes.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Your link preview is often the first thing a stranger sees of your product. Spend one afternoon on it, and every link you share from now on will look like it came from a real, finished product.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>marketing</category>
      <category>beginners</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Know When Your Vibe-Coded App Breaks Before Your Users Do: A 7-Step Beginner Guide to Error Alerts</title>
      <dc:creator>Vishal Chauhan</dc:creator>
      <pubDate>Thu, 22 Oct 2026 06:32:21 +0000</pubDate>
      <link>https://dev.to/siteory/how-to-know-when-your-vibe-coded-app-breaks-before-your-users-do-a-7-step-beginner-guide-to-error-48mc</link>
      <guid>https://dev.to/siteory/how-to-know-when-your-vibe-coded-app-breaks-before-your-users-do-a-7-step-beginner-guide-to-error-48mc</guid>
      <description>&lt;p&gt;Your app is live. You tested signup, you tested the main feature, everything worked. Then a week later someone replies to your launch post: "Is your site broken? I can't log in."&lt;/p&gt;

&lt;p&gt;Too often, that is how founders find out their app is broken: from a user, days late, after other people quietly gave up and left. AI tools like Lovable, Bolt, v0, Cursor and Claude Code are great at building the happy path. They usually do not set up the part that tells you when something fails in production.&lt;/p&gt;

&lt;p&gt;This guide fixes that in an afternoon. By the end you will have three things: an error tracker that catches crashes and silent failures, an outside uptime check that notices when the app or database is down, and alerts that actually reach you. All of it starts on free plans.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quick glossary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Error tracker:&lt;/strong&gt; a service (this guide uses Sentry) that records every error your app throws in a real user's browser or on your server, groups repeats together, and shows you where it happened.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DSN:&lt;/strong&gt; the address your app uses to send errors to your Sentry project. Sentry's docs say it is safe to keep public, because it only allows sending new events, not reading any data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Uptime monitor:&lt;/strong&gt; an outside robot that loads a URL every few minutes and alerts you when it fails or the page looks wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Source map:&lt;/strong&gt; a file that translates your minified production code back into your real file names and line numbers, so error reports are readable.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Write down the 5 things that must never break
&lt;/h2&gt;

&lt;p&gt;Before installing anything, decide what "broken" means for your app. You do not need to watch everything. Fill in this template:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MUST-NEVER-BREAK LIST
1. Signup (new account, new device, logged out)
2. Login and password reset
3. The one core action (the reason people use the app)
4. Saving data (create / edit / delete the main thing)
5. Payment and access after paying (if you charge)

For each one:
- Where does it run? (browser / API route / Edge Function / webhook)
- What does the user see if it fails? (error message / blank screen / nothing)
- How would I find out today? (honest answer)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last line is the important one. If the honest answer is "a user would tell me", that flow goes to the top of your list.&lt;/p&gt;

&lt;p&gt;A hypothetical example: in a habit-tracker app, the core action is "mark today as done". If that save fails quietly, the user sees the checkmark, refreshes the next day, and finds their streak gone. Nothing crashed, so nobody gets told. That is exactly the kind of failure this guide is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Add Sentry to your frontend
&lt;/h2&gt;

&lt;p&gt;Create a free account at sentry.io, create a project, and pick your framework. Sentry's free Developer plan is for one person and includes error monitoring and email alerts. Check the pricing page for the current limits.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If your app is Next.js&lt;/strong&gt; (common with v0, Cursor and Claude Code), run the official wizard in your project folder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @sentry/wizard@latest &lt;span class="nt"&gt;-i&lt;/span&gt; nextjs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It installs the SDK, creates the browser, server and edge config files, and wraps your &lt;code&gt;next.config&lt;/code&gt; so source maps upload on build. It also asks whether to route browser requests through your own server to avoid ad blockers. Saying yes means fewer missed reports, with one trade-off from Sentry's docs: it can add a little server load and hosting cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If your app is React + Vite&lt;/strong&gt; (common with Lovable and Bolt), install the SDK:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Then create one small file and import it first:&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;// src/instrument.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Sentry&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;@sentry/react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;dsn&lt;/span&gt;&lt;span class="p"&gt;:&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;meta&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="nx"&gt;VITE_SENTRY_DSN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="p"&gt;:&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;meta&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="nx"&gt;MODE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// "production" on your live build&lt;/span&gt;
  &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="p"&gt;:&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;meta&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="nx"&gt;PROD&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;// ignore errors from your laptop&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/main.tsx - this import must be the FIRST line&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;./instrument&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;Put the DSN in your host's environment variables as &lt;code&gt;VITE_SENTRY_DSN&lt;/code&gt;. A &lt;code&gt;VITE_&lt;/code&gt; variable ends up in the browser, which is fine for a DSN and never fine for secret keys.&lt;/p&gt;

&lt;p&gt;Finally, wrap your app so a crash shows a friendly message instead of a white screen, and gets reported:&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="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ErrorBoundary&lt;/span&gt; &lt;span class="na"&gt;fallback&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;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Something went wrong. Please refresh.&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;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;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;App&lt;/span&gt; &lt;span class="p"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nc"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ErrorBoundary&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Test it properly.&lt;/strong&gt; Add a temporary button that throws &lt;code&gt;new Error("Sentry test")&lt;/code&gt;, deploy, click it on the live site, and check the Issues page. Sentry's docs warn that errors you trigger from the browser DevTools console are sandboxed and will not show up, so use a real button. Delete the button afterwards.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Report the errors that do not crash anything
&lt;/h2&gt;

&lt;p&gt;This is the step most guides skip, and it matters most for vibe-coded apps. Sentry automatically catches uncaught errors. But many real failures never throw:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Supabase queries return errors instead of throwing them.&lt;/strong&gt; The official pattern is &lt;code&gt;const { data, error } = await supabase.from(...)...&lt;/code&gt;. If the code ignores &lt;code&gt;error&lt;/code&gt;, the save fails silently and Sentry never hears about it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fetch()&lt;/code&gt; does not fail on 404 or 500.&lt;/strong&gt; MDN is clear: the promise only rejects on network-level failures. A 500 from your API looks like success unless you check &lt;code&gt;response.ok&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI-written &lt;code&gt;try/catch&lt;/code&gt; blocks that only &lt;code&gt;console.log&lt;/code&gt;&lt;/strong&gt; the error. The log disappears in the user's browser.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Add one helper and use it everywhere something important can fail:&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;// src/lib/report.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Sentry&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;@sentry/react&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// or "@sentry/nextjs"&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;report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;where&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="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;err&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;Error&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;err&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;captureException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;where&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 use it on your must-never-break flows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;error&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;habits&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newHabit&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;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;save-habit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;showToast&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Could not save. Please try again.&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="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/generate&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;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;generate&lt;/span&gt;&lt;span class="dl"&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;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`API returned &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user gets a clear message instead of a fake success, and the &lt;code&gt;where&lt;/code&gt; tag lets you filter Sentry by flow later.&lt;/p&gt;

&lt;p&gt;You do not have to do this by hand. Paste this into your AI tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Find every Supabase query, fetch() call and try/catch block in this
project. For each one on these flows: [paste your must-never-break list],
make sure errors are (1) passed to the report() helper in
src/lib/report.ts with a short "where" tag, and (2) shown to the user
as a clear message. Never show success if the operation failed.
Do not change any other behavior. List every file you changed.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 4: Cover the backend too
&lt;/h2&gt;

&lt;p&gt;Errors in your server code never reach the browser, so the frontend setup will not see them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Next.js:&lt;/strong&gt; the wizard from Step 2 already covers API routes, server components and middleware through its &lt;code&gt;instrumentation.ts&lt;/code&gt; file. Nothing extra to do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supabase Edge Functions&lt;/strong&gt; (where Lovable puts most backend logic): Supabase's own docs show adding the Sentry Deno SDK. The short version is to wrap your handler in &lt;code&gt;try/catch&lt;/code&gt;, capture the error, and flush before returning:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;Sentry&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;npm:@sentry/deno@^8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;dsn&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;SENTRY_DSN&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="na"&gt;defaultIntegrations&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// inside your handler:&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ...your existing code...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;captureException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;e&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Sentry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;flush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// send before the function shuts down&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Internal error&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&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;Add &lt;code&gt;SENTRY_DSN&lt;/code&gt; under Edge Function Secrets in the Supabase dashboard. Sentry recommends one project per part of your app, so create a second Sentry project for the backend and use its DSN here. That way frontend and backend errors do not mix.&lt;/p&gt;

&lt;p&gt;Do webhooks (Stripe, auth hooks) first. When those fail, no user is on screen to complain; the work just never happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Turn on alerts that actually reach you
&lt;/h2&gt;

&lt;p&gt;An error tracker you never open is the same as no error tracker. In Sentry, go to Alerts and create an alert for your project with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Environment:&lt;/strong&gt; &lt;code&gt;production&lt;/code&gt; only, so local testing never pings you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Triggers:&lt;/strong&gt; "A new issue is created" and "A resolved issue regresses". The first tells you about anything new. The second tells you when a bug you fixed comes back, which happens a lot when AI rewrites a file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A second alert for spikes:&lt;/strong&gt; trigger "An event or issue activity is captured", then add a frequency filter: number of events more than, say, 10 in one hour. Pick a number that fits your traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Action:&lt;/strong&gt; the free plan sends email. Add an inbox filter so Sentry emails are never archived, muted or sent to spam.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Throttle:&lt;/strong&gt; at most once every 30 or 60 minutes per issue, so one bad bug does not send 200 emails.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use the "Send Test Notification" button before saving, and confirm the email actually lands in your inbox, not spam.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Add an outside uptime check
&lt;/h2&gt;

&lt;p&gt;Sentry only sees errors from code that runs. If your host is down, your domain expired, or your database is unreachable, there may be nothing to report. You need something outside your app knocking on the door.&lt;/p&gt;

&lt;p&gt;First, add a tiny health endpoint that also touches the database. For Next.js:&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;// app/api/health/route.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="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;dynamic&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;force-dynamic&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// never serve a cached "ok"&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="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;process&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="nx"&gt;NEXT_PUBLIC_SUPABASE_URL&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;process&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="nx"&gt;NEXT_PUBLIC_SUPABASE_ANON_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;error&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;habits&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;limit&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;error&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;db-fail&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&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;For a Lovable or Vite app, ask your AI tool to create the same thing as a Supabase Edge Function named &lt;code&gt;health&lt;/code&gt;. Because the uptime robot has no login token, that one function needs &lt;code&gt;verify_jwt = false&lt;/code&gt; under &lt;code&gt;[functions.health]&lt;/code&gt; in &lt;code&gt;supabase/config.toml&lt;/code&gt;. Keep it boring: it reads one row through Row Level Security and returns only "ok" or "db-fail", never data.&lt;/p&gt;

&lt;p&gt;Then create a free UptimeRobot account. The free plan includes 50 monitors with checks every 5 minutes, plus keyword monitors. Set up two:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Keyword monitor on your homepage:&lt;/strong&gt; alert if a word that always appears (your product name or headline) is missing. This catches the "page loads but is blank" problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP monitor on &lt;code&gt;/api/health&lt;/code&gt;:&lt;/strong&gt; it alerts when the endpoint returns an error status, which ours does (500) whenever the database check fails.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Five-minute checks are fine for a first SaaS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Make errors readable, then let your AI help fix them
&lt;/h2&gt;

&lt;p&gt;A raw production error often points to something like &lt;code&gt;index-a8f3.js:1:48213&lt;/code&gt;, which tells you nothing. Source maps fix that. Next.js users already got this from the wizard. For Vite, run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx @sentry/wizard@latest &lt;span class="nt"&gt;-i&lt;/span&gt; sourcemaps
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It configures your build to upload source maps to Sentry. Sentry's docs warn that public source maps can expose your source code, so keep the option that deletes &lt;code&gt;.map&lt;/code&gt; files after upload. Keep the Sentry auth token in your host's environment variables, never in Git.&lt;/p&gt;

&lt;p&gt;Now the fun part. Sentry runs an official MCP server, so your AI tool can read your real errors. In Claude Code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add &lt;span class="nt"&gt;--transport&lt;/span&gt; http sentry https://mcp.sentry.dev/mcp/YOUR-ORG/YOUR-PROJECT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cursor can add the same URL in its MCP settings, and Lovable lists Sentry as a chat connector. Then use a prompt 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;Read the newest unresolved Sentry issue in production. Explain in plain
English what the user was doing and why it failed. Propose the smallest
fix, show me the diff before applying it, and add a check so this
error is reported with report() if it happens again.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Finish with a 15-minute weekly habit: open Sentry, sort by users affected, fix the top issue, mark it resolved, and ignore known noise (like browser-extension errors).&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Testing only while logged in.&lt;/strong&gt; Many breaks only hit new users. Run your must-never-break list from a private window with a fresh account after every big change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Leaving Sentry on in development.&lt;/strong&gt; Your own test errors flood the inbox and train you to ignore alerts. Use the &lt;code&gt;production&lt;/code&gt; environment filter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swallowing errors.&lt;/strong&gt; A &lt;code&gt;catch&lt;/code&gt; block that only logs, or a Supabase call that ignores &lt;code&gt;error&lt;/code&gt;, hides exactly the bugs you most need to see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Showing success after failure.&lt;/strong&gt; If the save failed, say so. A fake checkmark is worse than an error message.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alerting on everything.&lt;/strong&gt; Too many emails and you will mute them. Start with new issues, regressions and one volume rule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checking only the homepage.&lt;/strong&gt; A homepage can load fine while login, the API or the database is down. Monitor the health endpoint too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sending personal data you do not need.&lt;/strong&gt; Tag users by ID, not email, and leave Session Replay's default masking on if you enable it. Sentry masks all text and blocks media by default.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Your action summary
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Write your must-never-break list and answer "how would I find out today?" for each flow.&lt;/li&gt;
&lt;li&gt;Add Sentry to the frontend (wizard for Next.js, &lt;code&gt;@sentry/react&lt;/code&gt; for Vite), deploy, and test with a real button.&lt;/li&gt;
&lt;li&gt;Add a &lt;code&gt;report()&lt;/code&gt; helper and use it wherever Supabase, &lt;code&gt;fetch()&lt;/code&gt; or &lt;code&gt;try/catch&lt;/code&gt; can fail quietly.&lt;/li&gt;
&lt;li&gt;Add Sentry to API routes and Edge Functions, starting with webhooks.&lt;/li&gt;
&lt;li&gt;Create production-only alerts for new issues, regressions and spikes, and send a test notification.&lt;/li&gt;
&lt;li&gt;Add a &lt;code&gt;/api/health&lt;/code&gt; endpoint plus two UptimeRobot monitors.&lt;/li&gt;
&lt;li&gt;Upload source maps, connect Sentry to your AI tool, and do a 15-minute review every week.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Your users should never be your monitoring system. Set this up once, and the next time something breaks, you will be the first to know.&lt;/p&gt;

</description>
      <category>beginners</category>
      <category>vibecoding</category>
      <category>webdev</category>
      <category>monitoring</category>
    </item>
    <item>
      <title>How to Set Up Onboarding Emails That Bring New Users Back: A 7-Step Beginner Guide for Your First SaaS</title>
      <dc:creator>Vishal Chauhan</dc:creator>
      <pubDate>Wed, 21 Oct 2026 11:26:53 +0000</pubDate>
      <link>https://dev.to/siteory/how-to-set-up-onboarding-emails-that-bring-new-users-back-a-7-step-beginner-guide-for-your-first-4om5</link>
      <guid>https://dev.to/siteory/how-to-set-up-onboarding-emails-that-bring-new-users-back-a-7-step-beginner-guide-for-your-first-4om5</guid>
      <description>&lt;p&gt;Someone signs up for your app. They click around for two minutes, get distracted, and never come back. You never hear from them again, and you have no idea why.&lt;/p&gt;

&lt;p&gt;A short set of onboarding emails is one of the cheapest ways to close the gap between "signed up" and "got value". Not a newsletter, not a sales blast: a few plain, helpful emails that show up at the right moment and point to one next step.&lt;/p&gt;

&lt;p&gt;This guide walks you through building that set for your first SaaS, even if you built the app with Cursor, Claude Code, Lovable, Bolt, Replit or v0 and have never written a marketing email.&lt;/p&gt;

&lt;p&gt;Three quick terms first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Onboarding emails:&lt;/strong&gt; the emails a new user gets in their first 1 to 2 weeks, meant to help them start using the product.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First win (or activation):&lt;/strong&gt; the one action that shows a new user your product works for them. For an invoicing app, that might be "sent the first invoice".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trigger:&lt;/strong&gt; the rule that decides when an email goes out, like "24 hours after signup, only if they have not done the first win yet".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One note before we start: this is not about getting your signup or password reset emails out of spam. If those are not arriving, fix your sending domain setup (SPF, DKIM, DMARC) first. Everything below assumes your app can already send email reliably.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Pick your one "first win" action
&lt;/h2&gt;

&lt;p&gt;Every onboarding email you write will point at one thing, so pick it carefully.&lt;/p&gt;

&lt;p&gt;Ask yourself: what is the smallest action after which a new user can honestly say "oh, this is useful"? It should be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Specific:&lt;/strong&gt; something your database can see, like a row being created.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Early:&lt;/strong&gt; possible in the first session, ideally in under 10 minutes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tied to value:&lt;/strong&gt; not "completed profile", but "created the thing they came for".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Examples (hypothetical products):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A link-in-bio builder: "published a page with at least one link".&lt;/li&gt;
&lt;li&gt;A meeting notes tool: "uploaded or recorded a first meeting".&lt;/li&gt;
&lt;li&gt;An AI resume helper: "downloaded a first improved resume".&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have two candidates, pick the one closer to the result the user signed up for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Track three simple things in your database
&lt;/h2&gt;

&lt;p&gt;You do not need a fancy analytics tool for this. You need to know three facts about each user:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;When they signed up.&lt;/li&gt;
&lt;li&gt;Whether (and when) they hit the first win.&lt;/li&gt;
&lt;li&gt;Whether they said no to these emails.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You also need a record of which emails you already sent, so nobody gets the same one twice. Here is a minimal setup for Postgres (Supabase included). Rename the columns to match your app.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- On your existing profiles/users table&lt;/span&gt;
&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;profiles&lt;/span&gt; &lt;span class="k"&gt;add&lt;/span&gt; &lt;span class="k"&gt;column&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="n"&gt;activated_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;profiles&lt;/span&gt; &lt;span class="k"&gt;add&lt;/span&gt; &lt;span class="k"&gt;column&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="n"&gt;last_seen_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;profiles&lt;/span&gt; &lt;span class="k"&gt;add&lt;/span&gt; &lt;span class="k"&gt;column&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="n"&gt;onboarding_emails_off&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- One row per onboarding email sent&lt;/span&gt;
&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="n"&gt;onboarding_email_log&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;profiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;cascade&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;email_key&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;-- e.g. 'welcome', 'nudge_24h'&lt;/span&gt;
  &lt;span class="n"&gt;sent_at&lt;/span&gt; &lt;span class="n"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="k"&gt;primary&lt;/span&gt; &lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;email_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;-- makes double-sends impossible&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then set &lt;code&gt;activated_at&lt;/code&gt; in the same code path where the first win happens (for example, right after the first invoice is saved), and update &lt;code&gt;last_seen_at&lt;/code&gt; when a signed-in user loads the app. If you use Supabase, keep Row Level Security on for the new table and only write to it from your server code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Map a short sequence with clear stop rules
&lt;/h2&gt;

&lt;p&gt;Beginners often copy a 12-email "drip" from a template site. Start smaller. Five emails over 14 days is plenty, and most users will only get two or three of them because the rules skip emails that no longer make sense.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Email&lt;/th&gt;
&lt;th&gt;When it goes out&lt;/th&gt;
&lt;th&gt;Who gets it&lt;/th&gt;
&lt;th&gt;Its one job&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;welcome&lt;/td&gt;
&lt;td&gt;Right after signup&lt;/td&gt;
&lt;td&gt;Everyone&lt;/td&gt;
&lt;td&gt;Point to the first win&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nudge_24h&lt;/td&gt;
&lt;td&gt;About 24 hours after signup&lt;/td&gt;
&lt;td&gt;Not activated yet&lt;/td&gt;
&lt;td&gt;Remove the most common blocker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;help_day3&lt;/td&gt;
&lt;td&gt;Day 3&lt;/td&gt;
&lt;td&gt;Still not activated&lt;/td&gt;
&lt;td&gt;Ask what got in the way&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;next_step&lt;/td&gt;
&lt;td&gt;About 24 hours after activation&lt;/td&gt;
&lt;td&gt;Activated users&lt;/td&gt;
&lt;td&gt;Show the second most useful thing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;checkin_day10&lt;/td&gt;
&lt;td&gt;Day 10&lt;/td&gt;
&lt;td&gt;Activated, but not seen for 5+ days&lt;/td&gt;
&lt;td&gt;Bring them back with a reason&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Stop rules (write these down, your code will need them):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The moment a user activates, they stop getting "please get started" emails.&lt;/li&gt;
&lt;li&gt;Nobody gets more than one onboarding email per day.&lt;/li&gt;
&lt;li&gt;If a user turns these emails off, nothing else in this sequence goes to them.&lt;/li&gt;
&lt;li&gt;After day 14, the sequence ends. What happens next belongs to your normal product and billing emails.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the key difference from a fixed drip: emails are based on what the user did, not only on the calendar.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Write each email like a short note from a person
&lt;/h2&gt;

&lt;p&gt;Plain text, short, one link, and an honest invitation to reply. It is far easier to write than a designed newsletter, and replies are the best feedback you will get this early.&lt;/p&gt;

&lt;p&gt;Copy these and fill in the brackets. Keep each one under about 120 words.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EMAIL: welcome
Subject: Your [Product] account is ready - start here

Hi [First name],

Thanks for signing up for [Product]. The fastest way to see if it is
useful for you: [first win, e.g. "create your first invoice"]. It takes
about [X] minutes.

Start here: [deep link straight to that screen]

If anything looks broken or confusing, just reply. I read every reply.

[Your name], founder of [Product]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EMAIL: nudge_24h
Subject: The step most people get stuck on

Hi [First name],

You have not [first win] yet, so here is the part that trips most
people up: [one common blocker, e.g. "connecting your bank account"].

Here is how to do it in [X] minutes: [link to a 60-90 second video or
a short help page]

Or skip it for now and [simpler path], then come back later.

[Your name]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EMAIL: help_day3
Subject: Quick question about [Product]

Hi [First name],

You signed up a few days ago but have not [first win] yet. Totally fine.

Could you hit reply and tell me what got in the way? One line is
plenty: not the right fit, too confusing, no time, something else.

I will use your answer to fix the product.

[Your name]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EMAIL: next_step
Subject: Nice work on your first [thing]

Hi [First name],

You just [first win]. Most people who keep using [Product] do one more
thing next: [second action, e.g. "set up automatic reminders"].

Here is where to do it: [deep link]

[Your name]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EMAIL: checkin_day10
Subject: Your [thing] is waiting

Hi [First name],

Last time you were here you [what they did]. [One concrete reason to
come back, e.g. "Two of your invoices are now overdue."]

Pick up where you left off: [deep link]

[Your name]

You are getting this because you signed up for [Product].
Turn off these tips: [unsubscribe link]
[Your business postal address]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few rules that make these work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deep links, not your homepage.&lt;/strong&gt; Send people straight to the screen where the next step happens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One link per email.&lt;/strong&gt; More choices means fewer clicks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No fake urgency.&lt;/strong&gt; No "last chance" in week one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subject says what is inside.&lt;/strong&gt; Not "Re:" or "Fwd:" tricks. Gmail's sender guidelines explicitly say not to use those unless the message really is a reply or forward.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 5: Send them with a small scheduled job
&lt;/h2&gt;

&lt;p&gt;You might be tempted to schedule all five emails at signup. Some providers let you do that (Resend, for example, can schedule an email up to 30 days ahead). The problem: the user's behavior changes after you schedule, so you would have to cancel emails every time someone activates.&lt;/p&gt;

&lt;p&gt;A simpler pattern for beginners: run one job on a schedule that asks "who should get which email right now?" and sends only those. Two common ways to run it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vercel Cron Jobs:&lt;/strong&gt; a route in your Next.js app that Vercel calls on a schedule. On the free Hobby plan, cron jobs can only run once per day, and the exact time can drift within the hour. That is fine for days 3 and 10, less precise for "24 hours".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supabase Cron:&lt;/strong&gt; Supabase supports &lt;code&gt;pg_cron&lt;/code&gt; plus &lt;code&gt;pg_net&lt;/code&gt;, so Postgres can call an Edge Function on any schedule you set, such as every hour.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Send the welcome email directly from your signup code, since it should arrive right away. The job handles everything else.&lt;/p&gt;

&lt;p&gt;Here is the core query for one email, &lt;code&gt;nudge_24h&lt;/code&gt;, in Postgres:&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;select&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;first_name&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;profiles&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'24 hours'&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'3 days'&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;activated_at&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;onboarding_emails_off&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;onboarding_email_log&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;
    &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email_key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'nudge_24h'&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the send step, using the Resend Node SDK as an example (any provider works the same way). It writes to the log first, so if the job runs twice, the unique key stops a second send:&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;// Inside your scheduled job, for each user from the query above&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;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;logError&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;db&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;onboarding_email_log&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;insert&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;email_key&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;nudge_24h&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;logError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;resend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;emails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&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;Sam at Example &amp;lt;sam@updates.example.com&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;replyTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sam@example.com&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;The step most people get stuck on&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;nudgeText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// your template from Step 4&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;Before turning it on for real users, add a "dry run" mode that only logs who would get which email, and run it against your own test accounts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Stay legal and out of the spam folder
&lt;/h2&gt;

&lt;p&gt;This part is short but important. (Not legal advice; check the rules where you and your users live.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Know which emails are "commercial".&lt;/strong&gt; Under the US CAN-SPAM Act, the FTC separates commercial messages (promoting your product) from transactional or relationship messages (like account and transaction details). Onboarding tips that encourage people to use or buy your product may count as commercial, and the FTC says to read the transactional categories narrowly. The simple, safe choice: treat every email in this sequence, except a pure account notice, as commercial.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;For commercial email, CAN-SPAM requires:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Accurate From and subject lines.&lt;/li&gt;
&lt;li&gt;Your valid physical postal address.&lt;/li&gt;
&lt;li&gt;A clear way to opt out, honored within 10 business days.&lt;/li&gt;
&lt;li&gt;Your opt-out has to keep working for at least 30 days after you send.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Follow Gmail's sender guidelines:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Only email people who signed up with you.&lt;/li&gt;
&lt;li&gt;Make unsubscribing easy. If you ever send 5,000+ messages a day to Gmail, marketing messages must support one-click unsubscribe and show a visible unsubscribe link.&lt;/li&gt;
&lt;li&gt;Use the same From address for each category (for example, account emails from one address, tips from another).&lt;/li&gt;
&lt;li&gt;Keep your spam rate in Google Postmaster Tools below 0.10%, and never let it reach 0.30%.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In practice: add an unsubscribe link that sets &lt;code&gt;onboarding_emails_off = true&lt;/code&gt;, include your postal address in the footer, and keep password resets and receipts in a separate stream so turning off tips never blocks a login email.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Measure the right numbers every week
&lt;/h2&gt;

&lt;p&gt;Open rates are tempting, but they are unreliable. Google even notes in its sender guidelines that it does not track open rates and cannot verify third-party open numbers. Track what users do instead.&lt;/p&gt;

&lt;p&gt;Once a week, write down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Activation rate:&lt;/strong&gt; of the people who signed up last week, how many hit the first win within 7 days?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time to first win:&lt;/strong&gt; for those who activated, how long did it take?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per email:&lt;/strong&gt; how many people activated within 48 hours of getting &lt;code&gt;nudge_24h&lt;/code&gt; or &lt;code&gt;help_day3&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replies:&lt;/strong&gt; read every one, and tag them (confusing, bug, wrong fit, no time).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then change one thing at a time. If &lt;code&gt;help_day3&lt;/code&gt; replies keep saying "I could not find the import button", the fix is probably in the product, not the email.&lt;/p&gt;

&lt;h2&gt;
  
  
  Copy-paste prompt for your AI coding tool
&lt;/h2&gt;

&lt;p&gt;Paste this into Cursor, Claude Code, Lovable, Bolt or Replit. Adjust the bracketed parts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;I want behavior-based onboarding emails for my SaaS. Before writing code,
read my project and show me a plan. Do not change anything until I approve.

My first win action is: [describe it, e.g. "user creates first invoice"].

1. Add activated_at, last_seen_at and onboarding_emails_off to my users or
   profiles table, and an onboarding_email_log table with a unique
   (user_id, email_key) key. Keep Row Level Security on; only server code
   writes to the log.
2. Set activated_at where the first win happens, only if it is still null.
3. Send a "welcome" email from the signup flow.
4. Create one scheduled job that sends nudge_24h, help_day3, next_step
   and checkin_day10 using these rules: [paste the table from Step 3].
   Insert into the log before sending. Max one onboarding email per user
   per day. Never email users with onboarding_emails_off = true.
5. Add an unsubscribe link and page that sets onboarding_emails_off = true,
   and put my postal address in the email footer.
6. Add a DRY_RUN env var that logs who would get which email without
   sending. Keep all API keys server-side.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Common mistakes to avoid
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sending a feature tour.&lt;/strong&gt; Five features in one email means zero clicks. One email, one step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nagging people who already activated.&lt;/strong&gt; This is what stop rules are for.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Using a no-reply address.&lt;/strong&gt; Replies are your best early feedback. Use a real inbox.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mixing tips with password resets.&lt;/strong&gt; Keep account emails separate so an unsubscribe never blocks a login.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Judging emails by opens.&lt;/strong&gt; Judge them by activation and replies.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Action summary
&lt;/h2&gt;

&lt;p&gt;Do this in the next two evenings:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Write down your one first win action.&lt;/li&gt;
&lt;li&gt;Add the three tracking columns and the email log table.&lt;/li&gt;
&lt;li&gt;Copy the sequence table and stop rules from Step 3.&lt;/li&gt;
&lt;li&gt;Fill in the five templates with your product details and deep links.&lt;/li&gt;
&lt;li&gt;Send the welcome email from signup and set up one scheduled job for the rest, starting in dry run mode.&lt;/li&gt;
&lt;li&gt;Add an unsubscribe link, your postal address, and a real reply-to inbox.&lt;/li&gt;
&lt;li&gt;Every week, check activation rate and read every reply.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Your onboarding emails do not need to be clever. They need to show up when someone is stuck, point to one next step, and stop when the job is done.&lt;/p&gt;

</description>
      <category>saas</category>
      <category>startup</category>
      <category>email</category>
      <category>beginners</category>
    </item>
    <item>
      <title>How to Stop Your AI App From Leaking API Keys and Running Up Your Bill: A 7-Step Beginner Guide</title>
      <dc:creator>Vishal Chauhan</dc:creator>
      <pubDate>Wed, 21 Oct 2026 06:26:53 +0000</pubDate>
      <link>https://dev.to/siteory/how-to-stop-your-ai-app-from-leaking-api-keys-and-running-up-your-bill-a-7-step-beginner-guide-3hie</link>
      <guid>https://dev.to/siteory/how-to-stop-your-ai-app-from-leaking-api-keys-and-running-up-your-bill-a-7-step-beginner-guide-3hie</guid>
      <description>&lt;p&gt;Your AI feature works and the demo looks great. One question before you share the link: where does your AI API key live?&lt;/p&gt;

&lt;p&gt;If you built your app with Cursor, Claude Code, Lovable, Bolt, Replit or v0, there is a real chance the answer is "in the browser". That means anyone who opens your site can copy the key and spend your credits. It is one of the most common mistakes posted in r/vibecoding, usually right after a surprise bill.&lt;/p&gt;

&lt;p&gt;This guide shows you how to check, clean up, and make sure it cannot happen again. No security background needed.&lt;/p&gt;

&lt;p&gt;Three quick terms first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;API key:&lt;/strong&gt; a password your app sends to a service (OpenAI, Anthropic, Stripe) to prove it is allowed to use it. Whoever has the key can spend your money.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frontend:&lt;/strong&gt; the code that runs in your visitor's browser. Anyone can read it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backend (or server):&lt;/strong&gt; code that runs on a machine you control, like a Next.js route, a Supabase Edge Function or a Cloudflare Worker. Visitors only see what it sends back.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Check if your key is already exposed
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open your live app in Chrome and open DevTools (F12, or right-click and choose Inspect).&lt;/li&gt;
&lt;li&gt;Go to the &lt;strong&gt;Network&lt;/strong&gt; tab and use your AI feature once.&lt;/li&gt;
&lt;li&gt;Look at the request it makes. If it goes straight to &lt;code&gt;api.openai.com&lt;/code&gt; or &lt;code&gt;api.anthropic.com&lt;/code&gt;, your key is in the browser. If it goes to your own domain (like &lt;code&gt;/api/chat&lt;/code&gt;), that is a good sign.&lt;/li&gt;
&lt;li&gt;Press Ctrl+Shift+F (Cmd+Option+F on Mac) to search across all loaded files. Search for &lt;code&gt;sk-&lt;/code&gt; and the name of your key, like &lt;code&gt;OPENAI&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then search your project folder too:&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="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rnE&lt;/span&gt; &lt;span class="s2"&gt;"sk-|sk_live|sk_test|sb_secret_|service_role|dangerouslyAllowBrowser|VITE_.*KEY|NEXT_PUBLIC_.*KEY"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exclude-dir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;node_modules &lt;span class="nt"&gt;--exclude-dir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;.git &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What each match means:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;sk-...&lt;/code&gt; or &lt;code&gt;sk_live...&lt;/code&gt; written directly in a file: a secret key is hardcoded. Fix it today.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;dangerouslyAllowBrowser&lt;/code&gt;: the official OpenAI and Anthropic JavaScript libraries refuse to run in a browser unless you set this flag, because it exposes your secret key. If your AI tool set it to make an error go away, that is your leak.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;VITE_OPENAI_API_KEY&lt;/code&gt; or &lt;code&gt;NEXT_PUBLIC_OPENAI_API_KEY&lt;/code&gt;: see Step 3. The prefix itself is the problem.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If nothing shows up and requests go to your own domain, skip to Step 5.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: If it leaked, revoke the key first
&lt;/h2&gt;

&lt;p&gt;Do not start with your code. Start at the provider.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Go to your provider's API keys page (OpenAI, Anthropic, Stripe and so on).&lt;/li&gt;
&lt;li&gt;Create a new key.&lt;/li&gt;
&lt;li&gt;Revoke (delete) the old one.&lt;/li&gt;
&lt;li&gt;Check the usage page for spending you do not recognize.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Why first? Removing the key from your code does not remove it from copies people already downloaded or from your git history. GitHub's own guide on removing sensitive data says the first step for a leaked password or token is to revoke or rotate it, and that once it is revoked, that may be enough. Rewriting git history is risky and often unnecessary.&lt;/p&gt;

&lt;p&gt;Two things worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub runs secret scanning on public repositories for free. For keys from partner providers, GitHub notifies the provider, which may revoke the key. OpenAI joined that program in 2021. Do not count on it: private repos are not covered the same way, and bots can use a key before anyone revokes it.&lt;/li&gt;
&lt;li&gt;Pasting a key into your coding agent's chat is a common way it ends up in source code. Lovable detects keys pasted into chat and points you to its Secrets feature. With other tools, add keys yourself in the right place (Step 4).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 3: Learn which keys are safe to be public
&lt;/h2&gt;

&lt;p&gt;Not every key is secret. Some are designed for the browser, and mixing them up is how many leaks happen.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Key&lt;/th&gt;
&lt;th&gt;Safe in the browser?&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Supabase anon or publishable key&lt;/td&gt;
&lt;td&gt;Yes, if Row Level Security is on&lt;/td&gt;
&lt;td&gt;Supabase says so in its docs. RLS is what protects your data.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stripe publishable key (&lt;code&gt;pk_...&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Stripe says it can't create charges or read account data.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google Maps browser key&lt;/td&gt;
&lt;td&gt;Yes, if restricted to your domain&lt;/td&gt;
&lt;td&gt;Lovable's docs call this out as the exception.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI, Anthropic or other AI keys&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Anyone can spend your credits.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stripe secret or restricted key (&lt;code&gt;sk_...&lt;/code&gt;, &lt;code&gt;rk_...&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;A secret key has unrestricted access to all Stripe APIs.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Supabase service_role or secret key&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Bypasses Row Level Security, so it can read every row.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Now the trap. Build tools copy some environment variables into the code they ship to browsers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vite&lt;/strong&gt; (used by Lovable, Bolt and many React apps) puts any variable starting with &lt;code&gt;VITE_&lt;/code&gt; into your client code. Vite's docs say plainly that &lt;code&gt;VITE_*&lt;/code&gt; variables should not contain sensitive information such as API keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Next.js&lt;/strong&gt; (common with v0 and Cursor) inlines any variable starting with &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; into the JavaScript sent to the browser.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So &lt;code&gt;.env&lt;/code&gt; does not mean "hidden". &lt;code&gt;VITE_OPENAI_API_KEY&lt;/code&gt; is public once you build. Rename it without the prefix and read it only on the server.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Move the AI call to your backend
&lt;/h2&gt;

&lt;p&gt;The shape you want is simple:&lt;/p&gt;

&lt;p&gt;Browser -&amp;gt; your backend (holds the key) -&amp;gt; AI provider&lt;/p&gt;

&lt;p&gt;Here is a minimal Next.js route handler. It reads the key on the server only, and the browser never sees it.&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;// app/api/chat/route.ts&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;OpenAI&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;openai&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="c1"&gt;// reads OPENAI_API_KEY from the server environment&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;message&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;responses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&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="nx"&gt;AI_MODEL&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// set on the server, never sent by the browser&lt;/span&gt;
    &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;message&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;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output_text&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;Your frontend now calls your own route, with no key anywhere:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/api/chat&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;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&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/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&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="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;userText&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="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;reply&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set &lt;code&gt;OPENAI_API_KEY&lt;/code&gt; (no prefix) in your hosting dashboard's environment variables, and in &lt;code&gt;.env.local&lt;/code&gt; for local work. Make sure &lt;code&gt;.env*&lt;/code&gt; files are in &lt;code&gt;.gitignore&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Not on Next.js? Same idea, different place:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lovable:&lt;/strong&gt; store the key in Secrets. Lovable's docs say Secrets are encrypted, injected into Edge Functions or server functions, and never reach the browser.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Supabase (Bolt, Cursor or Claude Code builds):&lt;/strong&gt; put the call in an Edge Function and set the key with &lt;code&gt;supabase secrets set OPENAI_API_KEY=...&lt;/code&gt;. The function reads it with &lt;code&gt;Deno.env.get("OPENAI_API_KEY")&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plain HTML or a mobile app:&lt;/strong&gt; you still need a small backend, such as a Cloudflare Worker or a Supabase Edge Function.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 5: Lock the endpoint so strangers cannot use it
&lt;/h2&gt;

&lt;p&gt;If &lt;code&gt;/api/chat&lt;/code&gt; accepts requests from anyone, a stranger can skip your site and call it directly. They never see your key, but they still spend your money.&lt;/p&gt;

&lt;p&gt;Add three checks at the top of your route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// 1. Only signed-in users. Use your auth library's server-side check.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;user&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;getSignedInUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="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;user&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Please sign in&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. Only reasonable input&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;message&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;message&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="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Message too long&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 3. The server decides the model and the output size, not the browser&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;responses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&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="nx"&gt;AI_MODEL&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;max_output_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&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;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;reply&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;output_text&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;getSignedInUser&lt;/code&gt; is a placeholder. Replace it with the server-side check from your auth provider (Supabase, Clerk, Firebase, Auth.js and so on). With Supabase Edge Functions, keep JWT verification on for user-facing functions. Supabase's docs warn that turning it off to clear a 401 removes the platform's check.&lt;/p&gt;

&lt;p&gt;The third check matters: if the browser can send &lt;code&gt;model&lt;/code&gt; or &lt;code&gt;max_tokens&lt;/code&gt;, someone can swap in your most expensive model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Add a rate limit and a spending cap
&lt;/h2&gt;

&lt;p&gt;Logged-in users can still abuse a free plan, and one buggy loop can send thousands of requests. You want two safety nets.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A per-user rate limit in your app.&lt;/strong&gt; A rate limit means "each user gets at most N requests per time window". Upstash Ratelimit is a small library built for serverless setups like Vercel, Cloudflare Workers and Deno. After creating an Upstash Redis database and adding its two environment variables, the code is short:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Ratelimit&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;@upstash/ratelimit&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&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;Redis&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;@upstash/redis&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;ratelimit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Ratelimit&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromEnv&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;limiter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Ratelimit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slidingWindow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;1 h&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// 20 AI requests per user per hour&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// inside POST, right after the sign-in check:&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;success&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;ratelimit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&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;success&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="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Too many requests, try again later&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;429&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;20 per hour is an example; pick numbers that fit your product. Limit by user ID rather than IP address, since many real users can share one IP.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A spending cap at the provider.&lt;/strong&gt; This is your last line of defense if everything else fails.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI:&lt;/strong&gt; set a monthly spend limit for your project and turn on hard enforcement. OpenAI's help center notes that spend alerts alone do not stop traffic, and spend may slightly exceed the limit because enforcement is not instant.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anthropic:&lt;/strong&gt; set your own spend limit on the Billing page in the Claude Console. Once reached, requests return an error until you raise the limit or the period resets.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also use a separate key per project, and OpenAI recommends keys with an expiration date plus regular rotation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Stop it happening again
&lt;/h2&gt;

&lt;p&gt;A few habits keep this fixed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Add rules for your AI tool.&lt;/strong&gt; Put this in your project rules file (&lt;code&gt;AGENTS.md&lt;/code&gt;, &lt;code&gt;.cursor/rules&lt;/code&gt;, &lt;code&gt;CLAUDE.md&lt;/code&gt;, or Lovable's knowledge settings):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Security rules for this project:
- Never put secret API keys in frontend code, HTML, or any variable starting
  with VITE_ or NEXT_PUBLIC_.
- All calls to AI providers, Stripe, or email services go through server code
  (API routes, server functions, or Edge Functions) that reads keys from the
  server environment.
- Never set dangerouslyAllowBrowser.
- Every endpoint that calls a paid API must check the signed-in user, validate
  input length, set the model and max output tokens on the server, and apply a
  per-user rate limit.
- If a task needs a new secret, stop and ask me to add it. Never write a key
  into a file.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Scan before you push.&lt;/strong&gt; Gitleaks is a free, open-source scanner that finds secrets in files and git history. On a Mac with Homebrew:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install &lt;/span&gt;gitleaks
gitleaks git &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;   &lt;span class="c"&gt;# scans your git history&lt;/span&gt;
gitleaks &lt;span class="nb"&gt;dir&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;   &lt;span class="c"&gt;# scans the files on disk&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It can also run as a pre-commit hook on every commit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use your platform's checks.&lt;/strong&gt; GitHub push protection for users is on by default and stops you from pushing supported secrets to public repositories. Lovable runs a quick security scan on every publish and offers a deeper scan that looks for leaked secrets and endpoints anyone can trigger. Helpful, but not a replacement for checking yourself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repeat the Step 1 check after every deploy.&lt;/strong&gt; It takes a minute.&lt;/p&gt;

&lt;p&gt;And once your keys are safe, it is worth checking the rest of the live site too: security headers, HTTPS redirects, what search engines see. I build Siteory, a free audit that checks a live site for security, SEO and AI-search issues and gives you a developer-ready fix list, if you want a second pair of eyes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Copy-paste audit prompt
&lt;/h2&gt;

&lt;p&gt;Paste this into Cursor, Claude Code, Lovable or Bolt to have your AI tool check its own work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Audit this project for exposed secrets and abusable paid API calls.

1. List every place an API key, token, or secret appears in code, config,
   or env files, and say whether that file is shipped to the browser.
2. Flag any variable starting with VITE_ or NEXT_PUBLIC_ that holds a secret.
3. Flag any direct browser call to OpenAI, Anthropic, Stripe, or other paid
   APIs, and any use of dangerouslyAllowBrowser.
4. For every server endpoint that calls a paid API, check: does it require a
   signed-in user, limit input size, set model and max tokens on the server,
   and apply a per-user rate limit?

Show me the list first. Do not change code until I approve. Never print the
full value of any secret you find.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Common mistakes to avoid
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Deleting the key from code but not revoking it.&lt;/strong&gt; The old key still works for whoever copied it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Moving the key to the server but leaving the endpoint open.&lt;/strong&gt; Strangers can still spend your money through your own route.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Relying on spend alerts.&lt;/strong&gt; Alerts tell you; hard limits stop the spending.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pasting keys into the AI chat.&lt;/strong&gt; Add them through your platform's secrets screen instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Action summary
&lt;/h2&gt;

&lt;p&gt;Do this today, in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open DevTools on your live site, use your AI feature, and check where the request goes.&lt;/li&gt;
&lt;li&gt;Search your project for &lt;code&gt;sk-&lt;/code&gt;, &lt;code&gt;VITE_&lt;/code&gt;, &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; and &lt;code&gt;dangerouslyAllowBrowser&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If any secret was exposed, create a new key, revoke the old one, and check usage.&lt;/li&gt;
&lt;li&gt;Move every paid API call to a server route, Edge Function or Lovable Secret.&lt;/li&gt;
&lt;li&gt;Require sign-in, cap input size, and set the model and max output tokens on the server.&lt;/li&gt;
&lt;li&gt;Add a per-user rate limit and a hard monthly spend limit at your provider.&lt;/li&gt;
&lt;li&gt;Add the rules to your AI tool, install Gitleaks, and repeat the DevTools check after every deploy.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Your AI key is a credit card number with no PIN. Keep it on the server, put limits around it, and check it every time you ship.&lt;/p&gt;

</description>
      <category>security</category>
      <category>beginners</category>
      <category>vibecoding</category>
      <category>webdev</category>
    </item>
    <item>
      <title>How to Get Your First Real Testimonials for Your SaaS (Without Faking Them): A 7-Step Beginner Guide</title>
      <dc:creator>Vishal Chauhan</dc:creator>
      <pubDate>Tue, 20 Oct 2026 11:31:39 +0000</pubDate>
      <link>https://dev.to/siteory/how-to-get-your-first-real-testimonials-for-your-saas-without-faking-them-a-7-step-beginner-guide-nkn</link>
      <guid>https://dev.to/siteory/how-to-get-your-first-real-testimonials-for-your-saas-without-faking-them-a-7-step-beginner-guide-nkn</guid>
      <description>&lt;p&gt;Your landing page probably has a testimonials section. The question is whether any of it is real.&lt;/p&gt;

&lt;p&gt;If you built your first SaaS with Lovable, Bolt, v0, Cursor or Claude Code and asked for "a complete landing page", the AI may have filled that section with friendly quotes from people who do not exist. Something like "This changed how our team works!" - Sarah K., Product Lead (a made-up example, which is exactly the point). It looks finished, so it ships.&lt;/p&gt;

&lt;p&gt;That is a problem. Visitors have seen those quotes a hundred times and skip them, and made-up customer quotes on your own site are not a gray area in places like the US and UK. This guide shows you how to replace placeholder proof with real testimonials from your first users, in 7 steps, with templates you can copy.&lt;/p&gt;

&lt;p&gt;Quick note: this is practical advice, not legal advice. The rules below come from official FTC, UK legislation, Trustpilot, Product Hunt and Google pages. If you sell elsewhere, check local rules too.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, two words you will see a lot
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Testimonial&lt;/strong&gt;: a quote you put in your own marketing (landing page, pricing page, ads, emails). The US FTC describes it as an advertising message that people are likely to believe reflects the real opinion or experience of someone who used the product.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Review&lt;/strong&gt;: something a user posts on a platform built for reviews, like Trustpilot, Product Hunt or an app store.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The difference matters. The FTC says a business that puts testimonials on its own website is "disseminating" them, not just hosting them, so if they are fake or false, the business can be liable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Delete every fake or placeholder quote today
&lt;/h2&gt;

&lt;p&gt;Before you collect anything, clean up what is already there. Search your project for typical placeholder content:&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="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rniE&lt;/span&gt; &lt;span class="s2"&gt;"testimonial|lorem|john doe|jane doe|trusted by|loved by|happy customers|rating"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;.tsx &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;.jsx &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;.ts &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;.html &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;*&lt;/span&gt;.vue &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--exclude-dir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;node_modules &lt;span class="nt"&gt;--exclude-dir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;.next &lt;span class="nt"&gt;--exclude-dir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dist &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then ask your AI tool to do a proper sweep, because invented proof also hides in hero text ("Join 10,000+ founders") and logo strips:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Audit this project for invented social proof. Find every testimonial, customer
quote, reviewer name, avatar, star rating, company logo strip, and user or
customer count ("10,000+ users", "trusted by teams at...").

For each one, list the file and line, and tell me whether it looks like
placeholder content. Do not invent replacements. Remove the placeholder items
and leave a single TODO comment where the testimonials section was. If a
number or logo might be real, ask me instead of deleting it.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero testimonials is fine for now. An honest "Free while in beta, built by one developer" beats a page full of Sarahs.&lt;/p&gt;

&lt;p&gt;Why this is step one: the FTC's Consumer Reviews and Testimonials Rule (in effect since October 21, 2024) bans fake or false consumer testimonials and lets courts impose civil penalties for knowing violations. In the UK, the Digital Markets, Competition and Consumers Act 2024 made submitting or commissioning fake reviews a banned practice from 6 April 2025, and its definition of "writing" includes "creating by any means". AI-generated quotes are still invented quotes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Find the people who have actually had a win
&lt;/h2&gt;

&lt;p&gt;You do not need paying customers to get your first testimonials. You need people who used your product and got something out of it. Good sources:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Beta testers and early free users&lt;/li&gt;
&lt;li&gt;People you helped one-on-one over email, chat or a call&lt;/li&gt;
&lt;li&gt;Users who did your product's main action several times (they keep coming back for a reason)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your app runs on Supabase or Postgres, a query like this finds your most active recent users. It is a hypothetical example, so rename the tables and columns to match your app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Hypothetical example: "reports" is whatever your main action creates&lt;/span&gt;
&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;count&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="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;actions_last_30_days&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;last_active&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;reports&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;profiles&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;interval&lt;/span&gt; &lt;span class="s1"&gt;'30 days'&lt;/span&gt;
&lt;span class="k"&gt;group&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;
&lt;span class="k"&gt;having&lt;/span&gt; &lt;span class="k"&gt;count&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="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;actions_last_30_days&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;
&lt;span class="k"&gt;limit&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What about friends and family? You can include them, but the relationship has to be clear. The FTC's Endorsement Guides say an ad with a relative or employee as the endorser is misleading unless that connection is made clear. So "My brother, who runs a bakery, uses it for invoices" is fine. Presenting your brother as a random customer is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Save the nice things people already said
&lt;/h2&gt;

&lt;p&gt;The easiest source of testimonials is your inbox. Support replies, DMs, comments and call notes often already contain the sentence you need, like "oh wow, that took 2 minutes instead of an hour".&lt;/p&gt;

&lt;p&gt;Start a simple "proof log" and paste every piece of positive (and negative) feedback into it with the date and where it came from. It can be a spreadsheet or a JSON file in your repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"t-001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Priya"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Freelance designer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"quote"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"I sent my first invoice in under five minutes."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"support email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"said_on"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-10-12"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"permission"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pending"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"show_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"show_photo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"show_company_logo"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"incentive"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"none"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"relationship"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"none"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(Priya is a made-up example to show the format.)&lt;/p&gt;

&lt;p&gt;Capturing feedback people gave on their own has a bonus. The FTC's FAQ notes that if you tell people in advance their comments might be featured in your ads, that chance of being featured can itself count as a benefit worth disclosing (for example, "Customers were told in advance they might be featured"). Unprompted feedback first, permission second, is the cleanest path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Ask at the right moment, in one short message
&lt;/h2&gt;

&lt;p&gt;Ask right after a specific win: their first export works, a support issue gets solved, they come back for the third time this week. Keep the ask tiny. People ignore requests that feel like homework.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Template A: they already said something nice&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Subject: Can I quote you?

Hi [first name],

Thanks again for your note on [date]. You wrote:

"[their exact words]"

Would you be OK with me putting that on the [product] website with your
first name and role ("[role]")? Happy to change the wording or leave your
name off. A simple yes or no is perfect.

[your name]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Template B: they are active but have not said anything yet&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Subject: 3 quick questions about [product]

Hi [first name],

You've been using [product] for a few weeks, so I'd love your honest take.
Reply with a sentence for each, good or bad:

1. What were you doing before [product]?
2. What changed after you started using it?
3. What almost stopped you from trying it?

Thanks, this really helps me improve it.

[your name]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Template B does not mention quoting, so the feedback stays unprompted. If an answer is quotable, follow up with Template A. It also asks for criticism, on purpose. The FTC's FAQ says only asking customers you expect to be happy can be misleading if it substantially skews the picture. Trustpilot is stricter: invite everyone "consistently and fairly", at the same point in the journey, whether their experience was good or bad. On your own landing page you choose which quotes to show, but do not present a best-case result as normal (see Step 5).&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5: Edit lightly and get a clear yes in writing
&lt;/h2&gt;

&lt;p&gt;You can fix typos and trim a long message down to its strongest sentence. You cannot change what it means. Send the final version back to the person and get approval before it goes live.&lt;/p&gt;

&lt;p&gt;Rules of thumb:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep their meaning.&lt;/strong&gt; The FTC says a business shouldn't write the text of a testimonial unless it has a reasonable basis to believe the text truthfully reflects that person's experience. Drafting from their own words and asking for approval is the safe version of this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask separately for name, photo and company logo.&lt;/strong&gt; A happy user does not mean their employer agreed to appear on your site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Be careful with big numbers.&lt;/strong&gt; If a quote says "this saved me 10 hours a week", the FTC says readers will usually assume that is a typical result. "Results may vary" does not change that. Either have proof that most users get similar results, or clearly state what users generally get. With five users, the simpler move is to prefer quotes about the experience ("I sent my first invoice in five minutes") over quotes that promise outcomes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Record the yes.&lt;/strong&gt; Update your proof log: &lt;code&gt;"permission": "yes - email 2026-10-14"&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 6: Know the incentive and review-site rules
&lt;/h2&gt;

&lt;p&gt;Thinking of offering a free month for a testimonial? Here is what the official sources say:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;th&gt;Can you offer an incentive?&lt;/th&gt;
&lt;th&gt;Key rule&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;US (FTC)&lt;/td&gt;
&lt;td&gt;Yes, if not tied to a positive opinion&lt;/td&gt;
&lt;td&gt;Disclose it clearly. Paying for 5-star reviews is banned even with a disclosure.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UK (DMCC Act)&lt;/td&gt;
&lt;td&gt;Only if made obvious&lt;/td&gt;
&lt;td&gt;Commissioning a review that hides that it was incentivised is a banned practice.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Trustpilot&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No discounts, promo codes, prize draws or freebies for reviews. No selective invites.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Product Hunt&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;No asking for or incentivizing upvotes, no mass messaging.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a first SaaS, the simplest policy is: no incentives at all. Just ask honestly and say thank you. If you do offer something, say so right next to the quote, for example "Got 1 free month for sharing feedback". The FTC says a disclosure behind a link or hover is avoidable, which means it is not clear enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Show them well and keep them fresh
&lt;/h2&gt;

&lt;p&gt;Display tips:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Put proof next to decisions.&lt;/strong&gt; Under the signup button, on the pricing page, next to the feature the quote talks about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Show specifics.&lt;/strong&gt; First name, role, date and the concrete thing they did. "I sent my first invoice in five minutes" beats "Amazing product!!!"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real screenshots work.&lt;/strong&gt; A public post or email (with permission, private details blurred) feels more real than a styled quote card.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not add star-rating markup to hand-picked quotes.&lt;/strong&gt; Google's review snippet guidelines say rating markup is for an aggregate evaluation by many people that users can see on the page, that you should not aggregate reviews from other websites, and that fake or undisclosed incentivized reviews are not allowed. A handful of quotes you picked is not that, so skip the schema.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When you are ready to build the section, give your AI tool strict instructions so it cannot slip invented quotes back in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Build a testimonials section that reads ONLY from data/testimonials.json.
Show an item only if "permission" starts with "yes". Show the name only if
show_name is true, and the photo only if show_photo is true. If "incentive"
is not "none", show it as visible text under the quote (not a tooltip).
If "relationship" is not "none", show that too. If there are fewer than 2
approved items, hide the whole section. Never add placeholder or example
testimonials, names, avatars, logos, star ratings or user counts.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then refresh your proof log every month or two. The FTC notes that if your product has changed, it's best to get new endorsements, and a quote that implies someone still uses your product is only fine while you have good reason to believe they do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes to avoid
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shipping placeholder quotes "for now".&lt;/strong&gt; Now becomes forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inviting only your happiest users to review sites.&lt;/strong&gt; Trustpilot forbids it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Polishing a quote into marketing copy.&lt;/strong&gt; It stops sounding like a person, and may stop being what they said.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Using a company logo because one employee likes you.&lt;/strong&gt; Get separate permission.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hiding the disclosure.&lt;/strong&gt; Small grey text behind a link does not count.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Waiting for big review sites first.&lt;/strong&gt; Start with quotes on your own site from people you have talked to.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Action summary
&lt;/h2&gt;

&lt;p&gt;Do these in order this week:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Run the grep and the AI audit prompt, then remove every placeholder quote, logo and fake count&lt;/li&gt;
&lt;li&gt;[ ] List 10 to 20 people who had a real win (beta users, people you helped, your most active users)&lt;/li&gt;
&lt;li&gt;[ ] Start a proof log and paste in every piece of feedback you already have&lt;/li&gt;
&lt;li&gt;[ ] Send Template A to anyone who already said something nice&lt;/li&gt;
&lt;li&gt;[ ] Send Template B to active users at their next win&lt;/li&gt;
&lt;li&gt;[ ] Get a written yes, and record name, photo and logo permission separately&lt;/li&gt;
&lt;li&gt;[ ] Decide your incentive policy (simplest: none) and write it down&lt;/li&gt;
&lt;li&gt;[ ] Rebuild the testimonials section from the JSON file using the strict prompt&lt;/li&gt;
&lt;li&gt;[ ] Put a monthly reminder in your calendar to refresh old quotes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Three real sentences from three real users will do more for your signup page than a wall of invented praise. Ask at the right moment and keep it honest.&lt;/p&gt;

</description>
      <category>saas</category>
      <category>marketing</category>
      <category>startup</category>
      <category>beginners</category>
    </item>
    <item>
      <title>How to Get Your App's Signup and Password Reset Emails Out of Spam: A 7-Step Fix for Beginners</title>
      <dc:creator>Vishal Chauhan</dc:creator>
      <pubDate>Tue, 20 Oct 2026 06:31:26 +0000</pubDate>
      <link>https://dev.to/siteory/how-to-get-your-apps-signup-and-password-reset-emails-out-of-spam-a-7-step-fix-for-beginners-7d3</link>
      <guid>https://dev.to/siteory/how-to-get-your-apps-signup-and-password-reset-emails-out-of-spam-a-7-step-fix-for-beginners-7d3</guid>
      <description>&lt;p&gt;You built the app with Cursor, Lovable, Bolt or Claude Code. Signup works. You tested it five times. Then a real person signs up, never gets the confirmation email, and you never hear from them again.&lt;/p&gt;

&lt;p&gt;It's one of the most common "works on my machine" traps in vibe-coded apps, and it's rarely a code bug. It's about who sends your email and whether inboxes trust that sender. Here is the fix, in order, for apps on Supabase Auth (the login system behind many AI-built apps) or Lovable Cloud.&lt;/p&gt;

&lt;p&gt;Three terms first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Transactional email:&lt;/strong&gt; email triggered by something the user did, like signing up or resetting a password. The opposite of a newsletter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SMTP provider:&lt;/strong&gt; a company whose servers send your app's emails for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DNS records:&lt;/strong&gt; small public settings on your domain (yourapp.com). Inboxes read three of them to decide whether an email is really from you.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Step 1: Find out which problem you actually have
&lt;/h2&gt;

&lt;p&gt;Don't change anything yet. First, test like a stranger.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open your &lt;strong&gt;live&lt;/strong&gt; app (not the preview) in a private browser window.&lt;/li&gt;
&lt;li&gt;Sign up with a Gmail address that is &lt;strong&gt;not&lt;/strong&gt; on your Supabase or Lovable team.&lt;/li&gt;
&lt;li&gt;Wait two minutes. Check the inbox, the Promotions tab and Spam.&lt;/li&gt;
&lt;li&gt;Open your auth logs (Supabase: Auth logs under Logs; Lovable Cloud: authentication logs).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Why a non-team address? Supabase's built-in sender only delivers to your project's team. Everyone else fails with &lt;code&gt;Email address not authorized&lt;/code&gt;, which is why it worked every time you tested it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you see&lt;/th&gt;
&lt;th&gt;Most likely cause&lt;/th&gt;
&lt;th&gt;Go to&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Email address not authorized&lt;/code&gt; in the logs&lt;/td&gt;
&lt;td&gt;Still on Supabase's built-in sender&lt;/td&gt;
&lt;td&gt;Step 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Error 429 or "rate limit"&lt;/td&gt;
&lt;td&gt;Hourly email limit reached&lt;/td&gt;
&lt;td&gt;Steps 2 and 5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Email lands in Spam or Promotions&lt;/td&gt;
&lt;td&gt;Domain not authenticated, or content looks like marketing&lt;/td&gt;
&lt;td&gt;Steps 3, 4, 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Link opens &lt;code&gt;localhost&lt;/code&gt; or says the token expired&lt;/td&gt;
&lt;td&gt;Link settings or email scanners&lt;/td&gt;
&lt;td&gt;Step 6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Logs say sent, nothing arrives&lt;/td&gt;
&lt;td&gt;Bounce or suppressed address at your provider&lt;/td&gt;
&lt;td&gt;Step 7&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Step 2: Replace the built-in sender
&lt;/h2&gt;

&lt;p&gt;Supabase's default sender is for testing: it only mails your team, is currently limited to 2 messages per hour (which can change without notice), and has no delivery guarantee. Supabase urges custom SMTP for anything beyond demos.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Your own Supabase project&lt;/strong&gt; (common with Cursor, Claude Code, Bolt and v0 builds, and Lovable apps connected to your own Supabase): plug an email provider's SMTP details into Supabase. Supabase lists Resend, AWS SES, Postmark, Twilio SendGrid, ZeptoMail and Brevo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lovable Cloud:&lt;/strong&gt; the built-in Emails feature (More, then Cloud, then Emails) sets up your sending domain and DNS records for you. It needs a paid workspace and DNS access, and includes 50,000 transactional emails a month with hourly caps (500 auth emails per hour on Pro). Free plans stay on the default sender.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This guide uses Resend because its free plan fits a first launch: 3,000 emails a month, capped at 100 per day. For example, if a launch post brings 150 signups in one day, the free plan won't cover it, because each signup sends at least one email. Upgrade before a launch, not during it.&lt;/p&gt;

&lt;p&gt;Using Firebase, Clerk or Replit's auth instead? The screens differ, but Steps 3, 4, 6 and 7 still apply.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Send from your own subdomain
&lt;/h2&gt;

&lt;p&gt;Providers like Resend only send from a domain you own, so a Gmail address is out.&lt;/p&gt;

&lt;p&gt;Pick a subdomain just for app email:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;auth.yourapp.com   signup, magic links, password resets
mail.yourapp.com   newsletters and announcements (later)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Resend recommends subdomains to isolate your sending reputation. Supabase goes further: separate domains, ideally separate services, for auth and marketing email, so a newsletter can't drag your password resets into spam. Lovable uses a &lt;code&gt;notify&lt;/code&gt; subdomain by default.&lt;/p&gt;

&lt;p&gt;Use a From address like &lt;code&gt;hello@auth.yourapp.com&lt;/code&gt; with your app's name as the sender name. Resend's deliverability checks flag &lt;code&gt;no-reply&lt;/code&gt; addresses, so pick one a person can reply to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Add SPF, DKIM and DMARC
&lt;/h2&gt;

&lt;p&gt;In plain terms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SPF&lt;/strong&gt; is the guest list: which servers may send mail for this domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DKIM&lt;/strong&gt; is a tamper-proof seal: your provider signs each email, and a public key in your DNS lets inboxes verify it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DMARC&lt;/strong&gt; is your instruction to inboxes: what to do with mail that fails, and where to send reports.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Gmail requires SPF or DKIM from every sender, and all three from anyone sending more than 5,000 messages a day to Gmail. Yahoo requires all three from its bulk senders, and Outlook.com from senders of 5,000 or more messages. Google recommends all three at any volume, so do it from day one.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;In Resend, open Domains, click Add Domain and enter your subdomain. It shows the exact records to create.&lt;/li&gt;
&lt;li&gt;Log in where your DNS actually lives (registrar, Cloudflare or Vercel). Not sure? Look up your nameservers on a site like dns.email.&lt;/li&gt;
&lt;li&gt;Copy each record character for character. With Resend, DKIM sits at &lt;code&gt;resend._domainkey&lt;/code&gt; and SPF on a &lt;code&gt;send&lt;/code&gt; subdomain; domains added after August 2026 may show CNAME records instead. Copy whatever your dashboard shows.&lt;/li&gt;
&lt;li&gt;Add DMARC yourself, because Resend doesn't create it. Start in monitoring mode:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Type:  TXT
Name:  _dmarc
Value: v=DMARC1; p=none; rua=mailto:dmarc@yourapp.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;p=none&lt;/code&gt; means "report, don't block," and reports go to the &lt;code&gt;rua&lt;/code&gt; inbox. Once everything passes, move to &lt;code&gt;p=quarantine&lt;/code&gt;. Check for an existing &lt;code&gt;_dmarc&lt;/code&gt; record first: under the DMARC standard, a domain with two gets no DMARC at all.&lt;/p&gt;

&lt;p&gt;Then click verify. Resend says domains often verify within 15 minutes, though DNS can take up to 72 hours. To check from a terminal, swap in your names:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;nslookup &lt;span class="nt"&gt;-type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;TXT resend._domainkey.auth.yourapp.com
nslookup &lt;span class="nt"&gt;-type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;TXT _dmarc.yourapp.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 5: Connect Supabase to your provider
&lt;/h2&gt;

&lt;p&gt;In Supabase, open Authentication, then Emails (under Notifications), then SMTP Settings, and enable custom SMTP. For Resend:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Host:         smtp.resend.com
Port:         465
Username:     resend
Password:     &amp;lt;your Resend API key&amp;gt;
Sender email: hello@auth.yourapp.com
Sender name:  YourApp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things beginners miss:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Raise the rate limit.&lt;/strong&gt; After the switch, Supabase starts you at 30 emails per hour. Go to Authentication, then Rate Limits, and set a number that fits your launch and your provider's plan.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the API key out of your code.&lt;/strong&gt; Paste it only into the Supabase dashboard, not into your AI chat, frontend code or a public repo. If it leaks, delete it in Resend and create a new one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now send yourself a password reset. If the Auth logs mention &lt;code&gt;gomail&lt;/code&gt;, Supabase lists the usual causes: a misconfigured domain, the wrong port, provider rate limiting or downtime.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6: Make the emails and links trustworthy
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Fix the Site URL.&lt;/strong&gt; It defaults to &lt;code&gt;http://localhost:3000&lt;/code&gt;, and Supabase calls it critical for confirmations and resets. Set your production URL under URL Configuration and add every real domain to Redirect URLs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Turn off open and click tracking for this domain.&lt;/strong&gt; Supabase warns that tracking rewrites auth links so they stop working, and Resend says it can hurt transactional deliverability. In Resend it's a per-domain switch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Plan for link scanners.&lt;/strong&gt; Some workplace inboxes open every link to scan it (Supabase's example: Safe Links in Microsoft Defender). Supabase links work once, so the real user then sees "Token has expired or is invalid." One fix from Supabase's docs is emailing a 6-digit code instead of a link:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;h2&amp;gt;&lt;/span&gt;Confirm your email&lt;span class="nt"&gt;&amp;lt;/h2&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Enter this code in the app: {{ .Token }}&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your app then checks it with &lt;code&gt;supabase.auth.verifyOtp({ email, token, type: 'email' })&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep auth emails boring.&lt;/strong&gt; Supabase's list: no promotions or taglines, few links and images, short subjects with few emojis, and rare template changes. Gmail's guidelines agree: don't mix promotions into other message types. A password reset should say what happened, offer one button or code, and stop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7: Prove it works, then keep watching
&lt;/h2&gt;

&lt;p&gt;Don't flood your own inbox: Lovable's docs warn that repeated test emails look like artificial traffic.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Gmail "Show original."&lt;/strong&gt; In Gmail on a computer, open the email, click More (three dots) next to Reply, then Show original. You want PASS next to SPF, DKIM and DMARC. The raw header looks roughly like this (simplified):
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Authentication-Results: mx.google.com;
  dkim=pass header.i=@auth.yourapp.com;
  spf=pass smtp.mailfrom=send.auth.yourapp.com;
  dmarc=pass header.from=auth.yourapp.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Get a spam score.&lt;/strong&gt; mail-tester.com gives you a one-time address. Sign up in your app with it and read the report. The free version allows 3 tests in 24 hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Try one Outlook or Yahoo address.&lt;/strong&gt; Different inboxes run different filters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read your provider's logs weekly.&lt;/strong&gt; Bounces show up there, and Resend automatically stops sending to addresses that bounce permanently (its suppression list).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;With real volume, add your domain to Google Postmaster Tools. Google asks senders to keep the reported spam rate below 0.1% and never reach 0.3%.&lt;/p&gt;

&lt;p&gt;New domains start with no reputation, so a few early emails may still hit spam. Lovable's docs call this normal for the first days or weeks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sending newsletters later? Give them their own lane
&lt;/h2&gt;

&lt;p&gt;Send them from the other subdomain (&lt;code&gt;mail.yourapp.com&lt;/code&gt;), ideally through a separate service. Only email people who opted in (Gmail and Yahoo both say never buy lists) and include a visible unsubscribe link. Bulk senders must also support one-click unsubscribe, and Yahoo wants unsubscribes honored within 2 days.&lt;/p&gt;

&lt;h2&gt;
  
  
  Copy-paste prompt: have your AI audit the setup
&lt;/h2&gt;

&lt;p&gt;Paste this into Cursor, Claude Code, Lovable or Bolt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Audit how this app sends auth emails. Report first; do not change code yet.
1. Which service sends signup, magic link and password reset emails?
   Is it still the Supabase built-in sender?
2. List every place an email API key or SMTP password appears.
   Flag any in frontend code, committed files or tracked .env files.
3. Check every redirectTo / emailRedirectTo value. Flag localhost,
   preview domains and relative URLs.
4. Is there a "Resend confirmation email" button with a 60-second
   cooldown (Supabase's default per-user limit)? If not, propose one
   using supabase.auth.resend.
5. Review the email templates. Flag marketing copy, more than one link,
   images, emojis in subjects, and user-provided data.
Give me a numbered list of findings with the exact fix for each.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Adding a second SPF record.&lt;/strong&gt; Google's docs say each domain can have one. Merge new senders into it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Editing DNS in the wrong place.&lt;/strong&gt; If your nameservers point to Cloudflare or Vercel, records added at your registrar do nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Doubled domain names.&lt;/strong&gt; Some DNS panels append your domain, creating &lt;code&gt;send.yourapp.com.yourapp.com&lt;/code&gt;. Enter just the host part; for values, Resend suggests a trailing dot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proxied CNAMEs on Cloudflare.&lt;/strong&gt; Email CNAME records must be gray-cloud (DNS only).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Starting DMARC at &lt;code&gt;p=reject&lt;/code&gt;.&lt;/strong&gt; One forgotten sender and you block your own mail.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Turning off email confirmation because "emails don't work."&lt;/strong&gt; Supabase says not to disable confirmations under pressure; without them, anyone can sign up with an address they don't own.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;ul&gt;
&lt;li&gt;[ ] Stranger test passed on the live app&lt;/li&gt;
&lt;li&gt;[ ] Custom SMTP (or Lovable Emails) on&lt;/li&gt;
&lt;li&gt;[ ] Sending from auth.yourapp.com (or similar)&lt;/li&gt;
&lt;li&gt;[ ] SPF and DKIM verified; DMARC at p=none&lt;/li&gt;
&lt;li&gt;[ ] Rate limit above 30 per hour; plan covers launch day&lt;/li&gt;
&lt;li&gt;[ ] API key only in the dashboard&lt;/li&gt;
&lt;li&gt;[ ] Site URL and Redirect URLs on production&lt;/li&gt;
&lt;li&gt;[ ] Tracking off for auth email&lt;/li&gt;
&lt;li&gt;[ ] Templates short: one link or a code&lt;/li&gt;
&lt;li&gt;[ ] Show original: SPF, DKIM, DMARC all PASS&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Action summary
&lt;/h2&gt;

&lt;p&gt;Today: run the stranger test and read your auth logs. This week: pick a sending subdomain, add SPF, DKIM and DMARC, switch to custom SMTP and raise the rate limit. Before launch: fix the Site URL, turn off tracking, simplify templates and confirm three PASS results in Gmail. It's about an afternoon, and it means people who want your app can actually get in.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>email</category>
      <category>beginners</category>
      <category>vibecoding</category>
    </item>
  </channel>
</rss>
