<?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: WaterShow</title>
    <description>The latest articles on DEV Community by WaterShow (@watershow).</description>
    <link>https://dev.to/watershow</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%2F4206643%2F2c1b57c7-fecb-4907-99a7-0b67c18b3946.png</url>
      <title>DEV Community: WaterShow</title>
      <link>https://dev.to/watershow</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/watershow"/>
    <language>en</language>
    <item>
      <title>What changed in open models and AI agent tools this week</title>
      <dc:creator>WaterShow</dc:creator>
      <pubDate>Sun, 01 Nov 2026 15:38:31 +0000</pubDate>
      <link>https://dev.to/watershow/what-changed-in-open-models-and-ai-agent-tools-this-week-59m2</link>
      <guid>https://dev.to/watershow/what-changed-in-open-models-and-ai-agent-tools-this-week-59m2</guid>
      <description>&lt;p&gt;New open weights and agent tools shipped this week, alongside a security risk in a popular developer extension. &lt;/p&gt;

&lt;p&gt;You get a new coding model to self-host, a way to build chat bots that remember past sessions, an Android agent that clicks buttons for you, and a reason to check your code editor extensions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistral Large 4 open weights
&lt;/h2&gt;

&lt;p&gt;Mistral released &lt;a href="https://mistral.ai/news/mistral-large-4-oss/" rel="noopener noreferrer"&gt;Mistral Large 4&lt;/a&gt; as an open weights model. It is a 1-trillion parameter model trained on European infrastructure. The focus of this release is coding and defensive cybersecurity.&lt;/p&gt;

&lt;p&gt;The model supports three reasoning modes. Low mode is for simple tasks. Medium mode handles everyday tasks. High mode is the default and is used for use cases requiring high precision.&lt;/p&gt;

&lt;p&gt;It scores 48 percent on the Artificial Analysis Coding Agent Index. On the Artificial Analysis Cyber Index, it scores 51 percent. This index measures the ability of a model to discover vulnerabilities, reproduce them, and patch them. The model has a refusal rate of 96.6 percent on malicious cyber requests. &lt;/p&gt;

&lt;h2&gt;
  
  
  Persona AI SDK
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://github.com/tavern-labs-ai/persona-ai-sdk" rel="noopener noreferrer"&gt;Persona AI SDK&lt;/a&gt; is a TypeScript package for building AI characters that remember past conversations. It separates the persona, memory, model, persistence, and interface into different layers. &lt;/p&gt;

&lt;p&gt;You can store memory in-memory, as atomic JSON files, or in a SQLite database. The SDK includes adapters for OpenAI and Amazon Bedrock. It comes with default personas like atlas, a strategic analyst, and sage, a customer support agent. &lt;/p&gt;

&lt;p&gt;To install the core runtime and adapters, run this command:&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;persona-ai-sdk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want to use the SQLite storage adapter, you need to install the native 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;better-sqlite3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Mika for Android
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/wangzan101/Mika" rel="noopener noreferrer"&gt;Mika&lt;/a&gt; is an open source on-device AI agent for Android written in Kotlin. It operates apps and websites for you. It runs on a hidden background screen so you can keep using your phone normally. &lt;/p&gt;

&lt;p&gt;Mika looks at the screen and finds buttons visually. It does not rely on specific app APIs. You need to provide your own model API key or sign in with an account from providers like OpenAI or Anthropic.&lt;/p&gt;

&lt;p&gt;The agent defaults to Ask mode. This mode asks for your permission before spending money or placing orders. You can also switch it to Autonomous mode. Mika includes a Linux sandbox for arm64 devices to run Python, Node, and shell commands on files.&lt;/p&gt;

&lt;p&gt;To set up a model, configure it in the app settings. &lt;/p&gt;

&lt;h2&gt;
  
  
  Claude Code extension backdoor
&lt;/h2&gt;

&lt;p&gt;Phorion found a backdoor in the official &lt;a href="https://phorion.io/blog/offical-claude-code-vsx-compromise/" rel="noopener noreferrer"&gt;Claude Code for VS Code extension&lt;/a&gt; distributed on the Cursor Marketplace. The affected versions are 2.1.312 and 2.1.313. Cursor removed these versions from the marketplace.&lt;/p&gt;

&lt;p&gt;The backdoor drops an SSH key appended to the authorized keys file. The key uses the comment &lt;code&gt;svc-telemetry&lt;/code&gt;. It sets up a reverse SSH tunnel to an external server on port 22. The malware uses an embedded private key commented with &lt;code&gt;svc-telemetry-tun&lt;/code&gt; to authenticate to the tunnel server.&lt;/p&gt;

&lt;p&gt;The script establishes persistence using cron jobs on Linux, launch agents on macOS, and scheduled tasks on Windows. It also sends a beacon to the attacker. This beacon includes the hostname, username, public IP address, and up to 40 environment variable names that match keywords for secrets or cloud credentials.&lt;/p&gt;

&lt;p&gt;On macOS, the setup script contains a bug. It runs a check for the remote login status without root privileges. The system prints an error and exits. The script treats this error as confirmation that SSH is already on. It skips the command to enable SSH entirely. &lt;/p&gt;

&lt;h2&gt;
  
  
  The catch
&lt;/h2&gt;

&lt;p&gt;Mistral Large 4 is a 1-trillion parameter model. Running a model of this size requires significant hardware infrastructure. &lt;/p&gt;

&lt;p&gt;The Persona AI SDK provides the building blocks for memory and personas, but horizontally scaled deployments require you to implement your own memory store using a database like PostgreSQL. &lt;/p&gt;

&lt;p&gt;Mika needs Android 11 or later for basic chat and web browsing. Operating phone apps requires Android 12, but Android 14 or later is recommended. Operating third-party apps automatically with Mika may break their terms of service. Password entry pages often block screenshots, so Mika will ask you to take over manual control on those screens.&lt;/p&gt;

&lt;p&gt;For the Claude Code backdoor, the macOS SSH bug breaks the inbound access path if SSH was not already running. The malware still successfully drops the SSH keys, installs persistence, and beacons host reconnaissance data back to the operator. &lt;/p&gt;

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

&lt;h3&gt;
  
  
  What reasoning modes does Mistral Large 4 support?
&lt;/h3&gt;

&lt;p&gt;Mistral Large 4 supports Low, Medium, and High reasoning modes. Low mode is for simple tasks, Medium is for everyday tasks, and High is the default for complex use cases.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which versions of the Claude Code extension contain the backdoor?
&lt;/h3&gt;

&lt;p&gt;Versions 2.1.312 and 2.1.313 of the Claude Code for VS Code extension on the Cursor Marketplace contain the backdoor. Cursor has removed these versions. &lt;/p&gt;

&lt;h3&gt;
  
  
  How does Mika interact with Android applications?
&lt;/h3&gt;

&lt;p&gt;Mika looks at the screen and finds buttons visually. It runs on a hidden background screen and does not require specific app APIs.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://watershowdigital.com/blog/ai-updates-mistral-large-4-persona-sdk" rel="noopener noreferrer"&gt;watershowdigital.com/blog/ai-updates-mistral-large-4-persona-sdk&lt;/a&gt;.&lt;/em&gt; Need a hand with your app or automation? &lt;a href="https://watershowdigital.com/build" rel="noopener noreferrer"&gt;WaterShow Digital&lt;/a&gt; fixes and builds it, fixed quote first.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>llm</category>
      <category>security</category>
    </item>
    <item>
      <title>Fix slow Supabase queries caused by complex RLS policies</title>
      <dc:creator>WaterShow</dc:creator>
      <pubDate>Sat, 31 Oct 2026 12:43:19 +0000</pubDate>
      <link>https://dev.to/watershow/fix-slow-supabase-queries-caused-by-complex-rls-policies-8hk</link>
      <guid>https://dev.to/watershow/fix-slow-supabase-queries-caused-by-complex-rls-policies-8hk</guid>
      <description>&lt;p&gt;Your Supabase app feels fast when you test it alone. Then you launch, users add real data, and pages take seconds to load. You might see timeout errors in your Next.js app or a blank loading screen in your Lovable single page app. &lt;/p&gt;

&lt;p&gt;The problem is rarely your frontend code. If the database is slow, it is usually because of Row Level Security (RLS) policies. When you write a database query in your application, Postgres takes the condition defined in your RLS policy and attaches it to your query.&lt;/p&gt;

&lt;p&gt;If your RLS policy asks the database to look up a user's role in a different table, Postgres might perform that lookup for every row it scans. A query that returns 50 rows could trigger thousands of background lookups. You can test if RLS is the culprit by running the exact same query using your Supabase service_role key on a private backend. The service_role key bypasses RLS entirely. If the query is fast with the service_role key but slow in the browser, your policies are causing the slowdown.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Looking up roles in another table
&lt;/h2&gt;

&lt;p&gt;If your policy uses a SQL statement to check another table, it forces Postgres into a nested loop. The most common case is checking a team members table or a user profiles table to see if the user has access to a document.&lt;/p&gt;

&lt;p&gt;To confirm this is the issue, look at your policy definition in the project settings. If you see SQL that includes &lt;code&gt;EXISTS (SELECT 1 FROM team_members WHERE ...)&lt;/code&gt; inside the policy, you are doing a table lookup. As your tables grow, this lookup gets slower.&lt;/p&gt;

&lt;p&gt;The fix is to inject custom data directly into the user's auth token. Supabase allows you to set custom JWT claims using an auth hook. If you put the user's role or team ID directly into the token when they log in, the RLS policy can read it from memory instead of scanning the disk.&lt;/p&gt;

&lt;p&gt;You can read the token data in your policy using a jsonb operator:&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'team_id'&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;team_id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This changes the policy from a database read to a string comparison.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Writing complex logic without security definer functions
&lt;/h2&gt;

&lt;p&gt;Sometimes you cannot avoid looking up data in another table. If your permission logic is complex and spans multiple tables, writing it directly in the RLS policy box makes the Postgres query planner struggle to optimize it. &lt;/p&gt;

&lt;p&gt;Complicated joins inside the policy definition lead to poor execution plans.&lt;/p&gt;

&lt;p&gt;The fix is to move the logic into a dedicated Postgres function and tell the policy to call that function instead. You must create the function using the &lt;code&gt;SECURITY DEFINER&lt;/code&gt; keyword. This tells Postgres to run the function with the privileges of the user who created it, bypassing RLS checks for the tables queried inside the function itself. If you do not bypass RLS inside your RLS check, Postgres can get stuck in infinite recursion or extremely slow execution paths.&lt;/p&gt;

&lt;p&gt;Create the function 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;CREATE&lt;/span&gt; &lt;span class="k"&gt;OR&lt;/span&gt; &lt;span class="k"&gt;REPLACE&lt;/span&gt; &lt;span class="k"&gt;FUNCTION&lt;/span&gt; &lt;span class="n"&gt;user_can_access_project&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target_project_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;RETURNS&lt;/span&gt; &lt;span class="nb"&gt;boolean&lt;/span&gt;
&lt;span class="k"&gt;LANGUAGE&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt;
&lt;span class="k"&gt;SECURITY&lt;/span&gt; &lt;span class="k"&gt;DEFINER&lt;/span&gt;
&lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
&lt;span class="k"&gt;BEGIN&lt;/span&gt;
  &lt;span class="k"&gt;RETURN&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;project_members&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;project_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;target_project_id&lt;/span&gt;
    &lt;span class="k"&gt;AND&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;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;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="err"&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 update your RLS policy to be a single function call:&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="n"&gt;user_can_access_project&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;project_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Missing indexes on policy columns
&lt;/h2&gt;

&lt;p&gt;Postgres uses indexes to find rows without reading the entire table. If your RLS policy filters by a column that does not have an index, Postgres has to read every row in the table.&lt;/p&gt;

&lt;p&gt;Look at the columns used in your RLS policy. If you have a documents table and the policy requires the user ID to match, Postgres needs to find all documents for that user. Open your table structure in the project settings to check if the column used in the policy has an active index.&lt;/p&gt;

&lt;p&gt;If the column is not indexed, run a SQL command to add one:&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;idx_documents_user_id&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Adding an index to a column frequently checked by RLS drops query times.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Calling auth.uid() too many times
&lt;/h2&gt;

&lt;p&gt;The built-in &lt;code&gt;auth.uid()&lt;/code&gt; function is fast, but calling it multiple times in a complex policy can cause the query planner to evaluate the function repeatedly for every row instead of evaluating it once and reusing the result.&lt;/p&gt;

&lt;p&gt;You can confirm this by looking at your policy text to see if &lt;code&gt;auth.uid()&lt;/code&gt; appears multiple times in different &lt;code&gt;OR&lt;/code&gt; and &lt;code&gt;AND&lt;/code&gt; conditions. &lt;/p&gt;

&lt;p&gt;Wrap the function call in a way that forces Postgres to evaluate it once. The cleanest approach is to use the security definer function method described above. By passing the user ID into a plpgsql variable, you guarantee the value is only computed once per query operation. You can also restructure your SQL to pull the &lt;code&gt;auth.uid()&lt;/code&gt; check to the top level of the statement.&lt;/p&gt;

&lt;h2&gt;
  
  
  Still stuck?
&lt;/h2&gt;

&lt;p&gt;If your queries are still timing out after simplifying your RLS policies, you might have a missing index on your main query or a frontend fetching problem. I can audit your Supabase database and fix the slow queries for you. &lt;a href="https://dev.to/build"&gt;this is the kind of fix I do&lt;/a&gt;.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Why is my Supabase query timing out?
&lt;/h3&gt;

&lt;p&gt;Your Row Level Security policies are likely running slow subqueries on every row. Postgres attaches your RLS rules to the query, turning fetches into background lookups.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I speed up RLS policies?
&lt;/h3&gt;

&lt;p&gt;Add indexes to the columns your policies filter on. If you must do table lookups, move that logic into a Postgres function created with the SECURITY DEFINER keyword.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I read JWT claims in Supabase RLS?
&lt;/h3&gt;

&lt;p&gt;Yes, you can read custom data stored in the auth token. Extract it using the auth.jwt() function in your policy, which avoids reading from the database entirely.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://watershowdigital.com/blog/fix-slow-supabase-queries-rls-performance" rel="noopener noreferrer"&gt;watershowdigital.com/blog/fix-slow-supabase-queries-rls-performance&lt;/a&gt;.&lt;/em&gt; Need a hand with your app or automation? &lt;a href="https://watershowdigital.com/build" rel="noopener noreferrer"&gt;WaterShow Digital&lt;/a&gt; fixes and builds it, fixed quote first.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>performance</category>
    </item>
    <item>
      <title>How to force Gemini API to return strict JSON in Node.js</title>
      <dc:creator>WaterShow</dc:creator>
      <pubDate>Thu, 29 Oct 2026 16:45:40 +0000</pubDate>
      <link>https://dev.to/watershow/how-to-force-gemini-api-to-return-strict-json-in-nodejs-97i</link>
      <guid>https://dev.to/watershow/how-to-force-gemini-api-to-return-strict-json-in-nodejs-97i</guid>
      <description>&lt;p&gt;You ask an AI model for a JSON response, and it gives you a markdown string starting with backticks and the word json. Your scraper or bot tries to run &lt;code&gt;JSON.parse()&lt;/code&gt; on the output and immediately crashes.&lt;/p&gt;

&lt;p&gt;We can fix this by forcing the Gemini API to return strict, raw JSON every time. Instead of trying to clean up the string with regular expressions, we will configure the Node.js SDK to lock the output format to a specific schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting up the Gemini SDK
&lt;/h2&gt;

&lt;p&gt;First, you need the official Google Gen AI SDK for Node.js. If you are using the older version, update to the current &lt;code&gt;@google/generative-ai&lt;/code&gt; package. You will also need your API key from the Google AI Studio dashboard. Click &lt;strong&gt;Get API key&lt;/strong&gt; to generate a new one if you do not have it yet.&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; @google/generative-ai
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You must pass your API key into the client as an environment variable or a direct string. Keep this key secure. If you are building a web app, do not expose it to the browser. &lt;/p&gt;

&lt;h2&gt;
  
  
  Defining the output schema
&lt;/h2&gt;

&lt;p&gt;To get strict JSON, pass two specific properties in the &lt;code&gt;generationConfig&lt;/code&gt; object of your request: &lt;code&gt;responseMimeType&lt;/code&gt; and &lt;code&gt;responseSchema&lt;/code&gt;. &lt;/p&gt;

&lt;p&gt;The &lt;code&gt;responseMimeType&lt;/code&gt; tells the model to drop all conversational text and markdown formatting. Setting it to &lt;code&gt;application/json&lt;/code&gt; ensures the raw string returned is just the JSON structure.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;responseSchema&lt;/code&gt; tells the model exactly what keys and data types to use. Gemini uses a subset of the OpenAPI schema format. You build this using the &lt;code&gt;SchemaType&lt;/code&gt; enum provided by the SDK.&lt;/p&gt;

&lt;p&gt;Here is an example schema for extracting product details from a block of text. We want an array of objects, where each object has a name, price, and category.&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;GoogleGenerativeAI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;SchemaType&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;@google/generative-ai&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;genAI&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;GoogleGenerativeAI&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;GEMINI_API_KEY&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;productSchema&lt;/span&gt; &lt;span class="o"&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="nx"&gt;SchemaType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ARRAY&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;A list of products extracted from the text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&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="nx"&gt;SchemaType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OBJECT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;productName&lt;/span&gt;&lt;span class="p"&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="nx"&gt;SchemaType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRING&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;The official name of the product&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;price&lt;/span&gt;&lt;span class="p"&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="nx"&gt;SchemaType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NUMBER&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;The price in USD&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;inStock&lt;/span&gt;&lt;span class="p"&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="nx"&gt;SchemaType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;BOOLEAN&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="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;True if the item is available&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;category&lt;/span&gt;&lt;span class="p"&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="nx"&gt;SchemaType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRING&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;enum&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;electronics&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;clothing&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;home&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;other&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;The product category&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="na"&gt;required&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;productName&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;price&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;category&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Using &lt;code&gt;enum&lt;/code&gt; restricts the model to a predefined list of strings. Providing a clear &lt;code&gt;description&lt;/code&gt; for each field acts as an inline prompt, guiding the model on what specific data belongs in that key.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sending the request
&lt;/h2&gt;

