Web Engineering ·

From Vibe Code to Production: Rescuing AI-Generated Codebases

A systematic methodology for taking an AI-generated codebase through triage, stabilization, and hardening. Covers the specific failure patterns that appear at scale, a security and architecture audit checklist, the stabilize-refactor-harden rescue sequence, and a decision framework for rewrite vs. refactor.

From Vibe Code to Production: Rescuing AI-Generated Codebases

In 2025, the tooling got good enough that a non-technical founder could prompt their way to a working demo in a weekend. Cursor, Bolt, Replit, v0, Lovable — the category exploded, and thousands of products shipped on top of AI-generated code. Now it is 2026, those products have users, investors, and ambitions. The codebase does not.

This is not a problem with the tools. It is a predictable result of optimizing for speed-to-demo at the expense of everything else. The AI followed the prompt faithfully. Nobody asked it to think about secrets management, input validation, or what happens when ten thousand users hit the same endpoint simultaneously.

This article covers what actually breaks, how to assess the damage quickly, how to fix it in the right order, and how to decide whether fixing it is even the right call.

What Vibe-Coded Codebases Actually Look Like

Before touching any code, it helps to know what you are walking into. AI-generated code tends to cluster around a specific set of failure modes.

Hardcoded Secrets

The most immediate security risk. AI models trained on tutorial code learned to write const apiKey = "sk-..." directly in source files. You will find secrets in .env.local that got committed, in config objects, in the middle of fetch calls. Run git log --all -S "sk-" -- '*.ts' before assuming your current branch is clean.

Missing Input Validation

AI generates the happy path. There is rarely a guard at the boundary between user input and your data layer. You get routes that destructure req.body directly into SQL queries or ORM calls, string fields with no length limits, numeric fields that accept NaN, and file uploads with no type or size checks.

No Error Boundaries

The frontend crashes completely on any unhandled promise rejection. The backend returns raw stack traces to the client when something throws. There are no try-catch blocks around third-party API calls, no graceful degradation, no user-visible error messages that say anything useful.

Copy-Paste Duplication Across Files

AI generates code per-file, per-prompt. When you ask it to add similar functionality in a different part of the application, it writes a new implementation rather than reusing the existing one. You end up with four different functions that validate email addresses, three database connection setups, and two authentication token parsers — none of which agree on edge cases.

No Tests

Tests are almost never part of a vibe-coded prototype. The AI will write them if asked, but the prompting flow for an MVP runs through UI first, then API, then integration — and tests never make the list until the feature works. By the time you look at the codebase, coverage is usually near zero.

Auth That Only Works in Demo Mode

The auth flow works for the happy path: sign in, get a JWT, decode it on the next request. But the middleware only checks for the token’s presence, not its validity. Token expiry is not handled. Refresh logic either does not exist or is hardwired to never expire. Role checks are missing. Admin routes are guarded with if (user.role === "admin") in the component, not in the API.

No Rate Limiting

Every public endpoint accepts unlimited requests. The AI did not add rate limiting because rate limiting was not in the prompt. This means your OpenAI integration will cost you $3,000 in a weekend if someone decides to hammer it, and your search endpoint will take down the database if someone runs a scraper against it.

N+1 Queries

ORMs make this easy to introduce and invisible until it hurts. The typical pattern is a list view that loads all rows, then for each row calls .findOne() to get a related entity. With 10 records in development, it is imperceptibly fast. With 10,000 records in production, it is a full-table scan per page load.

No Connection Pooling

The database connection is opened per request, or a new Prisma/Drizzle client is instantiated per module with no singleton pattern. Under any real load, you hit the database’s connection limit and everything starts timing out.

Triage: Assessing the Damage Before You Touch Anything

Resist the urge to start refactoring on day one. You need a clear picture of what you have before you decide what to fix and in what order. This triage takes two to four days and produces a prioritized list you can execute against.

Security Audit First

Start here because security issues can require you to act before the project even has users.

# Check for secrets in commit history
git log --all --full-history -- '**/*.env*'
git log --all -S "api_key" --source --all

# Run a static secrets scanner
npx secretlint "**/*"

# Check for exposed .env files in git tracking
git ls-files | grep -E "\.env"

Beyond secrets, audit the authentication layer manually. Open every route file and answer: does this route require a valid, verified token? Does it check roles? Does it validate the body before using it?

// Red flag: token presence check only
const auth = (req: Request, res: Response, next: NextFunction) => {
  const token = req.headers.authorization?.split(" ")[1];
  if (!token) return res.status(401).json({ error: "Unauthorized" });
  next(); // ← token is never verified
};

// What it should look like
const auth = (req: Request, res: Response, next: NextFunction) => {
  const token = req.headers.authorization?.split(" ")[1];
  if (!token) return res.status(401).json({ error: "Unauthorized" });

  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!);
    req.user = payload as JWTPayload;
    next();
  } catch (err) {
    return res.status(401).json({ error: "Invalid or expired token" });
  }
};

