Skip to content
Vibe Coders

v0 to Next.js: The Production Bridge Most Founders Skip

v0 generates beautiful components but does not build apps. Walk through the bridge: scaffolding a Next.js repo, importing v0 components, wiring up routing, state, and data fetching. Templates...

By WitsCode11 min read

You opened v0, typed a prompt, watched it think for a few seconds, and then stared at a dashboard that looked like a shipped product. You clicked refine, asked for a dark variant, tweaked the header copy, and within ten minutes you had five screens that could pass for a funded SaaS. You copied the code, pasted it into your editor, and nothing rendered. Or it rendered, but the colors were missing, the buttons had no radius, and the icons were broken squares. Or everything looked right until you clicked a link and realized there was nowhere for it to go.

This is the gap v0's marketing never quite describes. v0 is a component generator, not an app generator. It hands you a single React file, sometimes with a few shadcn primitives, and leaves every other piece of a production application to you. The routing layer. The data layer. The auth boundary. The build configuration. The piece of glue code that turns a nice-looking screen into a page with a URL your users can reach. This article walks the bridge that connects a v0 artifact to a deployed Next.js application, and it covers the three or four things that go wrong in every handoff, because the gap between component and app is where most vibe coders stall.

What v0 actually hands you, and what it does not

v0.dev, from Vercel, generates React components. Its default output is a single TSX file that default-exports one component. Under the hood it composes shadcn/ui primitives, which are themselves headless React components wrapped around Radix, styled with Tailwind CSS, and shipped into your codebase as source files rather than as an npm dependency. When v0 writes a pricing page, what it actually writes is a function that returns JSX, referencing Button, Card, Badge, and a handful of icons from lucide-react. Everything is client-side React.

What v0 does not produce is everything else. It does not set up the Next.js App Router file tree beyond the single page you asked for. It does not write a root layout, a middleware file, an API route, a server action, a database client, or an auth configuration. It does not pin a Node version, write a Vercel config, or split your screen into a server shell and a client island. It does not know what other pages exist in your app, so it cannot wire any of its links to real routes. The hrefs you see in a v0 output are placeholders. Every anchor points to the hash symbol or to a fictional path like /dashboard that does not yet exist.

The mental model worth carrying into the rest of this article is that v0 gives you pages, and you give v0 an app to live in. The component is correct. The scaffold around it is your job.

The scaffold: create-next-app, shadcn init, and Tailwind alignment

The first mistake founders make is to paste v0 output into a Vite React app, a Create React App shell, or an Astro project. v0 targets Next.js with the App Router, and it assumes shadcn conventions. Any other host environment will work for the markup but will fight you on routing, on server components, and on the data patterns covered later.

Start with a clean Next.js app. Run npx create-next-app@latest my-app and accept the defaults that matter: TypeScript yes, Tailwind yes, App Router yes, ESLint yes. Skip the src directory unless you have a reason. This gives you a repo that matches v0's assumptions about where files live and which alias paths resolve.

Next, initialize shadcn. Run npx shadcn@latest init inside the new project. Note the package name. It is shadcn, not shadcn-ui. The old package under the shadcn-ui name is deprecated, and if you pull from a year-old tutorial you will install it and then find that every v0 component imports from paths the old package does not create. The init command will ask you about style, base color, and whether to use CSS variables. Pick the Default style, the base color that matches your v0 output (usually Slate or Neutral), and say yes to CSS variables. v0 writes components that reference CSS variables like hsl(var(--background)), and if your globals.css is missing the variable block that shadcn init writes, every surface in every v0 component will render transparent or black.

Verify three files after init. components.json should show your aliases pointing at @/components and @/lib/utils. tailwind.config.ts should include shadcn's theme extension with color tokens like background, foreground, primary, muted, and accent, each bound to a CSS variable. app/globals.css should have a long :root block inside @layer base defining those variables for light mode, and a .dark block redefining them for dark mode. If any of those three files looks bare, the init did not complete. Rerun it before you paste a single line of v0 code, because the time spent diagnosing missing variables later is greater than the time to start over now.

Tailwind version is the second alignment point. v0 currently generates against Tailwind v4 with the new @theme and @import "tailwindcss" conventions, while some older shadcn setups still use the Tailwind v3 config shape. create-next-app on the latest version gives you v4, which matches v0. If you inherit an older repo on v3, you either upgrade Tailwind to v4 before importing v0 components or you tell v0 explicitly in its prompt to target Tailwind v3. Mixing the two is the fastest way to spend a Saturday debugging a spacing token.

Importing v0 components without breaking styles

v0 offers two ways to get a component into your codebase. The first is to click the "Open in v0" or "Add to codebase" button and copy the CLI command it gives you. The command looks like npx shadcn@latest add "https://v0.dev/chat/b/some-hash". When you run it inside your Next.js project, the CLI pulls the generated component, installs any missing shadcn primitives it depends on, and writes them into your components folder. This is the path to use almost every time, because it handles dependencies you would otherwise miss.

The second way is to copy the raw TSX from the v0 UI, paste it into a new file in your components/ folder, and then run npx shadcn@latest add button input card dialog for each primitive the component imports. This is fine for a component or two, but it gets brittle as you accumulate screens. You will eventually paste a component that imports Sheet or Drawer and forget to install it, and the error will fire only on the route that mounts that screen.

After importing, check three things before you try to render the page. First, the top of the file. If the component uses hooks like useState, useEffect, or event handlers like onClick, it must have "use client" on the first line. v0 usually includes this, but when you copy only the function body, the directive can get lost. Second, the icon imports. v0 uses lucide-react. If your project does not have lucide-react installed, icons throw at build time. Run npm install lucide-react once and forget it. Third, the path aliases. Any import starting with @/ depends on the paths entry in tsconfig.json. create-next-app sets this up, but if you inherited a project that lacks it, every shadcn import will fail to resolve.

Mount the component at a route. Create app/dashboard/page.tsx with a single line of body that imports and renders the v0 component. Run npm run dev and hit the URL. If colors and spacing look right, the stylesheet alignment worked. If you see a wall of unstyled text, you are looking at the CSS variable issue, and you go back to globals.css and make sure the :root and .dark blocks are present and the @layer base wrapper is correct.

The subtlest thing v0 does not do is connect its pages to each other. Every v0 screen is a closed universe. A pricing page lists three plans with buttons that say Choose Plan and point at the hash symbol. A dashboard has a sidebar with items that look like links but are anchors without destinations. A sign-up form posts to nowhere. v0 cannot know your URL structure because v0 is a component generator, not an app planner.

The wire-up pattern is to treat each v0 component as a body and write the link layer yourself. Start with app/layout.tsx. Put your persistent nav in there, import Link from next/link, and hard-code hrefs that match your App Router folder structure. <Link href="/pricing">Pricing</Link> points to app/pricing/page.tsx, which renders the v0 pricing component. Then walk the v0 output and replace every placeholder href and every anchor-styled button with a real Link component or a real server action. This sounds tedious. It usually takes twenty minutes per page and it is the step that turns a gallery of v0 screens into an app.

Dynamic routes come next. When v0 gives you a product detail page with a hardcoded SKU, the production move is to drop that component into app/products/[id]/page.tsx and accept params as a prop. Read params.id in the page wrapper and pass it into the v0 component as a prop, replacing the hardcoded value. The same applies to user profiles, order confirmations, blog posts, any screen whose content depends on a URL segment.

Finish the routing layer with loading and error boundaries. v0 does not output loading.tsx or error.tsx. Next.js expects them adjacent to any page.tsx that does async work. A bare skeleton in loading.tsx that reuses your v0 card primitive with a Skeleton wrapper goes a long way. An error.tsx that logs the error and shows a retry button closes the loop. Without these, a slow query yields a blank page and a thrown error crashes the segment.

The data layer: server components for reads, server actions for forms

v0 hardcodes data. A dashboard will have an array of mock rows declared at the top of the file. A table of customers will map over a literal. A form will have an onSubmit handler that logs to the console or awaits a fake promise. None of this survives contact with real users, and the conversion to a production data layer is the part of the bridge where most vibe coders walk off the plank.

The pattern that works in the App Router is a two-part split. Server components do the reads. Client components do the interactions. For a dashboard, rename the v0 component to DashboardClient, add "use client" at the top if it is not already there, and have it accept a rows prop instead of defining its own mock data. Then create a server wrapper at app/dashboard/page.tsx that is an async function, fetches the real rows from your database or API, and passes them into DashboardClient. The server wrapper runs on the server, streams fast, and never ships the query code to the browser. The client component stays interactive for sorts, filters, and modals.

For forms, the pattern is server actions. v0 gives you a <form onSubmit={handleSubmit}> with a client-side handler. The production move is to define a server action in a file marked "use server" at the top, export an async function that accepts FormData, do the insert or update, and then pass the action directly to the form via <form action={createProduct}>. Wire up useActionState in the client component to show pending state and validation errors coming back from the server. This replaces the v0 stub without touching the visual layer. The buttons still look the same. The difference is that submitting now hits a server function, mutates a real database, and revalidates the cache so the dashboard reflects the change on the next render.

Mutations with immediate feedback use useOptimistic in the client component. You show the row as added before the server confirms, and reconcile when the action resolves. This is the pattern for add-to-cart, for quick edits, for toggle states. v0 never outputs this, but it slots in cleanly next to a v0 list because the list is already designed to accept a rows prop.

Global state stays out of v0 components. A cart, an auth session, a theme toggle, and feature flags belong in context providers declared in app/layout.tsx or in a store like Zustand set up once and consumed via hooks. v0 components consume that state but should not own it. If you find yourself rewriting a v0 component to manage global state, stop and hoist the state up instead.

Auth is the same shape. Middleware at middleware.ts or an auth() call in a server component parent decides whether the page renders. The v0 component itself stays unaware. This keeps v0 regeneration safe. You can ask v0 for a new variant of the page and drop it back in without rewriting the auth wrapper around it.

The deploy and the last mile

Once the component renders, the routes connect, the forms persist, and the reads come from the database, deployment is the easiest part. Push to GitHub, import the repo into Vercel, set your environment variables in the Vercel dashboard, and ship. v0 and Next.js are both Vercel products, so the deploy path is paved. The only gotcha is environment variable parity. Anything in your local .env.local must also exist in the Vercel project settings, or the first server-rendered request will throw a reference error that you will not see until you hit the production URL.

The template shape worth cloning for every v0-driven project looks like this. A Next.js app scaffolded with the defaults above. A components/v0/ folder where imported screens land. A components/ui/ folder where shadcn primitives live. An app/ folder where each route is a thin server wrapper that imports a v0 client component and feeds it props from a server-side query. An actions/ folder of server actions the forms call. A lib/db.ts file with a single database client. An auth.ts config for your auth provider. A middleware.ts for route protection. A .env.example that tells the next contributor which variables to set. Every time you generate a new screen in v0, it drops into this shape without requiring rework.

The bridge between v0 and a shipped app is a week of careful wiring, not a single export. Most founders skip it because v0's demo makes the bridge look invisible. The demo is honest about what it shows: a beautifully generated component, running in a sandbox. The work that sits between that sandbox and a URL your customer trusts is the work WitsCode does every week. If you have a Figma-free v0 output that looks like a product and a backlog that says ship this next month, the fastest path is to hand the v0 outputs to an engineer who has built the bridge enough times to skip the mistakes.

WitsCode v0 to production engagement

Get weekly field notes.

Practical writing on shipping products, straight to your inbox. No spam.

Need help with this?

MVP Development

We design and build web apps, MVPs, and SaaS products. Talk to us about what you are working on.

Talk to us

Want to discuss vibe coders for your business?

Start a project and we'll talk through where you are, what's working, and the highest-leverage moves for the next 90 days.