Vibe coding best practices for your first AI-built app
Learn practical vibe coding best practices for reviewing AI-generated code, isolating user data, testing failure paths, and understanding what you ship.

There is a specific kind of silence that happens when someone points at your app and asks, "How does that part work?" You know the feature works because you clicked through it. You may not know where its state lives, why its API is safe, or what happens when the database fails.
That gap is not a reason to stop using AI. It is a reason to change what you do after the first working demo.
Vibe coding is excellent at turning an idea into a prototype. A prototype proves that one path can work. Software that other people can rely on needs more: ownership boundaries, failure behavior, validation, tests, and enough understanding to change the code without guessing.
This guide gives you a practical review process. The fastest diagnostic is simple: try adding a second user.
Why the second user changes everything
A personal to-do app can look complete while all tasks live in one global array. The first user sees the right screen because there is only one collection to show. Add a second user and the hidden assumptions become visible:
- Which user owns each task?
- Where does the application get that user's identity?
- Can one user request another user's task by changing an ID?
- Does every read, update, and delete enforce ownership?
- What happens to both users' data when the process restarts?
These are not edge cases. They define the product you thought you had built.
AI-generated code often follows the exact scenario in the prompt. If the prompt describes one person creating a task, the result may correctly implement that happy path without designing multi-user isolation. The code is not necessarily broken. Its contract is simply narrower than you assumed.
A concrete before and after
Here is a plausible first version of a task module:
const tasks = []
export function listTasks() {
return tasks
}
export function addTask(title) {
const task = {
id: crypto.randomUUID(),
title,
completed: false,
}
tasks.push(task)
return task
}It works in a demo. It also has four important limitations:
- Every caller shares the same array.
- A process restart erases every task.
- Empty and malformed titles are accepted.
- Storage, validation, and business behavior are fused together.
Do not start by asking AI to "make this production-ready." That phrase does not define a contract. Write down the behaviors first, then make the design support them. Before you let an assistant rewrite code that already works, save it with a commit so you can compare and roll back. Git for beginners: the 10 commands you'll use every day covers the basics.
A service boundary can make the missing decisions explicit:
export function createTaskService({ taskRepository }) {
return {
async listTasks({ userId }) {
if (typeof userId !== "string" || !userId) {
throw new Error("A signed-in user is required")
}
return taskRepository.findByOwner({ ownerId: userId })
},
async addTask({ userId, title }) {
if (typeof userId !== "string" || !userId) {
throw new Error("A signed-in user is required")
}
if (typeof title !== "string") {
throw new Error("A task title is required")
}
const normalizedTitle = title.trim()
if (!normalizedTitle) {
throw new Error("A task title is required")
}
return taskRepository.create({
ownerId: userId,
title: normalizedTitle,
completed: false,
})
},
}
}This is not a complete application or an authentication system. It is a better-defined unit. The service says what it needs, validates its inputs, scopes reads by owner, and delegates persistence through a boundary that can be replaced and tested.
The route or server handler has a separate responsibility: get the authenticated identity from a trusted session and pass it to the service.
export async function createTaskRoute(request) {
const session = await requireSession(request)
const body = await request.json()
return taskService.addTask({
userId: session.user.id,
title: body.title,
})
}Never trust a userId sent in a request body, query string, or browser-controlled header. Derive identity on the server from a verified session. A database record ID is also not authorization. Reads, updates, and deletes must enforce that the authenticated user owns the record.
The exact session and database APIs depend on your stack. The invariant does not: user A must not be able to read or change user B's data.
Trace where state actually lives
"State" can mean several different things in the same app:
- Temporary interface state, such as whether a dialog is open
- Client cache state, such as the tasks currently rendered in a browser
- Server process memory, such as the global
tasksarray above - Durable state, such as rows in a database
- Identity state, such as a server-verified session
AI can generate code that makes these layers look interchangeable. They are not. A browser refresh may clear client state. A deployment may replace server memory. A cache may contain stale data. A database persists, but only if every query uses the right ownership boundary.
For each important value, answer four questions:
- Who creates it?
- Where is it stored?
- Who is allowed to read or change it?
- What causes it to disappear or become stale?
If you cannot answer one, trace the value from the user action to storage and back before adding another feature.
Review failure paths, not only the happy path
A demo usually proves this sequence:
- A user enters valid data.
- The network is available.
- The database responds.
- The request succeeds once.
- The interface shows the result.
A useful review deliberately breaks those assumptions:
- Submit an empty title and whitespace-only title.
- Submit the same action twice.
- Ask for a record that does not exist.
- Ask for a record owned by another account.
- Make the repository throw an error.
- Refresh halfway through an operation.
- Run two updates close together.
Decide which failures are expected and how callers should receive them. Validation errors, missing records, denied access, conflicts, and infrastructure failures should not all collapse into the same generic success screen or raw stack trace.
The Validate At The Boundary (opens in a new tab) lesson goes deeper on separating untrusted input from trusted program state. If you are building an Express API, The Error Handler (opens in a new tab) demonstrates how a final error boundary can return a safe response while preserving the real error for server-side logs.
Turn assumptions into tests
Tests are where your understanding becomes executable. They also give an AI assistant a much clearer contract than "please improve this code."
The task service above can be tested without a real database because persistence is passed in:
import { describe, expect, test } from "bun:test"
import { createTaskService } from "./tasks.js"
function createMemoryTaskRepository() {
const rows = []
return {
async create(task) {
const saved = { id: String(rows.length + 1), ...task }
rows.push(saved)
return saved
},
async findByOwner({ ownerId }) {
return rows.filter((row) => row.ownerId === ownerId)
},
}
}
describe("task service", () => {
test("keeps each user's tasks isolated", async () => {
const service = createTaskService({
taskRepository: createMemoryTaskRepository(),
})
await service.addTask({ userId: "user-a", title: "Ship the app" })
await service.addTask({ userId: "user-b", title: "Review the app" })
const userATasks = await service.listTasks({ userId: "user-a" })
expect(userATasks).toHaveLength(1)
expect(userATasks[0].title).toBe("Ship the app")
expect(userATasks[0].ownerId).toBe("user-a")
})
test("rejects an empty task title", async () => {
const service = createTaskService({
taskRepository: createMemoryTaskRepository(),
})
await expect(
service.addTask({ userId: "user-a", title: " " }),
).rejects.toThrow("A task title is required")
})
test("does not disguise a storage failure as success", async () => {
const service = createTaskService({
taskRepository: {
async create() {
throw new Error("Database unavailable")
},
async findByOwner() {
return []
},
},
})
await expect(
service.addTask({ userId: "user-a", title: "Ship the app" }),
).rejects.toThrow("Database unavailable")
})
})These tests are valuable because they describe behavior, not implementation details. You can replace the in-memory repository with a real database adapter without changing the service's contract.
For a deeper treatment of this design seam, work through Code You Cannot Test (opens in a new tab).
Use AI to interrogate the code
Once the app works, change the conversation with your coding assistant. Ask questions that force it to show the path through the system:
- Trace this button click from the component to durable storage. Name every function and file involved.
- Where does the server get the current user's identity? Which values come from the browser?
- Show every query that reads or changes this record. Where is ownership enforced?
- List the assumptions this function makes about its input.
- What failures can this external call produce? Which ones are handled?
- Write a failing test that demonstrates whether two users can see each other's data.
- Which part of this code would be hardest to replace, and why?
Do not accept the first explanation automatically. Open the named files, follow the calls, and run the tests. The goal is not to quiz the AI. It is to build a map you can verify.
A comprehension checklist before you ship
You do not need to recall every line. You should be able to explain the important contracts in plain language:
- I can trace one core user action from interface to storage and back.
- I know which state is temporary, cached, process-local, and durable.
- I know how the server verifies the current user's identity.
- Every read, update, and delete enforces the correct ownership rule.
- Untrusted input is validated at a named boundary.
- Expected failures produce intentional responses, not raw errors or fake success.
- The most important behavior has a test that fails when I break it.
- I can name the part most likely to change and the boundary that contains it.
- I know which generated code I have not yet verified.
If several answers are "no," the app is still a useful prototype. Call it that and keep working. The dangerous step is renaming a demo as production without changing its guarantees.
Do not learn authentication by improvising production auth
Rebuilding a small feature can be an excellent way to learn what generated code was doing. Authentication is the exception that deserves a bright warning label.
Build toy authentication only in an isolated learning project with no real users or sensitive data. For an application you intend to deploy, use a maintained authentication system and follow its current server-side integration guidance. Password hashing, session rotation, cookie settings, cross-site request protections, account recovery, rate limits, and authorization rules are security responsibilities, not optional polish.
You still need to understand the trust boundary. Using a library does not remove the need to know how a request becomes an authenticated user, or where authorization is enforced.
The bottom line
The problem with vibe coding is not that AI wrote the code. The problem is shipping assumptions you have never named.
Start with the second-user test. Trace the state. Derive identity from a trusted server session. Enforce ownership in every operation. Break the failure paths on purpose. Turn the important guarantees into tests. Then ask whether you can explain the system well enough to change it safely.
That process turns a fast prototype into something far more valuable: software you actually understand.
If the app is part of a portfolio, our guide to standing out as a junior developer explains how to present that understanding as inspectable evidence instead of surface polish.
Build the JavaScript, API, validation, and design skills that make this review process concrete:










