Skip to main content
RapidDev - Software Development Agency

How to Build Support ticket system with V0

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.

What you'll build

  • Customer-facing ticket submission form with priority Select and category classification
  • Agent ticket queue with sortable Table, priority Badge coloring, and status filtering
  • Conversation thread view with Card components for messages and Tabs for customer vs internal notes
  • Round-robin auto-assignment Server Action that distributes tickets to agents with least open tickets
  • Supabase Realtime subscription for instant notification when customers reply to assigned tickets
  • Resolution metrics dashboard with average response time and tickets closed today
Book a free consultation
4.9Clutch rating
600+Happy partners
17+Countries served
190+Team members
Intermediate11 min read1-2 hoursV0 FreeApril 2026RapidDev Engineering Team
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.

What you're building

Every product needs a way for customers to report issues and get help. Email threads get messy, chats disappear, and without a ticket system, requests fall through the cracks. A structured help desk ensures every issue is tracked, assigned, and resolved.

V0 generates the ticket forms, agent dashboard, and conversation views from prompts. Supabase handles the database with RLS for role-based access, and Realtime for instant notifications when customers reply. The multi-role design serves customers, agents, and admins from the same codebase.

The architecture uses Server Components for the ticket queue and detail pages, Server Actions for ticket mutations (create, assign, change status/priority), an API route for auto-assignment logic, and Supabase Realtime for live message notifications to agents.

Final result

A complete support ticket system with customer submission, agent queue, conversation threads, auto-assignment, email notifications, and resolution tracking.

Tech stack

V0AI Code Generator
Next.jsFull-Stack Framework
Tailwind CSSStyling
shadcn/uiComponent Library
SupabaseDatabase
ResendEmail Notifications

Prerequisites

  • 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

Build steps

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.

prompt.txt
1// Paste this prompt into V0's AI chat:
2// Create a Supabase schema for a support ticket system:
3// 1. users table: id (uuid PK references auth.users), email (text), role (text — 'customer', 'agent', 'admin'), display_name (text)
4// 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)
5// 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)
6// Add indexes on status, priority, and assigned_agent_id for fast queue queries.
7// RLS: customers see own tickets, agents see assigned tickets and unassigned, admins see all.
8// Enable Realtime on ticket_messages table.
9// 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.

app/actions/tickets.ts
1'use server'
2
3import { createClient } from '@/lib/supabase/server'
4import { revalidatePath } from 'next/cache'
5import { redirect } from 'next/navigation'
6
7export async function createTicket(formData: FormData) {
8 const supabase = await createClient()
9 const { data: { user } } = await supabase.auth.getUser()
10 if (!user) return { error: 'Not authenticated' }
11
12 const subject = formData.get('subject') as string
13 const description = formData.get('description') as string
14 const priority = formData.get('priority') as string
15 const category = formData.get('category') as string
16
17 // Find agent with least open tickets (round-robin)
18 const { data: agents } = await supabase
19 .from('users')
20 .select('id')
21 .eq('role', 'agent')
22
23 let assignedAgentId = null
24 if (agents && agents.length > 0) {
25 const agentCounts = await Promise.all(
26 agents.map(async (agent) => {
27 const { count } = await supabase
28 .from('tickets')
29 .select('*', { count: 'exact', head: true })
30 .eq('assigned_agent_id', agent.id)
31 .in('status', ['open', 'in_progress'])
32 return { id: agent.id, count: count ?? 0 }
33 })
34 )
35 agentCounts.sort((a, b) => a.count - b.count)
36 assignedAgentId = agentCounts[0].id
37 }
38
39 const { data: ticket, error } = await supabase.from('tickets').insert({
40 subject,
41 description,
42 priority,
43 category,
44 customer_id: user.id,
45 assigned_agent_id: assignedAgentId,
46 }).select('id').single()
47
48 if (error) return { error: error.message }
49
50 revalidatePath('/tickets')
51 redirect(`/tickets/${ticket.id}`)
52}

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.

app/admin/tickets/page.tsx
1import { createClient } from '@/lib/supabase/server'
2import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
3import { Badge } from '@/components/ui/badge'
4import { Button } from '@/components/ui/button'
5import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
6import Link from 'next/link'
7
8const priorityColors: Record<string, string> = {
9 urgent: 'bg-red-100 text-red-800',
10 high: 'bg-orange-100 text-orange-800',
11 medium: 'bg-yellow-100 text-yellow-800',
12 low: 'bg-green-100 text-green-800',
13}
14
15const statusColors: Record<string, string> = {
16 open: 'bg-blue-100 text-blue-800',
17 in_progress: 'bg-purple-100 text-purple-800',
18 waiting_on_customer: 'bg-yellow-100 text-yellow-800',
19 resolved: 'bg-green-100 text-green-800',
20 closed: 'bg-gray-100 text-gray-800',
21}
22
23export default async function AgentQueue() {
24 const supabase = await createClient()
25 const { data: { user } } = await supabase.auth.getUser()
26
27 const { data: tickets } = await supabase
28 .from('tickets')
29 .select('*, users!tickets_customer_id_fkey(display_name)')
30 .or(`assigned_agent_id.eq.${user!.id},assigned_agent_id.is.null`)
31 .in('status', ['open', 'in_progress', 'waiting_on_customer'])
32 .order('priority', { ascending: true })
33 .order('created_at', { ascending: true })
34
35 return (
36 <div className="p-6 space-y-4">
37 <h1 className="text-2xl font-bold">Ticket Queue</h1>
38 <Table>
39 <TableHeader>
40 <TableRow>
41 <TableHead>Subject</TableHead>
42 <TableHead>Customer</TableHead>
43 <TableHead>Priority</TableHead>
44 <TableHead>Status</TableHead>
45 <TableHead>Created</TableHead>
46 <TableHead>Actions</TableHead>
47 </TableRow>
48 </TableHeader>
49 <TableBody>
50 {tickets?.map((ticket) => (
51 <TableRow key={ticket.id}>
52 <TableCell>
53 <Link href={`/tickets/${ticket.id}`} className="font-medium hover:underline">
54 {ticket.subject}
55 </Link>
56 </TableCell>
57 <TableCell>{ticket.users?.display_name}</TableCell>
58 <TableCell>
59 <Badge className={priorityColors[ticket.priority]}>{ticket.priority}</Badge>
60 </TableCell>
61 <TableCell>
62 <Badge className={statusColors[ticket.status]}>{ticket.status.replace(/_/g, ' ')}</Badge>
63 </TableCell>
64 <TableCell className="text-sm text-muted-foreground">
65 {new Date(ticket.created_at).toLocaleDateString()}
66 </TableCell>
67 <TableCell>
68 <DropdownMenu>
69 <DropdownMenuTrigger asChild>
70 <Button variant="ghost" size="sm">Actions</Button>
71 </DropdownMenuTrigger>
72 <DropdownMenuContent>
73 <DropdownMenuItem>Assign to me</DropdownMenuItem>
74 <DropdownMenuItem>Change priority</DropdownMenuItem>
75 <DropdownMenuItem>Close ticket</DropdownMenuItem>
76 </DropdownMenuContent>
77 </DropdownMenu>
78 </TableCell>
79 </TableRow>
80 ))}
81 </TableBody>
82 </Table>
83 </div>
84 )
85}

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.

