# How to Build a Feedback Collection Tool with Lovable

- Tool: How to Build with Lovable
- Difficulty: Beginner
- Compatibility: Lovable (any plan), Supabase Free tier, Slack workspace
- Last updated: April 2026

## TL;DR

Build an embeddable feedback widget in Lovable that lets anyone submit bugs, feature requests, or improvements anonymously. Submissions are stored in Supabase with rate limiting to prevent spam. Admins filter by status using Tabs and DataTable, and every new submission triggers a Slack notification via Edge Function.

## Before you start

- Lovable account (Free plan works)
- Supabase project created at supabase.com
- Slack workspace with an Incoming Webhook URL configured
- Basic familiarity with Lovable's chat prompt interface
- Supabase project URL and anon key ready to paste

## Step-by-step guide

### 1. Create the database schema in Supabase

Open your Supabase project, go to the SQL Editor, and run the schema below. It creates a feedback table with type, status, vote count, and an IP hash column for rate limiting. RLS is enabled so anonymous users can only insert and upvote, while authenticated admins can read and update everything.

```
-- Run in Supabase SQL Editor
create table public.feedback (
  id uuid primary key default gen_random_uuid(),
  type text not null check (type in ('bug', 'feature', 'improvement')),
  title text not null,
  description text,
  status text not null default 'new' check (status in ('new', 'in-progress', 'done')),
  votes integer not null default 0,
  ip_hash text,
  created_at timestamptz not null default now()
);

alter table public.feedback enable row level security;

-- Anyone can insert
create policy "anon_insert" on public.feedback
  for insert to anon with check (true);

-- Authenticated admins can read and update
create policy "admin_select" on public.feedback
  for select to authenticated using (true);

create policy "admin_update" on public.feedback
  for update to authenticated using (true);

-- Rate limiting table
create table public.feedback_rate_limit (
  ip_hash text primary key,
  count integer not null default 1,
  window_start timestamptz not null default now()
);
```

> Pro tip: Add a unique index on (ip_hash, created_at::date) if you want to limit one submission per IP per day instead of per window.

**Expected result:** Two tables appear in your Supabase Table Editor: feedback and feedback_rate_limit. RLS is shown as enabled on feedback.

### 2. Connect Supabase to Lovable and scaffold the project

In Lovable, open the Cloud tab, go to the Database section, and paste your Supabase URL and anon key. Then send the prompt below to generate the full project structure including the feedback form and admin dashboard.

```
// Lovable prompt — paste into chat
// Create a feedback collection app with Supabase.
// Tables: feedback (id, type, title, description, status, votes, ip_hash, created_at).
// Pages:
//   1. /feedback — public form: Select for type (bug/feature/improvement),
//      Input for title, Textarea for description, Submit button.
//      Use React Hook Form + Zod. On submit call supabase.from('feedback').insert().
//   2. /admin — protected by Supabase Auth.
//      Tabs: All | New | In Progress | Done.
//      DataTable with columns: type (Badge), title, votes, status (Badge), created_at, Actions.
//      Actions: DropdownMenu with status change options.
// Use shadcn/ui throughout. Show Sonner toast on successful submit.
```

> Pro tip: Switch to Plan Mode first (toggle in the Lovable top bar) to review the architecture before Lovable writes any code. Click 'Implement the plan' only after you're happy with the structure.

**Expected result:** Lovable generates /feedback and /admin routes, a Supabase client file, form component, and DataTable. Preview shows the feedback form rendering in the right panel.

### 3. Build the public feedback form with rate limiting

Add client-side rate limiting by storing the last submission timestamp in localStorage and checking it before allowing another insert. The form uses shadcn Select for type, Input for title, and Textarea for description.

```
// src/components/FeedbackForm.tsx
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { supabase } from '@/lib/supabase'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
import { toast } from 'sonner'

const schema = z.object({
  type: z.enum(['bug', 'feature', 'improvement']),
  title: z.string().min(5, 'Title must be at least 5 characters').max(120),
  description: z.string().max(600).optional()
})

type FormValues = z.infer<typeof schema>

export function FeedbackForm() {
  const form = useForm<FormValues>({ resolver: zodResolver(schema) })

  async function onSubmit(values: FormValues) {
    const lastSubmit = localStorage.getItem('fb_last')
    if (lastSubmit && Date.now() - Number(lastSubmit) < 60_000) {
      toast.error('Please wait 60 seconds before submitting again.')
      return
    }
    const { error } = await supabase.from('feedback').insert(values)
    if (error) { toast.error('Submission failed. Try again.'); return }
    localStorage.setItem('fb_last', String(Date.now()))
    toast.success('Thanks for your feedback!')
    form.reset()
  }

  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 max-w-lg">
        <FormField control={form.control} name="type" render={({ field }) => (
          <FormItem>
            <FormLabel>Type</FormLabel>
            <Select onValueChange={field.onChange}>
              <FormControl><SelectTrigger><SelectValue placeholder="Select type" /></SelectTrigger></FormControl>
              <SelectContent>
                <SelectItem value="bug">Bug</SelectItem>
                <SelectItem value="feature">Feature Request</SelectItem>
                <SelectItem value="improvement">Improvement</SelectItem>
              </SelectContent>
            </Select>
            <FormMessage />
          </FormItem>
        )} />
        <FormField control={form.control} name="title" render={({ field }) => (
          <FormItem>
            <FormLabel>Title</FormLabel>
            <FormControl><Input placeholder="Short summary" {...field} /></FormControl>
            <FormMessage />
          </FormItem>
        )} />
        <Button type="submit" disabled={form.formState.isSubmitting}>Submit Feedback</Button>
      </form>
    </Form>
  )
}
```

**Expected result:** The feedback form renders with a working type selector, title input, and submit button. Submitting once saves a row in Supabase; submitting again within 60 seconds shows the rate limit toast.

### 4. Create the admin dashboard with Tabs and DataTable

The admin page fetches all feedback rows, filters them by the active Tab, and renders a DataTable with status badge and actions dropdown. Status updates are done with supabase.from('feedback').update().

```
// src/pages/Admin.tsx (key section)
import { useEffect, useState } from 'react'
import { supabase } from '@/lib/supabase'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
import { DataTable } from '@/components/ui/data-table'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { MoreHorizontal } from 'lucide-react'
import type { ColumnDef } from '@tanstack/react-table'

type Feedback = { id: string; type: string; title: string; votes: number; status: string; created_at: string }

const statusColors: Record<string, 'default' | 'secondary' | 'outline'> = {
  new: 'default', 'in-progress': 'secondary', done: 'outline'
}

export function AdminPage() {
  const [rows, setRows] = useState<Feedback[]>([])
  const [tab, setTab] = useState('all')

  useEffect(() => { supabase.from('feedback').select('*').order('created_at', { ascending: false }).then(({ data }) => setRows(data ?? [])) }, [])

  const filtered = tab === 'all' ? rows : rows.filter(r => r.status === tab)

  const columns: ColumnDef<Feedback>[] = [
    { accessorKey: 'type', header: 'Type', cell: ({ row }) => <Badge variant="outline">{row.original.type}</Badge> },
    { accessorKey: 'title', header: 'Title' },
    { accessorKey: 'votes', header: 'Votes' },
    { accessorKey: 'status', header: 'Status', cell: ({ row }) => <Badge variant={statusColors[row.original.status]}>{row.original.status}</Badge> },
    { id: 'actions', cell: ({ row }) => (
      <DropdownMenu>
        <DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
        <DropdownMenuContent>
          {['new','in-progress','done'].map(s => <DropdownMenuItem key={s} onClick={() => supabase.from('feedback').update({ status: s }).eq('id', row.original.id).then(() => setRows(prev => prev.map(r => r.id === row.original.id ? { ...r, status: s } : r)))}>{s}</DropdownMenuItem>)}
        </DropdownMenuContent>
      </DropdownMenu>
    )}
  ]

  return (
    <div className="p-6">
      <Tabs value={tab} onValueChange={setTab}>
        <TabsList><TabsTrigger value="all">All</TabsTrigger><TabsTrigger value="new">New</TabsTrigger><TabsTrigger value="in-progress">In Progress</TabsTrigger><TabsTrigger value="done">Done</TabsTrigger></TabsList>
        <TabsContent value={tab}><DataTable columns={columns} data={filtered} /></TabsContent>
      </Tabs>
    </div>
  )
}
```

> Pro tip: Add a realtime subscription with supabase.channel('feedback').on('postgres_changes', ...) so the admin table updates live without refreshing.

**Expected result:** Admin page loads all feedback rows. Switching tabs filters the DataTable. Clicking a status option in the Actions dropdown immediately updates the badge without a page reload.

### 5. Add Slack notifications via Supabase Edge Function

In Lovable's Cloud tab, go to Edge Functions and create a new function called notify-slack. Paste the code below, then add your SLACK_WEBHOOK_URL to Secrets. Finally, create a Supabase Database Webhook that calls this function on every INSERT into the feedback table.

```
// supabase/functions/notify-slack/index.ts
import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'

serve(async (req: Request) => {
  const payload = await req.json()
  const record = payload.record
  const webhookUrl = Deno.env.get('SLACK_WEBHOOK_URL')
  if (!webhookUrl) return new Response('Missing webhook', { status: 500 })

  const text = `:loudspeaker: *New ${record.type} feedback*\n*${record.title}*\nStatus: ${record.status}`
  await fetch(webhookUrl, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ text })
  })
  return new Response('ok', { status: 200 })
})
```

> Pro tip: In Supabase dashboard, go to Database → Webhooks, create a new webhook on the feedback table for INSERT events, and point it to your Edge Function URL (found in the Edge Functions section of your Supabase project).

**Expected result:** After submitting a test feedback entry, a Slack message appears in your configured channel within 2-3 seconds containing the feedback type and title.

## Complete code example

File: `src/components/FeedbackForm.tsx`

```typescript
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { supabase } from '@/lib/supabase'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import {
  Select, SelectContent, SelectItem,
  SelectTrigger, SelectValue
} from '@/components/ui/select'
import {
  Form, FormControl, FormField,
  FormItem, FormLabel, FormMessage
} from '@/components/ui/form'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { toast } from 'sonner'

const schema = z.object({
  type: z.enum(['bug', 'feature', 'improvement']),
  title: z.string().min(5).max(120),
  description: z.string().max(600).optional()
})
type FormValues = z.infer<typeof schema>

export function FeedbackForm() {
  const form = useForm<FormValues>({ resolver: zodResolver(schema) })

  async function onSubmit(values: FormValues) {
    const lastSubmit = localStorage.getItem('fb_last')
    if (lastSubmit && Date.now() - Number(lastSubmit) < 60_000) {
      toast.error('Please wait 60 seconds before submitting again.')
      return
    }
    const { error } = await supabase.from('feedback').insert(values)
    if (error) {
      toast.error('Submission failed. Please try again.')
      return
    }
    localStorage.setItem('fb_last', String(Date.now()))
    toast.success('Thanks for your feedback!')
    form.reset()
  }

  return (
    <Card className="max-w-lg mx-auto mt-10">
      <CardHeader>
        <CardTitle>Share your feedback</CardTitle>
      </CardHeader>
      <CardContent>
        <Form {...form}>
          <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
            <FormField control={form.control} name="type" render={({ field }) => (
              <FormItem>
                <FormLabel>Type</FormLabel>
                <Select onValueChange={field.onChange} defaultValue={field.value}>
                  <FormControl>
                    <SelectTrigger><SelectValue placeholder="Select type" /></SelectTrigger>
                  </FormControl>
                  <SelectContent>
                    <SelectItem value="bug">Bug report</SelectItem>
                    <SelectItem value="feature">Feature request</SelectItem>
                    <SelectItem value="improvement">Improvement</SelectItem>
                  </SelectContent>
                </Select>
                <FormMessage />
              </FormItem>
            )} />
            <FormField control={form.control} name="title" render={({ field }) => (
              <FormItem>
                <FormLabel>Title</FormLabel>
                <FormControl>
                  <Input placeholder="One-line summary" {...field} />
                </FormControl>
                <FormMessage />
              </FormItem>
            )} />
            <FormField control={form.control} name="description" render={({ field }) => (
              <FormItem>
                <FormLabel>Details (optional)</FormLabel>
                <FormControl>
                  <Textarea placeholder="Steps to reproduce, context..." {...field} />
                </FormControl>
                <FormMessage />
              </FormItem>
            )} />
            <Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
              {form.formState.isSubmitting ? 'Submitting...' : 'Submit Feedback'}
            </Button>
          </form>
        </Form>
      </CardContent>
    </Card>
  )
}
```

