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
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
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.
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.
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.
1'use server'23import { createClient } from '@/lib/supabase/server'4import { revalidatePath } from 'next/cache'5import { redirect } from 'next/navigation'67export 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' }1112 const subject = formData.get('subject') as string13 const description = formData.get('description') as string14 const priority = formData.get('priority') as string15 const category = formData.get('category') as string1617 // Find agent with least open tickets (round-robin)18 const { data: agents } = await supabase19 .from('users')20 .select('id')21 .eq('role', 'agent')2223 let assignedAgentId = null24 if (agents && agents.length > 0) {25 const agentCounts = await Promise.all(26 agents.map(async (agent) => {27 const { count } = await supabase28 .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].id37 }3839 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()4748 if (error) return { error: error.message }4950 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.
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.
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'78const 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}1415const 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}2223export default async function AgentQueue() {24 const supabase = await createClient()25 const { data: { user } } = await supabase.auth.getUser()2627 const { data: tickets } = await supabase28 .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 })3435 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.
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.
1'use client'23import { 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'1112type Message = {13 id: string14 content: string15 is_internal: boolean16 author_id: string17 created_at: string18}1920export function TicketThread({21 ticketId,22 initialMessages,23 currentUserId,24}: {25 ticketId: string26 initialMessages: Message[]27 currentUserId: string28}) {29 const [messages, setMessages] = useState(initialMessages)30 const supabase = createClient()3132 useEffect(() => {33 const channel = supabase34 .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])4647 const publicMessages = messages.filter((m) => !m.is_internal)48 const internalMessages = messages.filter((m) => m.is_internal)4950 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.
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.
1import { NextRequest, NextResponse } from 'next/server'2import { Resend } from 'resend'34const resend = new Resend(process.env.RESEND_API_KEY)56export async function POST(req: NextRequest) {7 const { to, subject, ticketId, message, type } = await req.json()89 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 })1718 if (error) {19 return NextResponse.json({ error: error.message }, { status: 500 })20 }2122 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
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'89const 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}1516export default async function AgentQueue() {17 const supabase = await createClient()18 const { data: { user } } = await supabase.auth.getUser()1920 const { data: tickets } = await supabase21 .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')2930 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 <Link47 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.
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.
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.
Talk to an Expert
Our team has built 600+ apps. Get personalized help with your project.
Book a free consultation