Back to blog
Next.js Architecture Claude Code AI Development TypeScript SaaS Engineering

How to Stop AI from Vibe-Coding Your Next.js App into a Mess

By Ingenix Online · Published on April 23, 2026

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: 14 Rules With Teeth

Here’s the structure we now ship into every engagement. These aren’t suggestions — they’re constraints the AI must satisfy before it considers a solution complete.

1. Routes Are Transport Only

// 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: Never put business logic in route handlers or layouts. Always: validate input → authorize → call use-case/service → map result to HTTP.

2. Enforce Strict Layers

// REQUIRED — service depends on injected repository interface
export async function createLead(input: CreateLeadInput, deps: Deps) {
  const duplicate = await deps.contacts.findByEmail(input.email);
  if (duplicate) throw new DuplicateLeadError();
  return deps.contacts.insert(input);
}

// VIOLATION — service touches framework and DB directly
export async function handleSubmit(input: CreateLeadInput) {
  const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
  const user = await clerkClient.users.getUser(input.userId);
  return db.query("INSERT INTO leads ...");
}

Rule encoded: UI calls services. Services call repositories and adapters. Repositories talk to the database. UI must never import repositories or raw SQL.

3. Separate Persistence Types, Domain Types, and API DTOs

// REQUIRED — three distinct layers with explicit mapper
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 define Row → Domain → ResponseDTO with explicit mappers between each layer.

4. One Validation System, One Error Envelope

// 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: Never read raw request bodies and hand-validate fields. Always parse with Zod. Always return the same error shape: { error: string, issues?: ... }.

5. No Internal Boundary Mixing

// 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: Never fetch your own API from server code that also uses direct DB access for the same feature. Pick one boundary and keep it consistent.

6. Depend on Ports, Not SDK 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: Never let domain logic import Clerk, Stripe, OpenAI, Redis, time, or randomness directly. Always inject ports/adapters so core logic is unit-testable.

7. Security Must Be Explicit and Fail Closed

// REQUIRED
if (!session.userId) {
  return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
if (!rateLimiterAvailable) {
  return NextResponse.json({ error: "Service unavailable" }, { status: 503 });
}

// VIOLATION — hidden superuser, silent fail-open
const userId = process.env.MASTER_USER_ID ?? session.userId;
if (!redis) return handler(); // silently skips rate limiting

Rule encoded: Never encode authorization, tenancy, or safety controls in env-var shortcuts. If rate limiting is unavailable, sensitive routes must reject the request — not silently pass it.

8. Batch Data Access — No N+1 in Request Paths

// REQUIRED
const users = await db.query(
  "SELECT * FROM users WHERE id = ANY($1)", [ids]
);
const enriched = await Promise.all(
  users.map(u => enrichmentApi.fetch(u.externalId))
);

// VIOLATION
for (const id of ids) {
  const user = await db.query("SELECT * FROM users WHERE id = $1", [id]);
  enriched.push(await enrichmentApi.fetch(user.externalId));
}

9. Explicit Row Types for Every Query — No SELECT *, No as unknown as

// REQUIRED
type DashboardStatsRow = { active_contacts: number; pending_sends: number };
const [stats] = await sql<DashboardStatsRow[]>`
  SELECT COUNT(*) AS active_contacts, SUM(pending) 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 };

Rules 10–14: Encoded as Short Constraints

These go directly into CLAUDE.md as written rules, not code examples:

  • Product defaults are config, not repository logic. Never hardcode onboarding campaigns or experiment weights inside query functions. Define them in seed/config modules and keep runtime services data-driven.
  • No as unknown as at DB boundaries. Use derived transaction types, typed JSON helpers, and runtime checks instead.
  • JSONB and array payloads need validation. Never trust postgres.js decoded JSONB just because it parsed. Always validate shape and item types with Zod before use in enrichment logic, rate limits, or response DTOs.
  • Keep modules small and cohesive. A component that knows endpoint URLs, runs validation, manages state, and renders UI is a policy violation. Split into: presentational UI, feature controller, use-case service, repository.
  • Aggregate scalars need typed row shapes. as number on a bare sql count result is a lie. Type the row, alias explicitly, read directly.

How to Wire This Into Claude Code

The minimum viable version is a CLAUDE.md at your project root that includes these constraints as mandatory patterns with the good/bad code examples. Claude Code reads this file at the start of every session and applies the rules to every generation.

The sections that matter most in the file:

## Architecture Rules

These are non-negotiable constraints. Satisfy all of them before considering a task complete.

### Routes are transport only
Never put business workflows in route handlers, server components, or layouts.
Always: validate input → authorize → call a use-case/service → map result to HTTP/UI.

### Enforce strict layers
UI may call typed application services.
Application services may call repositories and external-provider adapters.
Repositories may talk to the database.
UI must not import repositories or raw SQL.

[... remaining rules with code examples ...]

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 posts

AUDITMYPIPELINE

Send me your current setup and I'll break down exactly where your pipeline is leaking and what to fix first — no fluff, just a clear diagnosis.