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
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
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.
1-- Run in Supabase SQL Editor2create 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);1213alter table public.feedback enable row level security;1415-- Anyone can insert16create policy "anon_insert" on public.feedback17 for insert to anon with check (true);1819-- Authenticated admins can read and update20create policy "admin_select" on public.feedback21 for select to authenticated using (true);2223create policy "admin_update" on public.feedback24 for update to authenticated using (true);2526-- Rate limiting table27create 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.
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.
1// Lovable prompt — paste into chat2// 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.
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.
1// src/components/FeedbackForm.tsx2import { 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'1213const 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})1819type FormValues = z.infer<typeof schema>2021export function FeedbackForm() {22 const form = useForm<FormValues>({ resolver: zodResolver(schema) })2324 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 return29 }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 }3637 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.
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().
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'1112type Feedback = { id: string; type: string; title: string; votes: number; status: string; created_at: string }1314const statusColors: Record<string, 'default' | 'secondary' | 'outline'> = {15 new: 'default', 'in-progress': 'secondary', done: 'outline'16}1718export function AdminPage() {19 const [rows, setRows] = useState<Feedback[]>([])20 const [tab, setTab] = useState('all')2122 useEffect(() => { supabase.from('feedback').select('*').order('created_at', { ascending: false }).then(({ data }) => setRows(data ?? [])) }, [])2324 const filtered = tab === 'all' ? rows : rows.filter(r => r.status === tab)2526 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 ]4041 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.
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.
1// supabase/functions/notify-slack/index.ts2import { serve } from 'https://deno.land/std@0.177.0/http/server.ts'34serve(async (req: Request) => {5 const payload = await req.json()6 const record = payload.record7 const webhookUrl = Deno.env.get('SLACK_WEBHOOK_URL')8 if (!webhookUrl) return new Response('Missing webhook', { status: 500 })910 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
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, SelectValue11} from '@/components/ui/select'12import {13 Form, FormControl, FormField,14 FormItem, FormLabel, FormMessage15} from '@/components/ui/form'16import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'17import { toast } from 'sonner'1819const 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>2526export function FeedbackForm() {27 const form = useForm<FormValues>({ resolver: zodResolver(schema) })2829 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 return34 }35 const { error } = await supabase.from('feedback').insert(values)36 if (error) {37 toast.error('Submission failed. Please try again.')38 return39 }40 localStorage.setItem('fb_last', String(Date.now()))41 toast.success('Thanks for your feedback!')42 form.reset()43 }4445 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.
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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation