# How to Build Support ticket system with V0

- Tool: v0
- Difficulty: Intermediate
- Compatibility: V0 Free
- Last updated: April 2026

## TL;DR

Build a support ticket system with V0 featuring customer ticket submission, agent queue with priority sorting, conversation threads, auto-assignment, and resolution tracking. You'll create a multi-role interface with real-time notifications using Next.js, Supabase, and Supabase Realtime — all in about 1-2 hours.

## Before you start

- A V0 account (free tier works for this project)
- A Supabase project (free tier works — connect via V0's Connect panel)
- A Resend account for email notifications (free tier: 100 emails/day)
- Supabase Auth configured with email/password authentication

## Step-by-step guide

### 1. Set up the ticket system database schema

Open V0 and create a new project. Use the Connect panel to add Supabase. Create the tickets, ticket_messages, and users tables with role-based access control and proper indexes for queue sorting.

```
// Paste this prompt into V0's AI chat:
// Create a Supabase schema for a support ticket system:
// 1. users table: id (uuid PK references auth.users), email (text), role (text — 'customer', 'agent', 'admin'), display_name (text)
// 2. tickets table: id (uuid PK), subject (text NOT NULL), description (text), status (text DEFAULT 'open' — 'open', 'in_progress', 'waiting_on_customer', 'resolved', 'closed'), priority (text DEFAULT 'medium' — 'low', 'medium', 'high', 'urgent'), category (text), customer_id (uuid FK), assigned_agent_id (uuid FK nullable), created_at (timestamptz), updated_at (timestamptz), resolved_at (timestamptz nullable)
// 3. ticket_messages table: id (uuid PK), ticket_id (uuid FK), author_id (uuid FK), content (text), is_internal (boolean DEFAULT false — internal notes vs customer-visible), created_at (timestamptz)
// Add indexes on status, priority, and assigned_agent_id for fast queue queries.
// RLS: customers see own tickets, agents see assigned tickets and unassigned, admins see all.
// Enable Realtime on ticket_messages table.
// Seed 3 agent users and 10 sample tickets across all priorities.
```

> Pro tip: Enable Supabase Realtime on ticket_messages in Supabase Dashboard > Database > Replication. This powers instant notifications when customers reply to tickets.

**Expected result:** Three tables created with role-based RLS policies, indexes on sort columns, Realtime enabled on ticket_messages, and sample data seeded.

### 2. Build the customer ticket submission form

Create a ticket submission page where customers describe their issue, select a priority and category, and submit. The Server Action creates the ticket and triggers auto-assignment.

```
'use server'

import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'

export async function createTicket(formData: FormData) {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()
  if (!user) return { error: 'Not authenticated' }

  const subject = formData.get('subject') as string
  const description = formData.get('description') as string
  const priority = formData.get('priority') as string
  const category = formData.get('category') as string

  // Find agent with least open tickets (round-robin)
  const { data: agents } = await supabase
    .from('users')
    .select('id')
    .eq('role', 'agent')

  let assignedAgentId = null
  if (agents && agents.length > 0) {
    const agentCounts = await Promise.all(
      agents.map(async (agent) => {
        const { count } = await supabase
          .from('tickets')
          .select('*', { count: 'exact', head: true })
          .eq('assigned_agent_id', agent.id)
          .in('status', ['open', 'in_progress'])
        return { id: agent.id, count: count ?? 0 }
      })
    )
    agentCounts.sort((a, b) => a.count - b.count)
    assignedAgentId = agentCounts[0].id
  }

  const { data: ticket, error } = await supabase.from('tickets').insert({
    subject,
    description,
    priority,
    category,
    customer_id: user.id,
    assigned_agent_id: assignedAgentId,
  }).select('id').single()

  if (error) return { error: error.message }

  revalidatePath('/tickets')
  redirect(`/tickets/${ticket.id}`)
}
```

**Expected result:** Submitting a ticket creates the record, auto-assigns it to the agent with fewest open tickets, and redirects to the ticket detail page.

### 3. Build the agent ticket queue with filtering and sorting

Create the agent dashboard showing all assigned and unassigned tickets in a sortable Table. Priority and status columns use colored Badge components. Agents can filter by status and claim unassigned tickets.

```
import { createClient } from '@/lib/supabase/server'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import Link from 'next/link'

const priorityColors: Record<string, string> = {
  urgent: 'bg-red-100 text-red-800',
  high: 'bg-orange-100 text-orange-800',
  medium: 'bg-yellow-100 text-yellow-800',
  low: 'bg-green-100 text-green-800',
}

const statusColors: Record<string, string> = {
  open: 'bg-blue-100 text-blue-800',
  in_progress: 'bg-purple-100 text-purple-800',
  waiting_on_customer: 'bg-yellow-100 text-yellow-800',
  resolved: 'bg-green-100 text-green-800',
  closed: 'bg-gray-100 text-gray-800',
}

export default async function AgentQueue() {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()

  const { data: tickets } = await supabase
    .from('tickets')
    .select('*, users!tickets_customer_id_fkey(display_name)')
    .or(`assigned_agent_id.eq.${user!.id},assigned_agent_id.is.null`)
    .in('status', ['open', 'in_progress', 'waiting_on_customer'])
    .order('priority', { ascending: true })
    .order('created_at', { ascending: true })

  return (
    <div className="p-6 space-y-4">
      <h1 className="text-2xl font-bold">Ticket Queue</h1>
      <Table>
        <TableHeader>
          <TableRow>
            <TableHead>Subject</TableHead>
            <TableHead>Customer</TableHead>
            <TableHead>Priority</TableHead>
            <TableHead>Status</TableHead>
            <TableHead>Created</TableHead>
            <TableHead>Actions</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {tickets?.map((ticket) => (
            <TableRow key={ticket.id}>
              <TableCell>
                <Link href={`/tickets/${ticket.id}`} className="font-medium hover:underline">
                  {ticket.subject}
                </Link>
              </TableCell>
              <TableCell>{ticket.users?.display_name}</TableCell>
              <TableCell>
                <Badge className={priorityColors[ticket.priority]}>{ticket.priority}</Badge>
              </TableCell>
              <TableCell>
                <Badge className={statusColors[ticket.status]}>{ticket.status.replace(/_/g, ' ')}</Badge>
              </TableCell>
              <TableCell className="text-sm text-muted-foreground">
                {new Date(ticket.created_at).toLocaleDateString()}
              </TableCell>
              <TableCell>
                <DropdownMenu>
                  <DropdownMenuTrigger asChild>
                    <Button variant="ghost" size="sm">Actions</Button>
                  </DropdownMenuTrigger>
                  <DropdownMenuContent>
                    <DropdownMenuItem>Assign to me</DropdownMenuItem>
                    <DropdownMenuItem>Change priority</DropdownMenuItem>
                    <DropdownMenuItem>Close ticket</DropdownMenuItem>
                  </DropdownMenuContent>
                </DropdownMenu>
              </TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  )
}
```

> Pro tip: Use Design Mode (Option+D) to visually adjust Badge colors for each priority level and tweak Table row padding at zero credit cost.

**Expected result:** An agent queue Table with color-coded priority and status Badges, sortable columns, and a DropdownMenu for quick actions on each ticket.

### 4. Build the ticket conversation thread with real-time updates

Create the ticket detail page showing the conversation thread. Messages from customers and agents display in Card components. Supabase Realtime pushes new messages instantly to connected agents.

```
'use client'

import { useEffect, useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import { Card, CardContent } from '@/components/ui/card'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Badge } from '@/components/ui/badge'
import { addMessage } from '@/app/actions/tickets'

type Message = {
  id: string
  content: string
  is_internal: boolean
  author_id: string
  created_at: string
}

export function TicketThread({
  ticketId,
  initialMessages,
  currentUserId,
}: {
  ticketId: string
  initialMessages: Message[]
  currentUserId: string
}) {
  const [messages, setMessages] = useState(initialMessages)
  const supabase = createClient()

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

  const publicMessages = messages.filter((m) => !m.is_internal)
  const internalMessages = messages.filter((m) => m.is_internal)

  return (
    <div className="space-y-4">
      <Tabs defaultValue="public">
        <TabsList>
          <TabsTrigger value="public">Customer Thread</TabsTrigger>
          <TabsTrigger value="internal">
            Internal Notes <Badge variant="secondary" className="ml-1">{internalMessages.length}</Badge>
          </TabsTrigger>
        </TabsList>
        <TabsContent value="public" className="space-y-3">
          {publicMessages.map((msg) => (
            <Card key={msg.id} className={msg.author_id === currentUserId ? 'bg-primary/5' : ''}>
              <CardContent className="p-3">
                <p className="text-sm">{msg.content}</p>
                <p className="text-xs text-muted-foreground mt-1">
                  {new Date(msg.created_at).toLocaleString()}
                </p>
              </CardContent>
            </Card>
          ))}
        </TabsContent>
        <TabsContent value="internal" className="space-y-3">
          {internalMessages.map((msg) => (
            <Card key={msg.id} className="border-dashed">
              <CardContent className="p-3">
                <p className="text-sm">{msg.content}</p>
              </CardContent>
            </Card>
          ))}
        </TabsContent>
      </Tabs>
      <form action={addMessage} className="flex gap-2">
        <input type="hidden" name="ticketId" value={ticketId} />
        <Textarea name="content" placeholder="Type your reply..." required />
        <div className="flex flex-col gap-2">
          <Button type="submit" name="type" value="public">Reply</Button>
          <Button type="submit" name="type" value="internal" variant="outline">Internal Note</Button>
        </div>
      </form>
    </div>
  )
}
```

**Expected result:** A conversation thread with Tabs for customer-visible messages and internal notes. New messages from customers appear instantly via Supabase Realtime without page refresh.

### 5. Add email notifications for ticket updates

Send email notifications to customers when agents reply, and to agents when customers respond. Use Resend for email delivery via an API route.

```
import { NextRequest, NextResponse } from 'next/server'
import { Resend } from 'resend'

const resend = new Resend(process.env.RESEND_API_KEY)

export async function POST(req: NextRequest) {
  const { to, subject, ticketId, message, type } = await req.json()

  const { error } = await resend.emails.send({
    from: 'Support <support@yourdomain.com>',
    to,
    subject: type === 'customer_reply'
      ? `New reply on ticket: ${subject}`
      : `Update on your ticket: ${subject}`,
    text: `${message}\n\nView ticket: ${process.env.NEXT_PUBLIC_SITE_URL}/tickets/${ticketId}`,
  })

  if (error) {
    return NextResponse.json({ error: error.message }, { status: 500 })
  }

  return NextResponse.json({ sent: true })
}
```

**Expected result:** Email notifications fire when agents reply to customer tickets and when customers add new messages. Add RESEND_API_KEY in V0's Vars tab (server-only).

## Complete code example

File: `app/admin/tickets/page.tsx`

```typescript
import { createClient } from '@/lib/supabase/server'
import {
  Table, TableBody, TableCell, TableHead,
  TableHeader, TableRow,
} from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import Link from 'next/link'

const priorityColors: Record<string, string> = {
  urgent: 'bg-red-100 text-red-800',
  high: 'bg-orange-100 text-orange-800',
  medium: 'bg-yellow-100 text-yellow-800',
  low: 'bg-green-100 text-green-800',
}

export default async function AgentQueue() {
  const supabase = await createClient()
  const { data: { user } } = await supabase.auth.getUser()

  const { data: tickets } = await supabase
    .from('tickets')
    .select('*')
    .or(
      `assigned_agent_id.eq.${user!.id},assigned_agent_id.is.null`
    )
    .in('status', ['open', 'in_progress', 'waiting_on_customer'])
    .order('priority')
    .order('created_at')

  return (
    <div className="p-6">
      <h1 className="text-2xl font-bold mb-4">Ticket Queue</h1>
      <Table>
        <TableHeader>
          <TableRow>
            <TableHead>Subject</TableHead>
            <TableHead>Priority</TableHead>
            <TableHead>Status</TableHead>
            <TableHead>Created</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {tickets?.map((ticket) => (
            <TableRow key={ticket.id}>
              <TableCell>
                <Link
                  href={`/tickets/${ticket.id}`}
                  className="font-medium hover:underline"
                >
                  {ticket.subject}
                </Link>
              </TableCell>
              <TableCell>
                <Badge className={priorityColors[ticket.priority]}>
                  {ticket.priority}
                </Badge>
              </TableCell>
              <TableCell>
                <Badge variant="outline">
                  {ticket.status.replace(/_/g, ' ')}
                </Badge>
              </TableCell>
              <TableCell className="text-sm text-muted-foreground">
                {new Date(ticket.created_at).toLocaleDateString()}
              </TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </div>
  )
}
```

## Common mistakes

- **Not separating internal notes from customer-visible messages** — Agents need to discuss tickets privately. If internal notes are visible to customers, sensitive information leaks and agents self-censor, reducing collaboration quality. Fix: Use the is_internal boolean on ticket_messages. Filter messages based on the viewer's role — customers only see is_internal=false messages.
- **Not enabling Supabase Realtime on the ticket_messages table** — Without Realtime replication, agents never receive live notifications of customer replies. They must manually refresh the page to see new messages. Fix: Enable Realtime on ticket_messages in Supabase Dashboard > Database > Replication. The client component subscribes to INSERT events filtered by ticket_id.
- **Auto-assigning tickets without checking agent availability** — If an agent is offline or on vacation, tickets pile up in their queue without being worked on. Round-robin alone doesn't account for availability. Fix: Add an is_available boolean to the users table. Only include available agents in the round-robin query. Add a toggle for agents to set their availability.

## Best practices

- Use role-based RLS policies so customers only see their own tickets and agents see their assigned plus unassigned tickets
- Enable Supabase Realtime on ticket_messages for instant notifications when customers reply to assigned tickets
- Store RESEND_API_KEY in V0's Vars tab as a server-only secret (no NEXT_PUBLIC_ prefix) for email notifications
- Use Badge components with priority-specific colors (urgent=red, high=orange, medium=yellow, low=green) for quick visual scanning
- Use Design Mode (Option+D) to adjust Badge colors and Table row spacing for the agent queue at zero credit cost
- Use Server Components for ticket lists and detail pages — they load faster and reduce client-side JavaScript
- Implement round-robin auto-assignment to distribute tickets evenly across available agents

## Frequently asked questions

### How does the auto-assignment work?

When a customer submits a ticket, a Server Action queries all agents, counts each agent's open tickets, and assigns the new ticket to the agent with the fewest. This distributes workload evenly. If all agents have equal counts, the least recently assigned agent gets the ticket.

### Can customers see internal notes?

No. Internal notes have is_internal=true in the database. The customer view filters to show only is_internal=false messages. RLS policies can enforce this at the database level for additional security.

### How do real-time notifications work?

Supabase Realtime sends PostgreSQL change events over WebSockets. The ticket detail component subscribes to INSERT events on ticket_messages filtered by ticket_id. When a customer replies, the agent sees the new message instantly without refreshing.

### What V0 plan do I need?

V0 Free tier works. The ticket system uses standard Server Components, Server Actions, and shadcn/ui components. Supabase Realtime is included on Supabase free tier.

### How do I add email notifications?

Create a Resend account (free tier: 100 emails/day) and add RESEND_API_KEY in V0's Vars tab. The notification API route sends emails when agents reply to customers and when customers respond to tickets.

### Can RapidDev help build a custom support system?

Yes. RapidDev has built 600+ apps including help desk systems with SLA tracking, knowledge bases, chatbots, and multi-channel support. Book a free consultation to discuss your support workflow requirements.

### How do I deploy this to production?

Click Share > Publish in V0. The Supabase connection is auto-configured. Add RESEND_API_KEY in the Vercel Dashboard environment variables. Make sure Realtime is enabled on ticket_messages in Supabase Dashboard.

---

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