&lt;p&gt;Pass the schema to the model instance. You must use a model that supports structured outputs, such as Gemini 1.5 Pro or Gemini 1.5 Flash.&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;extractProducts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;genAI&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getGenerativeModel&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;gemini-1.5-flash&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;generationConfig&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;responseMimeType&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;responseSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;productSchema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`Extract all the products from this text: \n\n &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generateContent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;prompt&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="nx"&gt;result&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rawText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&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;parsedData&lt;/span&gt; &lt;span class="o"&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;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rawText&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;parsedData&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Example usage&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sourceText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;We have a new Sony TV for 499.99. It is currently in stock. Also selling a cotton t-shirt for 15, but we are sold out.&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;extractProducts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sourceText&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;then&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;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because we set the MIME type, the &lt;code&gt;response.text()&lt;/code&gt; method returns a clean JSON string. There are no backticks. You can immediately pass it to &lt;code&gt;JSON.parse()&lt;/code&gt; without any string manipulation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling complex nested structures
&lt;/h2&gt;

&lt;p&gt;You can nest objects and arrays as deep as you need. However, every level of an array must define the &lt;code&gt;items&lt;/code&gt; property, and every object must define the &lt;code&gt;properties&lt;/code&gt; property. &lt;/p&gt;

&lt;p&gt;If you want a response that includes metadata alongside the main data array, you start the schema as an object.&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="nx"&gt;documentSchema&lt;/span&gt; &lt;span class="o"&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="nx"&gt;SchemaType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;OBJECT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="p"&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="nx"&gt;SchemaType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRING&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;A one sentence summary of the entire text&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;tags&lt;/span&gt;&lt;span class="p"&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="nx"&gt;SchemaType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ARRAY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&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="nx"&gt;SchemaType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;STRING&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;products&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;productSchema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;// Reusing the schema from earlier&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;required&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;summary&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;products&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This modular approach makes it easier to manage large schemas. Build smaller pieces and reference them inside the main response schema.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things that bit me
&lt;/h2&gt;

&lt;p&gt;The model will still occasionally hallucinate keys if you do not strictly define the &lt;code&gt;required&lt;/code&gt; array in your schema. If a field is optional, the model might omit it entirely or return null, which can break downstream code expecting a specific type.&lt;/p&gt;

&lt;p&gt;Setting &lt;code&gt;responseMimeType: 'application/json'&lt;/code&gt; without providing a &lt;code&gt;responseSchema&lt;/code&gt; will return JSON, but the model decides the structure. It will change the keys based on the input text, making the output impossible to rely on for automated scripts.&lt;/p&gt;

&lt;p&gt;Large outputs can get cut off if you hit the &lt;code&gt;maxOutputTokens&lt;/code&gt; limit. The response will end in the middle of a string, and &lt;code&gt;JSON.parse()&lt;/code&gt; will throw an error. You must check the finish reason in the response object or catch the parse error to detect this.&lt;/p&gt;

&lt;p&gt;If your prompt explicitly tells the model to format the output as markdown, it will fight the SDK configuration. It might return invalid JSON that includes markdown syntax inside the string values.&lt;/p&gt;

&lt;p&gt;If you need help building reliable extractions, you can check out &lt;a href="https://dev.to/tools"&gt;other things I build&lt;/a&gt;.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  How do I stop Gemini from returning markdown code blocks?
&lt;/h3&gt;

&lt;p&gt;Configure the generation settings with &lt;code&gt;responseMimeType&lt;/code&gt; set to &lt;code&gt;application/json&lt;/code&gt;. This tells the API to return the raw string without wrapping it in markdown backticks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need to provide a response schema?
&lt;/h3&gt;

&lt;p&gt;If you only set the MIME type, the model will return JSON but it will invent its own keys. Providing a &lt;code&gt;responseSchema&lt;/code&gt; forces the model to use the exact keys and data types you define.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does JSON.parse fail on the Gemini response?
&lt;/h3&gt;

&lt;p&gt;The output might be cut off if it exceeds the maximum output tokens. Also, if your prompt contradicts the JSON settings, the model might include invalid characters inside the string.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://watershowdigital.com/blog/force-gemini-api-strict-json-nodejs" rel="noopener noreferrer"&gt;watershowdigital.com/blog/force-gemini-api-strict-json-nodejs&lt;/a&gt;.&lt;/em&gt; Need a hand with your app or automation? &lt;a href="https://watershowdigital.com/build" rel="noopener noreferrer"&gt;WaterShow Digital&lt;/a&gt; fixes and builds it, fixed quote first.&lt;/p&gt;

</description>
      <category>gemini</category>
      <category>node</category>
      <category>api</category>
    </item>
    <item>
      <title>Fix Stripe payment status not updating in Supabase</title>
      <dc:creator>WaterShow</dc:creator>
      <pubDate>Wed, 28 Oct 2026 12:57:03 +0000</pubDate>
      <link>https://dev.to/watershow/fix-stripe-payment-status-not-updating-in-supabase-207e</link>
      <guid>https://dev.to/watershow/fix-stripe-payment-status-not-updating-in-supabase-207e</guid>
      <description>&lt;p&gt;A user types in their credit card, the payment goes through, and Stripe charges them. They are redirected back to your app, but their account still says they are on the free plan. They refresh the page, but nothing changes.&lt;/p&gt;

&lt;p&gt;This happens when the communication between Stripe and your Supabase database breaks down. Stripe knows the payment succeeded, but your app never processed it. &lt;/p&gt;

&lt;p&gt;Fixing this requires looking at how your app listens for Stripe events.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Updating the database from the browser
&lt;/h2&gt;

&lt;p&gt;Check your frontend code. If you run a Supabase update query right after the user returns from the Stripe checkout page, you cannot trust the browser to update the payment status. A user can close the tab before the redirect happens, which means the code to update Supabase never runs. They get charged, but their account remains locked. A malicious user can also fake a visit to the success page to grant themselves premium access without paying.&lt;/p&gt;

&lt;p&gt;You need a webhook to handle this. A webhook is an API route on your server that Stripe calls directly when a payment succeeds. In a Next.js app, this is usually an API route. For a Lovable or Bolt single page app, this is often a Supabase Edge Function.&lt;/p&gt;

&lt;p&gt;When a user pays, Stripe calls your webhook in the background. Your webhook then updates Supabase.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The webhook is using the wrong Supabase key
&lt;/h2&gt;

&lt;p&gt;Go to your Stripe dashboard. Click on &lt;strong&gt;Developers&lt;/strong&gt; and then click on &lt;strong&gt;Webhooks&lt;/strong&gt;. Find your endpoint and look at the failed events. If you see HTTP 500 or 401 errors, your server is rejecting Stripe's request. &lt;/p&gt;

&lt;p&gt;When Stripe calls your webhook, there is no logged-in user making the request. If your webhook tries to update the database using the standard Supabase anonymous key, Row Level Security will block it. &lt;/p&gt;

&lt;p&gt;Your webhook code must use the Supabase service role key to bypass Row Level Security.&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="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="s1"&gt;@supabase/supabase-js&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;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;SUPABASE_URL&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;SUPABASE_SERVICE_ROLE_KEY&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The service role key gives full admin access to your database. It must never reach the browser. Keep it entirely in your server environment variables. In Next.js, ensure the variable name does not start with NEXT_PUBLIC_. In a Vite app, do not prefix it with VITE_.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The webhook secret is missing or mismatched
&lt;/h2&gt;

&lt;p&gt;Check your webhook server logs. If your code verifies the Stripe signature and throws an error about a mismatch, your server will reject the payload.&lt;/p&gt;

&lt;p&gt;Every Stripe webhook endpoint has a unique signing secret. It usually starts with whsec_. &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open the Stripe dashboard and go to your webhook endpoint settings.&lt;/li&gt;
&lt;li&gt;Reveal the signing secret and copy it.&lt;/li&gt;
&lt;li&gt;Add it to your server environment variables as STRIPE_WEBHOOK_SECRET.&lt;/li&gt;
&lt;li&gt;Restart your local server or trigger a redeploy for your hosted backend.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Make sure your code uses this exact secret to construct the Stripe event before doing any database updates.&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="nx"&gt;event&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;rawBody&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;signatureHeader&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;STRIPE_WEBHOOK_SECRET&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you test locally using the Stripe CLI, the CLI generates a different webhook secret for your local machine. You must use that local secret in your local environment file, and the live secret in your production environment variables.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Webhook retries and race conditions
&lt;/h2&gt;

&lt;p&gt;A user upgrades, then cancels immediately. Your database shows them as upgraded, even though the cancellation event happened last.&lt;/p&gt;

&lt;p&gt;Stripe sends multiple events like checkout.session.completed and customer.subscription.updated. Sometimes these arrive out of order. If your webhook fails or your server is slow, Stripe will retry sending the event for up to three days. &lt;/p&gt;

&lt;p&gt;Your database updates must be idempotent. If the same webhook is processed twice, it should not break your data or create duplicate records. &lt;/p&gt;

&lt;p&gt;To fix this, store the Stripe event ID in a dedicated Supabase table when you process it. &lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a table in Supabase called stripe_events with a single text column for the event ID.&lt;/li&gt;
&lt;li&gt;Set the event ID column as the primary key.&lt;/li&gt;
&lt;li&gt;In your webhook code, before updating the user profile, attempt to insert the event ID into this table.&lt;/li&gt;
&lt;li&gt;If the database throws an error because the ID already exists, stop processing.&lt;/li&gt;
&lt;li&gt;Return a 200 success code to Stripe so it stops retrying.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This ensures you process a specific payment event once, even if Stripe sends it to your server multiple times.&lt;/p&gt;

&lt;h2&gt;
  
  
  Still stuck?
&lt;/h2&gt;

&lt;p&gt;If your Stripe and Supabase setup is still dropping updates or randomly failing, &lt;a href="https://dev.to/build"&gt;this is the kind of fix I do&lt;/a&gt;.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Why is the Supabase service role key needed for Stripe webhooks?
&lt;/h3&gt;

&lt;p&gt;Stripe webhooks run in the background without a logged-in user session. The service role key allows your server to bypass Row Level Security and update the user payment status.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I test Stripe webhooks locally?
&lt;/h3&gt;

&lt;p&gt;You can use the Stripe CLI to forward webhook events to your local server. This provides a temporary webhook secret you can place in your local environment variables to verify your code updates Supabase.&lt;/p&gt;

&lt;h3&gt;
  
  
  What happens if my webhook server goes down?
&lt;/h3&gt;

&lt;p&gt;Stripe will retry sending the webhook event for up to three days. Once your server is back online, it will receive the pending events and update Supabase.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://watershowdigital.com/blog/fix-stripe-payment-status-supabase" rel="noopener noreferrer"&gt;watershowdigital.com/blog/fix-stripe-payment-status-supabase&lt;/a&gt;.&lt;/em&gt; Need a hand with your app or automation? &lt;a href="https://watershowdigital.com/build" rel="noopener noreferrer"&gt;WaterShow Digital&lt;/a&gt; fixes and builds it, fixed quote first.&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>supabase</category>
      <category>webhooks</category>
    </item>
    <item>
      <title>Build an approve/reject Telegram bot flow in Node.js</title>
      <dc:creator>WaterShow</dc:creator>
      <pubDate>Tue, 27 Oct 2026 15:26:48 +0000</pubDate>
      <link>https://dev.to/watershow/build-an-approvereject-telegram-bot-flow-in-nodejs-4fn8</link>
      <guid>https://dev.to/watershow/build-an-approvereject-telegram-bot-flow-in-nodejs-4fn8</guid>
      <description>&lt;p&gt;We are building a Node.js Telegram bot flow that sends a message with inline Approve and Reject buttons. When you click a button, the bot processes the decision, updates the original message to reflect the choice, and removes the buttons.&lt;/p&gt;

&lt;p&gt;This is useful for admin tasks like moderating user content, authorizing payments, or approving database writes. &lt;/p&gt;

&lt;h2&gt;
  
  
  Sending the message with inline buttons
&lt;/h2&gt;

&lt;p&gt;To interact with the Telegram API, we will use the &lt;code&gt;node-telegram-bot-api&lt;/code&gt; package. You will need a bot token from BotFather to authenticate your requests.&lt;/p&gt;

&lt;p&gt;The bot sends a message containing an inline keyboard. Each button needs a &lt;code&gt;callback_data&lt;/code&gt; string. This is the payload Telegram sends back to your server when the user clicks the button.&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="nx"&gt;TelegramBot&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;node-telegram-bot-api&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;token&lt;/span&gt; &lt;span class="o"&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;TELEGRAM_BOT_TOKEN&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bot&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;TelegramBot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;polling&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sendApprovalRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chatId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;userId&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;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`New user signup pending approval:\n\nUser ID: &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="s2"&gt;\nContent: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chatId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;reply_markup&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;inline_keyboard&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="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Approve&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;callback_data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`approve_&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="s2"&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;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Reject&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;callback_data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`reject_&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="s2"&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;h2&gt;
  
  
  Listening for the callback query
