Support Ticket Forms That Route Themselves
Generic support forms waste everyone's time. Users answer irrelevant questions. Agents manually sort tickets. Issues get lost. Smart forms ask the right questions and route to the right team automatically.
Why Routing Matters
A billing question shouldn't go to engineering. A critical bug shouldn't sit in the general queue. When forms route themselves:
- Faster resolution - Right team sees it first
- Better context - Category-specific fields capture what matters
- Accurate priority - System calculates urgency, not users
- Clear expectations - Users see where tickets go and when to expect response
Category Selection
Start with a dropdown that drives everything else. Define categories with their routing rules upfront. See the Selection Fields tutorial for dropdown patterns.
const CATEGORIES = [
{ id: 'bug', name: 'Bug Report', team: 'Engineering', priority: 'high' },
{ id: 'billing', name: 'Billing Issue', team: 'Finance', priority: 'high' },
{ id: 'feature', name: 'Feature Request', team: 'Product', priority: 'low' },
{ id: 'account', name: 'Account Help', team: 'Support', priority: 'medium' },
{ id: 'question', name: 'General Question', team: 'Support', priority: 'low' }
];
form.addRow(row => {
row.addDropdown('category', {
label: 'What do you need help with?',
options: CATEGORIES.map(c => ({ id: c.id, name: c.name })),
placeholder: 'Select a category...',
isRequired: true
});
});Each category has a team assignment. This data structure powers both the routing display and any backend integration.
Show Where Tickets Go
Transparency builds trust. Show users exactly where their ticket will be routed:
// Show where the ticket will be routed
form.addRow(row => {
row.addTextPanel('routing', {
computedValue: () => {
const categoryId = form.dropdown('category')?.value();
const category = CATEGORIES.find(c => c.id === categoryId);
if (!category) return '';
return `→ This will be sent to our ${category.team} team`;
},
isVisible: () => form.dropdown('category')?.value() !== null,
customStyles: {
color: '#6366f1',
fontWeight: '500',
fontSize: '0.9em'
}
});
});The message appears as soon as they select a category. No surprises.
Pro tip
Routing messages reduce "wrong department" complaints. Users self-correct if they picked the wrong category because they see where it's going.
Category-Specific Fields
Different issues need different information. Use conditional subforms to show relevant fields. See the Conditional Visibility tutorial for more patterns.
Bug Reports
Engineers need reproducible bugs. Capture severity, steps, and environment:
// Bug report specific fields
const bugSection = form.addSubform('bugDetails', {
title: 'Bug Details',
isVisible: () => form.dropdown('category')?.value() === 'bug'
});
bugSection.addRow(row => {
row.addDropdown('severity', {
label: 'Severity',
options: [
{ id: 'critical', name: 'Critical - System down' },
{ id: 'major', name: 'Major - Feature broken' },
{ id: 'minor', name: 'Minor - Cosmetic issue' }
],
isRequired: true
});
});
bugSection.addRow(row => {
row.addTextarea('steps', {
label: 'Steps to Reproduce',
placeholder: '1. Go to...\n2. Click on...\n3. See error',
rows: 4,
isRequired: true
});
});
bugSection.addRow(row => {
row.addTextbox('expected', {
label: 'Expected Behavior'
}, '1fr');
row.addTextbox('actual', {
label: 'Actual Behavior'
}, '1fr');
});
bugSection.addRow(row => {
row.addDropdown('browser', {
label: 'Browser',
options: [
{ id: 'chrome', name: 'Chrome' },
{ id: 'firefox', name: 'Firefox' },
{ id: 'safari', name: 'Safari' },
{ id: 'edge', name: 'Edge' },
{ id: 'other', name: 'Other' }
]
});
});The severity dropdown directly affects priority calculation. "Critical" bugs get urgent attention.
Billing Issues
Finance needs specifics: invoice numbers, amounts, dates:
// Billing issue specific fields
const billingSection = form.addSubform('billingDetails', {
title: 'Billing Information',
isVisible: () => form.dropdown('category')?.value() === 'billing'
});
billingSection.addRow(row => {
row.addDropdown('billingIssue', {
label: 'Issue Type',
options: [
{ id: 'charge', name: 'Unexpected charge' },
{ id: 'refund', name: 'Refund request' },
{ id: 'invoice', name: 'Invoice question' },
{ id: 'payment', name: 'Payment failed' },
{ id: 'cancel', name: 'Cancel subscription' }
],
isRequired: true
});
});
billingSection.addRow(row => {
row.addTextbox('invoiceNumber', {
label: 'Invoice Number (if applicable)',
placeholder: 'INV-XXXXX'
}, '1fr');
row.addMoney('amount', {
label: 'Amount in Question',
currency: '$'
}, '1fr');
});
billingSection.addRow(row => {
row.addDatepicker('chargeDate', {
label: 'Date of Charge'
});
});Different billing issues (refund vs payment failed) may route to different sub-teams or trigger different SLAs.
See conditional forms in action
Feature Requests
Product teams want to understand impact and use cases:
// Feature request specific fields
const featureSection = form.addSubform('featureDetails', {
title: 'Feature Request',
isVisible: () => form.dropdown('category')?.value() === 'feature'
});
featureSection.addRow(row => {
row.addTextbox('featureTitle', {
label: 'Feature Title',
placeholder: 'Short description of the feature',
isRequired: true
});
});
featureSection.addRow(row => {
row.addTextarea('useCase', {
label: 'Use Case',
placeholder: 'Describe how you would use this feature...',
rows: 3,
isRequired: true
});
});
featureSection.addRow(row => {
row.addRadioButton('impact', {
label: 'Business Impact',
options: [
{ id: 'blocker', name: 'Blocker - Cannot use product without it' },
{ id: 'major', name: 'Major - Significantly improves workflow' },
{ id: 'nice', name: 'Nice to have' }
]
});
});
featureSection.addRow(row => {
row.addCheckbox('canWorkaround', {
label: 'I have a workaround for now'
});
});The "Business Impact" field helps prioritize the backlog. Blockers get attention; nice-to-haves wait.
Account Issues
Account problems need the account email and specific issue type:
// Account help specific fields
const accountSection = form.addSubform('accountDetails', {
title: 'Account Information',
isVisible: () => form.dropdown('category')?.value() === 'account'
});
accountSection.addRow(row => {
row.addEmail('accountEmail', {
label: 'Account Email',
placeholder: 'Email associated with your account',
isRequired: true
});
});
accountSection.addRow(row => {
row.addDropdown('accountIssue', {
label: 'What do you need help with?',
options: [
{ id: 'login', name: 'Cannot log in' },
{ id: 'password', name: 'Reset password' },
{ id: '2fa', name: 'Two-factor authentication' },
{ id: 'email', name: 'Change email address' },
{ id: 'delete', name: 'Delete account' }
],
isRequired: true
});
});
// Show warning for account deletion
accountSection.addRow(row => {
row.addTextPanel('deleteWarning', {
computedValue: () => '⚠️ Account deletion is permanent and cannot be undone.',
isVisible: () => form.dropdown('accountIssue')?.value() === 'delete',
customStyles: {
backgroundColor: '#fef2f2',
color: '#dc2626',
padding: '12px',
borderRadius: '6px'
}
});
});Notice the warning for account deletion - conditional styling draws attention to irreversible actions.
Automatic Priority Calculation
Don't ask users "What's the priority?" They'll always say urgent. Calculate it based on their answers. See the Computed Values tutorial for calculation patterns.
// Auto-calculate priority based on inputs
const calculatePriority = form.computedValue(() => {
const category = form.dropdown('category')?.value();
const severity = form.dropdown('severity')?.value();
const billingIssue = form.dropdown('billingIssue')?.value();
const impact = form.radioButton('impact')?.value();
// Bug severity drives priority
if (category === 'bug') {
if (severity === 'critical') return { level: 'urgent', score: 100 };
if (severity === 'major') return { level: 'high', score: 75 };
return { level: 'medium', score: 50 };
}
// Billing issues are high priority
if (category === 'billing') {
if (billingIssue === 'charge' || billingIssue === 'payment') {
return { level: 'high', score: 80 };
}
return { level: 'medium', score: 60 };
}
// Feature requests based on impact
if (category === 'feature') {
if (impact === 'blocker') return { level: 'high', score: 70 };
if (impact === 'major') return { level: 'medium', score: 40 };
return { level: 'low', score: 20 };
}
// Default
return { level: 'medium', score: 50 };
});Priority depends on:
- Bug severity - Critical bugs are urgent
- Billing type - Payment failures need quick attention
- Feature impact - Blockers rank higher than nice-to-haves
Display Priority and SLA
Show the calculated priority with visual indicators:
// Display calculated priority
form.addRow(row => {
row.addTextPanel('priority', {
computedValue: () => {
const priority = calculatePriority();
const icons: Record<string, string> = {
urgent: '🔴 URGENT',
high: '🟠 High Priority',
medium: '🟡 Medium Priority',
low: '🟢 Low Priority'
};
return icons[priority.level] || '';
},
isVisible: () => form.dropdown('category')?.value() !== null,
customStyles: { fontWeight: 'bold', fontSize: '1.1em' }
});
});And set expectations with SLA times:
// Show expected response time based on priority
const SLA_TIMES: Record<string, string> = {
urgent: '< 1 hour',
high: '< 4 hours',
medium: '< 24 hours',
low: '< 48 hours'
};
form.addRow(row => {
row.addTextPanel('sla', {
computedValue: () => {
const priority = calculatePriority();
const sla = SLA_TIMES[priority.level];
return `Expected response time: ${sla}`;
},
isVisible: () => form.dropdown('category')?.value() !== null,
customStyles: { color: '#666', fontSize: '0.9em' }
});
});Users appreciate knowing when to expect a response. It reduces "just checking in" follow-ups.
Common Fields
Some fields apply to all categories. Add them after the conditional sections:
// Common fields for all categories
form.addSpacer({ height: '16px' });
form.addRow(row => {
row.addTextbox('subject', {
label: 'Subject',
placeholder: 'Brief summary of your issue',
isRequired: true
});
});
form.addRow(row => {
row.addTextarea('description', {
label: 'Description',
placeholder: 'Please provide as much detail as possible...',
rows: 5,
isRequired: true
});
});
form.addRow(row => {
row.addEmail('email', {
label: 'Your Email',
tooltip: 'We will respond to this email address',
isRequired: true
});
});Subject, description, and contact email are universal. Keep them at the end so category-specific context comes first.
Complete Example
A minimal self-routing support form:
export function supportTicketForm(form: FormTs) {
const CATEGORIES = [
{ id: 'bug', name: 'Bug Report', team: 'Engineering' },
{ id: 'billing', name: 'Billing Issue', team: 'Finance' },
{ id: 'feature', name: 'Feature Request', team: 'Product' },
{ id: 'account', name: 'Account Help', team: 'Support' },
{ id: 'question', name: 'General Question', team: 'Support' }
];
// Category selection
form.addRow(row => {
row.addDropdown('category', {
label: 'What do you need help with?',
options: CATEGORIES.map(c => ({ id: c.id, name: c.name })),
isRequired: true
});
});
// Routing indicator
form.addRow(row => {
row.addTextPanel('routing', {
computedValue: () => {
const catId = form.dropdown('category')?.value();
const cat = CATEGORIES.find(c => c.id === catId);
return cat ? `→ Routed to: ${cat.team} team` : '';
},
isVisible: () => form.dropdown('category')?.value() !== null
});
});
// Bug-specific fields
const bug = form.addSubform('bug', {
isVisible: () => form.dropdown('category')?.value() === 'bug'
});
bug.addRow(row => {
row.addDropdown('severity', {
label: 'Severity',
options: [
{ id: 'critical', name: 'Critical' },
{ id: 'major', name: 'Major' },
{ id: 'minor', name: 'Minor' }
]
});
});
bug.addRow(row => {
row.addTextarea('steps', { label: 'Steps to Reproduce', rows: 3 });
});
// Billing-specific fields
const billing = form.addSubform('billing', {
isVisible: () => form.dropdown('category')?.value() === 'billing'
});
billing.addRow(row => {
row.addTextbox('invoice', { label: 'Invoice Number' }, '1fr');
row.addMoney('amount', { label: 'Amount', currency: '$' }, '1fr');
});
// Common fields
form.addRow(row => {
row.addTextbox('subject', { label: 'Subject', isRequired: true });
});
form.addRow(row => {
row.addTextarea('description', { label: 'Description', rows: 4 });
});
form.addRow(row => {
row.addEmail('email', { label: 'Your Email', isRequired: true });
});
form.configureSubmitButton({ label: 'Submit Ticket' });
}Backend Integration
The form submission includes all the routing data you need:
category- Which team should handle itseverity/impact- For priority sorting- Category-specific fields - Already structured for your ticketing system
Send to your helpdesk API, create a Jira ticket, or trigger a Slack notification. The form does the categorization; your backend does the routing.
Advanced Patterns
Escalation Triggers
Certain combinations should escalate automatically:
- Critical bug + enterprise customer = Page on-call
- Billing issue > $1000 = Manager notification
- Account deletion request = Retention team review
Add these rules to your priority calculation or handle in the backend based on submission data.
Pre-fill from Context
If users are logged in, pre-fill their email and account info. If they came from a specific page, default the category.
Attachments
Bug reports benefit from screenshots. Billing issues may need invoice copies. Show file upload fields conditionally based on category.
Common Questions
Should I let users override the calculated priority?
Generally no. Users will always pick 'urgent'. If you must allow overrides, require justification and flag tickets where user priority differs from calculated priority for review.
How do I handle tickets that don't fit any category?
Add a 'General Question' or 'Other' category that routes to a triage team. They can re-categorize and route appropriately. Track how often this happens - frequent 'Other' selections may indicate a missing category.
Should category-specific fields be required?
Make critical fields required (severity for bugs, invoice number for billing). Make context fields optional but encouraged. Empty required fields block submission; empty optional fields just mean less context for agents.
How do I integrate with Zendesk/Freshdesk/Intercom?
Use webhooks to send form submissions to your helpdesk API. Map form fields to ticket fields. The category can set the ticket type or tag, and custom fields can capture the category-specific data.
Can I show different SLAs for different customer tiers?
Yes. If you know the customer tier (from login or URL parameter), adjust the SLA display and priority calculation. Enterprise customers might get faster SLAs across all categories.