# How to Build Form builder backend with V0

- Tool: v0
- Difficulty: Intermediate
- Compatibility: V0 Premium or higher
- Last updated: April 2026

## TL;DR

Build a form builder backend with V0 using Next.js, Supabase for form definitions and submissions, and dynamic Zod validation. You'll create a drag-and-drop form creator, public form renderer, submission analytics, and a dynamic validation engine — all in about 1-2 hours without touching a terminal.

## Before you start

- A V0 account (Premium plan for builder and renderer iterations)
- A Supabase project (free tier works — connect via V0's Connect panel)
- Understanding of form concepts (fields, validation, submissions)
- A use case: surveys, registrations, feedback, or data collection

## Step-by-step guide

### 1. Set up the project and form database schema

Open V0 and create a new project. Connect Supabase. Create the schema for forms (with JSON field definitions), submissions, and analytics tracking.

```
// Paste this prompt into V0's AI chat:
// Build a form builder. Create a Supabase schema with:
// 1. forms: id (uuid PK), owner_id (uuid FK to auth.users), title (text), description (text), fields (jsonb), settings (jsonb default '{"is_published": false, "requires_auth": false, "redirect_url": null, "submit_message": "Thank you!"}'), slug (text unique), submission_count (int default 0), created_at (timestamptz), updated_at (timestamptz)
// The fields jsonb stores: [{id, type, label, placeholder, required, options[], validation}]
// type is one of: text, textarea, email, number, select, multi_select, checkbox, radio, date, file, rating, scale
// 2. submissions: id (uuid PK), form_id (uuid FK to forms), data (jsonb), submitter_id (uuid FK to auth.users nullable), ip_address (text), created_at (timestamptz)
// 3. form_analytics: id (uuid PK), form_id (uuid FK to forms), date (date), views (int default 0), starts (int default 0), completions (int default 0), unique(form_id, date))
// Add RLS: owners manage their forms, anyone can submit to published forms.
```

> Pro tip: Queue the schema prompt first, then the builder UI, then the renderer — up to 10 prompts can be queued while V0 generates.

**Expected result:** Database schema created with JSON field storage and analytics tracking.

### 2. Build the visual form builder with drag-and-drop

Create the form builder page where users drag field types from a palette, reorder them, and configure each field's properties. A live preview shows how the form looks to respondents.

```
// Paste this prompt into V0's AI chat:
// Build a form builder page at app/builder/[id]/page.tsx as a 'use client' component.
// Requirements:
// - Left sidebar: field type palette with draggable Card items for each of the 12 types
// - Center: sortable field list using dnd-kit. Each field shows: label, type Badge, required indicator
// - Right sidebar: field settings editor when a field is selected:
//   - Input for label, Input for placeholder
//   - Switch for required toggle
//   - For select/radio/multi_select: Input list for options (add/remove)
//   - For number/scale: min and max Input
//   - For text/textarea: max length Input
// - Top bar: form title Input, "Preview" and "Settings" Tabs, Publish Switch
// - Tabs to switch between Builder (editor) and Preview (rendered form)
// - Settings tab: submit message Textarea, redirect URL Input, requires auth Switch
// - Auto-save: debounced Server Action that saves field definitions to the forms.fields jsonb
// - Use Card for the field palette items, Dialog for field settings on mobile
```

**Expected result:** Users can drag field types, reorder them, configure properties, and see a live preview. Changes auto-save.

### 3. Create the dynamic form renderer

Build the public form page that reads the JSON field definition and renders the appropriate shadcn/ui components. The form is a Server Component that fetches the definition, with a client component for interactive submission.

```
// Paste this prompt into V0's AI chat:
// Build the public form renderer at app/forms/[slug]/page.tsx.
// Requirements:
// - Server Component that fetches the form by slug
// - Use generateMetadata to set page title from form.title for SEO
// - If form is not published, show "This form is not available" message
// - Render a 'use client' FormRenderer component that maps each field in the fields jsonb to the corresponding shadcn/ui component:
//   - text → Input
//   - textarea → Textarea
//   - email → Input type="email"
//   - number → Input type="number"
//   - select → Select with SelectItem for each option
//   - multi_select → Checkbox group for each option
//   - checkbox → Checkbox with label
//   - radio → RadioGroup with RadioGroupItem for each option
//   - date → Calendar/DatePicker
//   - file → Input type="file"
//   - rating → custom 5-star rating using Button components
//   - scale → Slider or numbered Button row
// - Required fields show a red asterisk next to the label
// - Submit Button POSTs to /api/submit/[slug] and shows the success message from settings
// - Use Card to wrap the form with title and description at top
```

**Expected result:** Public form pages render the correct UI component for each field type and submit responses to the API.

### 4. Build the submission API with dynamic Zod validation

Create the API route that validates submission data against the form's field definitions by dynamically building a Zod schema. This ensures server-side validation matches the form configuration.

```
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'
import { z } from 'zod'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

type FieldDef = {
  id: string; type: string; label: string;
  required?: boolean; options?: string[];
  validation?: { min?: number; max?: number; maxLength?: number };
}

function buildZodSchema(fields: FieldDef[]) {
  const shape: Record<string, z.ZodTypeAny> = {}

  for (const field of fields) {
    let validator: z.ZodTypeAny

    switch (field.type) {
      case 'email': validator = z.string().email(); break
      case 'number': case 'scale': case 'rating':
        validator = z.number()
        if (field.validation?.min) validator = (validator as z.ZodNumber).min(field.validation.min)
        if (field.validation?.max) validator = (validator as z.ZodNumber).max(field.validation.max)
        break
      case 'checkbox': validator = z.boolean(); break
      case 'multi_select': validator = z.array(z.string()); break
      case 'date': validator = z.string(); break
      default: validator = z.string()
        if (field.validation?.maxLength) validator = (validator as z.ZodString).max(field.validation.maxLength)
    }

    shape[field.id] = field.required ? validator : validator.optional()
  }

  return z.object(shape)
}

export async function POST(
  req: NextRequest,
  { params }: { params: Promise<{ slug: string }> }
) {
  const { slug } = await params
  const body = await req.json()

  const { data: form } = await supabase
    .from('forms')
    .select('id, fields, settings')
    .eq('slug', slug)
    .single()

  if (!form || !form.settings.is_published) {
    return NextResponse.json({ error: 'Form not found' }, { status: 404 })
  }

  const schema = buildZodSchema(form.fields)
  const result = schema.safeParse(body)

  if (!result.success) {
    return NextResponse.json({ error: result.error.flatten() }, { status: 400 })
  }

  await supabase.from('submissions').insert({
    form_id: form.id,
    data: result.data,
    ip_address: req.headers.get('x-forwarded-for') ?? 'unknown',
  })

  await supabase.rpc('increment_submission_count', { form_id: form.id })

  return NextResponse.json({ message: form.settings.submit_message })
}
```

**Expected result:** The API dynamically builds a Zod schema from the form's field definitions and validates every submission server-side.

### 5. Build the submission results and analytics page

Create the results page showing all submissions and field-level response analytics with charts.

```
// Paste this prompt into V0's AI chat:
// Build a results page at app/forms/[id]/results/page.tsx.
// Requirements:
// - Server Component fetching form with all submissions
// - Summary Cards: total submissions, submissions today, completion rate (if analytics tracked)
// - Table showing all submissions with columns dynamically generated from field labels
// - Each cell shows the response value formatted by type (dates formatted, arrays joined, etc.)
// - Click a row to see full submission detail in a Sheet
// - Analytics section: for select/radio/checkbox fields, show a Recharts BarChart of response distribution
// - For rating/scale fields, show average value and distribution histogram
// - For text fields, show a word cloud or most common responses
// - Tabs to switch between Table view and Analytics view
// - Export Button to download all submissions as CSV
// - Use Badge for field type indicators in table headers
```

**Expected result:** The results page shows submissions in a dynamic table and field-level analytics with charts for select, rating, and scale fields.

## Complete code example

File: `app/api/submit/[slug]/route.ts`

```typescript
import { NextRequest, NextResponse } from 'next/server'
import { createClient } from '@supabase/supabase-js'
import { z } from 'zod'

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
)

type FieldDef = {
  id: string
  type: string
  label: string
  required?: boolean
  validation?: { min?: number; max?: number; maxLength?: number }
}

function buildZodSchema(fields: FieldDef[]) {
  const shape: Record<string, z.ZodTypeAny> = {}
  for (const field of fields) {
    let v: z.ZodTypeAny
    switch (field.type) {
      case 'email': v = z.string().email(); break
      case 'number': case 'scale': case 'rating': v = z.number(); break
      case 'checkbox': v = z.boolean(); break
      case 'multi_select': v = z.array(z.string()); break
      default: v = z.string()
    }
    shape[field.id] = field.required ? v : v.optional()
  }
  return z.object(shape)
}

export async function POST(
  req: NextRequest,
  { params }: { params: Promise<{ slug: string }> }
) {
  const { slug } = await params
  const body = await req.json()

  const { data: form } = await supabase
    .from('forms')
    .select('id, fields, settings')
    .eq('slug', slug)
    .single()

  if (!form || !form.settings?.is_published) {
    return NextResponse.json({ error: 'Not found' }, { status: 404 })
  }

  const schema = buildZodSchema(form.fields)
  const result = schema.safeParse(body)
  if (!result.success) {
    return NextResponse.json(
      { error: result.error.flatten() },
      { status: 400 }
    )
  }

  await supabase.from('submissions').insert({
    form_id: form.id,
    data: result.data,
    ip_address: req.headers.get('x-forwarded-for') ?? 'unknown',
  })

  await supabase
    .from('forms')
    .update({ submission_count: form.submission_count + 1 })
    .eq('id', form.id)

  return NextResponse.json({ message: form.settings.submit_message })
}
```

## Common mistakes

- **Validating submissions only on the client side** — Client-side validation can be bypassed by sending direct API requests. Invalid data enters the database. Fix: Build a Zod schema dynamically from the form's field definitions in the API route and validate every submission server-side.
- **Storing field definitions as separate database rows instead of JSON** — Normalized field tables require complex joins and make it hard to maintain field order and nested configurations. Fix: Store the complete field definition as a jsonb array in the forms table. This keeps the structure self-contained and order-preserving.
- **Not generating SEO metadata for public form pages** — Shared form links appear as generic pages in search results and social previews. Fix: Use generateMetadata in the public form page to set the title and description from the form's title and description fields.

## Best practices

- Store form field definitions as a jsonb array to preserve order and simplify queries.
- Build Zod validation schemas dynamically from field definitions at submission time for server-side validation.
- Use generateMetadata on public form pages so shared links have proper titles and descriptions.
- Queue prompts for the builder, renderer, and analytics as three separate V0 prompts.
- Use NEXT_PUBLIC_SUPABASE_ANON_KEY for the client-side builder and SUPABASE_SERVICE_ROLE_KEY for the submission API.
- Track form analytics (views, starts, completions) to measure form conversion rates.

## Frequently asked questions

### How does dynamic validation work?

The submission API reads the form's field definitions from the database and dynamically builds a Zod schema. Each field type maps to a Zod validator (string for text, number for ratings, email for email fields). Required flags and validation rules are applied automatically.

### How many field types are supported?

Twelve: text, textarea, email, number, select, multi_select, checkbox, radio, date, file, rating, and scale. Each renders as the appropriate shadcn/ui component.

### Can I embed forms on other websites?

Yes. Public forms at /forms/[slug] can be embedded in an iframe on any website. Add CORS headers if you need direct API submission from external domains.

### What V0 plan do I need?

Premium ($20/month) is recommended for the builder's drag-and-drop interface and renderer iterations. Free tier works for simpler form pages.

### How do I deploy?

Publish via V0's Share menu. Set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in Vars. Public form pages use generateMetadata for SEO.

### Can RapidDev help build a custom form builder?

Yes. RapidDev has built 600+ apps including form platforms with conditional logic, file uploads, and payment collection. Book a free consultation to discuss your requirements.

---

Source: https://www.rapidevelopers.com/how-to-build-v0/form-builder-backend
© RapidDev — https://www.rapidevelopers.com/how-to-build-v0/form-builder-backend
