<?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: Marco Gundlach</title>
    <description>The latest articles on DEV Community by Marco Gundlach (@mgundlach).</description>
    <link>https://dev.to/mgundlach</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%2F3983165%2F7eabbe25-f93a-4cb8-a585-76393fc52c68.png</url>
      <title>DEV Community: Marco Gundlach</title>
      <link>https://dev.to/mgundlach</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mgundlach"/>
    <language>en</language>
    <item>
      <title>Your vibe-coded app already has a data leak (and the code compiles fine)</title>
      <dc:creator>Marco Gundlach</dc:creator>
      <pubDate>Tue, 30 Jun 2026 15:51:58 +0000</pubDate>
      <link>https://dev.to/mgundlach/your-vibe-coded-app-already-has-a-data-leak-and-the-code-compiles-fine-1d78</link>
      <guid>https://dev.to/mgundlach/your-vibe-coded-app-already-has-a-data-leak-and-the-code-compiles-fine-1d78</guid>
      <description>&lt;p&gt;The first version always works. That is the trap.&lt;br&gt;
You describe a small internal tool, wait two minutes, and there it is, running in your browser with your real data in it. It feels like a magic trick. And because it works, you ship it. Why wouldn't you. It works.&lt;br&gt;
Here is the problem. "It works" and "it is safe" are two completely different claims, and vibe coding is very good at the first one while staying silent about the second.&lt;br&gt;
I build small and mid sized apps mostly through natural language, usually Next.js with Supabase behind it. This is the single most common security hole I see, in my own early drafts and in the projects people bring to my trainings. It shows up in almost every Supabase app that was built fast. It never shows up in the demo. It always shows up the moment real users arrive.&lt;br&gt;
Let me show you exactly what it looks like.&lt;br&gt;
What you asked for&lt;br&gt;
You are building a tiny internal dashboard. You prompt something reasonable:&lt;/p&gt;

&lt;p&gt;Add a page that lists our customers from Supabase. Table called customers with name, email, and mrr. Show them in a sortable table.&lt;/p&gt;

&lt;p&gt;The model happily gives you a clean client component.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="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;NEXT_PUBLIC_SUPABASE_URL&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;NEXT_PUBLIC_SUPABASE_ANON_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Customers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setRows&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;any&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="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="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;customers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;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="nf"&gt;setRows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="p"&gt;[]))&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;

  &lt;span class="c1"&gt;// ...render the table&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`

