# How to Build a Support Ticket System with Lovable

- Tool: How to Build with Lovable
- Difficulty: Intermediate
- Compatibility: Lovable Pro or higher
- Last updated: April 2026

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

## Before you start

- 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

## Step-by-step guide

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

```
Create a helpdesk support ticket system with Supabase. Set up these tables:

- 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
- ticket_messages: id, ticket_id, author_id, author_type (agent|customer), body, is_internal (bool), created_at
- categories: id, org_id, name, color
- canned_responses: id, org_id, title, body, category_id

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

```
Build a ticket queue page at src/pages/Tickets.tsx.

Requirements:
- Use shadcn/ui Tabs with values: all, open, pending, resolved, closed
- Each tab filters the Supabase query by status
- 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
- Sort by created_at descending by default
- Add a search input above the table that filters by subject or customer_name
- Clicking a row opens a Sheet component with the full ticket detail (build the Sheet as a separate TicketSheet component)
- 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.

```
import { useEffect, useRef, useState } from 'react'
import { supabase } from '@/integrations/supabase/client'
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Separator } from '@/components/ui/separator'
import { Switch } from '@/components/ui/switch'

type Message = {
  id: string
  author_type: 'agent' | 'customer'
  body: string
  is_internal: boolean
  created_at: string
  author_id: string
}

type Ticket = {
  id: string
  subject: string
  priority: string
  status: string
  customer_name: string
  customer_email: string
}

interface TicketSheetProps {
  ticket: Ticket | null
  onClose: () => void
}

export function TicketSheet({ ticket, onClose }: TicketSheetProps) {
  const [messages, setMessages] = useState<Message[]>([])
  const [reply, setReply] = useState('')
  const [isInternal, setIsInternal] = useState(false)
  const bottomRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    if (!ticket) return
    supabase
      .from('ticket_messages')
      .select('*')
      .eq('ticket_id', ticket.id)
      .order('created_at', { ascending: true })
      .then(({ data }) => setMessages(data ?? []))

    const channel = supabase
      .channel(`ticket-${ticket.id}`)
      .on('postgres_changes', {
        event: 'INSERT',
        schema: 'public',
        table: 'ticket_messages',
        filter: `ticket_id=eq.${ticket.id}`,
      }, (payload) => {
        setMessages((prev) => [...prev, payload.new as Message])
      })
      .subscribe()

    return () => { supabase.removeChannel(channel) }
  }, [ticket])

  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
  }, [messages])

  async function sendReply() {
    if (!reply.trim() || !ticket) return
    await supabase.from('ticket_messages').insert({
      ticket_id: ticket.id,
      body: reply,
      is_internal: isInternal,
      author_type: 'agent',
    })
    setReply('')
  }

  return (
    <Sheet open={!!ticket} onOpenChange={onClose}>
      <SheetContent className="w-[520px] flex flex-col">
        <SheetHeader>
          <SheetTitle className="text-base">{ticket?.subject}</SheetTitle>
          <div className="flex gap-2">
            <Badge variant="outline">{ticket?.priority}</Badge>
            <Badge>{ticket?.status}</Badge>
          </div>
        </SheetHeader>
        <Separator />
        <ScrollArea className="flex-1 pr-2">
          <div className="space-y-3 py-2">
            {messages.map((m) => (
              <div key={m.id} className={`rounded-lg p-3 text-sm ${
                m.is_internal ? 'bg-yellow-50 border border-yellow-200' :
                m.author_type === 'agent' ? 'bg-muted' : 'bg-primary/5'
              }`}>
                <p>{m.body}</p>
              </div>
            ))}
            <div ref={bottomRef} />
          </div>
        </ScrollArea>
        <Separator />
        <div className="space-y-2 pt-2">
          <Textarea value={reply} onChange={(e) => setReply(e.target.value)} placeholder="Write a reply..." className="min-h-[80px]" />
          <div className="flex items-center justify-between">
            <div className="flex items-center gap-2 text-sm">
              <Switch checked={isInternal} onCheckedChange={setIsInternal} id="internal" />
              <label htmlFor="internal">Internal note</label>
            </div>
            <Button onClick={sendReply} disabled={!reply.trim()}>Send Reply</Button>
          </div>
        </div>
      </SheetContent>
    </Sheet>
  )
}
```

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

```
Add claim and reassign functionality to the ticket queue and TicketSheet.

Requirements:
- 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().
- In the TicketSheet header, add a DropdownMenu with the label showing the assigned agent's name or 'Unassigned'.
- The DropdownMenu lists all agents in the org (query profiles where role = 'agent' or 'admin').
- Selecting an agent updates assigned_to on the ticket.
- After any assignment change, insert an internal ticket_messages row with body = 'Ticket assigned to [agent name]' and is_internal = true.
- 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.

```
Add a canned responses feature to the TicketSheet reply area.

Requirements:
- Add a 'Canned Responses' button next to the Send button (use a MessageSquare icon)
- Clicking it opens a shadcn/ui Command dialog
- The Command input searches canned_responses by title in real time using Supabase .ilike('title', '%query%')
- Results are grouped by category name using CommandGroup
- Each item shows the response title and a preview of the first 60 characters of the body
- Selecting an item closes the Command and sets the reply Textarea value to the canned response body
- 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 example

File: `src/components/tickets/PriorityBadge.tsx`

```typescript
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'

type Priority = 'urgent' | 'high' | 'normal' | 'low'

interface PriorityBadgeProps {
  priority: Priority
  className?: string
}

const priorityConfig: Record<Priority, { label: string; className: string }> = {
  urgent: {
    label: 'Urgent',
    className: 'bg-red-100 text-red-800 border-red-200 hover:bg-red-100',
  },
  high: {
    label: 'High',
    className: 'bg-orange-100 text-orange-800 border-orange-200 hover:bg-orange-100',
  },
  normal: {
    label: 'Normal',
    className: 'bg-blue-100 text-blue-800 border-blue-200 hover:bg-blue-100',
  },
  low: {
    label: 'Low',
    className: 'bg-gray-100 text-gray-700 border-gray-200 hover:bg-gray-100',
  },
}

export function PriorityBadge({ priority, className }: PriorityBadgeProps) {
  const config = priorityConfig[priority] ?? priorityConfig.normal
  return (
    <Badge
      variant="outline"
      className={cn('text-xs font-medium', config.className, className)}
    >
      {config.label}
    </Badge>
  )
}

type Status = 'open' | 'pending' | 'resolved' | 'closed'

interface StatusBadgeProps {
  status: Status
  className?: string
}

const statusConfig: Record<Status, { label: string; className: string }> = {
  open: { label: 'Open', className: 'bg-green-100 text-green-800 border-green-200 hover:bg-green-100' },
  pending: { label: 'Pending', className: 'bg-yellow-100 text-yellow-800 border-yellow-200 hover:bg-yellow-100' },
  resolved: { label: 'Resolved', className: 'bg-gray-100 text-gray-600 border-gray-200 hover:bg-gray-100' },
  closed: { label: 'Closed', className: 'bg-slate-100 text-slate-600 border-slate-200 hover:bg-slate-100' },
}

export function StatusBadge({ status, className }: StatusBadgeProps) {
  const config = statusConfig[status] ?? statusConfig.open
  return (
    <Badge
      variant="outline"
      className={cn('text-xs font-medium', config.className, className)}
    >
      {config.label}
    </Badge>
  )
}
```

## Common mistakes

- **Not adding Realtime cleanup in the ticket Sheet** — If the Supabase Realtime channel is not removed when the Sheet closes, reopening the same ticket creates duplicate subscriptions that fire events multiple times. Fix: 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.
- **Using author_id to display agent names without a join** — author_id is a UUID. Displaying it directly in the message thread confuses agents. Fix: 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.
- **Forgetting internal notes are visible to customers if RLS is not set** — If your customer portal queries ticket_messages without filtering is_internal = false, internal agent notes leak to customers. Fix: 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.
- **Claim button race condition with multiple agents** — Two agents clicking Claim at the same moment can both update assigned_to, creating ambiguity. Fix: 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.

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

---

Source: https://www.rapidevelopers.com/how-to-build-lovable/support-ticket-system
© RapidDev — https://www.rapidevelopers.com/how-to-build-lovable/support-ticket-system