&lt;/h2&gt;

&lt;p&gt;When a user taps an inline button, Telegram sends a &lt;code&gt;callback_query&lt;/code&gt; event to your bot. Listen for this event and extract the data string.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;callback_query&lt;/code&gt; object contains the data string you defined earlier, the message object, and a unique query ID. We split the string to separate the action type from the user ID.&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="nx"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;callback_query&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chatId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;query&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="nx"&gt;chat&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;messageId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;query&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="nx"&gt;message_id&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;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;query&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="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;chatId&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;messageId&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;data&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;action&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="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&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="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;action&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;approve&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;approveUser&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="k"&gt;else&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;action&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reject&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;rejectUser&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="p"&gt;});&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;approveUser&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Approved &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="s2"&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;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;rejectUser&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="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Rejected &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="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Updating the message and clearing state
&lt;/h2&gt;

&lt;p&gt;After you process the action, tell Telegram you received the callback by calling the &lt;code&gt;answerCallbackQuery&lt;/code&gt; method with the query ID. If you skip this step, the button will show a loading spinner on the user's device for several seconds. &lt;/p&gt;

&lt;p&gt;To remove the buttons, use the &lt;code&gt;editMessageText&lt;/code&gt; method to replace the original text with a final status. Passing an empty array to &lt;code&gt;inline_keyboard&lt;/code&gt; clears the buttons from the chat.&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="nx"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;callback_query&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;chatId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;query&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="nx"&gt;chat&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;messageId&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;query&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="nx"&gt;message_id&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;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;query&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="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;chatId&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;messageId&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;data&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;action&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="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&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="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;newText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;query&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="nx"&gt;text&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="nx"&gt;action&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;approve&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;approveUser&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="nx"&gt;newText&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s1"&gt;Status: Approved&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="k"&gt;else&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;action&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;reject&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="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;rejectUser&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="nx"&gt;newText&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s1"&gt;Status: Rejected&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;editMessageText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;newText&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;chat_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;chatId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;messageId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;reply_markup&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;inline_keyboard&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="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;bot&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;answerCallbackQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;query&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="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Things that bit me
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;callback_data&lt;/code&gt; string has a hard limit of 64 bytes. If you try to stuff a long JSON object or a full URL into it, the Telegram API will reject the message and your bot will crash. Store complex state in your database and only pass short IDs in the callback data.&lt;/p&gt;

&lt;p&gt;Multiple people in a group chat can click the buttons at the exact same time. If the message is in a shared group, your bot might receive and process duplicate callback requests before the first one finishes editing the message. You should handle idempotency in your database logic so a record is not approved twice.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  How do I stop the button from spinning in Telegram?
&lt;/h3&gt;

&lt;p&gt;Call &lt;code&gt;answerCallbackQuery&lt;/code&gt; with the query ID. This tells the Telegram app that your server received the click and stops the loading spinner.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I remove the inline buttons after a click?
&lt;/h3&gt;

&lt;p&gt;Use the &lt;code&gt;editMessageText&lt;/code&gt; method on the original message. Pass an empty &lt;code&gt;inline_keyboard&lt;/code&gt; array in the &lt;code&gt;reply_markup&lt;/code&gt; property to clear the buttons.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does my Telegram bot fail to send the inline keyboard?
&lt;/h3&gt;

&lt;p&gt;Your &lt;code&gt;callback_data&lt;/code&gt; string might be too long. Telegram limits the callback data to 64 bytes. Pass short identifiers instead of large payloads.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://watershowdigital.com/blog/telegram-approve-reject-bot-nodejs" rel="noopener noreferrer"&gt;watershowdigital.com/blog/telegram-approve-reject-bot-nodejs&lt;/a&gt;.&lt;/em&gt; Need a hand with your app or automation? &lt;a href="https://watershowdigital.com/build" rel="noopener noreferrer"&gt;WaterShow Digital&lt;/a&gt; fixes and builds it, fixed quote first.&lt;/p&gt;

</description>
      <category>telegram</category>
      <category>node</category>
      <category>bots</category>
    </item>
    <item>
      <title>Why Google and ChatGPT can't see your Lovable app (and how to fix it)</title>
      <dc:creator>WaterShow</dc:creator>
      <pubDate>Mon, 26 Oct 2026 20:59:22 +0000</pubDate>
      <link>https://dev.to/watershow/why-google-and-chatgpt-cant-see-your-lovable-app-and-how-to-fix-it-3en</link>
      <guid>https://dev.to/watershow/why-google-and-chatgpt-cant-see-your-lovable-app-and-how-to-fix-it-3en</guid>
      <description>&lt;p&gt;You built the app in Lovable, Bolt or v0, it works, people like it. Then you search for it on Google and get nothing useful, and when you ask ChatGPT about your product it has never heard of it.&lt;/p&gt;

&lt;p&gt;Most of the time the app is fine. The problem is what search engines and AI assistants receive when they open your link.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a crawler actually downloads
&lt;/h2&gt;

&lt;p&gt;Apps from AI builders are usually single-page React apps. When anyone opens the link, the server sends a tiny HTML file that looks roughly like this:&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;body&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;id=&lt;/span&gt;&lt;span class="s"&gt;"root"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;script &lt;/span&gt;&lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"module"&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"/assets/index-a1b2c3.js"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/script&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your headings, prices, features and contact details are not in that file. They appear only after the browser downloads and runs the JavaScript. People never notice, because their browser does it in a second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Who runs JavaScript and who doesn't
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Google&lt;/strong&gt; can run JavaScript, but it does it in a second pass that can come days later, and pages with heavy scripts sometimes get indexed with missing text or not at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AI assistants&lt;/strong&gt; are worse off. The crawlers behind ChatGPT, Claude and Perplexity mostly read the HTML and do not run JavaScript. In &lt;a href="https://vercel.com/blog/the-rise-of-the-ai-crawler" rel="noopener noreferrer"&gt;Vercel's analysis of AI crawler traffic&lt;/a&gt;, none of the major AI crawlers rendered JavaScript. For them your app is an empty &lt;code&gt;div&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So the same page that looks great to a visitor can be invisible to the tools people now use to find products.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check your app in 30 seconds
&lt;/h2&gt;

&lt;p&gt;The quickest way: paste your link into the free &lt;a href="https://dev.to/tools/visibility-check"&gt;visibility check&lt;/a&gt;. It shows how many words of your content are in the HTML, plus the title, description, link preview, sitemap, robots.txt, llms.txt and mobile speed, each with what to fix.&lt;/p&gt;

&lt;p&gt;If you prefer to do it by hand, open your site and press &lt;code&gt;Ctrl+U&lt;/code&gt; (or &lt;code&gt;Cmd+Option+U&lt;/code&gt; on a Mac) to view the page source. Search for a sentence that is visible on the page. If you cannot find it in the source, crawlers cannot find it either.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five fixes, from easiest to most thorough
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. Put the basics in index.html.&lt;/strong&gt; Even a single-page app has one real HTML file. Give it a proper &lt;code&gt;&amp;lt;title&amp;gt;&lt;/code&gt;, a meta description, Open Graph tags for link previews and a canonical link. In Lovable you can ask it to edit &lt;code&gt;index.html&lt;/code&gt;, or change it in the code on GitHub. This alone fixes how your link looks in search results and chats.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Add robots.txt, sitemap.xml and llms.txt.&lt;/strong&gt; Put them in the &lt;code&gt;public&lt;/code&gt; folder so they are served from the root of your domain. The sitemap lists your pages, robots.txt points crawlers to it, and llms.txt is a short plain-text summary of what your site offers, written for AI assistants.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Add structured data.&lt;/strong&gt; A small JSON-LD block in &lt;code&gt;index.html&lt;/code&gt; that says who you are, what you sell and where you are. Google and AI assistants use it to describe your business without guessing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Prerender your public pages.&lt;/strong&gt; This is the fix that actually puts your text into the HTML. A prerender step runs your app at build time and saves the finished HTML for each public route (home, pricing, features, blog). Visitors still get the full app, but crawlers receive real content. For a Vite app like the ones Lovable and Bolt generate, this is usually a plugin plus a list of routes to render.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Move the marketing pages to a framework that renders on the server.&lt;/strong&gt; If content and search traffic matter a lot, keep the logged-in app where it is and build the public pages with Next.js or Astro, which send full HTML by default. It is more work, but it is the most robust option.&lt;/p&gt;

