User feedback

Collect bug reports, feature requests, and support messages

This boilerplate includes a complete user feedback system with forms for bug reports, feature requests, and support messages, all stored in your database.

Overview

The feedback system provides:

  • Bug report form - Users can report bugs with details and context
  • Feature request form - Collect user suggestions for new features
  • Support form - General contact/support messages
  • Database storage - All feedback saved in PostgreSQL
  • User tracking - Know which user submitted each item
  • Easy access - Forms available in header and footer

Database schema

Feedback is stored in dedicated tables:

Bug reports

model BugReport {
  id        String   @id @default(dbgenerated("gen_random_uuid()"))
  type      String   // Type of bug
  details   String   // Detailed description
  userId    String   // Who reported it
  url       String   // Page URL where bug occurred
  createdAt DateTime @default(now())
}

Feature requests

model FeatureRequest {
  id        String   @id @default(dbgenerated("gen_random_uuid()"))
  type      String   // Type of feature
  details   String   // Detailed description
  userId    String   // Who requested it
  url       String   // Page URL where requested
  createdAt DateTime @default(now())
}

Bug report form

The bug report form is located in app/components/feedback/ReportBugForm.vue:

app/components/feedback/ReportBugForm.vue
<script setup lang="ts">
import { z } from 'zod'
import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'

const props = defineProps<{
  modelValue: boolean
}>()

const emit = defineEmits<{
  'update:modelValue': [value: boolean]
}>()

const isOpen = computed({
  get: () => props.modelValue,
  set: value => emit('update:modelValue', value),
})

const schema = toTypedSchema(
  z.object({
    type: z.string().min(1, 'Please select a bug type'),
    details: z.string().min(10, 'Please provide more details (at least 10 characters)'),
  })
)

const { handleSubmit, errors, defineField } = useForm({
  validationSchema: schema,
})

const [type] = defineField('type')
const [details] = defineField('details')

const isSubmitting = ref(false)

const onSubmit = handleSubmit(async values => {
  isSubmitting.value = true

  try {
    await $fetch('/api/bug-report', {
      method: 'POST',
      body: {
        type: values.type,
        details: values.details,
      },
    })

    toast.success('Bug report submitted successfully!')
    isOpen.value = false
  } catch (error) {
    toast.error('Failed to submit bug report')
  } finally {
    isSubmitting.value = false
  }
})
</script>

<template>
  <Dialog v-model:open="isOpen">
    <DialogContent>
      <DialogHeader>
        <DialogTitle>Report a bug</DialogTitle>
        <DialogDescription> Help us improve by reporting bugs you encounter </DialogDescription>
      </DialogHeader>

      <form @submit="onSubmit" class="space-y-4">
        <div class="space-y-2">
          <Label for="type">Bug type</Label>
          <Select v-model="type">
            <SelectTrigger>
              <SelectValue placeholder="Select bug type" />
            </SelectTrigger>
            <SelectContent>
              <SelectItem value="ui">UI/Display issue</SelectItem>
              <SelectItem value="functionality">Functionality not working</SelectItem>
              <SelectItem value="performance">Performance issue</SelectItem>
              <SelectItem value="security">Security concern</SelectItem>
              <SelectItem value="other">Other</SelectItem>
            </SelectContent>
          </Select>
          <p v-if="errors.type" class="text-sm text-destructive">
            {{ errors.type }}
          </p>
        </div>

        <div class="space-y-2">
          <Label for="details">Details</Label>
          <Textarea id="details" v-model="details" placeholder="Describe the bug in detail..." rows="5" />
          <p v-if="errors.details" class="text-sm text-destructive">
            {{ errors.details }}
          </p>
        </div>

        <DialogFooter>
          <Button type="button" variant="outline" @click="isOpen = false"> Cancel </Button>
          <Button type="submit" :disabled="isSubmitting">
            <span v-if="isSubmitting">Submitting...</span>
            <span v-else>Submit Report</span>
          </Button>
        </DialogFooter>
      </form>
    </DialogContent>
  </Dialog>
</template>

Using the bug report form

In your header or other components:

<script setup>
const showReportBugForm = ref(false)
</script>

<template>
  <Button @click="showReportBugForm = true"> Report a bug </Button>

  <ReportBugForm v-if="showReportBugForm" v-model="showReportBugForm" />
</template>

Feature request form

The feature request form is similar, located in app/components/feedback/RequestFeatureForm.vue:

<script setup lang="ts">
const schema = toTypedSchema(
  z.object({
    type: z.string().min(1, 'Please select a feature type'),
    details: z.string().min(10, 'Please provide more details'),
  })
)

// ... similar to bug report form

const onSubmit = handleSubmit(async values => {
  await $fetch('/api/feature-request', {
    method: 'POST',
    body: {
      type: values.type,
      details: values.details,
    },
  })

  toast.success('Feature request submitted!')
  isOpen.value = false
})
</script>

<template>
  <Dialog v-model:open="isOpen">
    <!-- Similar structure with different options -->
    <SelectContent>
      <SelectItem value="new-feature">New feature</SelectItem>
      <SelectItem value="improvement">Improvement</SelectItem>
      <SelectItem value="integration">Integration</SelectItem>
      <SelectItem value="other">Other</SelectItem>
    </SelectContent>
  </Dialog>
</template>

Support form

The support form allows users to send general inquiries. It's located in app/components/support/SupportForm.vue:

<script setup lang="ts">
const schema = toTypedSchema(
  z.object({
    subject: z.string().min(1, 'Subject is required'),
    message: z.string().min(10, 'Please provide more details'),
  })
)

const onSubmit = handleSubmit(async values => {
  await $fetch('/api/email/support', {
    method: 'POST',
    body: {
      subject: values.subject,
      message: values.message,
    },
  })

  toast.success('Message sent successfully!')
})
</script>

<template>
  <form @submit="onSubmit" class="space-y-4">
    <div class="space-y-2">
      <Label for="subject">Subject</Label>
      <Input id="subject" v-model="subject" placeholder="Brief description of your issue" />
    </div>

    <div class="space-y-2">
      <Label for="message">Message</Label>
      <Textarea id="message" v-model="message" placeholder="Provide more details..." rows="6" />
    </div>

    <Button type="submit" :disabled="isSubmitting"> Send message </Button>
  </form>
</template>

API endpoints

Bug report endpoint

server/api/bug-report.post.ts
import { requireAuth } from '~/server/utils/require-auth'
import { createBugReport } from '~/server/services/bug-reports-server-service'

export default defineEventHandler(async event => {
  const { user } = await requireAuth(event)
  const { type, details } = await readBody(event)

  // Get the referring URL
  const referer = getHeader(event, 'referer') || ''

  const bugReport = await createBugReport({
    type,
    details,
    userId: user.id,
    url: referer,
  })

  return { success: true, id: bugReport.id }
})

Feature request endpoint

server/api/feature-request.post.ts
import { requireAuth } from '~/server/utils/require-auth'
import { createFeatureRequest } from '~/server/services/feature-requests-server-service'

export default defineEventHandler(async event => {
  const { user } = await requireAuth(event)
  const { type, details } = await readBody(event)

  const referer = getHeader(event, 'referer') || ''

  const featureRequest = await createFeatureRequest({
    type,
    details,
    userId: user.id,
    url: referer,
  })

  return { success: true, id: featureRequest.id }
})

Support email endpoint

server/api/email/support.post.ts
import { requireAuth } from '~/server/utils/require-auth'
import { sendSupportEmail } from '~/server/services/email-server-service'

export default defineEventHandler(async event => {
  const { user } = await requireAuth(event)
  const { subject, message } = await readBody(event)

  await sendSupportEmail({
    subject: `Support: ${subject}`,
    html: `
      <h2>Support Request</h2>
      <p><strong>From:</strong> ${user.name} (${user.email})</p>
      <p><strong>Subject:</strong> ${subject}</p>
      <p><strong>Message:</strong></p>
      <p>${message.replace(/\n/g, '<br>')}</p>
    `,
  })

  return { success: true }
})

Service layer

The service layer handles database operations:

server/services/bug-reports-server-service.ts
import { prisma } from '@@/lib/prisma'

export async function createBugReport(data: { type: string; details: string; userId: string; url: string }) {
  return prisma.bugReport.create({
    data: {
      type: data.type,
      details: data.details,
      userId: data.userId,
      url: data.url,
    },
  })
}

export async function getBugReports(options?: { userId?: string; limit?: number; offset?: number }) {
  return prisma.bugReport.findMany({
    where: options?.userId ? { userId: options.userId } : undefined,
    take: options?.limit,
    skip: options?.offset,
    orderBy: { createdAt: 'desc' },
  })
}

Viewing feedback

Create an admin page to view all feedback:

app/pages/admin/feedback.vue
<script setup lang="ts">
definePageMeta({
  middleware: 'auth',
  // Add admin role check if needed
})

const activeTab = ref<'bugs' | 'features'>('bugs')

const { data: bugReports } = await useFetch('/api/admin/bug-reports')
const { data: featureRequests } = await useFetch('/api/admin/feature-requests')
</script>

<template>
  <div class="base-container py-8">
    <h1 class="text-4xl font-bold mb-8">User feedback</h1>

    <Tabs v-model="activeTab">
      <TabsList>
        <TabsTrigger value="bugs"> Bug Reports ({{ bugReports?.length || 0 }}) </TabsTrigger>
        <TabsTrigger value="features"> Feature Requests ({{ featureRequests?.length || 0 }}) </TabsTrigger>
      </TabsList>

      <TabsContent value="bugs">
        <div class="space-y-4">
          <Card v-for="bug in bugReports" :key="bug.id">
            <CardHeader>
              <CardTitle>{{ bug.type }}</CardTitle>
              <CardDescription>
                Reported {{ new Date(bug.createdAt).toLocaleDateString() }} at {{ bug.url }}
              </CardDescription>
            </CardHeader>
            <CardContent>
              <p>{{ bug.details }}</p>
            </CardContent>
          </Card>
        </div>
      </TabsContent>

      <TabsContent value="features">
        <div class="space-y-4">
          <Card v-for="feature in featureRequests" :key="feature.id">
            <CardHeader>
              <CardTitle>{{ feature.type }}</CardTitle>
              <CardDescription> Requested {{ new Date(feature.createdAt).toLocaleDateString() }} </CardDescription>
            </CardHeader>
            <CardContent>
              <p>{{ feature.details }}</p>
            </CardContent>
          </Card>
        </div>
      </TabsContent>
    </Tabs>
  </div>
</template>

Customizing feedback forms

Add more fields

const schema = toTypedSchema(
  z.object({
    type: z.string(),
    details: z.string(),
    severity: z.enum(['low', 'medium', 'high']), // Add severity
    screenshot: z.string().url().optional(), // Add screenshot URL
  })
)

Add file uploads

Use the storage integration to allow screenshot uploads:

<script setup>
const file = (ref < File) | (null > null)

async function uploadScreenshot() {
  if (!file.value) return null

  const formData = new FormData()
  formData.append('file', file.value)

  const { url } = await $fetch('/api/storage/upload', {
    method: 'POST',
    body: formData,
  })

  return url
}

const onSubmit = handleSubmit(async values => {
  const screenshotUrl = await uploadScreenshot()

  await $fetch('/api/bug-report', {
    method: 'POST',
    body: {
      ...values,
      screenshot: screenshotUrl,
    },
  })
})
</script>

<template>
  <Input type="file" @change="file = $event.target.files[0]" accept="image/*" />
</template>

Notifications

Get notified when users submit feedback:

Email notifications

server/api/bug-report.post.ts
import { sendEmail } from '~/server/services/email-server-service'

// After creating bug report
await sendEmail({
  to: 'admin@yourdomain.com',
  subject: 'New Bug Report',
  html: `
    <h2>New Bug Report</h2>
    <p><strong>Type:</strong> ${type}</p>
    <p><strong>Details:</strong> ${details}</p>
    <p><strong>User:</strong> ${user.email}</p>
    <p><strong>URL:</strong> ${url}</p>
  `,
})

Slack/Discord webhooks

async function notifySlack(bugReport: BugReport) {
  await fetch('https://hooks.slack.com/services/YOUR/WEBHOOK/URL', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      text: `New bug report: ${bugReport.type}`,
      blocks: [
        {
          type: 'section',
          text: { type: 'mrkdwn', text: bugReport.details },
        },
      ],
    }),
  })
}

Best practices

Require authentication

Only allow authenticated users to submit feedback:

const { user } = await requireAuth(event)

Rate limiting

Prevent spam by implementing rate limits:

const limiter = createRateLimiter({
  interval: 60000, // 1 minute
  max: 3, // 3 reports per minute
})

await limiter.check(event)

Sanitize input

Clean user input before storing:

import DOMPurify from 'dompurify'

const sanitizedDetails = DOMPurify.sanitize(details)

Auto-close duplicate reports

Implement deduplication logic to prevent duplicate reports.

Reference

Encourage users to provide detailed feedback by showing examples of good bug reports in your form placeholder text.