# How to Build Feedback collection tool with V0

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

## TL;DR

Build a feedback collection tool with V0 using Next.js, Supabase for feedback storage, and an embeddable widget for any website. You'll create a public feedback board with upvoting, team response threads, status tracking, and a JavaScript widget snippet — all in about 1-2 hours without touching a terminal.

## Before you start

- A V0 account (Premium plan for widget and board iterations)
- A Supabase project (free tier works — connect via V0's Connect panel)
- A product or website where you want to collect feedback
- A domain where the feedback widget will be embedded

## Step-by-step guide

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

Open V0 and create a new project. Connect Supabase via the Connect panel. Create the schema for projects, feedback items, upvotes, and comments.

```
// Paste this prompt into V0's AI chat:
// Build a feedback collection tool. Create a Supabase schema with:
// 1. projects: id (uuid PK), name (text), owner_id (uuid FK to auth.users), widget_key (text unique default gen_random_uuid()), domain (text), created_at (timestamptz)
// 2. feedback_items: id (uuid PK), project_id (uuid FK to projects), title (text), description (text), category (text default 'feature' check in 'feature','bug','improvement','question'), status (text default 'new' check in 'new','under_review','planned','in_progress','completed','declined'), priority (int default 0), submitter_email (text), submitter_name (text), user_id (uuid FK to auth.users nullable), upvote_count (int default 0), created_at (timestamptz)
// 3. upvotes: id (uuid PK), feedback_id (uuid FK to feedback_items), user_id (uuid FK to auth.users), created_at (timestamptz), unique(feedback_id, user_id)
// 4. comments: id (uuid PK), feedback_id (uuid FK to feedback_items), user_id (uuid FK to auth.users), body (text), is_team_reply (boolean default false), created_at (timestamptz)
// Add RLS: anyone can read feedback for published projects, authenticated users can upvote and submit.
```

> Pro tip: The widget_key is auto-generated as a UUID. Use it to identify which project the widget belongs to when receiving submissions from external sites.

**Expected result:** Supabase is connected with all four tables. The widget_key column provides a unique identifier for each project's embeddable widget.

### 2. Build the public feedback board with upvoting

Create the public-facing feedback board where users browse feature requests, filter by category and status, and upvote their favorites. Upvoting uses optimistic UI for instant feedback.

```
// Paste this prompt into V0's AI chat:
// Build a public feedback board at app/board/[project_id]/page.tsx.
// Requirements:
// - Server Component that fetches feedback_items for this project, ordered by upvote_count DESC
// - Each feedback item as a Card with: upvote Button (arrow up + count) on the left, title, description preview (2 lines), category Badge, status Badge with color coding
// - Filter bar with Tabs for status views (All, Planned, In Progress, Completed)
// - Select for category filter (feature/bug/improvement/question)
// - Sort options: Most Upvoted, Newest, Recently Updated
// - Upvote Button is a 'use client' component that calls a Server Action to toggle the upvote with optimistic UI
// - The Server Action upserts into upvotes (insert if not exists, delete if exists) and updates upvote_count via RPC
// - "Submit Feedback" Button at top that opens a Dialog with title Input, description Textarea, category Select, and submitter email Input
// - Feedback submission goes through a Server Action with Zod validation (title 5-100 chars, description 10-500 chars)
// - Use Separator between the filter bar and the feedback list
```

**Expected result:** The board shows feedback items sorted by upvotes with category and status filters. Users can upvote (toggling) and submit new feedback.

### 3. Create the widget API endpoint with origin validation

Build the API route that receives feedback submissions from the embeddable widget. It validates the widget key and checks the Origin header against the project's registered domain to prevent abuse.

```
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!
)

const widgetSchema = z.object({
  widget_key: z.string().uuid(),
  title: z.string().min(5).max(100),
  description: z.string().min(10).max(500),
  category: z.enum(['feature', 'bug', 'improvement', 'question']),
  submitter_email: z.string().email().optional(),
  submitter_name: z.string().max(100).optional(),
})

export async function POST(req: NextRequest) {
  const origin = req.headers.get('origin')
  const body = await req.json()

  const parsed = widgetSchema.safeParse(body)
  if (!parsed.success) {
    return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 })
  }

  const { data: project } = await supabase
    .from('projects')
    .select('id, domain')
    .eq('widget_key', parsed.data.widget_key)
    .single()

  if (!project) {
    return NextResponse.json({ error: 'Invalid widget key' }, { status: 403 })
  }

  if (project.domain && origin && !origin.includes(project.domain)) {
    return NextResponse.json({ error: 'Origin not allowed' }, { status: 403 })
  }

  await supabase.from('feedback_items').insert({
    project_id: project.id,
    title: parsed.data.title,
    description: parsed.data.description,
    category: parsed.data.category,
    submitter_email: parsed.data.submitter_email,
    submitter_name: parsed.data.submitter_name,
  })

  return NextResponse.json({ success: true }, {
    headers: {
      'Access-Control-Allow-Origin': origin ?? '*',
      'Access-Control-Allow-Methods': 'POST, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type',
    },
  })
}

export async function OPTIONS(req: NextRequest) {
  return NextResponse.json({}, {
    headers: {
      'Access-Control-Allow-Origin': req.headers.get('origin') ?? '*',
      'Access-Control-Allow-Methods': 'POST, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type',
    },
  })
}
```

> Pro tip: Use Design Mode (Option+D) to style the public board and widget to match your product's brand colors — free visual adjustments without spending credits.

**Expected result:** The widget API validates the key and origin, inserts feedback, and returns CORS headers for cross-origin widget requests.

### 4. Build the admin dashboard with team replies

Create the admin dashboard where product teams view feedback stats, change status, set priority, and reply to feedback items with team-flagged comments.

```
// Paste this prompt into V0's AI chat:
// Build an admin dashboard at app/page.tsx.
// Requirements:
// - Project selector at top if user has multiple projects
// - Stats Cards: total feedback, open items, planned features, completed
// - Feedback Table with columns: title, category Badge, status Badge, upvotes count, submitter, date
// - Clicking a row opens a Sheet (slide from right) with full feedback detail:
//   - Title, description, submitter info
//   - Status change via Select dropdown (new, under_review, planned, in_progress, completed, declined)
//   - Priority slider or Select (low, medium, high, critical)
//   - Comment thread showing all comments with Avatar, name, body, and "Team" Badge for is_team_reply=true
//   - Textarea + Button for adding a team reply (is_team_reply set to true)
// - Quick status change via Popover on the status Badge in the table
// - Filter by category using Tabs, sort by upvotes or date
// - Use Card for stats and Separator between sections
```

**Expected result:** The admin dashboard shows all feedback with stats. Clicking an item opens a detail Sheet with status controls and a team reply thread.

### 5. Create the embeddable widget snippet

Build a simple JavaScript snippet that customers add to their website. It injects an iframe pointing to your feedback form, using postMessage for cross-origin communication.

```
// Paste this prompt into V0's AI chat:
// Build the embeddable feedback widget system.
// 1. Create app/widget/[widget_key]/page.tsx — a minimal 'use client' page with:
//    - A floating Button (bottom-right, fixed) labeled "Feedback" with a message icon
//    - Clicking it expands to show a compact form: title Input, description Textarea, category Select, optional email Input
//    - Submit POSTs to /api/widget with the widget_key
//    - Show success message after submission
//    - Styled with Tailwind in a compact card layout suitable for iframe embedding
//    - Use minimal styles so it looks good as a small widget
// 2. Create app/api/embed/route.ts that returns a JavaScript snippet:
//    - The snippet creates an iframe pointing to your-domain.com/widget/{widget_key}
//    - Positions it fixed bottom-right with z-index 9999
//    - Starts collapsed (just a button), expands on click
//    - Customers add this script tag to their HTML: <script src="your-domain.com/api/embed?key=WIDGET_KEY"></script>
// Set NEXT_PUBLIC_APP_URL in Vars tab to the production domain for the iframe src.
```

**Expected result:** Customers can add a single script tag to their website. It shows a feedback button that expands into a submission form embedded in an iframe.

## Complete code example

File: `app/api/widget/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!
)

const schema = z.object({
  widget_key: z.string().uuid(),
  title: z.string().min(5).max(100),
  description: z.string().min(10).max(500),
  category: z.enum(['feature', 'bug', 'improvement', 'question']),
  submitter_email: z.string().email().optional(),
  submitter_name: z.string().max(100).optional(),
})

export async function POST(req: NextRequest) {
  const origin = req.headers.get('origin')
  const body = await req.json()
  const parsed = schema.safeParse(body)

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

  const { data: project } = await supabase
    .from('projects')
    .select('id, domain')
    .eq('widget_key', parsed.data.widget_key)
    .single()

  if (!project) {
    return NextResponse.json(
      { error: 'Invalid widget key' },
      { status: 403 }
    )
  }

  if (project.domain && origin && !origin.includes(project.domain)) {
    return NextResponse.json(
      { error: 'Origin not allowed' },
      { status: 403 }
    )
  }

  await supabase.from('feedback_items').insert({
    project_id: project.id,
    title: parsed.data.title,
    description: parsed.data.description,
    category: parsed.data.category,
    submitter_email: parsed.data.submitter_email,
    submitter_name: parsed.data.submitter_name,
  })

  const corsHeaders = {
    'Access-Control-Allow-Origin': origin ?? '*',
    'Access-Control-Allow-Methods': 'POST, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type',
  }

  return NextResponse.json({ success: true }, { headers: corsHeaders })
}

export async function OPTIONS(req: NextRequest) {
  return NextResponse.json({}, {
    headers: {
      'Access-Control-Allow-Origin': req.headers.get('origin') ?? '*',
      'Access-Control-Allow-Methods': 'POST, OPTIONS',
      'Access-Control-Allow-Headers': 'Content-Type',
    },
  })
}
```

## Common mistakes

- **Not validating the Origin header in the widget API endpoint** — Without origin validation, anyone can submit feedback to your project from any website, leading to spam and abuse. Fix: Store the allowed domain in the projects table and check the request Origin header against it. Return 403 if the origin doesn't match.
- **Allowing duplicate upvotes by not enforcing the unique constraint** — Without a unique(feedback_id, user_id) constraint, users can upvote multiple times by spamming the button. Fix: Add the unique constraint in the database and use upsert with onConflict in the Server Action. Toggle the upvote by checking if it exists first.
- **Incrementing upvote_count without using atomic operations** — A read-then-increment pattern loses counts when multiple users upvote simultaneously. Fix: Use a Supabase RPC function that atomically increments or decrements upvote_count in a single SQL statement.

## Best practices

- Validate the Origin header in the widget API to prevent cross-origin abuse from unauthorized domains.
- Use a unique(feedback_id, user_id) constraint on upvotes to prevent duplicate votes at the database level.
- Implement optimistic UI for upvote toggling — update the count instantly and revert on error.
- Use Design Mode (Option+D) to style the public board and widget to match your product's brand colors for free.
- Set NEXT_PUBLIC_SUPABASE_ANON_KEY for the public board's client-side operations and SUPABASE_SERVICE_ROLE_KEY for the widget API.
- Set NEXT_PUBLIC_APP_URL to the production domain so widget iframe src URLs are correct after deployment.

## Frequently asked questions

### How does the embeddable widget work?

You add a script tag to your website that injects a small iframe pointing to your feedback form. The iframe is positioned as a floating button in the bottom-right corner. When users click it, the form expands. Submissions go through the /api/widget endpoint with Origin validation.

### Can I prevent spam feedback from the widget?

Yes. The API validates the Origin header against the project's registered domain. You can also add rate limiting per IP address and require email verification for widget submissions.

### How does upvoting work without creating an account?

Authenticated users can upvote with a unique(feedback_id, user_id) constraint preventing duplicates. For anonymous users, you can track upvotes by session ID stored in a cookie.

### Can I customize the widget's appearance?

Yes. The widget page at /widget/[key] is a separate Next.js page you can style. Use Design Mode (Option+D) to adjust colors, fonts, and layout. Match your product's branding for a seamless experience.

### How do I deploy the widget?

Publish to Vercel via V0's Share menu. Set NEXT_PUBLIC_APP_URL to your production domain in the Vars tab. Give customers the script tag with their unique widget_key to embed on their site.

### Can RapidDev help build a custom feedback tool?

Yes. RapidDev has built 600+ apps including product feedback platforms with AI-powered categorization, Slack integrations, and public roadmap views. Book a free consultation to discuss your requirements.

### What V0 plan do I need?

Premium ($20/month) is recommended for the widget and board components. Free tier can build the basic feedback list but may need manual coding for the embeddable widget system.

---

Source: https://www.rapidevelopers.com/how-to-build-v0/feedback-collection-tool
© RapidDev — https://www.rapidevelopers.com/how-to-build-v0/feedback-collection-tool