And earlier, when you set up the database, it handed you this SQL to paste into the Supabase SQL editor:
`&lt;/span&gt;&lt;span class="nx"&gt;create&lt;/span&gt; &lt;span class="nx"&gt;table&lt;/span&gt; &lt;span class="nf"&gt;customers &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="nx"&gt;uuid&lt;/span&gt; &lt;span class="nx"&gt;primary&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;mrr&lt;/span&gt; &lt;span class="nx"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;created_at&lt;/span&gt; &lt;span class="nx"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;now&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;You ran it. The page loads. The customers show up. Everything is green.&lt;br&gt;
Nothing here is broken in the "the code compiles" sense. The model did its job. It made one assumption that happens to be false for your setup, and it never said a word about it.&lt;br&gt;
Why it looks completely fine&lt;br&gt;
In the demo you are the only user. You are logged into your own machine, looking at your own test data. There is no second person on the other side of the app trying to do something you did not intend. So the dangerous behaviour simply never gets a chance to appear.&lt;br&gt;
This is the thing about security holes built this way. They are not loud. The app does not crash. There is no red error. The code does exactly what you asked, and what you asked happened to be unsafe.&lt;/p&gt;
&lt;h2&gt;
  
  
  The hole
&lt;/h2&gt;

&lt;p&gt;Two facts that the generated code never put next to each other.&lt;br&gt;
First, that NEXT_PUBLIC_SUPABASE_ANON_KEY is public on purpose. Next.js inlines any NEXT_PUBLIC_ variable straight into the JavaScript bundle that ships to the browser. So does the project URL. Anyone who opens your site can read both out of the network tab in about five seconds. That is by design. The anon key is not a secret.&lt;br&gt;
Second, the only thing that makes a public anon key safe in Supabase is Row Level Security. RLS is what decides which rows the anon key is actually allowed to touch. And on a table you created with raw create table SQL, RLS is off. No policies. No gate. The table is fully exposed through Supabase's auto-generated REST API.&lt;br&gt;
Put those two facts together and you get this. Your customer table, with names, emails, and revenue per account, is readable by anyone on the internet who can copy two values out of your own front end.&lt;br&gt;
They do not even need your app. Here is the entire attack.&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;curl&lt;/span&gt; &lt;span class="s1"&gt;'https://YOURPROJECT.supabase.co/rest/v1/customers?select=*'&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt; &lt;span class="nv"&gt;"apikey: THE_PUBLIC_ANON_KEY_FROM_YOUR_BUNDLE"&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt; &lt;span class="nv"&gt;"Authorization: Bearer THE_PUBLIC_ANON_KEY_FROM_YOUR_BUNDLE"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That returns your whole table as JSON. Your UI, your sorting, your nice components, none of it matters. The REST endpoint sits underneath all of that and it answers to anyone holding the public key. Which is everyone.&lt;br&gt;
And it is not just reads. With no policies, depending on your grants, the same key can often insert, update, and delete. So now it is not only a leak. It is a write surface.&lt;br&gt;
To be fair to Supabase, the dashboard does warn you. If you create a table through the Table Editor UI, RLS gets switched on by default and an "Unrestricted" badge yells at you when it is off. The security advisor flags it too. But here is the catch. When you vibe code, you do not click through the UI. You paste the SQL the model gave you straight into the SQL editor. That path skips the guardrails the dashboard built for exactly this moment.&lt;br&gt;
The tool tried to protect you. The workflow routed around the protection.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why this keeps happening with AI specifically
&lt;/h2&gt;

&lt;p&gt;Models optimize for code that runs. RLS is invisible to "does it run". An app with wide open tables runs perfectly. It demos perfectly. The feedback loop the model is trained on, generate, see it work, move on, has no step in it where the missing policy ever surfaces.&lt;br&gt;
So unless you put security into the spec yourself, the model will quietly choose the version that works today and leaks tomorrow. Not out of malice. It just has no reason to pick otherwise, and "it works" is the only signal in the room.&lt;br&gt;
This is the general shape of the whole problem, by the way. The model fills every gap you leave with a plausible default. Most defaults are fine. Some of them ship your customer list to the public internet.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Three layers, from "do this right now" to "do this from now on".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Turn RLS on for every table. No exceptions.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;alter table customers &lt;span class="nb"&gt;enable &lt;/span&gt;row level security&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that and reload your app. It will break. The table goes silent and your dashboard shows nothing. That is correct. That is RLS doing its job: deny by default. An empty table in your UI right now is infinitely better than a full table in someone's curl later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Add explicit policies that say who can see what.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Deny-by-default is only useful once you grant the narrow thing you actually want. Assuming each customer row belongs to a user, give the table an owner_id and scope reads to the logged-in user.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;add&lt;/span&gt; &lt;span class="k"&gt;column&lt;/span&gt; &lt;span class="n"&gt;owner_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;
  &lt;span class="k"&gt;references&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;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;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Read own customers"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;customers&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;owner_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now an anon request gets nothing, an authenticated user gets only their own rows, and the same curl from earlier returns an empty array. Test it yourself with the public key and confirm you get []. If you get rows, you are not done.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Keep sensitive work on the server, and keep the service role server-only.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The model defaulted to a client component, which is fine for plenty of internal tools once RLS is solid. But for anything sensitive, move the data access into a server component or a route handler so the query never runs in the browser at all. And if you use the service_role key, which bypasses RLS entirely, it lives in server-only environment variables and never, ever gets a NEXT_PUBLIC_ prefix. One slip there and you have handed out a master key.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/customers/page.tsx  (server component, runs on the server only)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createServerClient&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;@/lib/supabase/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Customers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createServerClient&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rows&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;customers&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="c1"&gt;// RLS still applies, and nothing leaks into the client bundle&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Make the model do this for you&lt;/strong&gt;&lt;br&gt;
You should not have to remember this every time. Put the rule into the prompt once and let it carry.&lt;/p&gt;

&lt;p&gt;Any time you create a Supabase table, enable Row Level Security in the same step and write explicit policies scoped to the current user. Never expose a table through the anon key without RLS. Never put the service_role key in a NEXT_PUBLIC variable. If a table needs to be readable without auth, call it out explicitly and ask me first.&lt;/p&gt;

&lt;p&gt;That last sentence is the important one. It turns a silent default into a decision you get to make on purpose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The actual lesson&lt;/strong&gt;&lt;br&gt;
The dangerous sentence in vibe coding is not "I don't know how this works". It is "well, it runs".&lt;br&gt;
Running tells you the syntax is valid. It tells you nothing about whether the thing is safe, or whether it does what you actually meant under conditions you did not test. Generated code is a draft. You own it the moment you ship it, which means you read it, and when you hit something you do not understand, you ask the model to explain it before you accept it. Those five minutes on the RLS line are the cheapest insurance you will ever buy.&lt;br&gt;
Vibe coding did not make this mistake more common because the tools are bad. It made it more common because it removed the friction that used to slow you down long enough to notice. The speed is real. So is the bill, if you are not watching.&lt;br&gt;
Go check your tables. Right now. I will wait.&lt;br&gt;
If you found one, you are not alone, and I would genuinely like to hear how it slipped through. The comments are open&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Vibe coding works until you try to change something</title>
      <dc:creator>Marco Gundlach</dc:creator>
      <pubDate>Sun, 14 Jun 2026 19:16:12 +0000</pubDate>
      <link>https://dev.to/mgundlach/vibe-coding-works-until-you-try-to-change-something-776</link>
      <guid>https://dev.to/mgundlach/vibe-coding-works-until-you-try-to-change-something-776</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgxicjfd1y7xblkrpm7i7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgxicjfd1y7xblkrpm7i7.png" alt=" " width="800" height="336"&gt;&lt;/a&gt;&lt;br&gt;
The first version almost always works. That is the part nobody is ready for. You describe what you want in one sentence, wait two minutes, and a small tool is running in your browser. It feels like a trick.&lt;br&gt;
The second moment feels different. You want to change one small thing. The model cheerfully rewrites half the file. Now the part that worked a minute ago is broken, and you are not sure why. This is the exact spot where useful vibe coding splits off from throwaway code.&lt;br&gt;
I have spent the last year building small and mid sized apps mostly through natural language, usually on Next.js with Supabase behind it. Here is the short version of what I learned. The bottleneck is no longer typing code. It is how precisely you describe the thing and how carefully you read what comes back. Get that right and you end up with something you can still maintain six months later. Ignore it and you produce, in record time, the exact kind of system nobody wants to touch.&lt;/p&gt;

&lt;h2&gt;
  
  
  A spec beats a vibe
&lt;/h2&gt;

&lt;p&gt;The phrase "vibe coding" is a little misleading. It suggests you just vibe and code appears. In practice the opposite is true. The clearer your first prompt, the fewer rounds you need after it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A weak start looks like this:&lt;/strong&gt;&lt;br&gt;
Build me a dashboard for our sales numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A good start hands the model the context it would otherwise have to guess:&lt;/strong&gt;&lt;br&gt;
Add a page at /dashboard in our existing Next.js App Router project. Data comes from a Supabase table called "sales" with columns date, region, amount. Show a bar chart of total amount per region, and below it a sortable table. Reuse the components in our existing ui folder. Do not add new dependencies without asking first.&lt;/p&gt;

&lt;p&gt;The difference is not about being polite to the machine. The difference is that the second version makes decisions instead of leaving them open. Every decision you skip, the model makes for you, and it makes a slightly different one on every run.&lt;/p&gt;

&lt;p&gt;That last line matters more than it looks. "&lt;em&gt;Do not add new dependencies without asking&lt;/em&gt;" stops the model from quietly writing three extra packages into your package.json because they seemed handy at the time. Guardrails like that, written once into the prompt, save you hours later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Small steps, or pain
&lt;/h2&gt;

&lt;p&gt;The most common mistake is handing the model too much at once and then letting a big change run across existing code. These models love to rewrite more than you asked. Point one at a 400 line file with the task "change the date format" and you get back a 400 line file where the date format is correct and three other things are subtly broken.&lt;br&gt;
What helps is dull and it works anyway. Keep the units small. One function, one component, one clearly scoped task per run. And commit after every working state. Git is not a nice extra here. It is your seatbelt. When the next run breaks something, you roll it back in two seconds instead of playing detective.&lt;br&gt;
My loop now is almost always the same. Working state, commit, change one thing, test, commit. It sounds like more overhead. It is faster in the end, because I never land in the situation where I have to untangle a pile of mixed changes that all arrived together.&lt;/p&gt;

&lt;h2&gt;
  
  
  You own the code, so read it
&lt;/h2&gt;

&lt;p&gt;The most dangerous sentence in vibe coding is "well, it runs." Whether something runs tells you nothing about whether it does the right thing or whether it is safe.&lt;br&gt;
Here is a real one. Ask a model to load data from Supabase and it will often write the query straight into a client component and never mention Row Level Security. In a demo with test data this never shows up. The moment real users and real data arrive, you have shipped a data leak without noticing. The model did not do anything wrong in the "the code compiles" sense. It made an assumption that happens to be false in your setup.&lt;br&gt;
So treat generated code as a draft, not a finished product. You do not need to be able to write every line yourself from memory. You do need to read it and follow what it does. When you hit a spot you do not understand, ask the model to explain it before you accept it. Those five minutes are the best investment in the whole process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it pays off and where it does not
&lt;/h2&gt;

&lt;p&gt;After enough projects a pattern shows up for which jobs vibe coding actually saves time.&lt;br&gt;
It shines on internal tools with a contained scope. Configurators. Reports. Prototypes. Small automations. Anything with a clear task and a low blast radius if it goes wrong. A two week project regularly turns into an afternoon here, and without a drop in quality if you follow the points above.&lt;br&gt;
It gets miserable once a system is large, tightly connected, and business critical. The more moving parts, the harder it is to keep control through language alone. That does not mean you drop AI entirely on those systems. It means you keep an experienced developer in the loop who can judge the suggestions instead of accepting them blind.&lt;br&gt;
Knowing that line honestly is not a weakness. It is the difference between someone who uses vibe coding productively and someone who just buys themselves a new set of problems.&lt;/p&gt;

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

&lt;p&gt;If I had to reduce all of this to one thing, it is this. Vibe coding does not make programming faster. It changes who can build software at all. Someone who knows their problem cold but never learned to code can now build a working solution, as long as they pick up the handful of habits I described here.&lt;br&gt;
Write a clear spec. Work in small steps. Read the code you accept. Know where your own limit is. None of that is a secret and none of it is magic. It is a craft with a new tool in it. And like any tool, the result comes down to the hand holding it, not the tool.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>productivity</category>
      <category>supabase</category>
    </item>
    <item>
      <title>Vibe Coding, das man in sechs Monaten noch anfassen kann</title>
      <dc:creator>Marco Gundlach</dc:creator>
      <pubDate>Sat, 13 Jun 2026 20:42:58 +0000</pubDate>
      <link>https://dev.to/mgundlach/vibe-coding-das-man-in-sechs-monaten-noch-anfassen-kann-b1j</link>
      <guid>https://dev.to/mgundlach/vibe-coding-das-man-in-sechs-monaten-noch-anfassen-kann-b1j</guid>
      <description>&lt;p&gt;Die erste Version funktioniert fast immer. Das ist der Teil, der alle überrascht, wenn sie mit &lt;em&gt;Vibe Coding&lt;/em&gt; anfangen. Du beschreibst in einem Satz, was du brauchst, und nach zwei Minuten läuft ein kleines Tool im Browser. Dieser Moment fühlt sich wie Magie an.&lt;/p&gt;

&lt;p&gt;Der zweite Moment fühlt sich anders an. Du willst eine Kleinigkeit ändern, das Modell schreibt großzügig die halbe Datei um, und plötzlich funktioniert die Stelle nicht mehr, die vorher lief. Genau hier trennt sich brauchbares Vibe Coding von Wegwerf-Code.&lt;/p&gt;

&lt;p&gt;Ich baue seit über einem Jahr kleine bis mittlere Anwendungen größtenteils über natürliche Sprache, meistens auf Next.js mit Supabase im Hintergrund. Was ich in dieser Zeit gelernt habe, lässt sich in einem Satz zusammenfassen: Der Engpass ist nicht mehr das Tippen von Code. Der Engpass ist, wie präzise du beschreibst und wie gründlich du gegenliest. Wer das verinnerlicht, bekommt Ergebnisse, die ein halbes Jahr später noch wartbar sind. Wer es ignoriert, produziert in Rekordzeit genau die Art von System, die später niemand mehr anfassen will.&lt;br&gt;
Spezifikation schlägt Stimmung&lt;br&gt;
Der Begriff "Vibe Coding" ist irreführend, weil er nahelegt, dass man einfach drauflos vibt. In der Praxis ist das Gegenteil der Fall. Je klarer der erste Prompt, desto weniger Runden brauchst du danach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Spezifikation schlägt Stimmung
&lt;/h2&gt;

&lt;p&gt;Der Begriff "Vibe Coding" ist irreführend, weil er nahelegt, dass man einfach drauflos vibt. In der Praxis ist das Gegenteil der Fall. Je klarer der erste Prompt, desto weniger Runden brauchst du danach.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ein schlechter Start sieht so aus:&lt;/strong&gt;&lt;br&gt;
Bau mir ein Dashboard für unsere Verkaufszahlen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ein guter Start liefert dem Modell Kontext, den es sonst raten müsste:&lt;/strong&gt;&lt;br&gt;
Bau eine Seite unter /dashboard in unserem bestehenden Next.js App Router Projekt. Die Daten kommen aus einer Supabase Tabelle "sales" mit den Spalten date, region, amount. Zeige eine Summe pro Region als Balkendiagramm und darunter eine sortierbare Tabelle. Nutze die Komponenten aus unserem bestehenden ui Ordner. Keine neuen Abhängigkeiten ohne Rückfrage.&lt;/p&gt;

&lt;p&gt;Der Unterschied ist nicht Höflichkeit gegenüber der Maschine. Der Unterschied ist, dass die zweite Variante eine Entscheidung trifft, statt sie dem Modell zu überlassen. Jede Entscheidung, die du nicht triffst, trifft das Modell für dich, und zwar bei jedem Lauf anders.&lt;br&gt;
Der letzte Satz in dem Beispiel ist wichtiger, als er aussieht. "Keine neuen Abhängigkeiten ohne Rückfrage" verhindert, dass dir das Modell drei zusätzliche Pakete in die package.json schreibt, weil es gerade praktisch schien. Solche Leitplanken im Prompt sparen dir später Stunden.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kleine Schritte, sonst Schmerz
&lt;/h2&gt;

&lt;p&gt;Der häufigste Fehler ist, dem Modell zu viel auf einmal zu geben und dann eine große Änderung über bestehenden Code laufen zu lassen. Generative Modelle neigen dazu, mehr umzubauen als nötig. Wenn du sie auf eine 400 Zeilen lange Datei loslässt mit dem Auftrag "ändere das Datumsformat", bekommst du zurück eine 400 Zeilen lange Datei, in der das Datumsformat stimmt und drei andere Dinge subtil kaputt sind.&lt;/p&gt;

&lt;p&gt;Was hilft, ist banal und wirkt trotzdem: Halte die Einheiten klein. Eine Funktion, eine Komponente, ein klar umrissener Auftrag pro Lauf. Und committe nach jedem funktionierenden Stand. Git ist beim Vibe Coding kein Nice to have, sondern dein Sicherheitsnetz. Wenn der nächste Lauf etwas zerbricht, machst du ihn in zwei Sekunden rückgängig, statt zu raten, was sich geändert hat.&lt;/p&gt;

&lt;p&gt;Ich arbeite inzwischen fast immer so: funktionierender Stand, commit, eine Sache ändern, testen, commit. Das klingt nach mehr Aufwand, ist aber unterm Strich schneller, weil ich nie mehr in der Situation lande, einen Haufen vermischter Änderungen entwirren zu müssen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Du besitzt den Code, also lies ihn
&lt;/h2&gt;

&lt;p&gt;Der gefährlichste Satz beim Vibe Coding lautet "läuft ja". Ob etwas läuft, sagt nichts darüber aus, ob es das Richtige tut oder ob es sicher ist.&lt;/p&gt;

&lt;p&gt;Ein konkretes Beispiel aus dem Alltag: Bittet man ein Modell, Daten aus Supabase zu laden, schreibt es oft den Zugriff direkt in eine Client Komponente, ohne Row Level Security zu erwähnen. Im Demo Betrieb mit Testdaten fällt das nie auf. Sobald echte Nutzer und echte Daten dazukommen, hast du ein Datenleck gebaut, ohne es zu merken. Das Modell hat nichts falsch gemacht im Sinne von "der Code kompiliert nicht". Es hat nur eine Annahme getroffen, die in deinem Kontext nicht stimmt.&lt;/p&gt;

&lt;p&gt;Deshalb gilt: Generierter Code ist ein Entwurf, kein Endprodukt. Du musst ihn nicht Zeile für Zeile selbst hätten schreiben können, aber du musst ihn lesen und verstehen können. Wenn du an einer Stelle nicht weißt, was passiert, frag das Modell, es soll es erklären, bevor du es übernimmst. Diese fünf Minuten sind die beste Investition im ganzen Prozess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wo es sich lohnt und wo nicht
&lt;/h2&gt;

&lt;p&gt;Nach genug Projekten zeichnet sich ein klares Muster ab, für welche Aufgaben Vibe Coding wirklich Zeit spart.&lt;/p&gt;

&lt;p&gt;Es glänzt bei internen Werkzeugen mit überschaubarem Umfang: Konfiguratoren, Auswertungen, Prototypen, kleine Automatisierungen, Dinge mit klarer Aufgabe und begrenztem Schadensrisiko. Aus einem Projekt von zwei Wochen wird hier regelmäßig ein Nachmittag, und das ohne Qualitätsverlust, wenn man die Punkte oben beachtet.&lt;/p&gt;

&lt;p&gt;Es wird mühsam, sobald ein System groß, stark vernetzt und geschäftskritisch ist. Je mehr bewegliche Teile, desto schwerer ist es, allein über Sprache die Kontrolle zu behalten. Das heißt nicht, dass man dort gar keine KI nutzt. Es heißt, dass man dort einen erfahrenen Entwickler im Loop braucht, der die generierten Vorschläge einordnet, statt sie blind zu übernehmen.&lt;/p&gt;

&lt;p&gt;Diese Grenze ehrlich zu kennen, ist kein Eingeständnis von Schwäche. Es ist der Unterschied zwischen jemandem, der Vibe Coding produktiv einsetzt, und jemandem, der sich damit nur neue Probleme einkauft.&lt;/p&gt;

&lt;h2&gt;
  
  
  Der eigentliche Hebel
&lt;/h2&gt;

&lt;p&gt;Wenn ich das Ganze auf eine Sache reduzieren müsste, dann diese: Vibe Coding macht nicht das Programmieren schneller, es verschiebt, wer überhaupt Software bauen kann. Eine Fachkraft, die ihr Problem genau kennt, aber nie programmieren gelernt hat, kann heute eine brauchbare Lösung bauen, vorausgesetzt, sie lernt die paar Disziplinen, die ich hier beschrieben habe.&lt;/p&gt;

&lt;p&gt;Klar spezifizieren. In kleinen Schritten arbeiten. Den Code lesen, den man übernimmt. Wissen, wo die eigene Grenze liegt. Das ist kein großes Geheimnis, und es ist auch keine Magie. Es ist Handwerk, nur mit einem neuen Werkzeug. Und wie bei jedem Werkzeug entscheidet nicht das Werkzeug über das Ergebnis, sondern die Hand, die es führt.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>productivity</category>
      <category>supabase</category>
    </item>
  </channel>
</rss>