Architecture Review

Map the data flow from the frontend to the database. You are looking for:

  • Where does user input enter the system? Is it validated there?
  • How many places open a database connection? Is there a shared pool?
  • Are third-party API calls wrapped in any retry or circuit-breaker logic?
  • Is business logic in the API layer, the UI, or the database, or all three?

Do this with a diagram, even a rough one. It forces clarity and tends to surface the worst coupling problems quickly.

Dependency Audit

npm audit
npx depcheck   # find unused dependencies
npx npm-check  # find outdated packages

Vibe-coded projects often have inconsistent package versions, leftover packages from abandoned features, and transitive dependencies pinned to vulnerable versions. An npm audit --audit-level=high will give you the severity breakdown. Fix critical and high before anything else.

Test Coverage Measurement

npx jest --coverage
# or
npx vitest run --coverage

You will almost certainly see something under 5%. That is fine for this stage. The point is to establish a baseline so you can track progress and to identify which parts of the codebase have any tests at all (usually none, but occasionally the AI wrote a few snapshot tests for components).

Checklist Output

After triage, you should have a concrete list sorted by severity:

  1. Critical (fix before any users touch this): hardcoded secrets, broken auth verification, exposed admin routes, SQL injection surface
  2. High (fix before marketing): missing input validation, no rate limiting, N+1 queries on primary paths
  3. Medium (fix in the refactor phase): duplication, connection pooling, missing error handling
  4. Low (fix in the hardening phase): test coverage, logging, CI/CD setup

The Rescue Methodology: Stabilize, Refactor, Harden

Do not try to fix everything at once. Each phase builds on the previous one.

Phase 1: Stabilize

The goal is to stop the bleeding without introducing new bugs. You are not improving the architecture yet, just making it safe and minimally operational.

Fix secrets first.

# Rotate any exposed keys immediately — assume they are compromised
# Then remove from source and add to a secrets manager

# .env.example (committed)
OPENAI_API_KEY=

# .env (never committed, in .gitignore)
OPENAI_API_KEY=sk-...

# In application code
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

If secrets were ever committed, rotate them. Removing them from history with git filter-branch is not enough if the repo was ever pushed to a remote.

Add auth verification to every protected route. This is a targeted fix, not a refactor. You are not redesigning the auth system yet, just ensuring the existing one actually works.

Add rate limiting to public endpoints.

import rateLimit from "express-rate-limit";

// Global limiter for all routes
const limiter = rateLimit({
  windowMs: 60 * 1000, // 1 minute
  max: 100,
  standardHeaders: true,
  legacyHeaders: false,
});

// Stricter limiter for expensive operations
const aiLimiter = rateLimit({
  windowMs: 60 * 1000,
  max: 10,
  message: { error: "Too many requests, slow down" },
});

app.use(limiter);
app.post("/api/generate", aiLimiter, generateHandler);

Add top-level error handling. In React, that is an Error Boundary component wrapping the app root. In an Express API, that is a final (err, req, res, next) middleware that logs the error and returns a generic 500 without the stack trace.

// Express global error handler
app.use((err: Error, req: Request, res: Response, _next: NextFunction) => {
  console.error({ err, path: req.path, method: req.method });
  res.status(500).json({ error: "Internal server error" });
});

Fix the N+1 queries on the critical paths. You do not need to refactor the entire data layer, just find the two or three queries that run on every page load and add the appropriate includes or joins.

// Before: N+1 on orders list
const orders = await prisma.order.findMany();
const ordersWithUsers = await Promise.all(
  orders.map((o) => prisma.user.findUnique({ where: { id: o.userId } }))
);

// After: single query
const orders = await prisma.order.findMany({
  include: { user: true },
});

Phase 2: Refactor

Now that the codebase is safe to operate, improve the structure. The goal is to reduce duplication, centralize shared logic, and make the code maintainable enough to iterate on.

Extract shared logic into modules. Audit the duplication you found in triage and consolidate it. One email validator. One date formatter. One token parser. Put them in a lib/ directory and import them.

Set up connection pooling. This is typically a one-time change to create a singleton client.

// db/client.ts
import { PrismaClient } from "@prisma/client";

const globalForPrisma = global as unknown as { prisma: PrismaClient };

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === "development" ? ["query", "error"] : ["error"],
  });

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

Add input validation at API boundaries. Zod is the right tool for TypeScript codebases: it validates at runtime and narrows the type at compile time.

import { z } from "zod";

const CreateUserSchema = z.object({
  email: z.string().email().max(255),
  name: z.string().min(1).max(100),
  role: z.enum(["user", "admin"]).default("user"),
});

app.post("/api/users", auth, async (req, res) => {
  const result = CreateUserSchema.safeParse(req.body);
  if (!result.success) {
    return res.status(400).json({ error: result.error.flatten() });
  }
  const user = await prisma.user.create({ data: result.data });
  return res.status(201).json(user);
});

Rebuild the auth layer properly. The stabilization phase verified tokens correctly. Now design the layer for the actual requirements: short-lived access tokens, refresh tokens stored in httpOnly cookies, role enforcement at the middleware level, not the component level.

Phase 3: Harden

The codebase is now safe and reasonably structured. The final phase is about operational readiness: visibility, confidence in changes, and automated quality gates.

Write tests for the critical paths first. Not full coverage — focus on auth flows, payment logic, and any data mutation that is hard to undo.

describe("POST /api/users", () => {
  it("rejects requests without a valid auth token", async () => {
    const res = await request(app).post("/api/users").send({ email: "test@example.com", name: "Test" });
    expect(res.status).toBe(401);
  });

  it("rejects invalid email format", async () => {
    const res = await request(app)
      .post("/api/users")
      .set("Authorization", `Bearer ${validToken}`)
      .send({ email: "not-an-email", name: "Test" });
    expect(res.status).toBe(400);
  });

  it("creates a user with valid input", async () => {
    const res = await request(app)
      .post("/api/users")
      .set("Authorization", `Bearer ${validToken}`)
      .send({ email: "new@example.com", name: "New User" });
    expect(res.status).toBe(201);
    expect(res.body.email).toBe("new@example.com");
  });
});

Add structured logging. Raw console.log calls do not aggregate, filter, or alert. Replace them with a structured logger (pino is the right choice for most Node.js stacks) and add request ID propagation so you can trace a request through your logs.

Set up a basic CI/CD pipeline. At minimum: lint, type-check, and run tests on every pull request. Block merges on failures. This is the gate that prevents the next wave of quality regressions.

Add health check and readiness endpoints. Your deployment infrastructure needs to know when the application is ready to serve traffic and when it is degraded. This is a 20-line addition that prevents zero-downtime deployment from becoming a theory.

Rewrite vs. Refactor: When to Cut Your Losses

Not every codebase is worth rescuing. The decision framework is based on three variables: size, coupling, and business constraints.

FactorFavor RefactorFavor Rewrite
Codebase sizeUnder 10K linesOver 50K lines of tangled code
CouplingIsolated modules, clear boundariesBusiness logic spread across every layer
Test coverageSome tests that can anchor the refactorZero tests, no safety net at all
Data modelClean schema, understandableDenormalized, inconsistent, migration-hostile
Team familiarityTeam wrote the code, knows the intentOpaque AI-generated code nobody fully understands
Business timelineTime to refactor incrementallyHard deadline forcing a clean-slate bet
Active usersUsers in production, can’t afford a rewrite gapPre-launch, no migration required

The most common mistake is choosing to refactor because it feels safer, then discovering six months later that the coupling was too deep to fix incrementally. The heuristic: if you cannot explain the data flow from user input to database without looking at the code, and if three or more modules would need to change to add any new feature, the coupling is a rewrite signal.

The opposite mistake is choosing to rewrite because the code looks bad, then spending four months rebuilding functionality that worked fine, while the original codebase ships improvements ahead of you. Ugly code that works and has clear boundaries can be cleaned up. Code that is architecturally sound but poorly written is not a rewrite candidate.

A partial rewrite is often the right call: identify the two or three subsystems that are genuinely unsalvageable (usually the auth layer and the data access layer in vibe-coded projects), rewrite those with proper tests and clear interfaces, and leave the rest in place.

Production Considerations

A few things that tend to come up specifically in the context of AI-generated code at production scale:

Secrets rotation plan. Assume anything that was ever in your git history is compromised. Rotate it, document the rotation process, and set a calendar reminder to rotate credentials on a regular cycle. Most credential leaks are discovered months after the fact.

Database migration strategy. If the ORM was generating the schema with db push in development mode (the Prisma default for prototyping), there is no migration history. Before you can safely iterate on the schema in production, you need to generate an initial migration from the current state and switch to migrate deploy in your deployment pipeline. Do this before the first production schema change.

Observability before incidents. Add Sentry or equivalent structured error tracking in the hardening phase, before you have your first production incident. Debugging a production error with no stack traces, no logs, and no breadcrumbs is significantly harder than anything in this article.

Third-party API cost controls. Every AI API integration in a vibe-coded project tends to have no spending caps. Set hard limits in the API provider’s dashboard, add per-user daily quotas in your own rate limiting layer, and add alerting on usage spikes before they become invoice surprises.

Load testing the happy path. Once you have addressed the N+1 queries and connection pooling, run k6 or Artillery against the primary user flows before you go to production. This is the fastest way to find the next bottleneck you did not know about.

The pattern here is the same one that runs through the entire rescue process: the AI-generated code captured a working approximation of what you wanted. Your job is to make it correct, safe, observable, and maintainable. That is not a condemnation of how the prototype was built. It is just the work that comes next.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.