&lt;p&gt;For most apps, steps 1 to 4 are enough and take a day or two.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I usually find
&lt;/h2&gt;

&lt;p&gt;When I check apps built with AI tools, the pattern is almost always the same: a generic title like "Vite + React", no description, no link preview image, no sitemap, and fewer than twenty words of real content in the HTML. Each piece is small, but together they make the product invisible outside of direct links.&lt;/p&gt;

&lt;p&gt;If you want it done for you, I fix exactly this: prerendering, meta tags, link previews, sitemap, robots.txt, structured data and llms.txt, with a fixed quote first. Run the &lt;a href="https://dev.to/tools/visibility-check"&gt;free check&lt;/a&gt; and send me the report.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Can Google index a Lovable app at all?
&lt;/h3&gt;

&lt;p&gt;Yes, but slowly and not always completely. Google renders JavaScript in a second pass, so pages can sit unindexed for days or get indexed with missing text. Putting real text into the HTML with prerendering removes the guesswork.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does ChatGPT not know about my product?
&lt;/h3&gt;

&lt;p&gt;The crawlers that feed ChatGPT, Claude and Perplexity mostly read only the HTML and do not run JavaScript. If your content appears only after scripts run, they see an empty page and have nothing to quote.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need to rebuild my app to fix this?
&lt;/h3&gt;

&lt;p&gt;Usually not. Fixing index.html, adding a sitemap, robots.txt, llms.txt and structured data, and prerendering the public pages keeps your app as it is. Moving marketing pages to Next.js or Astro is only worth it when search traffic really matters.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://watershowdigital.com/blog/why-chatgpt-cant-see-your-lovable-app" rel="noopener noreferrer"&gt;watershowdigital.com/blog/why-chatgpt-cant-see-your-lovable-app&lt;/a&gt;.&lt;/em&gt; Need a hand with your app or automation? &lt;a href="https://watershowdigital.com/build" rel="noopener noreferrer"&gt;WaterShow Digital&lt;/a&gt; fixes and builds it, fixed quote first.&lt;/p&gt;

</description>
      <category>lovable</category>
      <category>seo</category>
      <category>webdev</category>
      <category>ai</category>
    </item>
    <item>
      <title>How to get Reddit data in 2026 now that the API is closed</title>
      <dc:creator>WaterShow</dc:creator>
      <pubDate>Mon, 26 Oct 2026 02:07:47 +0000</pubDate>
      <link>https://dev.to/watershow/how-to-get-reddit-data-in-2026-now-that-the-api-is-closed-5697</link>
      <guid>https://dev.to/watershow/how-to-get-reddit-data-in-2026-now-that-the-api-is-closed-5697</guid>
      <description>&lt;p&gt;If you tried to create a Reddit app recently, you hit a wall. Since November 2025 Reddit no longer hands out API keys on request: every new app needs manual approval under the Responsible Builder Policy, and personal or small commercial projects are often turned down or never hear back. Even automation tools like Make and Pipedream now ask you for your own approved Reddit app before they connect.&lt;/p&gt;

&lt;p&gt;So what do you do if you just need posts and comments for research, lead generation or monitoring? Here are the options that still work, from simplest to most capable.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. RSS feeds, for light monitoring
&lt;/h2&gt;

&lt;p&gt;Almost every Reddit listing has an RSS version. Add &lt;code&gt;.rss&lt;/code&gt; to the URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://www.reddit.com/r/SaaS/new/.rss
https://www.reddit.com/search.rss?q=looking+for+a+crm&amp;amp;sort=new
https://www.reddit.com/r/SaaS/comments/abc123/.rss
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get titles, full post text, authors and dates, up to 100 items per request. Good enough to watch a few subreddits or keywords.&lt;/p&gt;

&lt;p&gt;The limits: no scores, no comment counts, and Reddit rate limits RSS hard. If you poll more than a few times a minute from one IP you start getting &lt;code&gt;429 Too Many Requests&lt;/code&gt;. Combine several subreddits into one feed (&lt;code&gt;/r/a+b+c/new/.rss&lt;/code&gt;) to make fewer requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Apply for API access anyway
&lt;/h2&gt;

&lt;p&gt;If you need to post or vote, the API is the only legitimate way, so it is worth applying. Use the support request form on reddithelp.com, describe one concrete use case, the volume, and how you handle data. Be honest about commercial use. Expect anything from a week to no answer at all, so do not build a business that depends on it.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. A scraper that reads public pages
&lt;/h2&gt;

&lt;p&gt;For anything bigger than a few feeds, you want a tool that reads Reddit's public pages for you, handles rate limits and gives you clean data. I built &lt;a href="https://apify.com/watershowdigital/reddit-lead-finder" rel="noopener noreferrer"&gt;Reddit Scraper &amp;amp; AI Lead Finder&lt;/a&gt; for this. It needs no API key and no Reddit login:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;search posts by keyword, sitewide or inside subreddits&lt;/li&gt;
&lt;li&gt;pull new, hot, top or rising posts from any subreddit&lt;/li&gt;
&lt;li&gt;get posts from a user profile, or comments from a post URL&lt;/li&gt;
&lt;li&gt;scores, comment counts, upvote ratio and full text, ready for CSV or JSON
&lt;/li&gt;
&lt;/ul&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;"searchQueries"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"looking for a crm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"alternative to hubspot"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"subreddits"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"SaaS"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"startups"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sort"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"new"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timeRange"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"week"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxPostsPerSource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&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;It costs $1 per 1,000 posts. If you add a short description of what you sell, its optional AI mode scores each post for buying intent and drafts a reply, which turns it into a lead finder rather than a plain scraper.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which one to pick
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Watching a few keywords for yourself:&lt;/strong&gt; RSS feeds and a small script.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Research, dashboards, lead lists, anything over a few hundred posts a day:&lt;/strong&gt; a managed scraper.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Posting, voting or messaging:&lt;/strong&gt; only the official API. Do not automate those any other way, Reddit bans accounts for it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Play fair
&lt;/h2&gt;

&lt;p&gt;Whatever you use, stick to public data, keep request rates reasonable, and do not collect personal information you do not need. Reddit is a community first. Tools that read it to find people who need help are fine. Tools that spam it get everyone blocked.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  How can I read Reddit posts without an API key?
&lt;/h3&gt;

&lt;p&gt;You can use RSS feeds for light monitoring by adding .rss to Reddit URLs. For pulling larger amounts of data for research or dashboards, you can use a managed scraper. Scrapers read public pages without needing an API key or a Reddit login.&lt;/p&gt;

&lt;h3&gt;
  
  
  What data is included in a Reddit RSS feed?
&lt;/h3&gt;

&lt;p&gt;A Reddit RSS feed provides the titles, full post text, authors and dates for up to 100 items. It does not include post scores or comment counts. You also have to be careful with rate limits to avoid getting error messages.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I still automate posting and voting on Reddit?
&lt;/h3&gt;

&lt;p&gt;Only through the official API. Since November 2025 every new Reddit app needs manual approval under the Responsible Builder Policy, and small projects are often turned down. Automating posting or voting any other way gets accounts banned.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://watershowdigital.com/blog/reddit-data-without-api-2026" rel="noopener noreferrer"&gt;watershowdigital.com/blog/reddit-data-without-api-2026&lt;/a&gt;.&lt;/em&gt; Need a hand with your app or automation? &lt;a href="https://watershowdigital.com/build" rel="noopener noreferrer"&gt;WaterShow Digital&lt;/a&gt; fixes and builds it, fixed quote first.&lt;/p&gt;

</description>
      <category>reddit</category>
      <category>scraping</category>
      <category>api</category>
      <category>python</category>
    </item>
    <item>
      <title>Lovable app works in preview but breaks after deploy? Seven fixes</title>
      <dc:creator>WaterShow</dc:creator>
      <pubDate>Mon, 26 Oct 2026 02:07:46 +0000</pubDate>
      <link>https://dev.to/watershow/lovable-app-works-in-preview-but-breaks-after-deploy-seven-fixes-5211</link>
      <guid>https://dev.to/watershow/lovable-app-works-in-preview-but-breaks-after-deploy-seven-fixes-5211</guid>
      <description>&lt;p&gt;Everything works in the Lovable preview. You deploy it to your own domain on Vercel or Netlify, and suddenly login loops, pages go blank or the data never shows up.&lt;/p&gt;

&lt;p&gt;The app is almost never "broken". Something that the preview handled for you is missing in production. Here are the seven causes I see most often, in the order I check them.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Supabase still redirects to localhost or the preview URL
&lt;/h2&gt;

&lt;p&gt;Symptom: you log in, and you land on &lt;code&gt;localhost:3000&lt;/code&gt; or the old preview link, or you end up back on the login page.&lt;/p&gt;

&lt;p&gt;Fix: in Supabase go to &lt;strong&gt;Authentication → URL Configuration&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Site URL:&lt;/strong&gt; your real domain, for example &lt;code&gt;https://myapp.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redirect URLs:&lt;/strong&gt; add &lt;code&gt;https://myapp.com/**&lt;/code&gt; (and your Vercel preview domain if you use it)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you use Google or GitHub login, the provider's own console needs the Supabase callback URL too: &lt;code&gt;https://&amp;lt;project&amp;gt;.supabase.co/auth/v1/callback&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Environment variables are missing on the host
&lt;/h2&gt;

&lt;p&gt;Symptom: blank page, or errors like &lt;code&gt;supabaseUrl is required&lt;/code&gt; in the browser console.&lt;/p&gt;

&lt;p&gt;Lovable keeps your keys in the project. Vercel and Netlify do not know them. Add them in the host's settings:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;VITE_SUPABASE_URL&lt;/span&gt;=&lt;span class="n"&gt;https&lt;/span&gt;://&lt;span class="n"&gt;xxxx&lt;/span&gt;.&lt;span class="n"&gt;supabase&lt;/span&gt;.&lt;span class="n"&gt;co&lt;/span&gt;
&lt;span class="n"&gt;VITE_SUPABASE_PUBLISHABLE_KEY&lt;/span&gt;=...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then &lt;strong&gt;redeploy&lt;/strong&gt;. Variables are baked in at build time, so changing them without a new build does nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Refreshing a page gives a 404
&lt;/h2&gt;

&lt;p&gt;Symptom: the home page works, but refreshing &lt;code&gt;/dashboard&lt;/code&gt; or opening it from a link shows "404 Not Found".&lt;/p&gt;

&lt;p&gt;Lovable apps are single page apps: there is only one real &lt;code&gt;index.html&lt;/code&gt;. Tell the host to send every route to it.&lt;/p&gt;