components/ticket-thread.tsx
1'use client'
2
3import { useEffect, useState } from 'react'
4import { createClient } from '@/lib/supabase/client'
5import { Card, CardContent } from '@/components/ui/card'
6import { Textarea } from '@/components/ui/textarea'
7import { Button } from '@/components/ui/button'
8import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
9import { Badge } from '@/components/ui/badge'
10import { addMessage } from '@/app/actions/tickets'
11
12type Message = {
13 id: string
14 content: string
15 is_internal: boolean
16 author_id: string
17 created_at: string
18}
19
20export function TicketThread({
21 ticketId,
22 initialMessages,
23 currentUserId,
24}: {
25 ticketId: string
26 initialMessages: Message[]
27 currentUserId: string
28}) {
29 const [messages, setMessages] = useState(initialMessages)
30 const supabase = createClient()
31
32 useEffect(() => {
33 const channel = supabase
34 .channel(`ticket-${ticketId}`)
35 .on('postgres_changes', {
36 event: 'INSERT',
37 schema: 'public',
38 table: 'ticket_messages',
39 filter: `ticket_id=eq.${ticketId}`,
40 }, (payload) => {
41 setMessages((prev) => [...prev, payload.new as Message])
42 })
43 .subscribe()
44 return () => { supabase.removeChannel(channel) }
45 }, [supabase, ticketId])
46
47 const publicMessages = messages.filter((m) => !m.is_internal)
48 const internalMessages = messages.filter((m) => m.is_internal)
49
50 return (
51 <div className="space-y-4">
52 <Tabs defaultValue="public">
53 <TabsList>
54 <TabsTrigger value="public">Customer Thread</TabsTrigger>
55 <TabsTrigger value="internal">
56 Internal Notes <Badge variant="secondary" className="ml-1">{internalMessages.length}</Badge>
57 </TabsTrigger>
58 </TabsList>
59 <TabsContent value="public" className="space-y-3">
60 {publicMessages.map((msg) => (
61 <Card key={msg.id} className={msg.author_id === currentUserId ? 'bg-primary/5' : ''}>
62 <CardContent className="p-3">
63 <p className="text-sm">{msg.content}</p>
64 <p className="text-xs text-muted-foreground mt-1">
65 {new Date(msg.created_at).toLocaleString()}
66 </p>
67 </CardContent>
68 </Card>
69 ))}
70 </TabsContent>
71 <TabsContent value="internal" className="space-y-3">
72 {internalMessages.map((msg) => (
73 <Card key={msg.id} className="border-dashed">
74 <CardContent className="p-3">
75 <p className="text-sm">{msg.content}</p>
76 </CardContent>
77 </Card>
78 ))}
79 </TabsContent>
80 </Tabs>
81 <form action={addMessage} className="flex gap-2">
82 <input type="hidden" name="ticketId" value={ticketId} />
83 <Textarea name="content" placeholder="Type your reply..." required />
84 <div className="flex flex-col gap-2">
85 <Button type="submit" name="type" value="public">Reply</Button>
86 <Button type="submit" name="type" value="internal" variant="outline">Internal Note</Button>
87 </div>
88 </form>
89 </div>
90 )
91}

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.

app/api/notifications/email/route.ts
1import { NextRequest, NextResponse } from 'next/server'
2import { Resend } from 'resend'
3
4const resend = new Resend(process.env.RESEND_API_KEY)
5
6export async function POST(req: NextRequest) {
7 const { to, subject, ticketId, message, type } = await req.json()
8
9 const { error } = await resend.emails.send({
10 from: 'Support <support@yourdomain.com>',
11 to,
12 subject: type === 'customer_reply'
13 ? `New reply on ticket: ${subject}`
14 : `Update on your ticket: ${subject}`,
15 text: `${message}\n\nView ticket: ${process.env.NEXT_PUBLIC_SITE_URL}/tickets/${ticketId}`,
16 })
17
18 if (error) {
19 return NextResponse.json({ error: error.message }, { status: 500 })
20 }
21
22 return NextResponse.json({ sent: true })
23}

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

