Skip to content
Playbooks

v0 gives you a finished frontend. The backend is the actual project.

v0 gives you production React and no backend at all. How to derive a schema from the components you have, plus two server action security mistakes.

11 min readKato Chojiro

Of all the builders we see, v0 produces the code we most enjoy inheriting. The components are clean, the Tailwind is idiomatic rather than a wall of arbitrary values, the accessibility primitives are real, and the empty and loading states usually exist - which is more than can be said for a lot of hand-written frontends.

And in most projects that reach us, none of it is connected to anything. Mock data in the component files. Forms that resolve a promise. An optimistic update with nothing behind it to be optimistic about. This is not a criticism - it is what the tool is for - but it produces a specific and predictable misunderstanding about how far along the project is.

Why it feels like ninety per cent and is about half

Every screen exists. Every state is designed. You can click through the whole product. Naturally that feels like the end of the build, because in every other context you have experienced, it would be.

What is missing has never been visible at any point, which is why its absence does not register. Roughly, on a typical product:

LayerState after v0Remaining effort
Component library and layoutDone, and to a good standardNear zero
Screen states: loading, empty, errorUsually doneWiring to real conditions
Data model, migrations, constraintsDoes not existSubstantial - this is the project
Authentication and account lifecycleA login screenSessions, roles, verification, reset, deletion
Authorization per recordDoes not existEvery endpoint, plus tests
Server-side validationClient-side only, if anyMirror every rule onto the server
Background work, email, filesDoes not existDepends entirely on the product
Deployment, CI, monitoringPreview deploymentA week, roughly

The good news buried in that table: the frontend is normally half a project and it is done. Starting over would throw away the only part that is finished.

Deriving the schema from components you already have

The components are a specification. They just are not written as one. This is genuinely the fastest way we know to get a first-draft data model, and you can do it yourself in an afternoon.

  1. List every mock data array in the codebase. Each one is a table. The object keys are your columns and their JavaScript types are your first guess at column types.
  2. Find every place two mocks reference each other - a userId on a task, an items array on an order. Those are your foreign keys, and the array-shaped ones are join tables.
  3. Look at every filter, sort, and search in the UI. Each is an index you will need. A list sorted by createdAt and filtered by status wants a composite index on exactly that pair, and you know it before you have written a query.
  4. Look at every form. Each field is a validation rule that has to exist on the server, whatever the client does.
  5. Look at every screen that shows a count or a total. Those are either aggregate queries you should plan for or denormalised columns you should maintain deliberately.

What comes out is not the final schema, but it is a real first draft grounded in how the product actually behaves, which is a better starting point than a blank diagram.

The two security mistakes specific to server actions

Where a backend does exist in a v0 project, it is usually Next.js server actions, and there are exactly two ways they go wrong. Both are easy to check and both are easy to prevent permanently.

An action is a public endpoint

Every exported server action compiles to an HTTP endpoint with a generated id. Anyone who can read your bundle can call it, with any arguments they like, in any order, without ever loading the page it was written for. The fact that it looks like a local function call in your editor is the whole problem: nothing about the syntax suggests you are looking at a public API.

typescript
"use server";

// Wrong. Reads the session, never checks it against the record.
export async function updateOrder(orderId: string, data: OrderPatch) {
  const session = await auth();
  if (!session) throw new Error("Unauthorized");
  return db.order.update({ where: { id: orderId }, data });
}

// Right. The record must belong to the caller for the update to match anything.
export async function updateOrder(orderId: string, data: OrderPatch) {
  const session = await auth();
  if (!session) throw new Error("Unauthorized");

  const result = await db.order.updateMany({
    where: { id: orderId, organizationId: session.user.organizationId },
    data,
  });

  if (result.count === 0) throw new Error("Not found");
  return result;
}

The difference is the organizationId in the where clause. The first version authenticates and does not authorize: any logged-in user can pass any order id. The second scopes the write to records the caller owns, so a wrong id updates nothing rather than updating someone else's order.

The client boundary is one line thick

A server component becomes a client component the moment something in its tree needs an event handler, and everything it imported comes with it. A key that was safely server-only is suddenly in the bundle. There is no warning, because from the framework's point of view nothing invalid happened.

  • Keep secrets in modules that are never imported from a client component, and give those modules a naming convention you can grep for.
  • Add a build-time check that scans the client bundle for anything matching your secret patterns and fails the build. This is about fifteen lines of CI and it is the only version of this control that survives contact with a deadline.
  • Prefer server-only helper packages that throw at build time if imported into a client bundle, rather than relying on review.

Sequencing, when you have a demo next week

The temptation is to build the whole backend at once and end up with nothing demonstrable for a month. A better order, and the one we use:

  1. Real authentication and real accounts. Everything else depends on knowing who is calling.
  2. The two or three flows that carry the product's actual value, wired end to end with real persistence and real authorization. Not all of them - the ones the demo turns on.
  3. Deployment on your own domain with monitoring, so what people are looking at is the real thing.
  4. Everything else, in the order that customers ask for it.

What we do not do, at any deadline, is put an unauthorized backend in front of real user data. A demo with three working flows is a good demo. A demo with twelve flows and no authorization is an incident with a launch date.

Keep the design

One last thing, because founders ask it nervously every time: taking a v0 app to production does not mean rebuilding the interface in someone else's style. The v0 output is the strongest starting point of any builder in this category. Extract the components, name the tokens, write the patterns down, and it becomes a documented design system that new screens can be generated against. There is no reason to throw away the part that is finished.

KC

Written by Kato Chojiro

Takes AI-built MVPs to production: security, data, and the infrastructure underneath.

Connect on LinkedIn
Book a Free Call

Want a second pair of eyes on your app?

The Production Check is free, and the written report is yours to keep.

Book a Free Call