I Built a SaaS in 48 Hours Using Only v0 + Cursor AI
- v0 generates UI components, Cursor generates backend logic β together they cover the full stack
- AI handles 80% of implementation β the remaining 20% is architecture, security, and edge cases
- Define TypeScript interfaces first to constrain AI output and ensure type safety
- v0 generates production-ready React components from natural language descriptions
- Cursor AI writes backend logic, database queries, and API routes from prompts
- The workflow: v0 for UI, Cursor for backend, Vercel for deployment
- Authentication, payments, and database setup take 80% of the remaining manual work
- AI-generated code still needs human review for security, edge cases, and architecture
- Biggest mistake: shipping AI code without reviewing authentication and data validation
AI-generated code has TypeScript errors
npx tsc --noEmit 2>&1 | head -50npx tsc --noEmit 2>&1 | grep -c 'error TS'Auth middleware not applied to routes
grep -rn 'middleware\|authenticate\|verifyToken' app/api/ --include='*.ts' | wc -lls app/api/ | wc -lDatabase migrations out of sync
npx prisma migrate statusnpx prisma db push --preview-featurev0 component styles broken in production
grep -rn 'className' components/ --include='*.tsx' | grep 'bg-' | head -20cat tailwind.config.ts | grep contentProduction Incident
Production Debug GuideCommon issues when building with v0 and Cursor AI
AI-assisted development tools have crossed the threshold from novelty to production viability. v0 by Vercel generates production-quality React components from natural language. Cursor AI writes backend code, database queries, and API integrations from contextual prompts.
This article documents building a complete SaaS application β authentication, payments, database, and deployment β using only these two tools. The goal is not to prove that AI replaces engineers. It is to show where AI accelerates development, where it introduces risk, and what manual work remains after the AI generates the code.
The AI-Assisted Development Stack
The stack for this build consisted of three tools: v0 for frontend generation, Cursor AI for backend development, and Vercel for deployment. Each tool handles a specific layer of the application, and the integration between them determines the overall development velocity.
v0 generates React components with Tailwind CSS styling from natural language descriptions. It produces shadcn/ui-compatible components that integrate directly with Next.js projects. Cursor AI provides contextual code generation, refactoring, and debugging across the entire codebase. It understands project structure, existing patterns, and can generate code that matches your conventions.
The workflow is not fully automated β it is a human-AI collaboration where the human defines architecture and reviews output, and the AI handles implementation details. The human decides what to build. The AI builds it. The human verifies it works.
saas-app/ βββ app/ β βββ layout.tsx # Root layout (v0 generated) β βββ page.tsx # Landing page (v0 generated) β βββ dashboard/ β β βββ layout.tsx # Dashboard layout (v0 generated) β β βββ page.tsx # Dashboard home (v0 generated) β β βββ settings/ β β β βββ page.tsx # Settings page (v0 generated) β β βββ billing/ β β βββ page.tsx # Billing page (v0 generated) β βββ api/ β β βββ auth/ β β β βββ signup/route.ts # Auth API (Cursor generated) β β β βββ login/route.ts # Auth API (Cursor generated) β β β βββ [...nextauth]/ β β β βββ route.ts # NextAuth handler (Cursor generated) β β βββ webhooks/ β β β βββ stripe/route.ts # Stripe webhook (Cursor generated) β β βββ subscription/ β β βββ route.ts # Subscription API (Cursor generated) β βββ (auth)/ β βββ login/page.tsx # Login page (v0 generated) β βββ signup/page.tsx # Signup page (v0 generated) βββ components/ β βββ ui/ # shadcn/ui components (v0 generated) β β βββ button.tsx β β βββ card.tsx β β βββ input.tsx β β βββ dialog.tsx β βββ dashboard/ # Feature components (v0 generated) β β βββ sidebar.tsx β β βββ stats-cards.tsx β β βββ activity-feed.tsx β βββ billing/ # Billing components (v0 + Cursor) β βββ pricing-cards.tsx β βββ subscription-status.tsx βββ lib/ β βββ auth.ts # Auth config (Cursor generated) β βββ stripe.ts # Stripe client (Cursor generated) β βββ db.ts # Database client (Cursor generated) β βββ utils.ts # Utilities (Cursor generated) βββ prisma/ β βββ schema.prisma # Database schema (Cursor generated) β βββ migrations/ # Database migrations βββ middleware.ts # Global auth middleware (Cursor generated) βββ .cursorrules # Cursor project rules (manual) βββ tailwind.config.ts βββ next.config.ts βββ package.json
- v0 excels at UI components β it understands design patterns, responsive layouts, and Tailwind CSS
- Cursor excels at backend logic β it understands TypeScript, API patterns, and database queries
- Neither tool handles architecture decisions β the human defines the system design
- Neither tool handles security review β the human must verify auth, validation, and access control
- The integration point is the TypeScript interface β types constrain both tools' output
Building the Frontend with v0
v0 generates React components from natural language descriptions. It produces shadcn/ui-compatible components styled with Tailwind CSS. The output is production-quality β not a prototype that needs rewriting.
The key to effective v0 usage is prompt specificity. Vague prompts produce generic output. Detailed prompts with specific requirements, design tokens, and component behavior produce components that match your design system.
The workflow for each page: describe the page layout in v0, copy the generated component into your project, install any missing shadcn/ui dependencies, and adjust the data layer to connect to your API. The visual structure is done β the data wiring is manual.
// Dashboard page β generated by v0, data layer wired manually // v0 prompt: "Create a SaaS dashboard with a sidebar navigation, // stats cards showing MRR, active users, and churn rate, // and an activity feed showing recent events" import { Suspense } from 'react' import { Sidebar } from '@/components/dashboard/sidebar' import { StatsCards } from '@/components/dashboard/stats-cards' import { ActivityFeed } from '@/components/dashboard/activity-feed' import { getDashboardStats, getRecentActivity } from '@/lib/api' export default async function DashboardPage() { // Data layer β wired manually after v0 generated the UI const statsPromise = getDashboardStats() const activityPromise = getRecentActivity() return ( <div className="flex min-h-screen"> {/* Sidebar β v0 generated */} <Sidebar /> <main className="flex-1 p-6 lg:p-8"> <div className="space-y-8"> {/* Header β v0 generated */} <div> <h1 className="text-3xl font-bold tracking-tight"> Dashboard </h1> <p className="text-muted-foreground"> Overview of your SaaS metrics </p> </div> {/* Stats β v0 generated shell, data wired manually */} <Suspense fallback={<StatsCardsSkeleton />}> <StatsCardsWrapper promise={statsPromise} /> </Suspense> {/* Activity β v0 generated shell, data wired manually */} <Suspense fallback={<ActivityFeedSkeleton />}> <ActivityFeedWrapper promise={activityPromise} /> </Suspense> </div> </main> </div> ) } // Data wrapper components β Cursor generated async function StatsCardsWrapper({ promise }: { promise: ReturnType<typeof getDashboardStats> }) { const stats = await promise return <StatsCards stats={stats} /> } async function ActivityFeedWrapper({ promise }: { promise: ReturnType<typeof getRecentActivity> }) { const activities = await promise return <ActivityFeed activities={activities} /> } // Skeleton loaders β v0 generated function StatsCardsSkeleton() { return ( <div className="grid gap-4 md:grid-cols-3"> {Array.from({ length: 3 }).map((_, i) => ( <div key={i} className="h-32 rounded-lg border bg-muted animate-pulse" /> ))} </div> ) } function ActivityFeedSkeleton() { return ( <div className="space-y-4"> {Array.from({ length: 5 }).map((_, i) => ( <div key={i} className="h-16 rounded-lg border bg-muted animate-pulse" /> ))} </div> ) }
- Include design tokens in the prompt β colors, spacing, font sizes match your system
- Specify the component library β 'use shadcn/ui components' produces consistent output
- Describe responsive behavior β 'stack vertically on mobile, side-by-side on desktop'
- Include loading and empty states β 'add skeleton loaders for async data'
- Paste existing CSS variables into the prompt β v0 will use your design tokens
Building the Backend with Cursor AI
Cursor AI generates backend code β API routes, database queries, authentication logic, and payment integrations. It understands your project context through .cursorrules files and can generate code that matches your existing patterns.
The key to effective Cursor usage is defining project context upfront. The .cursorrules file tells Cursor your coding standards, file structure, naming conventions, and technology choices. Without this context, Cursor generates generic code that may not match your project conventions.
The workflow for each feature: define the TypeScript interface first, then ask Cursor to implement the route, query, or service matching that interface. The interface constrains the AI output and ensures type safety across the application.
// ============================================ // Cursor AI Generated Backend Code // ============================================ // ---- .cursorrules (Project Context) ---- // This file tells Cursor how to generate code for this project // Place in project root as .cursorrules /* Project: SaaS Application Framework: Next.js 16 with App Router Database: PostgreSQL with Prisma ORM Auth: NextAuth.js with JWT Payments: Stripe Styling: Tailwind CSS with shadcn/ui Coding Standards: - Use TypeScript strict mode - All API routes return JSON with { data, error } shape - Use Zod for input validation - Use Prisma for all database access - Never use any type β always define explicit types - Error messages must be user-safe β no stack traces - All dates in UTC, format on the client File Structure: - app/api/ for route handlers - lib/ for shared utilities and clients - types/ for TypeScript interfaces - prisma/ for database schema and migrations */ // ---- Types (defined first, then implemented) ---- // Cursor generates implementation matching these interfaces interface ApiResponse<T> { data: T | null error: string | null } interface Subscription { id: string userId: string plan: 'free' | 'pro' | 'enterprise' status: 'active' | 'canceled' | 'past_due' currentPeriodEnd: Date stripeSubscriptionId: string | null } interface DashboardStats { mrr: number activeUsers: number churnRate: number newSignupsToday: number } interface CreateSubscriptionInput { plan: 'pro' | 'enterprise' paymentMethodId: string } // ---- Cursor Generated: Stripe Webhook Handler ---- // Prompt: "Create a Stripe webhook handler that processes // subscription events and updates the database" import { NextRequest, NextResponse } from 'next/server' import { headers } from 'next/headers' import Stripe from 'stripe' import { prisma } from '@/lib/db' import { stripe } from '@/lib/stripe' export async function POST(req: NextRequest) { const body = await req.text() const headersList = headers() const signature = headersList.get('stripe-signature') if (!signature) { return NextResponse.json( { data: null, error: 'Missing stripe-signature header' }, { status: 400 } ) } let event: Stripe.Event try { event = stripe.webhooks.constructEvent( body, signature, process.env.STRIPE_WEBHOOK_SECRET! ) } catch (err) { return NextResponse.json( { data: null, error: 'Invalid signature' }, { status: 400 } ) } switch (event.type) { case 'checkout.session.completed': { const session = event.data.object as Stripe.Checkout.Session const userId = session.metadata?.userId const subscriptionId = session.subscription as string if (!userId) break const subscription = await stripe.subscriptions.retrieve( subscriptionId ) await prisma.subscription.update({ where: { userId }, data: { stripeSubscriptionId: subscription.id, status: 'active', currentPeriodEnd: new Date( subscription.current_period_end * 1000 ), }, }) break } case 'customer.subscription.updated': { const subscription = event.data.object as Stripe.Subscription await prisma.subscription.update({ where: { stripeSubscriptionId: subscription.id }, data: { status: subscription.status === 'active' ? 'active' : subscription.status === 'past_due' ? 'past_due' : 'canceled', currentPeriodEnd: new Date( subscription.current_period_end * 1000 ), }, }) break } case 'customer.subscription.deleted': { const subscription = event.data.object as Stripe.Subscription await prisma.subscription.update({ where: { stripeSubscriptionId: subscription.id }, data: { status: 'canceled', }, }) break } } return NextResponse.json({ data: { received: true }, error: null }) } // ---- Cursor Generated: Dashboard Stats API ---- // Prompt: "Create an API route that returns dashboard stats: // MRR, active users, churn rate, new signups today" // app/api/dashboard/stats/route.ts import { NextResponse } from 'next/server' import { getServerSession } from 'next-auth' import { prisma } from '@/lib/db' import { authOptions } from '@/lib/auth' export async function GET() { const session = await getServerSession(authOptions) if (!session?.user) { return NextResponse.json( { data: null, error: 'Unauthorized' }, { status: 401 } ) } const now = new Date() const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1) const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()) // MRR: sum of active subscription prices const activeSubscriptions = await prisma.subscription.findMany({ where: { status: 'active' }, select: { plan: true }, }) const planPrices: Record<string, number> = { free: 0, pro: 29, enterprise: 99, } const mrr = activeSubscriptions.reduce( (sum, sub) => sum + (planPrices[sub.plan] || 0), 0 ) // Active users: users with active subscriptions const activeUsers = activeSubscriptions.length // Churn rate: canceled this month / active at start of month const canceledThisMonth = await prisma.subscription.count({ where: { status: 'canceled', updatedAt: { gte: startOfMonth }, }, }) const churnRate = activeUsers > 0 ? Math.round((canceledThisMonth / activeUsers) * 100 * 10) / 10 : 0 // New signups today const newSignupsToday = await prisma.user.count({ where: { createdAt: { gte: startOfDay } }, }) const stats: DashboardStats = { mrr, activeUsers, churnRate, newSignupsToday, } return NextResponse.json({ data: stats, error: null }) }
- .cursorrules defines your coding standards β Cursor follows them in generated code
- TypeScript interfaces constrain AI output β define types before asking for implementation
- Cursor understands existing code patterns β it generates code that matches your conventions
- Pass error messages back to Cursor for fixes β it can debug its own output
- Review all security-critical code β Cursor optimizes for correctness, not security
Database and Authentication Setup
Database setup and authentication are the two areas where AI assistance has the most limitations. These components require careful architectural decisions β schema design, migration strategy, auth flow, and security configuration β that AI tools handle poorly without explicit guidance.
Cursor can generate Prisma schemas and NextAuth configurations, but the human must decide the data model, relationship strategy, and auth flow. AI-generated schemas often lack indexes, have suboptimal relationships, and miss security constraints.
The recommended approach: design the schema manually, then ask Cursor to generate the Prisma schema matching your design. For auth, use a proven library (NextAuth.js) and ask Cursor to configure it β do not ask Cursor to build auth from scratch.
// ============================================ // Database Schema β designed manually, generated by Cursor // ============================================ // prisma/schema.prisma // Cursor prompt: "Generate a Prisma schema for a SaaS with // users, subscriptions, and usage tracking" // ---- Prisma Schema (Cursor generated from manual design) ---- generator client { provider = "prisma-client-js" } datasource db { provider = "postgresql" url = env("DATABASE_URL") } model User { id String @id @default(cuid()) email String @unique name String? passwordHash String emailVerified DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt subscription Subscription? usageLogs UsageLog[] @@index([email]) @@index([createdAt]) } model Subscription { id String @id @default(cuid()) userId String @unique user User @relation(fields: [userId], references: [id]) plan String @default("free") status String @default("active") stripeCustomerId String? @unique stripeSubscriptionId String? @unique currentPeriodStart DateTime @default(now()) currentPeriodEnd DateTime createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([userId]) @@index([status]) @@index([stripeSubscriptionId]) } model UsageLog { id String @id @default(cuid()) userId String user User @relation(fields: [userId], references: [id]) action String resource String metadata Json? createdAt DateTime @default(now()) @@index([userId, createdAt]) @@index([action]) } // ---- Auth Configuration (Cursor generated) ---- // lib/auth.ts import { NextAuthOptions } from 'next-auth' import CredentialsProvider from 'next-auth/providers/credentials' import bcrypt from 'bcrypt' import { prisma } from './db' export const authOptions: NextAuthOptions = { providers: [ CredentialsProvider({ name: 'credentials', credentials: { email: { label: 'Email', type: 'email' }, password: { label: 'Password', type: 'password' }, }, async authorize(credentials) { if (!credentials?.email || !credentials?.password) { return null } const user = await prisma.user.findUnique({ where: { email: credentials.email }, }) if (!user) { return null } const passwordValid = await bcrypt.compare( credentials.password, user.passwordHash ) if (!passwordValid) { return null } return { id: user.id, email: user.email, name: user.name, } }, }), ], session: { strategy: 'jwt', maxAge: 30 * 24 * 60 * 60, // 30 days }, callbacks: { async jwt({ token, user }) { if (user) { token.id = user.id } return token }, async session({ session, token }) { if (session.user) { session.user.id = token.id as string } return session }, }, pages: { signIn: '/login', }, } // ---- Global Auth Middleware (Cursor generated) ---- // middleware.ts import { withAuth } from 'next-auth/middleware' import { NextResponse } from 'next/server' export default withAuth( function middleware(req) { return NextResponse.next() }, { callbacks: { authorized: ({ token }) => !!token, }, } ) // IMPORTANT: Exclude webhooks and public auth routes export const config = { matcher: [ '/dashboard/:path*', '/api/((?!webhooks/stripe|auth).*)', ], }
Lessons Learned and What AI Cannot Do
Building a SaaS in 48 hours with AI tools revealed clear boundaries between what AI accelerates and what still requires human judgment. The 80/20 rule applies β AI handles 80% of implementation, but the remaining 20% is the hardest and most critical.
AI excels at generating boilerplate, UI components, CRUD operations, and integration code. AI struggles with architecture decisions, security review, edge case handling, and performance optimization. The human engineer's value shifts from writing code to reviewing code, making architectural decisions, and verifying correctness.
The biggest risk is false confidence β AI-generated code looks correct and runs correctly in happy-path testing. But it often fails on edge cases, security boundaries, and production-scale data. Every line of AI-generated code that touches authentication, payments, or user data must be reviewed by a human.
// ============================================ // Lessons Learned: What AI Can and Cannot Do // ============================================ // ---- What AI Handles Well ---- // 1. UI Components (v0) // Prompt: "Create a pricing card with three tiers" // Output: Production-ready React component with Tailwind CSS // Human effort: 0 minutes (copy-paste) // 2. CRUD API Routes (Cursor) // Prompt: "Create CRUD routes for the products resource" // Output: GET, POST, PUT, DELETE routes with Prisma // Human effort: 5 minutes (review and add validation) // 3. Database Queries (Cursor) // Prompt: "Write a query that returns MRR grouped by plan" // Output: Prisma aggregate query // Human effort: 2 minutes (verify correctness) // 4. Integration Code (Cursor) // Prompt: "Create a Stripe checkout session for the pro plan" // Output: Stripe API integration with error handling // Human effort: 10 minutes (add webhook handling) // ---- What AI Handles Poorly ---- // 1. Architecture Decisions // AI cannot decide: monorepo vs separate repos, auth strategy, // caching strategy, deployment model // Human effort: 2-4 hours of design and documentation // 2. Security Review // AI-generated code often has: // - Missing auth checks on new routes // - SQL injection in raw queries (rare with Prisma) // - Exposed sensitive data in API responses // - Missing rate limiting // Human effort: 1-2 hours of security audit // 3. Edge Case Handling // AI optimizes for happy path. Missing: // - Concurrent request handling // - Race condition prevention // - Graceful degradation on service failure // - Input validation for malformed data // Human effort: 2-3 hours of testing and hardening // 4. Performance Optimization // AI-generated code is correct but not fast: // - Missing database indexes // - N+1 query patterns // - Unnecessary re-renders in React // - Missing caching layers // Human effort: 1-2 hours of profiling and optimization // ---- The 48-Hour Build Breakdown ---- const buildBreakdown = { 'Architecture design': '2 hours', 'v0 UI generation': '3 hours', 'Cursor backend generation': '4 hours', 'Data layer wiring': '3 hours', 'Auth setup and review': '2 hours', 'Stripe integration': '2 hours', 'Database schema design': '1 hour', 'Testing and edge cases': '4 hours', 'Security review': '2 hours', 'Deployment and config': '1 hour', 'Bug fixes and polish': '4 hours', 'Documentation': '1 hour', // Remaining: sleep, food, breaks } // Total coding time: ~29 hours over 2 days // AI generated ~70% of the code // Human wrote ~30% (wiring, review, edge cases, config) // ---- Key Metrics ---- const metrics = { totalLinesOfCode: 4200, aiGeneratedLines: 2940, // 70% humanWrittenLines: 1260, // 30% timeSpentReviewing: '8 hours', // 28% of coding time bugsFoundInReview: 7, bugsFoundInProduction: 2, securityIssuesFound: 3, // All in AI-generated code }
- AI generates code 10x faster β but bugs ship 10x faster too
- The human's role shifts from writing code to reviewing code β review is the bottleneck
- Security and edge cases are the AI's blind spots β humans must fill these gaps
- Architecture decisions cannot be delegated to AI β they require understanding business context
- The 48-hour build took 8 hours of review β without review, it would have shipped with critical bugs
| Task | Manual Coding | v0 + Cursor AI | Quality Difference |
|---|---|---|---|
| Landing page | 4-6 hours | 30 minutes | AI matches quality for standard layouts |
| Dashboard with charts | 8-12 hours | 1-2 hours | AI needs data wiring β visual quality matches |
| Auth system setup | 4-8 hours | 1-2 hours | AI needs security review β functional quality matches |
| Stripe integration | 4-6 hours | 1 hour | AI handles boilerplate β webhook logic needs review |
| Database schema | 2-4 hours | 30 minutes | AI misses indexes and optimizations |
| API routes (CRUD) | 2-4 hours | 20 minutes | AI matches quality for standard CRUD |
| Error handling | 2-3 hours | 30 minutes | AI generates basic handling β edge cases need manual work |
| Testing | 4-8 hours | 2-4 hours | AI generates happy-path tests β edge cases need manual tests |
π― Key Takeaways
- v0 generates UI components, Cursor generates backend logic β together they cover the full stack
- AI handles 80% of implementation β the remaining 20% is architecture, security, and edge cases
- Define TypeScript interfaces first to constrain AI output and ensure type safety
- Every line of AI-generated code touching auth, payments, or user data needs human review
- Spend at least 25% of development time reviewing AI-generated code
- Use global middleware patterns instead of per-route checks to prevent security gaps
β Common Mistakes to Avoid
Interview Questions on This Topic
- QHow would you use AI tools to accelerate development without compromising code quality?Mid-levelReveal
- QA developer on your team shipped AI-generated code that exposed user data. How do you prevent this from happening again?SeniorReveal
- QWhat is v0 and how does it differ from Cursor AI?JuniorReveal
Frequently Asked Questions
Can you really build a production SaaS in 48 hours with AI tools?
Yes, but with caveats. The 48 hours includes 8 hours of code review, 4 hours of testing, and 2 hours of security audit. AI generates the code in about 7 hours, but the human spends significant time reviewing, wiring data layers, and handling edge cases. The SaaS was functional and deployed, but it was an MVP β not a feature-complete product.
Is v0 free to use?
v0 has a free tier with limited generations per month. The paid plan provides more generations and priority access. For a 48-hour build, the free tier is sufficient for generating the core UI components. You may need the paid plan for iterative refinement.
Can Cursor AI replace a backend developer?
No. Cursor accelerates backend development by generating boilerplate and integration code. But it cannot make architectural decisions, design data models, handle security review, or optimize for production scale. The developer's role shifts from writing code to reviewing code and making design decisions.
What happens when AI-generated code has a bug in production?
The same process as any production bug: reproduce, diagnose, fix, deploy. However, debugging AI-generated code can be harder because the developer did not write it and may not understand the implicit assumptions. The fix is to pass the error and relevant code back to Cursor for diagnosis β it can often debug its own output.
Should I use AI tools for my next project?
Yes, but with the right expectations. AI tools accelerate development by 3-5x for standard features. They do not replace engineering judgment for architecture, security, and performance. Use them for what they do well β generating boilerplate, UI components, and integration code. Handle architecture, security review, and edge cases manually.
Developer and founder of TheCodeForge. I built this site because I was tired of tutorials that explain what to type without explaining why it works. Every article here is written to make concepts actually click.