app/admin/tickets/page.tsx
1import { createClient } from '@/lib/supabase/server'
2import {
3 Table, TableBody, TableCell, TableHead,
4 TableHeader, TableRow,
5} from '@/components/ui/table'
6import { Badge } from '@/components/ui/badge'
7import Link from 'next/link'
8
9const priorityColors: Record<string, string> = {
10 urgent: 'bg-red-100 text-red-800',
11 high: 'bg-orange-100 text-orange-800',
12 medium: 'bg-yellow-100 text-yellow-800',
13 low: 'bg-green-100 text-green-800',
14}
15
16export default async function AgentQueue() {
17 const supabase = await createClient()
18 const { data: { user } } = await supabase.auth.getUser()
19
20 const { data: tickets } = await supabase
21 .from('tickets')
22 .select('*')
23 .or(
24 `assigned_agent_id.eq.${user!.id},assigned_agent_id.is.null`
25 )
26 .in('status', ['open', 'in_progress', 'waiting_on_customer'])
27 .order('priority')
28 .order('created_at')
29
30 return (
31 <div className="p-6">
32 <h1 className="text-2xl font-bold mb-4">Ticket Queue</h1>
33 <Table>
34 <TableHeader>
35 <TableRow>
36 <TableHead>Subject</TableHead>
37 <TableHead>Priority</TableHead>
38 <TableHead>Status</TableHead>
39 <TableHead>Created</TableHead>
40 </TableRow>
41 </TableHeader>
42 <TableBody>
43 {tickets?.map((ticket) => (
44 <TableRow key={ticket.id}>
45 <TableCell>
46 <Link
47 href={`/tickets/${ticket.id}`}
48 className="font-medium hover:underline"
49 >
50 {ticket.subject}
51 </Link>
52 </TableCell>
53 <TableCell>
54 <Badge className={priorityColors[ticket.priority]}>
55 {ticket.priority}
56 </Badge>
57 </TableCell>
58 <TableCell>
59 <Badge variant="outline">
60 {ticket.status.replace(/_/g, ' ')}
61 </Badge>
62 </TableCell>
63 <TableCell className="text-sm text-muted-foreground">
64 {new Date(ticket.created_at).toLocaleDateString()}
65 </TableCell>
66 </TableRow>
67 ))}
68 </TableBody>
69 </Table>
70 </div>
71 )
72}

Customization ideas

Add SLA tracking

Define response time targets per priority level (urgent: 1hr, high: 4hr). Display a countdown timer on each ticket and highlight overdue tickets in the queue.

Add canned responses

Create a library of template responses that agents can insert with one click. Store templates in Supabase with category tags for quick filtering.

Add customer satisfaction surveys

When a ticket is resolved, send an automated email with a 1-5 star rating. Track CSAT scores per agent and display trends on the admin dashboard.

Add knowledge base integration

Suggest relevant help articles when customers type their ticket subject. Use Supabase full-text search to match against an articles table.

Common pitfalls

Pitfall: Not separating internal notes from customer-visible messages

How to avoid: Use the is_internal boolean on ticket_messages. Filter messages based on the viewer's role — customers only see is_internal=false messages.

Pitfall: Not enabling Supabase Realtime on the ticket_messages table

How to avoid: Enable Realtime on ticket_messages in Supabase Dashboard > Database > Replication. The client component subscribes to INSERT events filtered by ticket_id.

Pitfall: Auto-assigning tickets without checking agent availability

How to avoid: 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

AI prompts to try

Copy these prompts to build this project faster.

ChatGPT Prompt

I'm building a support ticket system with Next.js App Router and Supabase. I need: 1) Customer ticket submission with priority and category, 2) Agent queue with sortable Table and priority Badge colors, 3) Conversation threads with internal notes, 4) Round-robin auto-assignment, 5) Supabase Realtime for live message updates. Help me design the schema and role-based RLS policies.

Build Prompt

Create a round-robin ticket auto-assignment Server Action that: 1) Queries all users with role='agent' and is_available=true, 2) For each agent, counts open tickets (status in open, in_progress), 3) Assigns the new ticket to the agent with the fewest open tickets, 4) If all agents have equal counts, picks the one who was assigned least recently. Use Supabase for all queries.

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.

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.