You built it in a weekend. Maybe with Lovable, Bolt, Cursor or Claude Code. The demo is great, the first friends signed up, and for the first time in years shipping software felt easy.
Then the first real week happens. Someone sees another user's data. A payment goes through but the account never upgrades. The app slows to a crawl once the table passes a few thousand rows. And every time you ask the AI to fix one thing, two other things break.
I get a lot of messages that start this way now. The good news is that most of these apps don't need a rewrite. They need the same handful of fixes, because AI tools tend to make the same handful of mistakes.
Why AI-built apps break in the same places
AI coding tools are very good at the happy path. You ask for a feature, it writes code that makes the feature work on screen, and you see it work. That's the feedback loop, so that's what gets optimized.
What you don't see on screen is everything that happens when a user does something you didn't plan for: sends a request directly to your API, opens two tabs, pays and closes the browser, or types something weird into a form. None of that shows up in a demo, so none of it gets built unless someone asks for it.
Here are the eight problems I find most often, roughly in order of how much damage they do.
1. Secret keys shipped to the browser
This is the most common one and the most expensive. Your OpenAI key, Stripe secret key or Supabase service role key ends up in frontend code, which means anyone can open dev tools and copy it.
It usually happens through environment variables. In Next.js anything starting with NEXT_PUBLIC_ is bundled into the browser. In Vite it's VITE_. The AI needs the key to work in a component, so it adds the prefix, and now it's public.
How to check: build the app and search the output for your keys.
npm run build
grep -r "sk-" .next/static dist 2>/dev/nullHow to fix: move every call that needs a secret into a server route or edge function, and have the browser call that instead. Then rotate the key, because if it was public for even a day you should assume someone has it.
2. The database is open to everyone
If you're on Supabase, the AI may have turned off Row Level Security to get past an error. If you're on Firebase, the rules might still say allow read, write: if true. Either way, anyone with your public URL can read or change every row in your database without touching your app.
On Supabase, turn RLS on for every table and add a policy for each thing users are allowed to do:
alter table projects enable row level security;
create policy "Users can read their own projects"
on projects for select
using (auth.uid() = user_id);On Firebase, write rules that check who owns the document:
match /projects/{projectId} {
allow read, update, delete: if request.auth != null
&& request.auth.uid == resource.data.ownerId;
allow create: if request.auth != null
&& request.auth.uid == request.resource.data.ownerId;
}Then test it the way an attacker would: log in as user A and try to fetch user B's records directly.
3. Permissions checked only in the UI
The app hides the "Delete" button from people who shouldn't see it, so it looks secure. But the API route behind that button doesn't check anything. Anyone who knows the URL can call it.
The rule is simple: the server decides, the UI only reflects. Every route that reads or changes data should check who is asking and whether they're allowed.
export async function DELETE(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const user = await getCurrentUser();
if (!user) return new Response("Unauthorized", { status: 401 });
const { id } = await params;
const project = await db.project.findUnique({ where: { id } });
// Same response whether it doesn't exist or isn't theirs
if (!project || project.ownerId !== user.id) {
return new Response("Not found", { status: 404 });
}
await db.project.delete({ where: { id } });
return new Response(null, { status: 204 });
}4. No input validation
AI-generated forms usually validate in the browser and trust whatever reaches the server. That's how you end up with empty names, 50,000-character bios, negative quantities in an order, or worse.
Validate on the server with a schema. Zod is the easiest place to start:
import { z } from "zod";
const Signup = z.object({
email: z.string().email(),
name: z.string().trim().min(1).max(100),
});
const result = Signup.safeParse(await req.json());
if (!result.success) {
return Response.json({ error: "Check your name and email" }, { status: 400 });
}5. Queries that work with 20 rows and die with 20,000
In the demo you have twenty records, so loading all of them and filtering in JavaScript feels instant. In production that same page downloads the entire table on every visit.
The usual fixes:
- Paginate anything that's a list. Nobody needs 5,000 rows on one screen.
- Filter and sort in the database, not in the component.
- Add indexes on the columns you filter by, like
user_idandcreated_at. - Watch for loops that run one query per item. That's the N+1 problem, and it's everywhere in generated code.
If the whole site feels slow rather than one page, I wrote a separate guide on why websites load slowly and the fixes that work.
6. Payments that trust the browser
This one costs real money. The app sends the user to Stripe checkout, and when Stripe redirects back to /success, the app upgrades the account. But users close tabs, lose connection, or visit /success directly without paying.
The payment provider should tell your server, not the browser. In Stripe that's a webhook, and you have to verify the signature so nobody can fake one:
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: Request) {
const body = await req.text();
const signature = req.headers.get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch {
return new Response("Invalid signature", { status: 400 });
}
if (event.type === "checkout.session.completed") {
const session = event.data.object as Stripe.Checkout.Session;
await markOrderPaid(session.id); // make this safe to run twice
}
return new Response("ok");
}Stripe can send the same event more than once, so the code that marks an order as paid should do nothing if it's already paid.
7. You find out about errors from your users
There's no error tracking, so every bug report starts with a screenshot and "it doesn't work". You can't fix what you can't see.
Add an error tracker like Sentry on both the frontend and the backend. It takes under an hour and you'll immediately see which errors happen, how often, and to whom. At the very least, log errors on the server with enough context (user id, route, input shape) to reproduce them.
8. Every fix breaks something else
This is the one founders feel most. The codebase grew one prompt at a time, so the same logic exists in four places, files are 1,500 lines long, and there are no tests. When you ask the AI to change how prices are calculated, it updates one copy and misses the other three.
You don't need 100% test coverage. You need a few tests around the things that would hurt most if they broke: sign-up, login, checkout, and whatever your core action is. Then pull duplicated logic into one function so a change happens in one place.
A one-hour check before you launch
If you're about to share your app with real users, run through this first:
- Search your built frontend for API keys and secrets.
- Confirm RLS or security rules are on for every table or collection.
- Log in as one user and try to read another user's data through the API.
- Call one of your admin or delete routes without being logged in.
- Submit every form with empty, very long, and wrong-type values.
- Pay, then close the tab before the redirect. Did the account still upgrade?
- Add a few thousand fake rows and load your main list page.
- Make sure errors show up somewhere other than the user's screen.
If all eight pass, you're in better shape than most apps I'm asked to look at.
Rescue or rewrite?
Almost always rescue. A rewrite throws away the part AI tools are genuinely good at, which is getting a working product in front of people quickly. Rewrite only when the data model itself is wrong (for example, everything stored in one JSON blob) or when the stack can't do what the product needs.
Otherwise the path is boring and reliable: lock down security first, fix payments second, add error tracking third, then clean up the code one area at a time while the app keeps running.
When to bring in a developer
AI tools got you from idea to product, and that's a real achievement. But the problems above are the kind you only notice once they've already happened, and some of them, like leaked keys or open databases, can't be undone.
If your app is starting to get real users and you're not sure how many of these apply to you, that's the right time to get a second pair of eyes on it. I do this kind of audit and cleanup work regularly. You can see what I've built or tell me about your app.