## Common mistakes

- **Forgetting to enable anonymous inserts in RLS** — Supabase blocks all operations by default once RLS is enabled. Without an anon insert policy, every public form submission silently fails. Fix: Run the SQL policy: CREATE POLICY "anon_insert" ON public.feedback FOR INSERT TO anon WITH CHECK (true); in Supabase SQL Editor.
- **Putting the Slack webhook URL directly in the React code** — The URL is visible in browser DevTools and can be scraped to spam your Slack channel. Fix: Store SLACK_WEBHOOK_URL in Lovable's Cloud tab → Secrets and access it only inside the Supabase Edge Function via Deno.env.get('SLACK_WEBHOOK_URL').
- **Not resetting the form after successful submission** — Users see stale values in the form fields and may accidentally re-submit the same feedback. Fix: Call form.reset() inside the onSubmit handler after a successful Supabase insert.
- **Using .insert() without awaiting and checking for errors** — RLS violations and network errors return an error object, not a thrown exception. Ignoring it means you silently lose submissions. Fix: Always destructure { data, error } from the insert call and display a toast if error is truthy.
- **Assuming Lovable's preview URL is the production URL** — The preview iframe uses a different origin. Supabase Auth and some cookies behave differently there. Fix: Click the Publish icon in Lovable top-right to get a stable production URL before testing auth flows.

## Best practices

- Always enable RLS on every Supabase table and write the minimum necessary policies — start locked down, then open up.
- Store all third-party keys (Slack, email services) in Lovable's Cloud tab → Secrets, never in source code or environment variables visible in the UI.
- Use Zod schemas that match your database column constraints so validation errors surface in the form before hitting Supabase.
- Add optimistic UI updates in the admin DataTable (update state immediately) so status changes feel instant, then confirm with the Supabase response.
- Create a separate admin Supabase user role rather than using the service role key in your frontend — authenticated policies are more auditable.
- Rate limit at both the client (localStorage timestamp) and server (feedback_rate_limit table) layers for defense in depth.
- Use Supabase Realtime on the admin page so the DataTable updates live when new feedback arrives without requiring a manual refresh.
- Test your Edge Function locally in Lovable's Cloud tab → Logs before connecting the database webhook to avoid silent notification failures.

## Frequently asked questions

### Can users submit feedback without creating an account?

Yes. The Supabase RLS policy grants anonymous inserts, so the public form works without any authentication. Only the /admin route requires a logged-in Supabase user.

### How do I prevent spam submissions?

Two layers: a localStorage timestamp check on the client blocks rapid re-submissions from the same browser session, and the feedback_rate_limit table lets you enforce server-side limits per IP hash inside an Edge Function if needed.

### How do I deploy this to a custom domain?

Click the Publish icon in Lovable's top-right corner, then open Settings → Custom Domains on a Pro plan. Point your domain's CNAME to the provided Lovable hostname and SSL is provisioned automatically.

### Can I embed this widget on another website?

Yes — publish the /feedback page and add an iframe pointing to its URL in any HTML page. Adjust the iframe height and border styling to match your site design.

### The Slack notifications stopped working. What do I check?

First verify SLACK_WEBHOOK_URL is set correctly in Lovable's Cloud tab → Secrets. Then check the Edge Function logs in your Supabase project under Edge Functions → Logs for any Deno errors. Finally confirm the database webhook in Supabase Dashboard → Database → Webhooks is still active.

### How do I add more admins?

In Supabase Dashboard, go to Authentication → Users and invite new users by email. They will be able to sign in to the /admin route automatically because the authenticated RLS policies apply to all logged-in users.

### Can RapidDev help me extend this into a full product feedback platform?

Yes — RapidDev specializes in building on top of Lovable projects. If you need custom features like user-linked feedback, public voting boards, or integrations with Linear or Jira, reach out through rapiddev.io.

### Why does status update in the DataTable not persist after refresh?

Make sure the DropdownMenuItem onClick handler both calls supabase.from('feedback').update() and updates local React state with setRows(). If you only update state, the change is lost on next fetch. If you only call Supabase, the UI lags until the next reload.

---

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