We've been using AI coding tools — Claude, Cursor, GitHub Copilot — since they were barely useful. And now that they're actually good, we've also accumulated enough scar tissue to tell you what goes wrong when you treat them like a senior developer who never makes mistakes.
The term 'vibecoding' gets thrown around as a joke, but there's real signal underneath it: the feeling of just... flowing through a feature without friction. AI handles the boilerplate, you handle the thinking. It's genuinely great when it works. The problem is the morning after — when you wake up to a codebase that technically runs but that no one, including you, fully understands.
We've shipped a lot of Next.js templates at peal.dev using AI tools. We've also introduced bugs we didn't catch for weeks, created abstractions that made sense at 11pm but were insane at 9am, and copy-pasted AI output that worked but violated half a dozen things we care about. So here's what we've actually learned — not theory, not what the AI vendors say, but what works when you're building real things.
The Core Problem: You're the Reviewer, Not the Author
The biggest mindset shift that saved us: when you're vibecoding, you stop being the author and become the reviewer. That sounds fine until you realize most people are much worse at reviewing code than writing it. You read faster, you skim, you assume the logic is right because it looks plausible.
Claude will generate a 60-line function that looks authoritative and well-structured. It might have a subtle off-by-one error in a pagination query. It might handle the happy path perfectly and silently swallow errors everywhere else. It might use a pattern that made sense in 2021 but has a better alternative now. You won't catch any of this if you just hit accept and move on.
The AI's job is to generate plausible code. Your job is to decide if plausible is good enough — and for production, it usually isn't.
The practical fix: treat every AI output like it came from a smart junior developer who's been awake for 20 hours. Read it properly. Ask yourself why each piece is there. If you can't explain a line, you don't own it — and if you don't own it, it's future tech debt.
Write the Contract First, Then Let AI Fill It In
One pattern that dramatically improved our output quality: write the types and function signatures yourself, then ask the AI to implement them. This forces you to think about the interface before the implementation, and it constrains the AI to something you've already agreed makes sense.
// Write this yourself — think about what you actually need
type CreateSubscriptionResult =
| { success: true; subscriptionId: string; currentPeriodEnd: Date }
| { success: false; error: 'payment_failed' | 'customer_not_found' | 'plan_not_found' }
async function createSubscription(
customerId: string,
priceId: string,
trialDays?: number
): Promise<CreateSubscriptionResult> {
// NOW ask Claude to implement this body
// It has a contract to work within — it can't go completely off-script
}When you hand the AI a blank canvas, it invents the interface too — and that's where a lot of tech debt creeps in. Suddenly you have a function that returns a raw Stripe object when you wanted a domain type, or throws exceptions instead of returning a result type, because those were the author's choices and you didn't make any.
Defining the types first also means TypeScript will yell at you if the implementation is wrong. The AI respects type constraints pretty well. Let the compiler be your safety net.
Prompt Like You're Writing a Ticket, Not Chatting
Vague prompts produce vague code. We learned this the hard way building an auth flow at 2am where we told Claude to 'handle the login stuff' and got back something that technically worked but stored sensitive data in a way that made us uncomfortable later. We couldn't even fully articulate why it felt wrong — which is the worst kind of wrong.
Good prompts have constraints baked in. Instead of 'add rate limiting to this endpoint', say 'add rate limiting to this endpoint using Upstash Redis with a sliding window of 10 requests per minute per IP, return a 429 with a Retry-After header, and don't throw — return a typed error instead'. You're not just asking for a feature, you're specifying the boundaries.
// Weak prompt result — AI makes all the decisions
export async function POST(req: Request) {
// Some rate limiting thing the AI invented
const limit = rateLimit({ max: 100 }) // where does this state live?
const result = await limit.check(req)
if (!result.success) throw new Error('Rate limited') // throws! bad!
// ...
}
// Strong prompt result — you made the decisions, AI filled in the blanks
export async function POST(req: Request): Promise<Response> {
const ip = req.headers.get('x-forwarded-for') ?? 'anonymous'
const identifier = `rate_limit:login:${ip}`
const { success, reset } = await ratelimit.limit(identifier)
if (!success) {
return Response.json(
{ error: 'Too many requests' },
{
status: 429,
headers: { 'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)) },
}
)
}
// actual handler logic
}The difference isn't just code quality. It's that with a strong prompt, you can explain every decision in the output because those decisions came from you. The AI translated your intent — it didn't invent an intent and hand it back to you.
The 'Understand Before Merge' Rule
We have a rule that sounds obvious but is surprisingly hard to follow when you're in flow state: don't commit code you can't explain out loud. Not every line — that's unrealistic. But every meaningful chunk: why does this query have this shape? Why is this component split this way? What happens when this fails?
The test we use is a bit silly but effective: imagine Ștefan is going to code review this in six months having never seen it. Can you write a two-sentence comment that would make it click for him immediately? If yes, great. If you're struggling, you don't understand it well enough to ship it.
- If you can't explain why the AI chose this pattern, ask it to explain — and then ask if there's a simpler alternative
- If the AI generates an abstraction you didn't ask for, push back. 'Why did you create a helper for this? What if we just inlined it?'
- If you see a library import you didn't recognize, research it before accepting. AI will confidently use packages that are abandoned, over-engineered, or just wrong for your stack
- Error handling is where AI gets lazy — specifically look for empty catch blocks, generic error messages, and cases where errors silently become undefined
This isn't about distrusting AI. It's about owning your codebase. The code you commit is your responsibility regardless of who wrote it.
Keep AI Out of Your Core Abstractions
Here's a distinction that took us a while to internalize: AI is great at feature code, terrible at infrastructure code. Feature code is the thing you're building today — an endpoint, a form, a component. Infrastructure code is the thing everything else depends on — your auth session handling, your database client setup, your error reporting wrapper.
When you let AI design your infrastructure, you end up with something that works for the examples it was trained on but doesn't quite fit your specific setup. Then every feature built on top of that shaky foundation inherits the weirdness. We've seen this with database client wrappers that didn't handle connection pooling correctly in serverless, and auth helpers that passed the right data but in inconsistent shapes.
// Core abstraction — write this yourself, carefully
// lib/db.ts
import { drizzle } from 'drizzle-orm/postgres-js'
import postgres from 'postgres'
import * as schema from './schema'
// You understand exactly what this does and why
const client = postgres(process.env.DATABASE_URL!, {
max: 1, // serverless — don't hold connections open
idle_timeout: 20,
connect_timeout: 10,
})
export const db = drizzle(client, { schema })
export type DB = typeof db
// Feature code — let AI help freely
// app/api/users/route.ts
// 'Given this db client and these schema types, implement a paginated user list endpoint
// that filters by status and sorts by createdAt desc. Return typed results, don't throw.'Write your core abstractions yourself, document them, make them boring. Then hand AI the boundary-safe feature work where mistakes are isolated and fixable.
Refactor with AI, Not Just Generate
The best use of AI we've found isn't generating new code — it's refactoring existing code you already understand. This flips the risk profile completely. You know what the code should do, you know what the current implementation does, and you're asking AI to help you do it more cleanly.
'Here's a 90-line function that handles user creation. It works but it's doing too many things. Help me split it into smaller functions while keeping the same behavior. Don't change the function signatures externally.' That's a prompt where the AI genuinely shines and the risk of introducing new tech debt is low, because you're the expert on what correct behavior looks like.
We also use AI aggressively for tests — not to generate the implementation, but to generate test cases we might have missed. 'Here's this function, what edge cases should I be testing that I'm probably not?' It's a better use of the model's pattern-matching ability than using it to invent architecture.
Use AI to go faster on things you understand. Use your brain to figure out things you don't. That ratio is the whole game.
Practical Setup That Actually Helps
A few concrete things we do in Cursor and with Claude that reduce vibes-turned-debt:
- Keep a .cursorrules file or system prompt that specifies your stack, your patterns, and your preferences. 'We use Drizzle not Prisma. Server Actions over API routes unless there's a reason. Always return typed results instead of throwing. Prefer explicit over clever.' The AI will actually respect this.
- Review diffs at the file level, not the line level. If a file you didn't intend to change got modified, that's a flag. AI sometimes refactors things you didn't ask it to.
- When something feels too magical, ask Claude to walk you through it step by step. 'Explain what this middleware is doing, line by line, and why you made each choice.' This both educates you and sometimes reveals that the AI made questionable choices it can now correct.
- Set a personal rule: any file over 150 lines gets a human review before commit, regardless of how confident you feel. Complexity hides bugs.
- Use git commits as checkpoints. Commit working code often. If an AI-assisted session goes sideways, you want a clean rollback point.
The templates we build at peal.dev are specifically designed to give you a solid, understood foundation before you start vibecoding on top. Auth, payments, email, database — the infrastructure pieces are thought through so you're not asking AI to invent your session architecture at midnight. You start with something you can reason about, then use AI to move fast on the features.
Vibecoding isn't inherently bad. It's one of the most fun ways to build software we've encountered, and pretending otherwise would be dishonest. The problem is treating it like a setting you switch on and leave on. It's more like nitrous oxide — useful for bursts, dangerous if you forget you're using it.
The developers who are going to win with AI tools aren't the ones who prompt the best or have the cleverest context management tricks. They're the ones who stay sharp enough to know when the AI is leading them somewhere sensible versus somewhere that'll hurt later. That judgment doesn't come from the AI. It comes from experience, from having shipped things that broke, and from caring about the code you're leaving behind.
Vibe freely. But own every line.
