Build a full helpdesk support ticket system with Lovable and Supabase in 2–3 hours. You'll get a multi-queue DataTable, ticket detail Sheet with threaded messages, real-time updates, canned responses, priority Badges, and a claim/reassign workflow — all backed by Supabase with row-level security.
What you're building
A helpdesk system built in Lovable uses four Supabase tables: tickets, ticket_messages, categories, and canned_responses. RLS policies ensure agents only see tickets assigned to them or in their queue, while admins see everything. Supabase Realtime broadcasts new messages so the ticket detail view updates the moment a customer or colleague replies.
The main view is a DataTable with Tabs for queue filtering. An agent clicks Claim on an unassigned ticket to take ownership, or uses the Reassign dropdown to hand it to a colleague. The ticket detail opens in a Sheet panel on the right, showing the full message thread in a scrollable area with a reply composer at the bottom. Typing /canned opens a Command palette of saved responses the agent can insert with one click.
Priority is set by a Select on each ticket and shown as a color-coded Badge in the table. An SLA timer calculates how long the ticket has been open and turns red if the response deadline is approaching.
Final result
A team-ready helpdesk with queue management, real-time messaging, canned responses, and SLA visibility — deployed from Lovable in one click.
Tech stack
Prerequisites
- Lovable Pro account for the credits needed to build multi-step features
- Supabase project created at supabase.com (free tier works)
- Supabase URL and anon key ready to add to Cloud tab → Secrets
- A list of your support categories (e.g. Billing, Technical, Account)
- Optional: a few canned response texts to seed the database
Build steps
Generate the database schema and connect Supabase
Start with a single prompt that defines all four tables and their RLS policies. After Lovable generates the schema, go to Cloud tab → Secrets and add VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY from your Supabase project settings.
1Create a helpdesk support ticket system with Supabase. Set up these tables:23- tickets: id, org_id, subject, status (open|pending|resolved|closed), priority (urgent|high|normal|low), category_id, customer_email, customer_name, assigned_to (references auth.users), created_at, updated_at, resolved_at4- ticket_messages: id, ticket_id, author_id, author_type (agent|customer), body, is_internal (bool), created_at5- categories: id, org_id, name, color6- canned_responses: id, org_id, title, body, category_id78Enable RLS. Agents can read tickets where assigned_to = auth.uid() OR assigned_to IS NULL OR org_id matches their profile. Admins (role = 'admin' in profiles) can read all tickets in their org. All agents can INSERT ticket_messages on tickets they can read.Pro tip: Ask Lovable to also create a trigger function that automatically sets updated_at = now() on the tickets table whenever any column changes. This powers the SLA timer.
Expected result: Lovable generates the full schema SQL, TypeScript types, and the Supabase client. The preview shows a basic app shell.
Build the ticket queue DataTable with Tabs
Ask Lovable to build the main queue view. Tabs at the top switch between All, Open, Pending, and Resolved. The DataTable shows ticket subject, customer name, priority Badge, status Badge, assigned agent, and time since creation. Clicking a row opens the ticket detail Sheet.
1Build a ticket queue page at src/pages/Tickets.tsx.23Requirements:4- Use shadcn/ui Tabs with values: all, open, pending, resolved, closed5- Each tab filters the Supabase query by status6- DataTable columns: subject (clickable), customer_name, priority Badge (urgent=red, high=orange, normal=blue, low=gray), status Badge (open=green, pending=yellow, resolved=gray, closed=slate), assigned_to agent name (or 'Unassigned' with a Claim button), category name, time elapsed since created_at7- Sort by created_at descending by default8- Add a search input above the table that filters by subject or customer_name9- Clicking a row opens a Sheet component with the full ticket detail (build the Sheet as a separate TicketSheet component)10- Show total ticket count per tab as a Badge next to each tab labelPro tip: Ask Lovable to persist the active tab to localStorage so agents return to their last queue after a page refresh.
Expected result: A tabbed ticket queue renders with color-coded priority and status Badges. The count Badges on tabs show live totals. Clicking a row triggers the Sheet.
Build the ticket detail Sheet with threaded messages
Build the Sheet panel that shows the full ticket conversation. Messages from the agent and customer are visually separated, internal notes are highlighted differently, and the reply composer sits at the bottom with a canned response button.
1import { useEffect, useRef, useState } from 'react'2import { supabase } from '@/integrations/supabase/client'3import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'4import { Badge } from '@/components/ui/badge'5import { Button } from '@/components/ui/button'6import { Textarea } from '@/components/ui/textarea'7import { ScrollArea } from '@/components/ui/scroll-area'8import { Separator } from '@/components/ui/separator'9import { Switch } from '@/components/ui/switch'1011type Message = {12 id: string13 author_type: 'agent' | 'customer'14 body: string15 is_internal: boolean16 created_at: string17 author_id: string18}1920type Ticket = {21 id: string22 subject: string23 priority: string24 status: string25 customer_name: string26 customer_email: string27}2829interface TicketSheetProps {30 ticket: Ticket | null31 onClose: () => void32}3334export function TicketSheet({ ticket, onClose }: TicketSheetProps) {35 const [messages, setMessages] = useState<Message[]>([])36 const [reply, setReply] = useState('')37 const [isInternal, setIsInternal] = useState(false)38 const bottomRef = useRef<HTMLDivElement>(null)3940 useEffect(() => {41 if (!ticket) return42 supabase43 .from('ticket_messages')44 .select('*')45 .eq('ticket_id', ticket.id)46 .order('created_at', { ascending: true })47 .then(({ data }) => setMessages(data ?? []))4849 const channel = supabase50 .channel(`ticket-${ticket.id}`)51 .on('postgres_changes', {52 event: 'INSERT',53 schema: 'public',54 table: 'ticket_messages',55 filter: `ticket_id=eq.${ticket.id}`,56 }, (payload) => {57 setMessages((prev) => [...prev, payload.new as Message])58 })59 .subscribe()6061 return () => { supabase.removeChannel(channel) }62 }, [ticket])6364 useEffect(() => {65 bottomRef.current?.scrollIntoView({ behavior: 'smooth' })66 }, [messages])6768 async function sendReply() {69 if (!reply.trim() || !ticket) return70 await supabase.from('ticket_messages').insert({71 ticket_id: ticket.id,72 body: reply,73 is_internal: isInternal,74 author_type: 'agent',75 })76 setReply('')77 }7879 return (80 <Sheet open={!!ticket} onOpenChange={onClose}>81 <SheetContent className="w-[520px] flex flex-col">82 <SheetHeader>83 <SheetTitle className="text-base">{ticket?.subject}</SheetTitle>84 <div className="flex gap-2">85 <Badge variant="outline">{ticket?.priority}</Badge>86 <Badge>{ticket?.status}</Badge>87 </div>88 </SheetHeader>89 <Separator />90 <ScrollArea className="flex-1 pr-2">91 <div className="space-y-3 py-2">92 {messages.map((m) => (93 <div key={m.id} className={`rounded-lg p-3 text-sm ${94 m.is_internal ? 'bg-yellow-50 border border-yellow-200' :95 m.author_type === 'agent' ? 'bg-muted' : 'bg-primary/5'96 }`}>97 <p>{m.body}</p>98 </div>99 ))}100 <div ref={bottomRef} />101 </div>102 </ScrollArea>103 <Separator />104 <div className="space-y-2 pt-2">105 <Textarea value={reply} onChange={(e) => setReply(e.target.value)} placeholder="Write a reply..." className="min-h-[80px]" />106 <div className="flex items-center justify-between">107 <div className="flex items-center gap-2 text-sm">108 <Switch checked={isInternal} onCheckedChange={setIsInternal} id="internal" />109 <label htmlFor="internal">Internal note</label>110 </div>111 <Button onClick={sendReply} disabled={!reply.trim()}>Send Reply</Button>112 </div>113 </div>114 </SheetContent>115 </Sheet>116 )117}Expected result: The Sheet opens with the message thread. New messages from Realtime appear at the bottom. Sending a reply inserts to Supabase and appears instantly.
Add claim and reassign workflow
Unassigned tickets need a Claim button so agents can take ownership. Assigned tickets need a Reassign DropdownMenu so supervisors can shift workload. Both actions update the assigned_to column and add an internal activity note.
1Add claim and reassign functionality to the ticket queue and TicketSheet.23Requirements:4- In the DataTable, rows where assigned_to IS NULL show a 'Claim' Button. Clicking it sets assigned_to = auth.uid() and status = 'open' via supabase.from('tickets').update().5- In the TicketSheet header, add a DropdownMenu with the label showing the assigned agent's name or 'Unassigned'.6- The DropdownMenu lists all agents in the org (query profiles where role = 'agent' or 'admin').7- Selecting an agent updates assigned_to on the ticket.8- After any assignment change, insert an internal ticket_messages row with body = 'Ticket assigned to [agent name]' and is_internal = true.9- Show a Sonner toast: 'Ticket assigned to [name]' after each successful reassignment.Pro tip: Ask Lovable to also update the ticket status to 'pending' automatically when a ticket is reassigned, as a signal that the new agent needs to respond.
Expected result: Unassigned rows in the table show a Claim button. Claimed tickets show the agent name with a dropdown for reassignment. Every assignment is logged as an internal note.
Build the canned responses Command palette
Add a Command palette that agents trigger from the reply composer by clicking a button or pressing a slash key. It searches through canned_responses in Supabase and inserts the selected text into the reply Textarea.
1Add a canned responses feature to the TicketSheet reply area.23Requirements:4- Add a 'Canned Responses' button next to the Send button (use a MessageSquare icon)5- Clicking it opens a shadcn/ui Command dialog6- The Command input searches canned_responses by title in real time using Supabase .ilike('title', '%query%')7- Results are grouped by category name using CommandGroup8- Each item shows the response title and a preview of the first 60 characters of the body9- Selecting an item closes the Command and sets the reply Textarea value to the canned response body10- If the Textarea already has text, append the canned response after a newlineExpected result: Clicking the Canned Responses button opens a searchable Command palette. Selecting a response populates the reply Textarea instantly.
Complete code
1import { Badge } from '@/components/ui/badge'2import { cn } from '@/lib/utils'34type Priority = 'urgent' | 'high' | 'normal' | 'low'56interface PriorityBadgeProps {7 priority: Priority8 className?: string9}1011const priorityConfig: Record<Priority, { label: string; className: string }> = {12 urgent: {13 label: 'Urgent',14 className: 'bg-red-100 text-red-800 border-red-200 hover:bg-red-100',15 },16 high: {17 label: 'High',18 className: 'bg-orange-100 text-orange-800 border-orange-200 hover:bg-orange-100',19 },20 normal: {21 label: 'Normal',22 className: 'bg-blue-100 text-blue-800 border-blue-200 hover:bg-blue-100',23 },24 low: {25 label: 'Low',26 className: 'bg-gray-100 text-gray-700 border-gray-200 hover:bg-gray-100',27 },28}2930export function PriorityBadge({ priority, className }: PriorityBadgeProps) {31 const config = priorityConfig[priority] ?? priorityConfig.normal32 return (33 <Badge34 variant="outline"35 className={cn('text-xs font-medium', config.className, className)}36 >37 {config.label}38 </Badge>39 )40}4142type Status = 'open' | 'pending' | 'resolved' | 'closed'4344interface StatusBadgeProps {45 status: Status46 className?: string47}4849const statusConfig: Record<Status, { label: string; className: string }> = {50 open: { label: 'Open', className: 'bg-green-100 text-green-800 border-green-200 hover:bg-green-100' },51 pending: { label: 'Pending', className: 'bg-yellow-100 text-yellow-800 border-yellow-200 hover:bg-yellow-100' },52 resolved: { label: 'Resolved', className: 'bg-gray-100 text-gray-600 border-gray-200 hover:bg-gray-100' },53 closed: { label: 'Closed', className: 'bg-slate-100 text-slate-600 border-slate-200 hover:bg-slate-100' },54}5556export function StatusBadge({ status, className }: StatusBadgeProps) {57 const config = statusConfig[status] ?? statusConfig.open58 return (59 <Badge60 variant="outline"61 className={cn('text-xs font-medium', config.className, className)}62 >63 {config.label}64 </Badge>65 )66}Customization ideas
Customer-facing ticket portal
Add a public /tickets page where customers can submit new tickets without an account and track their existing tickets using a magic link sent to their email. Store the customer's email and a secure token in the tickets table for lookup.
SLA dashboard with breach alerts
Add a dashboard page that shows tickets approaching SLA breach (e.g. no response in 4 hours for urgent tickets). Use a Supabase scheduled Edge Function to check SLA status every 30 minutes and send email alerts to the assigned agent via Resend.
AI-powered ticket categorization
Add a Supabase Edge Function triggered on ticket INSERT that calls an LLM API with the ticket subject and first message body. The function returns a suggested category and priority, then updates the ticket automatically. Store the API key in Cloud tab → Secrets.
Ticket satisfaction rating
When a ticket moves to Resolved, trigger an Edge Function that emails the customer a one-click satisfaction rating link (1–5 stars). Store ratings in a ticket_ratings table and display average scores per agent in a leaderboard card on the dashboard.
Slack or Teams integration for new tickets
Add a Supabase Database Webhook on the tickets table that fires on INSERT and calls an Edge Function to post a Slack message to a #support channel. Include ticket subject, priority, and a link to open the ticket in the app.
Common pitfalls
Pitfall: Not adding Realtime cleanup in the ticket Sheet
How to avoid: Return a cleanup function from useEffect: return () => { supabase.removeChannel(channel) }. Make sure the ticket ID is in the dependency array so a new subscription forms when a different ticket opens.
Pitfall: Using author_id to display agent names without a join
How to avoid: Ask Lovable to join ticket_messages with profiles on author_id = profiles.id to fetch the full_name. Or store a denormalized author_name on insert for simpler queries.
Pitfall: Forgetting internal notes are visible to customers if RLS is not set
How to avoid: Add an RLS policy on ticket_messages: customer-facing queries must include AND is_internal = false. Or create a separate Supabase view that pre-filters internal notes for the customer portal.
Pitfall: Claim button race condition with multiple agents
How to avoid: Ask Lovable to use a conditional update: supabase.from('tickets').update({ assigned_to: userId }).eq('id', ticketId).is('assigned_to', null). If the update returns 0 rows affected, show a toast: 'Another agent already claimed this ticket.'
Best practices
- Enable Realtime only on the ticket_messages table, not on all tables. Limiting Realtime subscriptions reduces Supabase bandwidth usage on the free plan.
- Use is_internal flag to separate agent notes from customer-visible replies. This avoids needing a separate internal_notes table and simplifies the message thread query.
- Store canned_responses in Supabase, not hardcoded in the frontend. This lets support managers add or edit responses from the Supabase Table Editor without a code deploy.
- Add a closed_at timestamp to tickets rather than relying only on status. This enables accurate SLA time-to-resolution reports via SQL aggregates.
- Use TanStack Table's server-side pagination for queues larger than 100 tickets. Fetch rows with .range(from, to) and pass the count from select('*', { count: 'exact' }).
- Add a ticket number sequence (auto-increment integer) separate from the UUID id. Agents and customers can reference TKT-1042 verbally much more easily than a UUID.
- Log every status and assignment change as an internal ticket_messages row. This creates a full audit trail without a separate audit_log table.
AI prompts to try
Copy these prompts to build this project faster.
I have a support ticket system in Lovable with these Supabase tables: tickets (id, subject, status, priority, assigned_to, org_id), ticket_messages (id, ticket_id, body, author_type, is_internal, created_at), canned_responses (id, title, body, category_id). Write me TypeScript types for all three tables, a custom hook useTicketMessages(ticketId) that fetches messages and subscribes to Supabase Realtime, and a sendReply(ticketId, body, isInternal) function that inserts a message and updates ticket.updated_at.
Add a ticket analytics page at /analytics. Show four metric Cards at the top: Total Open Tickets, Average Response Time (in hours), Tickets Resolved Today, and Customer Satisfaction Score (from a ticket_ratings table). Below, show a bar chart using Recharts that plots tickets resolved per day for the last 14 days, grouped by priority (urgent, high, normal, low as stacked bars). Fetch all data from Supabase with appropriate aggregation queries.
In Supabase, create a Database Webhook on the tickets table for INSERT events. The webhook should call my Edge Function at /functions/v1/notify-new-ticket. Write the Edge Function code (Deno) that receives the webhook payload, fetches the assigned agent's email from the profiles table, and sends an email via the Resend API. The email subject should be 'New ticket assigned: [subject]'. Store the Resend API key as RESEND_API_KEY in Supabase Secrets.
Frequently asked questions
Can customers submit tickets without creating an account?
Yes. You can use Supabase anonymous auth or a public API endpoint via an Edge Function. Ask Lovable to create a public ticket submission form that calls an Edge Function with the service role key to bypass RLS and insert the ticket. The Edge Function validates the email and subject before inserting, so unauthenticated users can submit but not read other tickets.
How do I set up email notifications when a new ticket arrives?
Create a Supabase Database Webhook on the tickets table for INSERT events pointing to a Supabase Edge Function. The function fetches the assigned agent's email from your profiles table and calls the Resend or SendGrid API. Store your email provider key in Cloud tab → Secrets as RESEND_API_KEY. Ask Lovable to scaffold this Edge Function and it will generate the full Deno code.
Will Supabase Realtime on ticket messages work at scale?
Supabase free tier supports up to 200 concurrent Realtime connections. For a small to medium support team this is plenty. If you have many simultaneous open ticket threads, scope each subscription to a specific ticket_id using the filter option: .filter('ticket_id=eq.YOUR_ID'). This reduces the payload size and number of events each client processes.
Can I merge duplicate tickets?
Ask Lovable to add a merge feature: a Merge button in the ticket detail that opens a search Dialog for finding duplicate tickets. When you confirm a merge, an Edge Function marks the duplicate as closed, adds an internal note linking to the primary ticket, and moves all messages from the duplicate to the primary ticket by updating their ticket_id.
How do I prevent agents from seeing each other's ticket queues?
Add an RLS SELECT policy: tickets WHERE assigned_to = auth.uid() OR (assigned_to IS NULL AND org_id = ...). This lets agents claim unassigned tickets and see only their own. Admins get a separate policy: tickets WHERE org_id = (SELECT org_id FROM profiles WHERE id = auth.uid()) with no assigned_to restriction.
Can I add file attachments to ticket replies?
Yes. Ask Lovable to add file upload support using Supabase Storage. Create a private bucket called ticket-attachments. On message insert, upload the file to storage/ticket-attachments/ticketId/filename and store the storage path in a ticket_attachments table. Display attachments in the message thread as clickable links that generate a signed URL on demand.
Is there a service that can help build a custom helpdesk in Lovable?
RapidDev builds production-grade Lovable apps, including complex helpdesks with custom SLA rules, multi-channel intake, and CRM integrations. Get in touch if you need something beyond what you can prompt yourself.
How do I deploy this so customers can access the submission form?
Click the Publish icon (top-right in Lovable) to deploy the app to a public URL. For a custom domain, go to Publish → Settings and add your domain. Create a separate public route /submit that only shows the ticket submission form. Use Supabase Edge Functions to handle the form submission server-side so the database schema stays protected.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation