The honest part first: vibe-coding got something right.
It is a real achievement that a person with no React background can describe an app and have a working, clickable prototype in an afternoon. That used to take a team and a sprint. The win is not in the code quality — the win is that the gap between "idea" and "something I can click" has collapsed. That is worth saying out loud before anything else.
If you are using Lovable, Bolt, v0, or Cursor to explore an idea, you are using the right tool. Do not let anyone talk you out of that step.
Here is how to actually use these well today. For a UI exploration, point v0 at a component and ask for three variants; pick the one with the cleanest prop shape and stop. For a full-stack prototype, give Lovable a one-paragraph brief and iterate by editing the prompt, not by editing the generated files. For an existing codebase, run Cursor with a tight .cursorrules and a clear "do not refactor untouched files" rule. The common thread: the prompt is the spec. Once you start editing generated files by hand, you have lost the tool's use.
That last sentence is the hinge of this post.
What you actually receive when you click Export
Here is a representative slice of a vibe-coded export. The vendor differs; the shape is the same.
// src/pages/Dashboard.tsx — generated
import { useState, useEffect } from 'react';
import { createClient } from '@supabase/supabase-js';
import { useNavigate } from 'react-router-dom';
const supabase = createClient(
'
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' // anon key in source
);
export default function Dashboard() {
const navigate = useNavigate();
const [user, setUser] = useState(null);
const [tasks, setTasks] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function load() {
const { data: { user } } = await supabase.auth.getUser();
if (!user) { navigate('/login'); return; }
const { data, error } = await supabase
.from('tasks')
.select('*')
.eq('user_id', user.id)
.order('created_at', { ascending: false });
if (error) console.log(error);
setTasks(data || []);
setLoading(false);
}
load();
}, []);
// ... 180 more lines: inline JSX, inline styles, three more
// useEffects, a second anon key for a service role, and a
// stripe.checkout call hardcoded to a test price id
}
This is not a strawman. The same pattern shows up across vendors: a single file that owns everything, environment values baked into source, no separation between data fetching and view, no tests, no migrations, no env handling, no deploy target other than "the button in the dashboard."
You cannot git clone this and run it on a fresh machine. The anon key in the source means the production secret is now in your commit history. The error path is console.log(error). There is no migrations folder because the schema exists as clicks someone made on a hosted console. There is no consistent script surface — npm run dev does one thing in the generated project and fails in an edge-functions folder you forgot existed.
[[COMPARE: vibe-coded export vs owned starter codebase]]
What "shipped" actually means
The export gave you a UI. A shipped app needs at least:
- An auth flow that survives a refresh, a logout, and a password reset
- A database with migrations, not just tables that exist because someone clicked "create"
- Environment variables that are read at build time, not pasted into source
- A deploy target — domain, TLS, a build command, a rollback path
- Error handling that surfaces to you, not to
console.log - A way for a second developer to onboard without reading every file
That list is the boring 80% of every real product. None of it is in the export, because none of it is interesting to generate from a prompt. The prompt was "build me a task app." The model produced a task app. The deploy story was never in the spec, so the model never invented one.
This is a structural problem, not a vendor problem
It is tempting to read the export and blame the tool. That misses the point. The model is doing what it was asked to do: turning a description into a UI as fast as possible. It does not know about your CI, your monitoring, your tenancy model, your legal requirements. It cannot know, because none of that was in the prompt.
The honest framing: vibe-coding tools are an extraordinary front-end for the specification layer of an app. They are not yet a back-end for the durability layer. The gap between those two is what most exports reveal, and it is the gap the next six months of tooling will close — but it is not closed today.
What an owned starter does instead
An owned starter is a small codebase someone has already made the boring decisions in. The decisions are not exciting:
- Where state lives, and where it does not
- How env vars are read, validated, and typed
- Where auth middleware sits in the request
- What the deploy script does, in what order, with what rollback
- Which file is the entry point on web, iOS, and Android
A starter does not have a clever UI. It has a clean seam where the UI goes. When you drop a vibe-coded screen into a starter, the screen keeps whatever quality it had. The auth, the DB, the deploy, the env handling — those stop being your problem, because the starter already solved them in a way the next developer can read.
The cost is paid once, by whoever wrote the starter. Every project after that inherits the decisions.
The pattern that works in practice
The combo that has held up for the teams I have watched ship in the last six months:
- Use a vibe-coding tool to draft the bespoke screen — the one that has no library equivalent, the one that is the actual product
- Generate the screen as a single component with explicit props and zero side effects
- Drop that component into a real owned starter that handles auth, data, and deploy
- Iterate the bespoke screen in the vibe tool; iterate the durable spine in the starter
In other words: use the tool for the part that benefits from a fast iteration loop. Use the starter for the part that benefits from being boring. The two layers do not compete; they meet at a component boundary.
A small concrete example. The vibe tool outputs a <TaskBoard> that takes tasks: Task[] and an onComplete(id: string) => void. The starter supplies the page that fetches tasks from the DB, gates on auth, and wires the callback to a server action. Neither side has to know about the other.
// from the starter — the page
export default function TasksPage() {
const { user } = useAuth(); // starter owns this
const { data, mutate } = useTasks(user.id); // starter owns this
return (
<TaskBoard
tasks={data ?? []}
onComplete={(id) => mutate(completeTask(id))}
/>
);
}
// from the vibe tool — the screen
export function TaskBoard({ tasks, onComplete }: Props) {
return ( /* the bespoke UI */ );
}
The component is the contract. The starter does not care how the UI was generated; the UI does not care how the data was fetched.
Where this leaves you
If you are evaluating a vibe-coding tool for a real product, the question is not "can it build me a task app." It can. The question is what you have on the other side of the prompt when the prompt stops being useful. If the answer is "an export I can read, edit, and deploy with a script," you have a starter. If the answer is "a zip file I am afraid to open in a fresh repo," you do not yet have a codebase.
The fastest path I have seen is to treat the vibe tool as a screen generator, not an app generator. Generate the screens. Hand the durable parts to a starter that already knows what it is doing. Ship from there.
Top comments (0)