Skip to main content
RapidDev - Software Development Agency

How to Build a Feedback Collection Tool with Lovable

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.

What you'll build

  • Public feedback form with type selector (bug, feature, improvement)
  • Anonymous insert with RLS — no login required to submit
  • Admin dashboard with Tabs filtering by status (new, in-progress, done)
  • DataTable with sorting, search, and status update actions
  • Badge-coded status labels and vote counter per submission
  • Slack notification sent from Supabase Edge Function on each submission
  • Rate limiting by IP hash to block spam submissions
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Beginner13 min read2-3 hoursLovable (any plan), Supabase Free tier, Slack workspaceApril 2026RapidDev Engineering Team
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.

What you're building

A lightweight feedback collection tool that can be embedded on any page or shared as a standalone link. Anyone can submit feedback without creating an account. Admins log in to triage submissions, update statuses, and see vote counts. A Supabase Edge Function fires on each insert to post a Slack message, keeping your team informed in real time.

Final result

A live feedback portal at your Lovable URL where users submit feedback anonymously, admins manage it through a protected dashboard, and your Slack channel receives instant alerts for every new entry.

Tech stack

LovableAI-assisted UI and project scaffolding
SupabasePostgreSQL database, RLS policies, Edge Functions
shadcn/uiDataTable, Tabs, Badge, Select, Form, Dialog components
React Hook Form + ZodForm validation
Slack Incoming WebhooksNew submission notifications

Prerequisites

  • 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

Build steps

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.

supabase_schema.sql
1-- Run in Supabase SQL Editor
2create table public.feedback (
3 id uuid primary key default gen_random_uuid(),
4 type text not null check (type in ('bug', 'feature', 'improvement')),
5 title text not null,
6 description text,
7 status text not null default 'new' check (status in ('new', 'in-progress', 'done')),
8 votes integer not null default 0,
9 ip_hash text,
10 created_at timestamptz not null default now()
11);
12
13alter table public.feedback enable row level security;
14
15-- Anyone can insert
16create policy "anon_insert" on public.feedback
17 for insert to anon with check (true);
18
19-- Authenticated admins can read and update
20create policy "admin_select" on public.feedback
21 for select to authenticated using (true);
22
23create policy "admin_update" on public.feedback
24 for update to authenticated using (true);
25
26-- Rate limiting table
27create table public.feedback_rate_limit (
28 ip_hash text primary key,
29 count integer not null default 1,
30 window_start timestamptz not null default now()
31);

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.

prompt.txt
1// Lovable prompt — paste into chat
2// Create a feedback collection app with Supabase.
3// Tables: feedback (id, type, title, description, status, votes, ip_hash, created_at).
4// Pages:
5// 1. /feedback — public form: Select for type (bug/feature/improvement),
6// Input for title, Textarea for description, Submit button.
7// Use React Hook Form + Zod. On submit call supabase.from('feedback').insert().
8// 2. /admin — protected by Supabase Auth.
9// Tabs: All | New | In Progress | Done.
10// DataTable with columns: type (Badge), title, votes, status (Badge), created_at, Actions.
11// Actions: DropdownMenu with status change options.
12// 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
1// src/components/FeedbackForm.tsx
2import { useForm } from 'react-hook-form'
3import { zodResolver } from '@hookform/resolvers/zod'
4import { z } from 'zod'
5import { supabase } from '@/lib/supabase'
6import { Button } from '@/components/ui/button'
7import { Input } from '@/components/ui/input'
8import { Textarea } from '@/components/ui/textarea'
9import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
10import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'
11import { toast } from 'sonner'
12
13const schema = z.object({
14 type: z.enum(['bug', 'feature', 'improvement']),
15 title: z.string().min(5, 'Title must be at least 5 characters').max(120),
16 description: z.string().max(600).optional()
17})
18
19type FormValues = z.infer<typeof schema>
20
21export function FeedbackForm() {
22 const form = useForm<FormValues>({ resolver: zodResolver(schema) })
23
24 async function onSubmit(values: FormValues) {
25 const lastSubmit = localStorage.getItem('fb_last')
26 if (lastSubmit && Date.now() - Number(lastSubmit) < 60_000) {
27 toast.error('Please wait 60 seconds before submitting again.')
28 return
29 }
30 const { error } = await supabase.from('feedback').insert(values)
31 if (error) { toast.error('Submission failed. Try again.'); return }
32 localStorage.setItem('fb_last', String(Date.now()))
33 toast.success('Thanks for your feedback!')
34 form.reset()
35 }
36
37 return (
38 <Form {...form}>
39 <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 max-w-lg">
40 <FormField control={form.control} name="type" render={({ field }) => (
41 <FormItem>
42 <FormLabel>Type</FormLabel>
43 <Select onValueChange={field.onChange}>
44 <FormControl><SelectTrigger><SelectValue placeholder="Select type" /></SelectTrigger></FormControl>
45 <SelectContent>
46 <SelectItem value="bug">Bug</SelectItem>
47 <SelectItem value="feature">Feature Request</SelectItem>
48 <SelectItem value="improvement">Improvement</SelectItem>
49 </SelectContent>
50 </Select>
51 <FormMessage />
52 </FormItem>
53 )} />
54 <FormField control={form.control} name="title" render={({ field }) => (
55 <FormItem>
56 <FormLabel>Title</FormLabel>
57 <FormControl><Input placeholder="Short summary" {...field} /></FormControl>
58 <FormMessage />
59 </FormItem>
60 )} />
61 <Button type="submit" disabled={form.formState.isSubmitting}>Submit Feedback</Button>
62 </form>
63 </Form>
64 )
65}

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
1// src/pages/Admin.tsx (key section)
2import { useEffect, useState } from 'react'
3import { supabase } from '@/lib/supabase'
4import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
5import { Badge } from '@/components/ui/badge'
6import { DataTable } from '@/components/ui/data-table'
7import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
8import { Button } from '@/components/ui/button'
9import { MoreHorizontal } from 'lucide-react'
10import type { ColumnDef } from '@tanstack/react-table'
11
12type Feedback = { id: string; type: string; title: string; votes: number; status: string; created_at: string }
13
14const statusColors: Record<string, 'default' | 'secondary' | 'outline'> = {
15 new: 'default', 'in-progress': 'secondary', done: 'outline'
16}
17
18export function AdminPage() {
19 const [rows, setRows] = useState<Feedback[]>([])
20 const [tab, setTab] = useState('all')
21
22 useEffect(() => { supabase.from('feedback').select('*').order('created_at', { ascending: false }).then(({ data }) => setRows(data ?? [])) }, [])
23
24 const filtered = tab === 'all' ? rows : rows.filter(r => r.status === tab)
25
26 const columns: ColumnDef<Feedback>[] = [
27 { accessorKey: 'type', header: 'Type', cell: ({ row }) => <Badge variant="outline">{row.original.type}</Badge> },
28 { accessorKey: 'title', header: 'Title' },
29 { accessorKey: 'votes', header: 'Votes' },
30 { accessorKey: 'status', header: 'Status', cell: ({ row }) => <Badge variant={statusColors[row.original.status]}>{row.original.status}</Badge> },
31 { id: 'actions', cell: ({ row }) => (
32 <DropdownMenu>
33 <DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
34 <DropdownMenuContent>
35 {['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>)}
36 </DropdownMenuContent>
37 </DropdownMenu>
38 )}
39 ]
40
41 return (
42 <div className="p-6">
43 <Tabs value={tab} onValueChange={setTab}>
44 <TabsList><TabsTrigger value="all">All</TabsTrigger><TabsTrigger value="new">New</TabsTrigger><TabsTrigger value="in-progress">In Progress</TabsTrigger><TabsTrigger value="done">Done</TabsTrigger></TabsList>
45 <TabsContent value={tab}><DataTable columns={columns} data={filtered} /></TabsContent>
46 </Tabs>
47 </div>
48 )
49}

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
1// supabase/functions/notify-slack/index.ts
2import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'
3
4serve(async (req: Request) => {
5 const payload = await req.json()
6 const record = payload.record
7 const webhookUrl = Deno.env.get('SLACK_WEBHOOK_URL')
8 if (!webhookUrl) return new Response('Missing webhook', { status: 500 })
9
10 const text = `:loudspeaker: *New ${record.type} feedback*\n*${record.title}*\nStatus: ${record.status}`
11 await fetch(webhookUrl, {
12 method: 'POST',
13 headers: { 'Content-Type': 'application/json' },
14 body: JSON.stringify({ text })
15 })
16 return new Response('ok', { status: 200 })
17})

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

src/components/FeedbackForm.tsx
1import { useForm } from 'react-hook-form'
2import { zodResolver } from '@hookform/resolvers/zod'
3import { z } from 'zod'
4import { supabase } from '@/lib/supabase'
5import { Button } from '@/components/ui/button'
6import { Input } from '@/components/ui/input'
7import { Textarea } from '@/components/ui/textarea'
8import {
9 Select, SelectContent, SelectItem,
10 SelectTrigger, SelectValue
11} from '@/components/ui/select'
12import {
13 Form, FormControl, FormField,
14 FormItem, FormLabel, FormMessage
15} from '@/components/ui/form'
16import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
17import { toast } from 'sonner'
18
19const schema = z.object({
20 type: z.enum(['bug', 'feature', 'improvement']),
21 title: z.string().min(5).max(120),
22 description: z.string().max(600).optional()
23})
24type FormValues = z.infer<typeof schema>
25
26export function FeedbackForm() {
27 const form = useForm<FormValues>({ resolver: zodResolver(schema) })
28
29 async function onSubmit(values: FormValues) {
30 const lastSubmit = localStorage.getItem('fb_last')
31 if (lastSubmit && Date.now() - Number(lastSubmit) < 60_000) {
32 toast.error('Please wait 60 seconds before submitting again.')
33 return
34 }
35 const { error } = await supabase.from('feedback').insert(values)
36 if (error) {
37 toast.error('Submission failed. Please try again.')
38 return
39 }
40 localStorage.setItem('fb_last', String(Date.now()))
41 toast.success('Thanks for your feedback!')
42 form.reset()
43 }
44
45 return (
46 <Card className="max-w-lg mx-auto mt-10">
47 <CardHeader>
48 <CardTitle>Share your feedback</CardTitle>
49 </CardHeader>
50 <CardContent>
51 <Form {...form}>
52 <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
53 <FormField control={form.control} name="type" render={({ field }) => (
54 <FormItem>
55 <FormLabel>Type</FormLabel>
56 <Select onValueChange={field.onChange} defaultValue={field.value}>
57 <FormControl>
58 <SelectTrigger><SelectValue placeholder="Select type" /></SelectTrigger>
59 </FormControl>
60 <SelectContent>
61 <SelectItem value="bug">Bug report</SelectItem>
62 <SelectItem value="feature">Feature request</SelectItem>
63 <SelectItem value="improvement">Improvement</SelectItem>
64 </SelectContent>
65 </Select>
66 <FormMessage />
67 </FormItem>
68 )} />
69 <FormField control={form.control} name="title" render={({ field }) => (
70 <FormItem>
71 <FormLabel>Title</FormLabel>
72 <FormControl>
73 <Input placeholder="One-line summary" {...field} />
74 </FormControl>
75 <FormMessage />
76 </FormItem>
77 )} />
78 <FormField control={form.control} name="description" render={({ field }) => (
79 <FormItem>
80 <FormLabel>Details (optional)</FormLabel>
81 <FormControl>
82 <Textarea placeholder="Steps to reproduce, context..." {...field} />
83 </FormControl>
84 <FormMessage />
85 </FormItem>
86 )} />
87 <Button type="submit" className="w-full" disabled={form.formState.isSubmitting}>
88 {form.formState.isSubmitting ? 'Submitting...' : 'Submit Feedback'}
89 </Button>
90 </form>
91 </Form>
92 </CardContent>
93 </Card>
94 )
95}

Customization ideas

Public voting board

Add a public /roadmap page that lists approved feedback items sorted by votes. Users click an upvote button which calls an RPC function to increment votes atomically.

Email digest

Schedule a Supabase Edge Function with pg_cron to send a weekly summary of new feedback to an admin email address using Resend or SendGrid.

Embeddable iframe widget

Publish the /feedback page and embed it in any external website or documentation using an HTML iframe tag pointing to your Lovable URL.

Category tagging

Add a tags text[] column and a multi-select component in the form so users can tag submissions with product areas like 'onboarding', 'billing', or 'API'.

Status email notifications

Collect an optional email on the form, store it in the feedback row, and trigger an Edge Function when status changes to 'done' to notify the original submitter.

CSV export

Add an Export button in the admin dashboard that queries all rows and downloads them as a CSV file using the browser's Blob API.

Common pitfalls

Pitfall: Forgetting to enable anonymous inserts in RLS

How to avoid: Run the SQL policy: CREATE POLICY "anon_insert" ON public.feedback FOR INSERT TO anon WITH CHECK (true); in Supabase SQL Editor.

Pitfall: Putting the Slack webhook URL directly in the React code

How to avoid: 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').

Pitfall: Not resetting the form after successful submission

How to avoid: Call form.reset() inside the onSubmit handler after a successful Supabase insert.

Pitfall: Using .insert() without awaiting and checking for errors

How to avoid: Always destructure { data, error } from the insert call and display a toast if error is truthy.

Pitfall: Assuming Lovable's preview URL is the production URL

How to avoid: 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.

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a feedback collection tool with React, Supabase, and shadcn/ui. I need a form component with type select (bug/feature/improvement), title input, and description textarea. The form should validate with Zod, insert to Supabase anonymously, show a Sonner toast on success, and enforce a 60-second client-side rate limit using localStorage. Show me the complete TypeScript component.

Lovable Prompt

Add a /roadmap page to my feedback app that fetches all feedback rows with status 'done' or 'in-progress', sorted by votes descending, and displays them in Cards with a Badge for type and an upvote Button that calls a Supabase RPC function to increment votes atomically. No auth required to view the roadmap.

Build Prompt

In my Lovable project, create a Supabase Edge Function at supabase/functions/notify-slack/index.ts that receives a database webhook payload on feedback INSERT, reads SLACK_WEBHOOK_URL from Deno.env, and posts a formatted Slack message with the feedback type, title, and submission time. Add SLACK_WEBHOOK_URL to my Secrets in the Cloud tab.

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.

RapidDev

Talk to an Expert

Our team has built 600+ apps. Get personalized help with your project.

Book a free consultation

Need help building your app?

Our experts have built 600+ apps and can accelerate your development. Book a free consultation — no strings attached.

Book a free consultation

We put the rapid in RapidDev

Need a dedicated strategic tech and growth partner? Discover what RapidDev can do for your business! Book a call with our team to schedule a free, no-obligation consultation. We'll discuss your project and provide a custom quote at no cost.