Skip to main content
RapidDev - Software Development Agency

How to Build a Support Ticket System with Lovable

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'll build

  • Multi-queue ticket DataTable with Tabs for All, Open, Pending, Resolved views
  • Ticket detail Sheet with threaded message history and reply composer
  • Supabase Realtime sync so new replies appear instantly without refresh
  • Claim and reassign workflow with agent assignment Badges
  • Canned responses library with searchable Command palette for fast replies
  • Priority and status Badges (Urgent, High, Normal, Low) with color coding
  • Category tagging and SLA timer per ticket
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate14 min read2–3 hoursLovable Pro or higherApril 2026RapidDev Engineering Team
TL;DR

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

LovableFrontend
SupabaseDatabase & Realtime
Supabase AuthAuth
shadcn/uiUI Components
TanStack Table v8DataTable
Tailwind CSSStyling

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

1

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.

prompt.txt
1Create a helpdesk support ticket system with Supabase. Set up these tables:
2
3- 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_at
4- ticket_messages: id, ticket_id, author_id, author_type (agent|customer), body, is_internal (bool), created_at
5- categories: id, org_id, name, color
6- canned_responses: id, org_id, title, body, category_id
7
8Enable 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.

2

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.

prompt.txt
1Build a ticket queue page at src/pages/Tickets.tsx.
2
3Requirements:
4- Use shadcn/ui Tabs with values: all, open, pending, resolved, closed
5- Each tab filters the Supabase query by status
6- 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_at
7- Sort by created_at descending by default
8- Add a search input above the table that filters by subject or customer_name
9- 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 label

Pro 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.

3

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.

src/components/tickets/TicketSheet.tsx
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'
10
11type Message = {
12 id: string
13 author_type: 'agent' | 'customer'
14 body: string
15 is_internal: boolean
16 created_at: string
17 author_id: string
18}
19
20type Ticket = {
21 id: string
22 subject: string
23 priority: string
24 status: string
25 customer_name: string
26 customer_email: string
27}
28
29interface TicketSheetProps {
30 ticket: Ticket | null
31 onClose: () => void
32}
33
34export 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)
39
40 useEffect(() => {
41 if (!ticket) return
42 supabase
43 .from('ticket_messages')
44 .select('*')
45 .eq('ticket_id', ticket.id)
46 .order('created_at', { ascending: true })
47 .then(({ data }) => setMessages(data ?? []))
48
49 const channel = supabase
50 .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()
60
61 return () => { supabase.removeChannel(channel) }
62 }, [ticket])
63
64 useEffect(() => {
65 bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
66 }, [messages])
67
68 async function sendReply() {
69 if (!reply.trim() || !ticket) return
70 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 }
78
79 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.

4

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.

prompt.txt
1Add claim and reassign functionality to the ticket queue and TicketSheet.
2
3Requirements:
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.

5

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.

prompt.txt
1Add a canned responses feature to the TicketSheet reply area.
2
3Requirements:
4- Add a 'Canned Responses' button next to the Send button (use a MessageSquare icon)
5- Clicking it opens a shadcn/ui Command dialog
6- The Command input searches canned_responses by title in real time using Supabase .ilike('title', '%query%')
7- Results are grouped by category name using CommandGroup
8- Each item shows the response title and a preview of the first 60 characters of the body
9- Selecting an item closes the Command and sets the reply Textarea value to the canned response body
10- If the Textarea already has text, append the canned response after a newline

Expected result: Clicking the Canned Responses button opens a searchable Command palette. Selecting a response populates the reply Textarea instantly.

Complete code

src/components/tickets/PriorityBadge.tsx
1import { Badge } from '@/components/ui/badge'
2import { cn } from '@/lib/utils'
3
4type Priority = 'urgent' | 'high' | 'normal' | 'low'
5
6interface PriorityBadgeProps {
7 priority: Priority
8 className?: string
9}
10
11const 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}
29
30export function PriorityBadge({ priority, className }: PriorityBadgeProps) {
31 const config = priorityConfig[priority] ?? priorityConfig.normal
32 return (
33 <Badge
34 variant="outline"
35 className={cn('text-xs font-medium', config.className, className)}
36 >
37 {config.label}
38 </Badge>
39 )
40}
41
42type Status = 'open' | 'pending' | 'resolved' | 'closed'
43
44interface StatusBadgeProps {
45 status: Status
46 className?: string
47}
48
49const 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}
55
56export function StatusBadge({ status, className }: StatusBadgeProps) {
57 const config = statusConfig[status] ?? statusConfig.open
58 return (
59 <Badge
60 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.

ChatGPT Prompt

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.

Lovable Prompt

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.

Build Prompt

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.

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.