AI code compiles. That doesn't mean it's safe.
Cursor, Claude Code, Copilot, v0, Bolt. They all generate code that compiles, passes TypeScript, passes ESLint with zero warnings. But none of that tells you whether the code is actually production-ready. These are the 10 patterns we keep seeing in AI-generated codebases. They show up across every tool, every framework, every developer. Here they are, with real examples and fixes.
1. Hallucinated imports
AI models sometimes import packages that don't exist on npm. The code looks right, TypeScript might even infer types from the usage, but npm install never installed the package. The app crashes on startup.
import { formatCurrency } from 'currency-helpers'
import { slugify } from 'string-utils'
export function formatPrice(cents: number) {
return formatCurrency(cents / 100)
}src/lib/format.ts CRIT hallucinated-imports Package 'currency-helpers' is imported but not declared in package.json CRIT hallucinated-imports Package 'string-utils' is imported but not declared in package.jsonimport Dinero from 'dinero.js' // declared in package.json
export function formatPrice(cents: number) {
return Dinero({ amount: cents }).toFormat('$0,0.00')
}2. Hardcoded secret fallbacks
This one is everywhere. The AI writes process.env.JWT_SECRET with a fallback string "just in case." In development it works because the env var is set. In production, if the env var is missing for any reason, your app falls back to a secret that's committed to git and visible to anyone who reads the source.
const jwt = new JWT({
secret: process.env.JWT_SECRET || "my-super-secret-key",
})
const supabase = createClient(
process.env.SUPABASE_URL!,
process.env.SUPABASE_ANON_KEY ?? "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
)src/lib/auth.ts CRIT env-fallback-secret Secret environment variable 'JWT_SECRET' has a hardcoded fallbackconst secret = process.env.JWT_SECRET
if (!secret) throw new Error('JWT_SECRET is required')
const jwt = new JWT({ secret })3. Hardcoded API keys
The AI generates example code with real-looking API keys inline. Sometimes they're placeholder strings, sometimes they're actual test keys the model saw in training data. Either way, they end up in your repo.
const stripe = new Stripe(
"sk_test_51H7bmkCo5bvQHBz8kFTZM7vl8wnS4bS6CL",
{ apiVersion: "2024-12-18.acacia" }
)
const aws = new S3Client({
credentials: {
accessKeyId: "AKIAIOSFODNN7EXAMPLE",
secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
},
})src/lib/stripe.ts CRIT secrets Stripe secret key detected in code CRIT secrets AWS access key detected in codeconst stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
apiVersion: "2024-12-18.acacia",
})4. Hallucinated APIs
AI models mix up JavaScript APIs with similar methods from other languages. .flatten() is from Ruby, .contains() is from Java. JavaScript uses .flat() and .includes(). The code looks reasonable, passes a quick review, and crashes at runtime.
const allTags = userTags.flatten()
if (allTags.contains('admin')) {
grantAccess()
}
const slug = title.substr(0, 50).toLowerCase()src/lib/tags.ts CRIT hallucinated-api '.flatten()' does not exist. Use '.flat()' instead CRIT hallucinated-api '.contains()' does not exist. Use '.includes()' insteadconst allTags = userTags.flat()
if (allTags.includes('admin')) {
grantAccess()
}
const slug = title.substring(0, 50).toLowerCase()5. Supabase tables without RLS
When AI generates Supabase migration files, it creates the table and moves on. It doesn't add ALTER TABLE ... ENABLE ROW LEVEL SECURITY. Without RLS, anyone with your Supabase URL can read and write every row in that table. This is the single most dangerous pattern on this list.
-- supabase/migrations/001_init.sql
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text NOT NULL,
role text DEFAULT 'user'
);
CREATE TABLE documents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid REFERENCES users(id),
content text,
is_private boolean DEFAULT true
);supabase/migrations/001_init.sql CRIT supabase-missing-rls Table 'users' does not have row level security enabled CRIT supabase-missing-rls Table 'documents' does not have row level security enabledCREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text NOT NULL,
role text DEFAULT 'user'
);
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE TABLE documents (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid REFERENCES users(id),
content text,
is_private boolean DEFAULT true
);
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can read own docs"
ON documents FOR SELECT
USING (auth.uid() = user_id);6. Server actions without auth
Next.js server actions run on the server but they're exposed as POST endpoints. If a server action deletes data or writes to the database without checking who's making the request, anyone can call it. The AI generates the mutation logic but skips the auth check.
'use server'
export async function deleteProject(projectId: string) {
await db.delete(projects).where(eq(projects.id, projectId))
revalidatePath('/dashboard')
}
export async function updateRole(userId: string, role: string) {
await db.update(users).set({ role }).where(eq(users.id, userId))
}src/app/actions.ts CRIT server-action-auth Server action 'deleteProject' performs mutation without authentication check CRIT server-action-auth Server action 'updateRole' performs mutation without authentication check'use server'
export async function deleteProject(projectId: string) {
const session = await auth()
if (!session) throw new Error('Unauthorized')
await db.delete(projects)
.where(and(
eq(projects.id, projectId),
eq(projects.userId, session.user.id) // scope to owner
))
revalidatePath('/dashboard')
}7. Unvalidated form input in server actions
formData.get() returns unknown user input. Using it directly without validation means any string gets written to your database. The AI grabs the form field and passes it straight through.
'use server'
export async function createPost(formData: FormData) {
const title = formData.get('title')
const body = formData.get('body')
await db.insert(posts).values({
title,
body,
authorId: session.user.id,
})
}src/app/actions.ts CRIT next-server-action-validation Server action 'createPost' uses unvalidated form input'use server'
const createPostSchema = z.object({
title: z.string().min(1).max(200),
body: z.string().min(1).max(10000),
})
export async function createPost(formData: FormData) {
const { title, body } = createPostSchema.parse(
Object.fromEntries(formData)
)
await db.insert(posts).values({
title,
body,
authorId: session.user.id,
})
}8. API routes without rate limiting
Every public API route is a potential abuse vector. Without rate limiting, a single user can hammer your endpoint thousands of times per second. The AI never adds rate limiting unless you specifically ask for it.
// app/api/contact/route.ts
export async function POST(req: Request) {
const { email, message } = await req.json()
await sendEmail({ to: 'support@example.com', from: email, body: message })
return Response.json({ sent: true })
}src/app/api/contact/route.ts INFO rate-limiting API route has no rate limiting// app/api/contact/route.ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, '1 m'),
})
export async function POST(req: Request) {
const ip = req.headers.get('x-forwarded-for') ?? '127.0.0.1'
const { success } = await ratelimit.limit(ip)
if (!success) return Response.json({ error: 'Too many requests' }, { status: 429 })
const { email, message } = await req.json()
await sendEmail({ to: 'support@example.com', from: email, body: message })
return Response.json({ sent: true })
}9. API routes without error handling
AI wraps things in try/catch about half the time. The other half, your API route has zero error handling. An unexpected error returns a 500 with a stack trace that leaks internal paths and package versions.
// app/api/users/route.ts
export async function GET() {
const users = await db.select().from(usersTable)
return Response.json(users)
}
export async function POST(req: Request) {
const body = await req.json()
const user = await db.insert(usersTable).values(body).returning()
return Response.json(user)
}src/app/api/users/route.ts CRIT error-handling API route handler 'GET' has no error handling CRIT error-handling API route handler 'POST' has no error handling// app/api/users/route.ts
export async function GET() {
try {
const users = await db.select().from(usersTable)
return Response.json(users)
} catch (err) {
console.error('GET /api/users failed:', err)
return Response.json({ error: 'Internal server error' }, { status: 500 })
}
}10. Placeholder content left in production
Lorem ipsum, example@example.com, John Doe, password123, your-api-key-here. The AI scaffolds your app with placeholder content and you forget to replace it. Most of it is harmless. Some of it (like placeholder passwords) is not.
export const siteConfig = {
name: "My App",
description: "Lorem ipsum dolor sit amet, consectetur adipiscing elit",
contact: "example@example.com",
author: "John Doe",
}
export const defaultUser = {
email: "test@test.com",
password: "password123",
apiKey: "your-api-key-here",
}src/config.ts WARN placeholder-content 'Lorem ipsum' detected WARN placeholder-content Placeholder email 'example@example.com' detected WARN placeholder-content Placeholder name 'John Doe' detected WARN placeholder-content Placeholder password 'password123' detectedexport const siteConfig = {
name: "My App",
description: "Ship production-ready code with confidence",
contact: process.env.CONTACT_EMAIL!,
author: "Your Name",
}Catch all 10 in one command
Every pattern above is caught by prodlint. Zero config, runs in under 100ms, works on any JavaScript or TypeScript project. Add it to your CI pipeline with the GitHub Action, or use the MCP server inside Cursor or Claude Code for real-time feedback while you code.
Catch all of these automatically.
52 production readiness checks. Zero config. Under 100ms.