How to Stop AI from Vibe-Coding Your Next.js App into a Mess
You’re shipping fast. Claude or Cursor is autocompleting entire features in seconds. The test suite passes. The PRs merge. Three months later, your 73-route Next.js App Router codebase is a tangle no one wants to touch — and the AI helped you build every inch of it.
This isn’t a story about AI being bad at code. It’s a story about AI being very good at code and completely indifferent to architecture.
Left without guardrails, every AI coding assistant will take the path of least resistance. That path looks like: dump business logic into route handlers, mix raw SQL with fetch('/api/...') calls in the same component, reach for as unknown as when the types get inconvenient, and wire up Stripe, Clerk, and OpenAI directly inside the function that’s supposed to only handle HTTP.
The AI ships. You ship. Nobody catches it until it costs you.
A Real Post-Mortem: 14 Violations Across 73 Route Files
This isn’t hypothetical. We audited a production Next.js SaaS codebase after six months of active AI-assisted development. Here’s what we found across 73 route files:
No application layer. Route handlers and page layouts owned all workflow orchestration. Business rules were coupled directly to NextRequest, NextResponse, Clerk, and Stripe — making reuse impossible and testing expensive.
Mixed data-access boundaries. The same feature was reached through raw SQL in one place, query helpers in another, and internal fetch() calls from client components in a third. No consistent boundary meant duplicated permission checks and diverging behavior between server and client.
Raw SQL bucket, not a repository layer. Query modules returned SELECT * results directly as domain types. Schema changes cascaded everywhere because persistence types, API types, and UI models were all the same thing.
Centralized but unprotected types. One massive src/types/index.ts spread across DB, API, and UI layers. The query layer relied on sql<T> and as unknown as for “type safety” — which is trust, not verification.
Inconsistent validation. 18 of 73 routes used the shared API error helper. The other 49 read request.json() directly and hand-validated fields. Different status codes, different error shapes, no contract.
UI coupled to transport. Client pages knew endpoint URLs, request shapes, and validation details. Backend changes rippled into dozens of components.
Giant page shells. Route-local pages bundled UI rendering, state orchestration, and feature-specific business logic together in the same file.
No injectable ports. Core logic imported openai, Stripe, and Redis directly. Every unit test required framework mocking. The repo had minimal coverage for a large production app.
Security shortcuts. A MASTER_USER_ID env var created hidden superuser behavior. Rate limiting silently disabled itself when Redis was unavailable — fail-open, not fail-closed.
N+1 queries in request paths. Serial external API loops inside handlers. Latency that scaled linearly with dataset size.
Hardcoded product defaults. Onboarding campaigns and experiment defaults lived inside repository functions — policy welded to DB write paths.
Type erasure at every boundary. as unknown as, sql.json(... as never), bare sql calls cast with as number — the TypeScript was cosmetic.
Fourteen categories. Six months of fast, AI-assisted shipping.
The Real Problem: Instructions in Your Head Don’t Reach the AI
Most teams handle this with architecture docs, README notes, or verbal onboarding. Those approaches have one thing in common: they exist outside the coding loop.
When Claude or Cursor generates a route handler, it isn’t reading your Confluence page. It’s reading the code around it and, if you’ve set one up, your context file — CLAUDE.md, .cursorrules, or a Codex system prompt.
The fix isn’t more documentation. It’s encoding your architecture rules directly into the AI’s operating context, as non-negotiable constraints with enforced patterns. That’s what Claude Code’s skill system is built for.
A skill is a markdown file that Claude Code loads when invoked. But more practically, the rules you put in CLAUDE.md are the instructions Claude follows for every task in that project — automatically, on every file, every generation.
When you put your architecture rules there, the AI doesn’t just know them. It enforces them.
What Goes in the Skill: 13 Rules With Teeth
The first version of this list had 14 rules, one per finding in the audit. More projects shrank it to 13. Some rules merged into wider ones. Two new ones came from bugs the audit never caught: writes that skip the ownership check, and read-then-write races.
These aren’t suggestions. They’re constraints the AI must satisfy before it calls a task done.
Rules 1–3: Model the Problem Simply
These go into CLAUDE.md as short written rules:
- 1. Store facts, not assumptions. Save what happened (
sent_at,replied_at), then work out the rest when you read it. A stored guess likestatus = 'follow_up_due'goes stale the moment the world changes. - 2. Prefer fewer states and simpler workflows. Every extra status is another branch to test and another way for rows to get stuck. If two states behave the same, merge them.
- 3. Delete special cases when the general model covers them. A hardcoded onboarding campaign inside a query function is a special case. Put defaults in config and let the normal path run them.
4. Keep Persistence, Domain, and API Shapes Separate
// REQUIRED — three distinct shapes with explicit mappers
type ContactRow = { id: string; email: string; created_at: string };
type Contact = { id: string; email: string; createdAt: Date };
type ContactResponse = { id: string; email: string; createdAt: string };
const toContact = (row: ContactRow): Contact => ({
id: row.id,
email: row.email,
createdAt: new Date(row.created_at),
});
// VIOLATION — returning raw DB row as API response
const contacts = await sql<Contact[]>`SELECT * FROM contacts`;
return NextResponse.json(contacts);
Rule encoded: Never return raw rows as domain types. Always map Row → Domain → ResponseDTO, one mapper per step.
5. Validate at Every Boundary
// REQUIRED — parse JSONB before use, even though the driver decoded it
const tagsSchema = z.array(z.string().min(1));
const tags = tagsSchema.parse(row.tags);
// VIOLATION — trusting decoded JSONB because it parsed
const tags = row.tags as string[];
Rule encoded: Request bodies, provider responses, and JSON columns all go through Zod. “It parsed” is not “it’s valid.”
6. Type Query Results Explicitly
// REQUIRED
type DashboardStatsRow = { active_contacts: number; pending_sends: number };
const [stats] = await sql<DashboardStatsRow[]>`
SELECT COUNT(*)::int AS active_contacts, SUM(pending)::int AS pending_sends
FROM contacts
`;
return { activeContacts: stats.active_contacts };
// VIOLATION — type erasure on scalar result
const [stats] = await sql`SELECT COUNT(*) ...`;
return { activeContacts: stats.active_contacts as number };
Rule encoded: Every query gets a named row type. No SELECT *, no as unknown as, no as number on a bare count.
7. Routes Only Coordinate
// REQUIRED pattern — route handlers do exactly this:
export async function POST(req: NextRequest) {
const input = contactSchema.parse(await req.json());
const result = await createContact(input);
return NextResponse.json(result);
}
// VIOLATION — never do this in a route:
export async function POST(req: NextRequest) {
const body = await req.json();
const existing = await db.query("SELECT * FROM contacts WHERE email = $1", [body.email]);
if (existing.length) return NextResponse.json({ error: "exists" }, { status: 409 });
await db.query("INSERT INTO contacts ...");
return NextResponse.json({ ok: true });
}
Rule encoded: No business logic or SQL in route handlers or layouts. Always: validate input → authorize → call a service → map result to HTTP.
8. Domain Logic Must Not Depend on Framework Singletons
// REQUIRED — inject interfaces
type Ports = {
clock: () => Date;
ai: { generate(prompt: string): Promise<string> };
payments: { charge(amount: number): Promise<string> };
};
export async function processOrder(ports: Ports, input: OrderInput) {
const now = ports.clock();
const chargeId = await ports.payments.charge(input.amount);
return { chargeId, processedAt: now };
}
// VIOLATION — SDK singleton imported directly in domain logic
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
Rule encoded: Domain logic never imports Clerk, Stripe, OpenAI, Redis, time, or randomness directly. Inject ports so core logic is unit-testable.
9. Use One Path per Feature
// REQUIRED — pick one boundary per feature
const contacts = await getContacts(); // direct server call
// VIOLATION — mixing fetch and direct DB for the same feature
const contacts = await fetch("/api/contacts").then(r => r.json());
const more = await db.query("SELECT * FROM contacts");
Rule encoded: One feature, one way in. Two paths mean two permission checks that drift apart.
10. Make Risky Behavior Explicit and Bounded
// REQUIRED — fail closed, cap the blast radius
if (!rateLimiterAvailable) {
return NextResponse.json({ error: "Service unavailable" }, { status: 503 });
}
const batch = pending.slice(0, MAX_SENDS_PER_RUN);
// VIOLATION — hidden superuser, silent fail-open, no cap
const userId = process.env.MASTER_USER_ID ?? session.userId;
if (!redis) return handler(); // silently skips rate limiting
for (const job of pending) await send(job);
Rule encoded: No env-var shortcuts for auth or safety. If a guard is down, reject. Anything that sends, spends, or deletes gets a hard limit.
11. Use One Validation and Error Style Everywhere
// REQUIRED
const parsed = contactSchema.safeParse(await req.json());
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid input", issues: parsed.error.issues },
{ status: 400 }
);
}
// VIOLATION
const body = await req.json();
if (!body.email || !body.name) {
return new Response("missing fields", { status: 400 });
}
Rule encoded: Always parse with Zod. Always return the same error shape: { error: string, issues?: ... }.
12. Every UPDATE and DELETE Carries an Ownership Predicate
// REQUIRED — ownership checked in the write itself
const result = await sql`
DELETE FROM contacts
WHERE id = ${contactId} AND user_id = ${userId}
`;
if (result.count === 0) throw new NotFoundError();
// VIOLATION — ownership checked earlier, write trusts the id
const contact = await getContact(contactId, userId);
await sql`DELETE FROM contacts WHERE id = ${contact.id}`;
Rule encoded: No mutation without re-checking ownership at the point of write. A check three calls up is not a check.
13. Read-Then-Write Runs in a Transaction With FOR UPDATE
// REQUIRED — lock the row, then decide
await sql.begin(async (tx) => {
const [credits] = await tx<CreditsRow[]>`
SELECT balance FROM credits WHERE user_id = ${userId} FOR UPDATE
`;
if (credits.balance < cost) throw new InsufficientCreditsError();
await tx`UPDATE credits SET balance = balance - ${cost} WHERE user_id = ${userId}`;
});
// VIOLATION — two requests both pass the check, both spend
const [credits] = await sql`SELECT balance FROM credits WHERE user_id = ${userId}`;
if (credits.balance >= cost) {
await sql`UPDATE credits SET balance = balance - ${cost} WHERE user_id = ${userId}`;
}
Rule encoded: No check-then-act outside a lock.
How to Wire This Into Claude Code
The minimum viable version is a CLAUDE.md at your project root that lists these rules. Claude Code reads this file at the start of every session and applies the rules to every generation.
The section looks like this. Short lines, numbered, so a review can cite “rule 12” by number:
## Architecture Rules
1. Store facts, not assumptions.
2. Prefer fewer states and simpler workflows.
3. Delete special cases when the general model covers them.
4. Keep persistence, domain, and API shapes separate.
5. Validate at every boundary.
6. Type query results explicitly.
7. Routes only coordinate.
8. Domain logic must not depend on framework singletons.
9. Use one path per feature.
10. Make risky behavior explicit and bounded.
11. Use one validation and error style everywhere.
12. Every UPDATE/DELETE must include a user_id ownership predicate.
No mutation without re-validating ownership at the point of write.
13. Read-then-write sequences require a transaction with FOR UPDATE.
No check-then-act outside a lock.
A more robust version uses Claude Code’s skill system: a dedicated nextjs-arch-guard skill that runs as a pre-commit check against staged files. If any staged file contains a route-level DB import, a SELECT * return, or a raw request.json() without Zod — the skill fails before the code enters the codebase.
The key difference between documentation and a skill: documentation requires a human to remember it. A skill requires the AI to satisfy it before it calls anything done.
You can also add enforcement hooks in your project’s .claude/settings.json to run the arch-guard check automatically before every commit. The AI writes the code; the skill audits it; bad patterns never land.
Key Takeaways
AI coding tools are indifferent to architecture. They optimize for working code, not maintainable systems. Guardrails have to come from you — encoded in context, not written in a README nobody reads.
14 anti-patterns across 73 routes is not bad luck. It’s what happens when a team ships fast without machine-enforced constraints. Every violation in that post-mortem was individually reasonable in the moment. The AI made the locally correct call each time. Nobody was watching the global structure.
CLAUDE.md is architecture enforcement, not just documentation. When your rules live in the AI’s operating context, they apply to every generation automatically — whether it’s 9am on a Monday or 11pm during a push. The AI doesn’t forget. It doesn’t get tired. It doesn’t skip the checklist because the deadline is tomorrow.
Every engagement we take on includes a project-specific CLAUDE.md and architecture guardrail skill built from your stack, your patterns, and your constraints. We don’t assume the AI knows your standards. We encode them before it writes the first line. Reach out if you want the same.
More Posts
All postsOctober 26, 2026
The PR Review Skill: How We Check Every Change Before It Ships
AI can write a feature in minutes and hide a bug in it for months. Here's the free Claude Code skill we run on every change to client apps: a bug check, an architecture check, focused specialist checks, and one report with a clear verdict. Plain-English version first, technical version after.
October 26, 2026
The Weekly Review Skill: A Full Health Check of Your Whole App
Checking each change isn't enough. Some problems only show when you read the whole app. Here's the free Claude Code skill we run every week: every file read, every false alarm thrown out, last week's problems tracked, and a plain recommendation on structure. Plain-English version first, technical version after.