&lt;p&gt;Vercel, &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;"rewrites"&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;"destination"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"/index.html"&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;Netlify, a file &lt;code&gt;public/_redirects&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;/*  /index.html  200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. RLS blocks the data in production
&lt;/h2&gt;

&lt;p&gt;Symptom: the page loads but lists are empty, or saving fails with &lt;code&gt;new row violates row-level security policy&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In the preview you might have been testing as a different user, or with policies that changed since. Check that every table your app reads has a &lt;code&gt;select&lt;/code&gt; policy for logged in users and every table it writes has a matching &lt;code&gt;insert&lt;/code&gt; or &lt;code&gt;update&lt;/code&gt; policy. I wrote a separate guide on &lt;a href="https://dev.to/blog/supabase-rls-blocks-inserts"&gt;the six causes of RLS insert errors&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. The service role key ended up in the frontend
&lt;/h2&gt;

&lt;p&gt;Symptom: everything works, which is the dangerous part.&lt;/p&gt;

&lt;p&gt;If your code creates the Supabase client with the &lt;code&gt;service_role&lt;/code&gt; key, anyone can open the network tab, copy it and read or delete your whole database. Search your code for &lt;code&gt;service_role&lt;/code&gt;. In the browser only the publishable (anon) key belongs. Anything that needs full access moves to an Edge Function.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Stripe works in test mode only
&lt;/h2&gt;

&lt;p&gt;Symptom: checkout opens, payment succeeds, but the user never gets access.&lt;/p&gt;

&lt;p&gt;Usually the webhook is still pointing at the test endpoint or uses the test signing secret. In Stripe switch to live mode, create the webhook for your production URL, and put the live &lt;code&gt;STRIPE_WEBHOOK_SECRET&lt;/code&gt; into your function's secrets.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Emails go nowhere
&lt;/h2&gt;

&lt;p&gt;Symptom: sign up works but the confirmation email never arrives.&lt;/p&gt;

&lt;p&gt;Supabase's built in email is rate limited and meant for testing. For real users, connect your own SMTP (Resend, Postmark, SendGrid) under &lt;strong&gt;Authentication → SMTP Settings&lt;/strong&gt;, and add the DNS records your provider asks for so emails do not land in spam.&lt;/p&gt;

&lt;h2&gt;
  
  
  Still stuck?
&lt;/h2&gt;

&lt;p&gt;Open the browser console (F12) and the network tab, reproduce the problem and read the first red error. Nine times out of ten it names the cause. If it does not, or you would rather not spend the weekend on it, &lt;a href="https://dev.to/#founders"&gt;this is the kind of fix I do&lt;/a&gt;: fixed price up front, usually done in 48 hours.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Why do I get a 404 error when I refresh my Lovable app?
&lt;/h3&gt;

&lt;p&gt;This happens because Lovable apps are single page apps with only one real index.html file. To fix it, you need to tell your host to send every route to that file. You do this using a vercel.json file for Vercel or a public/_redirects file for Netlify.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is my Supabase data not loading after I deploy?
&lt;/h3&gt;

&lt;p&gt;Your environment variables might be missing from your Vercel or Netlify settings. If you add them, you must redeploy your app since variables are baked in at build time. It is also possible that your row-level security policies are blocking the data.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why am I redirected to localhost after logging in?
&lt;/h3&gt;

&lt;p&gt;This occurs when Supabase still redirects to your old preview link or localhost. Go to the URL Configuration under Authentication in Supabase to fix this. Update your Site URL to your real domain and add it to your Redirect URLs.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://watershowdigital.com/blog/lovable-app-breaks-after-deploy" rel="noopener noreferrer"&gt;watershowdigital.com/blog/lovable-app-breaks-after-deploy&lt;/a&gt;.&lt;/em&gt; Need a hand with your app or automation? &lt;a href="https://watershowdigital.com/build" rel="noopener noreferrer"&gt;WaterShow Digital&lt;/a&gt; fixes and builds it, fixed quote first.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>ai</category>
      <category>deployment</category>
      <category>javascript</category>
    </item>
    <item>
      <title>How to see every Google ad your competitor runs (and what to steal from them)</title>
      <dc:creator>WaterShow</dc:creator>
      <pubDate>Mon, 26 Oct 2026 02:02:33 +0000</pubDate>
      <link>https://dev.to/watershow/how-to-see-every-google-ad-your-competitor-runs-and-what-to-steal-from-them-1fa8</link>
      <guid>https://dev.to/watershow/how-to-see-every-google-ad-your-competitor-runs-and-what-to-steal-from-them-1fa8</guid>
      <description>&lt;p&gt;Your competitor has been testing ads for months and paying for every lesson. You can see the results for free, because Google publishes every ad in the &lt;a href="https://adstransparency.google.com/" rel="noopener noreferrer"&gt;Ads Transparency Center&lt;/a&gt;: Search, YouTube and Display, with the dates each ad ran and the countries it was shown in.&lt;/p&gt;

&lt;p&gt;Most people never open it. Here is how to use it properly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The manual way, for one competitor
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open &lt;a href="https://adstransparency.google.com/" rel="noopener noreferrer"&gt;adstransparency.google.com&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Type the competitor's domain, for example &lt;code&gt;notion.com&lt;/code&gt;, and pick the advertiser from the list. Big companies often have several verified advertiser accounts, one per country or product.&lt;/li&gt;
&lt;li&gt;Pick a country in the top bar. Without it you see ads from everywhere.&lt;/li&gt;
&lt;li&gt;Switch the format filter between text, image and video.&lt;/li&gt;
&lt;li&gt;Click an ad to see its versions, the countries it ran in and, for the EU, roughly how many times it was shown.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This works fine for a quick look at one brand. It gets painful when you want 300 ads from five competitors in a spreadsheet, with the ad text you can actually search and sort.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fast way: pull everything into a table
&lt;/h2&gt;

&lt;p&gt;I built a small tool for this: &lt;a href="https://apify.com/watershowdigital/competitor-ad-spy" rel="noopener noreferrer"&gt;Google Ads Spy&lt;/a&gt; on Apify. You give it domains or advertiser names, it returns one row per ad:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;headline, description and call to action&lt;/li&gt;
&lt;li&gt;landing page URL (where the ad actually sends people)&lt;/li&gt;
&lt;li&gt;the YouTube link for video ads&lt;/li&gt;
&lt;li&gt;first and last date shown, and how many days the ad ran&lt;/li&gt;
&lt;li&gt;countries, and impressions where Google publishes them
&lt;/li&gt;
&lt;/ul&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;"domains"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"notion.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"clickup.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"monday.com"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"region"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"US"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"format"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"video"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxAdsPerSource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&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;It costs $1.50 per 1,000 ads with full details, so a full teardown of three competitors is well under a dollar. If you also have a Meta Ad Library API token, the same run pulls their Facebook and Instagram ads.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to look for once you have the list
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. The ads that never stop
&lt;/h3&gt;

&lt;p&gt;Sort by how long each ad has been running. An ad that has run for 120 days is almost certainly profitable, nobody keeps paying for a loser that long. Those are the messages to study first.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The landing pages
&lt;/h3&gt;

&lt;p&gt;Group ads by landing page URL. You will usually find two or three pages that get most of the ads. That tells you which offer converts for them: a free trial, a demo, a template gallery, a comparison page against you.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. The angle, not the wording
&lt;/h3&gt;

&lt;p&gt;Do not copy the headline. Write down the angle behind it: speed ("set up in 5 minutes"), price ("free for teams under 10"), pain ("stop chasing invoices"), social proof ("used by 50,000 teams"). Then test the same angle in your own words.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Where they spend
&lt;/h3&gt;

&lt;p&gt;The country list shows where they are pushing. If a competitor suddenly starts running ads in Germany, they are probably launching there. If they stopped in a market, it probably did not pay off.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Video hooks
&lt;/h3&gt;

&lt;p&gt;For YouTube ads, watch only the first five seconds of each. That is the hook they paid to test. Collect the ones that keep running and you have a swipe file of hooks that work in your niche.&lt;/p&gt;

&lt;h2&gt;
  
  
  A simple weekly routine
&lt;/h2&gt;

&lt;p&gt;Run the same input once a week with "only running ads" turned on and save the results. Compare with last week:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;new ads mean they are testing something&lt;/li&gt;
&lt;li&gt;ads that disappeared were probably losing&lt;/li&gt;
&lt;li&gt;a new landing page means a new offer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Twenty minutes a week and you always know what your competitors are betting on, without paying for their mistakes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Good to know
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Search ads are published as an image of the ad, so for those you get a screenshot rather than separate text fields.&lt;/li&gt;
&lt;li&gt;Impression numbers are only published for ads shown in the EU.&lt;/li&gt;
&lt;li&gt;This is public data Google shows on purpose, so anyone can check who advertises what. Use it to learn, not to clone someone's brand.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h3&gt;
  
  
  How much does it cost to download competitor Google ads?
&lt;/h3&gt;

&lt;p&gt;Using the Google Ads Spy tool on Apify costs $1.50 per 1,000 ads to pull the data into a table. Viewing the ads manually in the Google Ads Transparency Center is completely free.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can you see how many impressions a competitor ad gets?
&lt;/h3&gt;

&lt;p&gt;Google only publishes impression numbers for ads shown in the European Union. For ads shown in other regions, you can see the active dates and countries but not the impression counts.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you know if a competitor Google ad is profitable?
&lt;/h3&gt;

&lt;p&gt;You should look at how long the ad has been running. If an ad stays active for a long time, it is almost certainly profitable because advertisers do not keep paying for losing ads.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://watershowdigital.com/blog/see-competitor-google-ads" rel="noopener noreferrer"&gt;watershowdigital.com/blog/see-competitor-google-ads&lt;/a&gt;.&lt;/em&gt; Need a hand with your app or automation? &lt;a href="https://watershowdigital.com/build" rel="noopener noreferrer"&gt;WaterShow Digital&lt;/a&gt; fixes and builds it, fixed quote first.&lt;/p&gt;

</description>
      <category>googleads</category>
      <category>marketing</category>
      <category>scraping</category>
      <category>advertising</category>
    </item>
    <item>
      <title>How to find Substack newsletters to sponsor (with real subscriber counts)</title>
      <dc:creator>WaterShow</dc:creator>
      <pubDate>Mon, 26 Oct 2026 02:02:32 +0000</pubDate>
      <link>https://dev.to/watershow/how-to-find-substack-newsletters-to-sponsor-with-real-subscriber-counts-4a2n</link>
      <guid>https://dev.to/watershow/how-to-find-substack-newsletters-to-sponsor-with-real-subscriber-counts-4a2n</guid>
      <description>&lt;p&gt;Newsletter sponsorships are one of the few ad channels where people still read the ad. The catch is finding the right newsletters: ones your customers actually read, that are still active, and that are big enough to matter but small enough to afford.&lt;/p&gt;

&lt;p&gt;Substack hosts thousands of them. Here is how to build a shortlist.&lt;/p&gt;

&lt;h2&gt;
  
  
  What makes a newsletter worth sponsoring
&lt;/h2&gt;

&lt;p&gt;Before you search, decide what "good" means for you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fit.&lt;/strong&gt; The topic matches your customer, not just your industry. A newsletter about "AI tools for lawyers" beats a general AI one if you sell to law firms.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Size.&lt;/strong&gt; Subscribers between 2,000 and 50,000 are usually the sweet spot for small budgets. Big names are expensive and often sold out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Activity.&lt;/strong&gt; It posts every week or two. A newsletter that last posted three months ago is dead, whatever its subscriber count says.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A paid tier.&lt;/strong&gt; Writers with paying readers usually have a more engaged audience.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A real person you can reach.&lt;/strong&gt; You need an email, X or LinkedIn to pitch them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The manual way
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Go to &lt;a href="https://substack.com/" rel="noopener noreferrer"&gt;substack.com&lt;/a&gt; and search your topic.&lt;/li&gt;
&lt;li&gt;Open each newsletter, note the subscriber count if it is shown, and check the date of the last post.&lt;/li&gt;
&lt;li&gt;Look at the "About" page and the author profile for contact links.&lt;/li&gt;
&lt;li&gt;Put everything in a spreadsheet.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It works, but it is slow: 10 to 15 minutes per newsletter, and many hide the subscriber count on their own page.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fast way
&lt;/h2&gt;

&lt;p&gt;I built &lt;a href="https://apify.com/watershowdigital/substack-newsletter-finder" rel="noopener noreferrer"&gt;Substack Newsletter Finder&lt;/a&gt; for exactly this. You give it keywords or categories and it returns a table:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;newsletter name and URL&lt;/li&gt;
&lt;li&gt;subscriber count and the author's follower count&lt;/li&gt;
&lt;li&gt;the author's X, LinkedIn, YouTube and website links&lt;/li&gt;
&lt;li&gt;last post date and how many posts in the last 30 days&lt;/li&gt;
&lt;li&gt;language and whether it has a paid plan
&lt;/li&gt;
&lt;/ul&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;"searchQueries"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"b2b saas"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"startup marketing"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"minSubscribers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"activeWithinDays"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"language"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"en"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"maxResults"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&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;200 newsletters cost about $0.50. Filter by subscribers and activity, sort, and you have a shortlist in a few minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to pitch
&lt;/h2&gt;

&lt;p&gt;Keep the first email short and specific:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one line on why their audience fits ("your readers are early stage founders, we make invoicing for them")&lt;/li&gt;
&lt;li&gt;what you want: one sponsored slot in an upcoming issue&lt;/li&gt;
&lt;li&gt;ask for their rate, or offer one if they do not have a media kit&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Start with three to five newsletters, not fifty. Run one slot each, track signups with a separate link or code per newsletter, and only rebook the ones that paid back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read before you buy
&lt;/h2&gt;

&lt;p&gt;Before paying, read the last five issues. Check that the writing is good, that ads are clearly marked and not stuffed in, and that readers reply or comment. Ten minutes of reading saves you from buying space in a newsletter nobody opens.&lt;/p&gt;

&lt;h2&gt;
  
  
  A shortcut for writers too
&lt;/h2&gt;

&lt;p&gt;If you run a newsletter yourself, the same list works in reverse: find newsletters of a similar size in your niche and propose a cross promotion. Both of you grow, nobody pays.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  How do I find the subscriber count for a Substack newsletter?
&lt;/h3&gt;

&lt;p&gt;You can look for the subscriber count manually by opening each newsletter on Substack, though many writers hide this number. Alternatively, you can use the Substack Newsletter Finder tool to automatically pull subscriber counts for a list of newsletters.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is a good subscriber size for a newsletter sponsorship?
&lt;/h3&gt;

&lt;p&gt;Subscribers between 2,000 and 50,000 are usually the best option for small budgets. Larger newsletters are often expensive and sold out.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I pitch a sponsorship to a Substack writer?
&lt;/h3&gt;

&lt;p&gt;Keep your first email short and explain why their audience fits your product. Tell them you want one sponsored slot in an upcoming issue. You should also ask for their rate or offer a price if they lack a media kit.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://watershowdigital.com/blog/find-substack-newsletters-to-sponsor" rel="noopener noreferrer"&gt;watershowdigital.com/blog/find-substack-newsletters-to-sponsor&lt;/a&gt;.&lt;/em&gt; Need a hand with your app or automation? &lt;a href="https://watershowdigital.com/build" rel="noopener noreferrer"&gt;WaterShow Digital&lt;/a&gt; fixes and builds it, fixed quote first.&lt;/p&gt;

</description>
      <category>substack</category>
      <category>marketing</category>
      <category>newsletter</category>
      <category>automation</category>
    </item>
    <item>
      <title>How to find customers on Reddit without spamming (or getting banned)</title>
      <dc:creator>WaterShow</dc:creator>
      <pubDate>Mon, 26 Oct 2026 01:57:10 +0000</pubDate>
      <link>https://dev.to/watershow/how-to-find-customers-on-reddit-without-spamming-or-getting-banned-2l4l</link>
      <guid>https://dev.to/watershow/how-to-find-customers-on-reddit-without-spamming-or-getting-banned-2l4l</guid>
      <description>&lt;p&gt;Every day somebody on Reddit writes "can anyone recommend a tool for..." or "I'm stuck with this, willing to pay someone to fix it". If you sell that thing, this is the warmest lead you will ever get: they asked first.&lt;/p&gt;

&lt;p&gt;The problem is finding those posts before 40 other people answer, and replying without looking like a spammer. Here is the approach that works for me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: know what a buying post looks like
&lt;/h2&gt;

&lt;p&gt;Most posts in your niche are not leads. Discussions, launches and memes are noise. Buying intent sounds like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"looking for a tool / app / service that..."&lt;/li&gt;
&lt;li&gt;"any alternative to X?" (they are unhappy with a competitor)&lt;/li&gt;
&lt;li&gt;"willing to pay", "budget is", "hiring", "need someone to"&lt;/li&gt;
&lt;li&gt;"how do I fix..." followed by a very concrete broken thing&lt;/li&gt;
&lt;li&gt;"we tried X and it didn't work"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Write down 10 to 20 phrases like these for your product. They become your search queries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: search the right places
&lt;/h2&gt;

&lt;p&gt;Reddit search is decent if you sort by new. Try queries like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"alternative to hubspot"
"looking for a crm"
"need help with supabase"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then list the subreddits where your customers hang out. Not the big general ones: the small, specific ones where people describe their actual problems. For a dev service that might be r/Supabase, r/nextjs, r/SaaS, r/indiehackers, r/forhire.&lt;/p&gt;

&lt;p&gt;Checking all of that by hand every day takes an hour. I built &lt;a href="https://apify.com/watershowdigital/reddit-lead-finder" rel="noopener noreferrer"&gt;Reddit Scraper &amp;amp; AI Lead Finder&lt;/a&gt; to do it: you give it search queries, subreddits and a short description of what you sell, and it returns the posts with an intent score, the pain point and a draft reply. You can schedule it every few hours and only look at posts that scored 7 or higher.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: answer like a person, not a brand
&lt;/h2&gt;

&lt;p&gt;This is where most people burn their account. Rules I follow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Help first.&lt;/strong&gt; Give one concrete, useful tip for their exact problem. If your reply is useful without your product, you are doing it right.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep it short.&lt;/strong&gt; Two or three sentences. Nobody reads a wall of text from a stranger.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No links in the first reply.&lt;/strong&gt; Links from new accounts get filtered or downvoted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One soft offer at the end.&lt;/strong&gt; Something like "I can help you set this up if you want, just message me." That is it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sound like yourself.&lt;/strong&gt; No "Great question!", no bullet points, no corporate tone.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A good reply looks like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;sounds like your redirect URL in Supabase auth still points to localhost, check Authentication then URL configuration. i fix this kind of thing for people, message me if you want a hand&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Step 4: move to DMs, then close
&lt;/h2&gt;

&lt;p&gt;When someone replies "yes please" or sends a DM, the hard part is over. Ask one or two questions about their setup, give a fixed price, and offer to start today. Speed wins here: the person who answers within an hour usually gets the job.&lt;/p&gt;

&lt;h2&gt;
  
  
  How not to get banned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Read each subreddit's rules. Some ban any self-promotion, respect that.&lt;/li&gt;
&lt;li&gt;Do not post the same reply twice. Reddit spots copy-paste fast.&lt;/li&gt;
&lt;li&gt;Keep the ratio healthy: most of your comments should be plain help with no offer at all.&lt;/li&gt;
&lt;li&gt;A few good replies a day beat fifty lazy ones.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to expect
&lt;/h2&gt;

&lt;p&gt;Most replies go nowhere, and that is fine. You are playing a numbers game with very warm leads: a handful of helpful replies a day, a few conversations a week, and every now and then someone who says "can you just do it for me". It costs half an hour a day, and the people you talk to came asking for help.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  How do I find people who want to buy on Reddit?
&lt;/h3&gt;

&lt;p&gt;You should look for posts where people specifically ask for tools, alternatives, or help fixing a concrete problem. Search for these buying phrases in small, specific subreddits where your customers actually hang out. You can check these manually or use a tool like Reddit Scraper &amp;amp; AI Lead Finder to automate the search.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I put links in my Reddit comments?
&lt;/h3&gt;

&lt;p&gt;Do not include links in your first reply to a user. Links from new accounts often get filtered out or downvoted. Instead, offer a helpful tip and tell the person to message you directly if they want more help.&lt;/p&gt;

&lt;h3&gt;
  
  
  How can I avoid getting banned when selling on Reddit?
&lt;/h3&gt;

&lt;p&gt;Read the rules of each subreddit and respect any bans on self-promotion. Make sure most of your comments just offer help without pitching your product at all. Never copy and paste the same reply twice because Reddit spots this very quickly.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://watershowdigital.com/blog/find-customers-on-reddit" rel="noopener noreferrer"&gt;watershowdigital.com/blog/find-customers-on-reddit&lt;/a&gt;.&lt;/em&gt; Need a hand with your app or automation? &lt;a href="https://watershowdigital.com/build" rel="noopener noreferrer"&gt;WaterShow Digital&lt;/a&gt; fixes and builds it, fixed quote first.&lt;/p&gt;

</description>
      <category>reddit</category>
      <category>marketing</category>
      <category>startup</category>
      <category>saas</category>
    </item>
    <item>
      <title>Supabase RLS blocks your inserts? Six causes and how to fix each</title>
      <dc:creator>WaterShow</dc:creator>
      <pubDate>Mon, 26 Oct 2026 01:57:09 +0000</pubDate>
      <link>https://dev.to/watershow/supabase-rls-blocks-your-inserts-six-causes-and-how-to-fix-each-4nc7</link>
      <guid>https://dev.to/watershow/supabase-rls-blocks-your-inserts-six-causes-and-how-to-fix-each-4nc7</guid>
      <description>&lt;p&gt;You turned on Row Level Security, wrote a policy that looks right, and now every insert from your app fails with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;new row violates row-level security policy for table "todos"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The good news: this error almost always comes from one of six causes. Go through them in order and you will find yours in a few minutes.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. There is no INSERT policy at all
&lt;/h2&gt;

&lt;p&gt;When RLS is on, everything is denied until a policy allows it. A lot of apps have a policy for &lt;code&gt;SELECT&lt;/code&gt; (often copied from a tutorial) but nothing for &lt;code&gt;INSERT&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Check what you have:&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;policyname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;with_check&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_policies&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'todos'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If there is no row with &lt;code&gt;cmd = 'INSERT'&lt;/code&gt; (or &lt;code&gt;ALL&lt;/code&gt;), add one:&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="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"users insert own rows"&lt;/span&gt;
&lt;span class="k"&gt;on&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;todos&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;
&lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. The policy uses USING instead of WITH CHECK
&lt;/h2&gt;

&lt;p&gt;For inserts, Postgres only looks at &lt;code&gt;with check&lt;/code&gt;. A &lt;code&gt;using&lt;/code&gt; clause is for rows that already exist (select, update, delete). If you wrote the insert rule into &lt;code&gt;using&lt;/code&gt;, Postgres either rejects the policy or the insert still fails.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;select / delete&lt;/strong&gt;: &lt;code&gt;using (...)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;insert&lt;/strong&gt;: &lt;code&gt;with check (...)&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;update&lt;/strong&gt;: both, &lt;code&gt;using&lt;/code&gt; for which rows you may touch, &lt;code&gt;with check&lt;/code&gt; for what they may become&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. user_id is empty, so the check can never pass
&lt;/h2&gt;

&lt;p&gt;Your policy says &lt;code&gt;auth.uid() = user_id&lt;/code&gt;, but the client sends the row without &lt;code&gt;user_id&lt;/code&gt;. Null is not equal to anything, so the check fails.&lt;/p&gt;

&lt;p&gt;Either send it from the client, or better, let the database fill it in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;todos&lt;/span&gt;
&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;column&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="k"&gt;default&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now the client can insert &lt;code&gt;{ title: "Buy milk" }&lt;/code&gt; and the row is still owned by the right user.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. The request is not actually logged in
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;auth.uid()&lt;/code&gt; returns null when the request comes in as &lt;code&gt;anon&lt;/code&gt;. This happens more often than you would think:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the user's session expired and the client did not refresh it&lt;/li&gt;
&lt;li&gt;you call Supabase from a server route or an edge function with the anon key, but never pass the user's access token&lt;/li&gt;
&lt;li&gt;the policy says &lt;code&gt;to authenticated&lt;/code&gt;, while the request arrives as &lt;code&gt;anon&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On the server, create the client with the user's token, so Postgres knows who is asking:&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="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;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;anonKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;global&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;accessToken&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. insert().select() also needs a SELECT policy
&lt;/h2&gt;

&lt;p&gt;This one catches almost everybody. If you write:&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="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;todos&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;title&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Supabase inserts the row and then reads it back. Reading it back is a &lt;code&gt;select&lt;/code&gt;, so you also need a select policy for that row. Without it the whole call fails with the same RLS error, even though your insert policy is fine.&lt;/p&gt;

&lt;p&gt;Either add the select policy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"users read own rows"&lt;/span&gt;
&lt;span class="k"&gt;on&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;todos&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
&lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;or drop the &lt;code&gt;.select()&lt;/code&gt; if you do not need the row back.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. A related table has its own rules
&lt;/h2&gt;

&lt;p&gt;If your insert fires a trigger that writes into another table, or the policy itself runs a subquery on another table (for example "is this user a member of this team?"), those tables have RLS too. The check fails because the subquery returns nothing for this user.&lt;/p&gt;

&lt;p&gt;Two ways out: give the user read access to the rows the check needs, or move the check into a &lt;code&gt;security definer&lt;/code&gt; function that runs with the owner's rights. Keep that function small and only return what the check needs.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to test a policy without guessing
&lt;/h2&gt;

&lt;p&gt;The SQL editor in the dashboard runs as a superuser, which skips RLS. So a query that works there tells you nothing. Pretend to be the user instead:&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;begin&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="k"&gt;local&lt;/span&gt; &lt;span class="k"&gt;role&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="k"&gt;local&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'{"sub": "PASTE-A-REAL-USER-ID"}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;into&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;todos&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;values&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'test'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;rollback&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If this fails, the problem is in your policies. If it works, the problem is in how your app sends the request (causes 4 and 5).&lt;/p&gt;

&lt;h2&gt;
  
  
  What not to do
&lt;/h2&gt;

&lt;p&gt;Do not "fix" it by turning RLS off, and do not put the &lt;code&gt;service_role&lt;/code&gt; key in the browser. Both make the error go away by giving everyone access to every row. The first person who opens the network tab gets your whole database.&lt;/p&gt;

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

&lt;h3&gt;
  
  
  Why does Supabase insert().select() fail with an RLS error?
&lt;/h3&gt;

&lt;p&gt;When you add the select method to an insert command, Supabase tries to read the row back after creating it. This means you need a select policy in addition to your insert policy. If you do not have a select policy, the entire call fails.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do I test Supabase RLS policies in the SQL editor?
&lt;/h3&gt;

&lt;p&gt;The SQL editor runs as a superuser and naturally skips RLS rules. To test a policy correctly, you need to write a transaction that sets the local role to authenticated and provides a real user ID. If the query works this way, your policies are fine and the problem is in your app code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Should I use using or with check for an insert policy?
&lt;/h3&gt;

&lt;p&gt;You must use with check for insert policies. Postgres only looks at the using clause for rows that already exist, such as when you select or delete data. If you write your insert rule with using, Postgres will reject it and the insert will fail.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://watershowdigital.com/blog/supabase-rls-blocks-inserts" rel="noopener noreferrer"&gt;watershowdigital.com/blog/supabase-rls-blocks-inserts&lt;/a&gt;.&lt;/em&gt; Need a hand with your app or automation? &lt;a href="https://watershowdigital.com/build" rel="noopener noreferrer"&gt;WaterShow Digital&lt;/a&gt; fixes and builds it, fixed quote first.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>security</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
