Feedback Widget

Last updated: 03/04/2026Edit this page

The Feedback component provides a "Was this page helpful?" widget for collecting page-level feedback from readers. It captures positive or negative sentiment along with specific reasons and optional free-text comments.

Key features

  • Collects user feedback with options for likes and dislikes.
  • Allows users to provide additional details through checkboxes and a free-text field.
  • Displays submission status using confirmation and error modals.
  • Integrates with an external API endpoint to persist feedback data.
  • Automatically picks up the current page pathname for context.
  • Keyboard accessible (Escape to close, focus management on modals).
  • Responsive design with dark mode support via design tokens.

Placement

You have two options for where the Feedback widget appears.

Option A: Automatic on every doc page

To show the widget at the bottom of every documentation page — similar to Docusaurus's DocItem/Footer — add it directly to the doc page layout in app/(docs)/[...slug]/page.tsx.

Import the component at the top of the file:

app/(docs)/[...slug]/page.tsx
import { Feedback } from '@/components/custom/feedback'

Then place it inside the <article> element, after the closing </div> of the prose block:

app/(docs)/[...slug]/page.tsx
<div className="prose max-w-none">
            <MDXRemote ... />
          </div>

          <Feedback />
        </article>

The widget renders automatically on every documentation topic. No per-page MDX changes are needed.

Advantages:

  • Consistent — every doc page gets the feedback prompt without authors having to remember to add it.
  • Centralized — one line of code controls the entire site. Updating the placement or removing it is a single edit.
  • No content clutter — MDX files stay focused on the topic content.

Disadvantages:

  • All or nothing — every doc page gets the widget, including pages where feedback might not be relevant (for example, changelogs, index pages).
  • To exclude specific pages, add conditional logic in the layout (for example, check doc.meta for an opt-out flag).

Option B: Per-page via MDX

The component is registered globally in MDX, so you can add it selectively to individual pages without an import:

<Feedback />

If you need to use it outside of MDX (for example, in a custom page or layout), import it explicitly:

import { Feedback } from '@/components/custom/feedback'

Advantages:

  • Selective — you choose exactly which pages show the widget.
  • No layout changes — everything is controlled at the content level, so authors can add or remove it per topic.
  • Good for sites where only a subset of pages benefit from feedback (for example, tutorials, guides) while others don't (for example, reference tables, legal pages).

Disadvantages:

  • Easy to forget — authors must remember to add <Feedback /> to each page that needs it.
  • Inconsistent — different pages might not have the widget depending on who authored them.
  • Harder to audit — no single place to see which pages have feedback enabled.

Which should I use?

For most documentation sites, Option A (automatic) is recommended. It ensures consistent coverage and is easier to maintain. Use Option B (per-page) if your site has a mix of content types and you only want feedback on specific pages.

How it works

  1. The reader selects thumbs up or thumbs down.
  2. A modal appears with contextual options (for example, "Easy to follow" for likes, "Hard to understand" for dislikes).
  3. If the reader selects "Provide details", a free-text field appears.
  4. On submit, the widget POSTs the data to your feedback API endpoint.
  5. A confirmation or error modal is shown.

Component structure

The component lives in a single file at components/custom/feedback.tsx and is organized into these parts:

PartDescription
getBaseUrl()Determines the API endpoint URL based on the current environment (localhost vs production).
submitFeedback()Sends the feedback payload to the backend via POST.
Feedback (main)Renders the thumbs up/down prompt, manages state, and orchestrates the modals.
Feedback modalDisplays contextual checkbox options and an optional text field after the reader selects a thumb button.
Thank-you modalConfirms successful submission.
Error modalShown on submission failure, with a link to report the issue on GitHub.

Dependencies

  • React (with useState, useMemo, useEffect, useRef hooks)
  • Next.js (usePathname for the current page path)
  • lucide-react (ThumbsUp, ThumbsDown, X icons)
  • Tailwind CSS with design-token CSS variables for theming
  • cn() utility from @/lib/utils for class composition

Backend requirement

The Feedback widget and Feedback Dashboard are front-end only components. They require a separate backend service with a database to receive, store, and query submissions. Trellis does not include a backend — you must provide your own.

API endpoints

Your backend must implement two endpoints:

MethodPathUsed byDescription
POST/api/feedback/submitFeedback widgetReceives and stores a single feedback submission.
GET/api/feedback/summary?period=all|30d|7dFeedback DashboardReturns aggregated feedback data for the dashboard.

Base URL configuration

The getBaseUrl() function in lib/feedback-api.ts determines where requests are sent:

EnvironmentURL
Development (localhost)http://localhost:3002/api/feedback
Production/api/feedback (relative to your site origin)

If your backend lives at a different URL, update this function. For multi-environment deployments (for example, dev, staging, production), you can extend the logic based on window.location.hostname:

lib/feedback-api.ts
export function getBaseUrl() {
  if (typeof window === 'undefined') return '/api/feedback'
  const hostname = window.location.hostname
  if (hostname === 'localhost' || hostname === '127.0.0.1') {
    return 'http://localhost:3002/api/feedback'
  }
  if (hostname.startsWith('docs-dev.')) {
    return 'https://dev.example.com/api/feedback'
  }
  if (hostname.startsWith('docs-staging.')) {
    return 'https://staging.example.com/api/feedback'
  }
  return '/api/feedback'
}

Submit payload format

The widget sends a POST request with this JSON body:

{
  "page": "/docs/getting-started/",
  "type": "like",
  "options": ["ACCURATE", "EASY_TO_FOLLOW"],
  "comment": "Optional free-text comment"
}
FieldTypeDescription
pagestringThe pathname of the page where feedback was submitted.
type"like" | "dislike"Whether the reader found the page helpful.
optionsstring[]Zero or more option keys the reader selected (see tables below).
commentstringFree-text comment, present only when the reader selects "Provide details".

Option keys

Like options:

KeyLabel shown to reader
ACCURATEAccurately describes the platform
RESOLVE_ISSUEHelped me resolve an issue
EASY_TO_FOLLOWEasy to follow and comprehend
CLEAR_CODE_SAMPLESCode samples were clear
ADOPT_PLATFORMConvinced me to adopt the platform
POSITIVE_ANOTHER_REASONProvide details

Dislike options:

KeyLabel shown to reader
INACCURATEDoesn't accurately describe the platform
NOT_FOUNDCouldn't find what I was looking for
MISSING_INFOMissing important information
HARD_TO_UNDERSTANDHard to understand
COMPLICATEDToo complicated or unclear
CODE_ERRORSCode sample errors
NEGATIVE_ANOTHER_REASONProvide details

Summary response format

The dashboard sends a GET request with a period query parameter. Your backend must return:

{
  "totals": {
    "total": 247,
    "likes": 189,
    "dislikes": 58,
    "satisfactionRate": 76.5
  },
  "byPage": [
    {
      "page": "/guides/writing-docs/",
      "likes": 45,
      "dislikes": 12,
      "total": 57,
      "satisfactionRate": 78.9
    }
  ],
  "byOption": [
    { "option": "EASY_TO_FOLLOW", "count": 28, "type": "like" },
    { "option": "MISSING_INFO", "count": 15, "type": "dislike" }
  ],
  "recentEntries": [
    {
      "id": "abc123",
      "page": "/guides/writing-docs/",
      "type": "like",
      "options": ["ACCURATE", "EASY_TO_FOLLOW"],
      "comment": "Great explanation of frontmatter!",
      "createdAt": "2026-02-23T14:30:00Z"
    }
  ]
}

API response handling

  • Success (submit): The thank-you modal is displayed and the form state is reset.
  • Success (summary): The dashboard renders all sections with the returned data.
  • Failure: The widget shows an error modal with a link to report the issue. The dashboard shows a friendly error with setup instructions.

Reference backend

The feedback API is a separate process from the Trellis static site, but lives inside your project in an api/ directory at the root. This keeps everything in one repository and makes it easy to manage, version, and deploy together.

my-docs/
├── api/                    ← feedback backend (node server.js → port 3002)
│   ├── server.js
│   └── package.json
├── app/
├── components/
├── config/
├── content/
├── lib/
├── public/
├── package.json            ← Trellis docs site (npm run dev → port 3000)
└── ...

The api/ directory has its own package.json and node_modules — it runs as a standalone Node.js service on a separate port. Trellis itself is a static site with no server runtime, so the two processes are independent even though they share a repository.

Below are complete, runnable Express.js backends for each supported database. Each implementation includes both endpoints (POST /api/feedback/submit and GET /api/feedback/summary) with input validation, CORS, date filtering, and aggregation. Pick the one that matches your stack.

All servers run on port 3002 by default, matching the getBaseUrl() development configuration. Start your docs site with npm run dev. Both the widget and dashboard connect automatically.

CORS configuration

Every implementation below includes a cors() middleware block. Update the origin array to include your docs site URLs before deploying:

app.use(cors({
  origin: [
    'http://localhost:3000',    // Next.js dev server
    'https://docs.example.com'  // production docs site
  ],
}))

Choosing a database

PlatformBest forFree tierNotes
MongoDB AtlasGeneral purpose, flexible schema512 MBFamiliar driver, large ecosystem
Azure Cosmos DBAzure-hosted sites, global distribution1000 RU/sNative SQL API or MongoDB-compatible API
PostgreSQLSQL queries, analytics, structured dataVaries by hostSupabase and Neon offer generous free tiers
Firebase FirestoreServerless, minimal backend code1 GiB storageCan be called directly from client if needed

MongoDB

A good choice for Node.js backends. The flexible document model maps naturally to the feedback payload.

Quick start:

node scripts/setup-feedback-api.js mongodb
cd api && npm install

Or set up manually:

Manual setup
mkdir api && cd api
npm init -y
npm install express cors mongodb

Server code:

server.js
const express = require('express')
const cors = require('cors')
const { MongoClient } = require('mongodb')

const app = express()
const port = process.env.PORT || 3002

app.use(express.json())
app.use(cors({
  origin: ['http://localhost:3000', 'https://docs.example.com'],
}))

const client = new MongoClient(process.env.MONGODB_URI || 'mongodb://localhost:27017')
const db = client.db('trellis')
const feedback = db.collection('feedback')

// --- POST /api/feedback/submit ---
app.post('/api/feedback/submit', async (req, res) => {
  try {
    const { page, type, options, comment } = req.body

    if (!page || !type) {
      return res.status(400).json({ error: 'Missing required fields: page, type' })
    }
    if (type !== 'like' && type !== 'dislike') {
      return res.status(400).json({ error: 'type must be "like" or "dislike"' })
    }

    await feedback.insertOne({
      page, type,
      options: options || [],
      comment: comment || '',
      createdAt: new Date(),
    })

    res.json({ success: true })
  } catch (err) {
    console.error('Submit error:', err)
    res.status(500).json({ error: 'Internal server error' })
  }
})

// --- GET /api/feedback/summary ---
app.get('/api/feedback/summary', async (req, res) => {
  try {
    const { period } = req.query
    const filter = {}

    if (period === '7d') {
      filter.createdAt = { $gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) }
    } else if (period === '30d') {
      filter.createdAt = { $gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000) }
    }

    const docs = await feedback.find(filter).sort({ createdAt: -1 }).toArray()

    const likes = docs.filter(d => d.type === 'like').length
    const dislikes = docs.filter(d => d.type === 'dislike').length
    const total = docs.length
    const satisfactionRate = total > 0 ? (likes / total) * 100 : 0

    const pageMap = new Map()
    for (const doc of docs) {
      const entry = pageMap.get(doc.page) || { page: doc.page, likes: 0, dislikes: 0, total: 0 }
      entry[doc.type === 'like' ? 'likes' : 'dislikes']++
      entry.total++
      pageMap.set(doc.page, entry)
    }
    const byPage = Array.from(pageMap.values()).map(p => ({
      ...p,
      satisfactionRate: p.total > 0 ? (p.likes / p.total) * 100 : 0,
    }))

    const optionMap = new Map()
    for (const doc of docs) {
      for (const opt of doc.options || []) {
        const key = `${opt}:${doc.type}`
        const entry = optionMap.get(key) || { option: opt, count: 0, type: doc.type }
        entry.count++
        optionMap.set(key, entry)
      }
    }
    const byOption = Array.from(optionMap.values())

    const recentEntries = docs.slice(0, 100).map(d => ({
      id: d._id.toString(),
      page: d.page,
      type: d.type,
      options: d.options || [],
      comment: d.comment || '',
      createdAt: d.createdAt.toISOString(),
    }))

    res.json({ totals: { total, likes, dislikes, satisfactionRate }, byPage, byOption, recentEntries })
  } catch (err) {
    console.error('Summary error:', err)
    res.status(500).json({ error: 'Internal server error' })
  }
})

client.connect().then(() => {
  app.listen(port, () => console.log(`Feedback API running on port ${port}`))
})

Run it:

MONGODB_URI=mongodb://localhost:27017 node server.js
VariableRequiredDescription
MONGODB_URIYesMongoDB connection string (for example, mongodb://localhost:27017 or your Atlas URI).
PORTNoServer port. Defaults to 3002.

Hosting options: MongoDB Atlas (free tier available), or self-hosted.

Azure Cosmos DB

Use the native Cosmos DB SQL API for full Azure integration. This is the recommended approach for Azure-hosted documentation sites.

Azure Portal setup (do this first):

  1. Create a Cosmos DB account (choose NoSQL API).
  2. Create a database named trellis.
  3. Create a container named feedback with /page as the partition key.
  4. Copy the URI and Primary Key from the Keys blade.

Quick start:

node scripts/setup-feedback-api.js cosmos
cd api && npm install

Or set up manually:

Manual setup
mkdir api && cd api
npm init -y
npm install express cors @azure/cosmos

Server code:

server.js
const express = require('express')
const cors = require('cors')
const { CosmosClient } = require('@azure/cosmos')

const app = express()
const port = process.env.PORT || 3002

app.use(express.json())
app.use(cors({
  origin: ['http://localhost:3000', 'https://docs.example.com'],
}))

const client = new CosmosClient({
  endpoint: process.env.COSMOS_ENDPOINT,
  key: process.env.COSMOS_KEY,
})
const container = client.database('trellis').container('feedback')

// --- POST /api/feedback/submit ---
app.post('/api/feedback/submit', async (req, res) => {
  try {
    const { page, type, options, comment } = req.body

    if (!page || !type) {
      return res.status(400).json({ error: 'Missing required fields: page, type' })
    }
    if (type !== 'like' && type !== 'dislike') {
      return res.status(400).json({ error: 'type must be "like" or "dislike"' })
    }

    await container.items.create({
      page, type,
      options: options || [],
      comment: comment || '',
      createdAt: new Date().toISOString(),
    })

    res.json({ success: true })
  } catch (err) {
    console.error('Submit error:', err)
    res.status(500).json({ error: 'Internal server error' })
  }
})

// --- GET /api/feedback/summary ---
app.get('/api/feedback/summary', async (req, res) => {
  try {
    const { period } = req.query

    // Build date filter
    let dateFilter = ''
    if (period === '7d') {
      dateFilter = `AND c.createdAt >= "${new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString()}"`
    } else if (period === '30d') {
      dateFilter = `AND c.createdAt >= "${new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString()}"`
    }

    const { resources: docs } = await container.items
      .query(`SELECT * FROM c WHERE 1=1 ${dateFilter} ORDER BY c.createdAt DESC`)
      .fetchAll()

    const likes = docs.filter(d => d.type === 'like').length
    const dislikes = docs.filter(d => d.type === 'dislike').length
    const total = docs.length
    const satisfactionRate = total > 0 ? (likes / total) * 100 : 0

    const pageMap = new Map()
    for (const doc of docs) {
      const entry = pageMap.get(doc.page) || { page: doc.page, likes: 0, dislikes: 0, total: 0 }
      entry[doc.type === 'like' ? 'likes' : 'dislikes']++
      entry.total++
      pageMap.set(doc.page, entry)
    }
    const byPage = Array.from(pageMap.values()).map(p => ({
      ...p,
      satisfactionRate: p.total > 0 ? (p.likes / p.total) * 100 : 0,
    }))

    const optionMap = new Map()
    for (const doc of docs) {
      for (const opt of doc.options || []) {
        const key = `${opt}:${doc.type}`
        const entry = optionMap.get(key) || { option: opt, count: 0, type: doc.type }
        entry.count++
        optionMap.set(key, entry)
      }
    }
    const byOption = Array.from(optionMap.values())

    const recentEntries = docs.slice(0, 100).map(d => ({
      id: d.id,
      page: d.page,
      type: d.type,
      options: d.options || [],
      comment: d.comment || '',
      createdAt: d.createdAt,
    }))

    res.json({ totals: { total, likes, dislikes, satisfactionRate }, byPage, byOption, recentEntries })
  } catch (err) {
    console.error('Summary error:', err)
    res.status(500).json({ error: 'Internal server error' })
  }
})

app.listen(port, () => console.log(`Feedback API running on port ${port}`))

Run it:

COSMOS_ENDPOINT=https://your-account.documents.azure.com:443/ COSMOS_KEY=your-primary-key node server.js
VariableRequiredDescription
COSMOS_ENDPOINTYesCosmos DB account URI (for example, https://your-account.documents.azure.com:443/).
COSMOS_KEYYesCosmos DB primary or secondary key.
PORTNoServer port. Defaults to 3002.

Tip: If you prefer the MongoDB-compatible API, create a Cosmos DB account with MongoDB API instead and use the MongoDB server code above — just replace MONGODB_URI with your Cosmos DB connection string from the Azure Portal.

PostgreSQL

A solid relational option if you prefer structured data with SQL queries.

Quick start:

node scripts/setup-feedback-api.js postgresql
cd api && npm install

The script also generates api/schema.sql with the CREATE TABLE statement. Run it against your database before starting the server.

Or set up manually:

Manual setup
mkdir api && cd api
npm init -y
npm install express cors pg

Create the table:

CREATE TABLE feedback (
  id SERIAL PRIMARY KEY,
  page TEXT NOT NULL,
  type TEXT NOT NULL CHECK (type IN ('like', 'dislike')),
  options TEXT[] DEFAULT '{}',
  comment TEXT DEFAULT '',
  created_at TIMESTAMPTZ DEFAULT NOW()
);

Server code:

server.js
const express = require('express')
const cors = require('cors')
const { Pool } = require('pg')

const app = express()
const port = process.env.PORT || 3002

app.use(express.json())
app.use(cors({
  origin: ['http://localhost:3000', 'https://docs.example.com'],
}))

const pool = new Pool({ connectionString: process.env.DATABASE_URL })

// --- POST /api/feedback/submit ---
app.post('/api/feedback/submit', async (req, res) => {
  try {
    const { page, type, options, comment } = req.body

    if (!page || !type) {
      return res.status(400).json({ error: 'Missing required fields: page, type' })
    }
    if (type !== 'like' && type !== 'dislike') {
      return res.status(400).json({ error: 'type must be "like" or "dislike"' })
    }

    await pool.query(
      'INSERT INTO feedback (page, type, options, comment) VALUES ($1, $2, $3, $4)',
      [page, type, options || [], comment || '']
    )

    res.json({ success: true })
  } catch (err) {
    console.error('Submit error:', err)
    res.status(500).json({ error: 'Internal server error' })
  }
})

// --- GET /api/feedback/summary ---
app.get('/api/feedback/summary', async (req, res) => {
  try {
    const { period } = req.query

    let dateClause = ''
    const params = []
    if (period === '7d') {
      dateClause = 'WHERE created_at >= NOW() - INTERVAL \'7 days\''
    } else if (period === '30d') {
      dateClause = 'WHERE created_at >= NOW() - INTERVAL \'30 days\''
    }

    const { rows: docs } = await pool.query(
      `SELECT id, page, type, options, comment, created_at FROM feedback ${dateClause} ORDER BY created_at DESC`,
      params
    )

    const likes = docs.filter(d => d.type === 'like').length
    const dislikes = docs.filter(d => d.type === 'dislike').length
    const total = docs.length
    const satisfactionRate = total > 0 ? (likes / total) * 100 : 0

    const pageMap = new Map()
    for (const doc of docs) {
      const entry = pageMap.get(doc.page) || { page: doc.page, likes: 0, dislikes: 0, total: 0 }
      entry[doc.type === 'like' ? 'likes' : 'dislikes']++
      entry.total++
      pageMap.set(doc.page, entry)
    }
    const byPage = Array.from(pageMap.values()).map(p => ({
      ...p,
      satisfactionRate: p.total > 0 ? (p.likes / p.total) * 100 : 0,
    }))

    const optionMap = new Map()
    for (const doc of docs) {
      for (const opt of doc.options || []) {
        const key = `${opt}:${doc.type}`
        const entry = optionMap.get(key) || { option: opt, count: 0, type: doc.type }
        entry.count++
        optionMap.set(key, entry)
      }
    }
    const byOption = Array.from(optionMap.values())

    const recentEntries = docs.slice(0, 100).map(d => ({
      id: d.id.toString(),
      page: d.page,
      type: d.type,
      options: d.options || [],
      comment: d.comment || '',
      createdAt: d.created_at.toISOString(),
    }))

    res.json({ totals: { total, likes, dislikes, satisfactionRate }, byPage, byOption, recentEntries })
  } catch (err) {
    console.error('Summary error:', err)
    res.status(500).json({ error: 'Internal server error' })
  }
})

app.listen(port, () => console.log(`Feedback API running on port ${port}`))

Run it:

DATABASE_URL=postgresql://user:password@localhost:5432/trellis node server.js
VariableRequiredDescription
DATABASE_URLYesPostgreSQL connection string.
PORTNoServer port. Defaults to 3002.

Hosting options: Supabase (free tier), Neon, Railway, or any managed PostgreSQL provider.

Firebase Firestore

A serverless-friendly option with a generous free tier.

Firebase Console setup:

  1. Create a Firebase project.
  2. Enable Firestore in the Firebase Console.
  3. Go to Project Settings > Service accounts and generate a new private key JSON file.
  4. Set the contents of that JSON file as the FIREBASE_CREDENTIALS environment variable.

Quick start:

node scripts/setup-feedback-api.js firebase
cd api && npm install

Or set up manually:

Manual setup
mkdir api && cd api
npm init -y
npm install express cors firebase-admin

Server code:

server.js
const express = require('express')
const cors = require('cors')
const { initializeApp, cert } = require('firebase-admin/app')
const { getFirestore, Timestamp } = require('firebase-admin/firestore')

const app = express()
const port = process.env.PORT || 3002

app.use(express.json())
app.use(cors({
  origin: ['http://localhost:3000', 'https://docs.example.com'],
}))

initializeApp({ credential: cert(JSON.parse(process.env.FIREBASE_CREDENTIALS)) })
const db = getFirestore()
const feedbackRef = db.collection('feedback')

// --- POST /api/feedback/submit ---
app.post('/api/feedback/submit', async (req, res) => {
  try {
    const { page, type, options, comment } = req.body

    if (!page || !type) {
      return res.status(400).json({ error: 'Missing required fields: page, type' })
    }
    if (type !== 'like' && type !== 'dislike') {
      return res.status(400).json({ error: 'type must be "like" or "dislike"' })
    }

    await feedbackRef.add({
      page, type,
      options: options || [],
      comment: comment || '',
      createdAt: Timestamp.now(),
    })

    res.json({ success: true })
  } catch (err) {
    console.error('Submit error:', err)
    res.status(500).json({ error: 'Internal server error' })
  }
})

// --- GET /api/feedback/summary ---
app.get('/api/feedback/summary', async (req, res) => {
  try {
    const { period } = req.query

    let query = feedbackRef.orderBy('createdAt', 'desc')
    if (period === '7d') {
      query = query.where('createdAt', '>=', Timestamp.fromDate(new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)))
    } else if (period === '30d') {
      query = query.where('createdAt', '>=', Timestamp.fromDate(new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)))
    }

    const snapshot = await query.get()
    const docs = snapshot.docs.map(d => ({ id: d.id, ...d.data() }))

    const likes = docs.filter(d => d.type === 'like').length
    const dislikes = docs.filter(d => d.type === 'dislike').length
    const total = docs.length
    const satisfactionRate = total > 0 ? (likes / total) * 100 : 0

    const pageMap = new Map()
    for (const doc of docs) {
      const entry = pageMap.get(doc.page) || { page: doc.page, likes: 0, dislikes: 0, total: 0 }
      entry[doc.type === 'like' ? 'likes' : 'dislikes']++
      entry.total++
      pageMap.set(doc.page, entry)
    }
    const byPage = Array.from(pageMap.values()).map(p => ({
      ...p,
      satisfactionRate: p.total > 0 ? (p.likes / p.total) * 100 : 0,
    }))

    const optionMap = new Map()
    for (const doc of docs) {
      for (const opt of doc.options || []) {
        const key = `${opt}:${doc.type}`
        const entry = optionMap.get(key) || { option: opt, count: 0, type: doc.type }
        entry.count++
        optionMap.set(key, entry)
      }
    }
    const byOption = Array.from(optionMap.values())

    const recentEntries = docs.slice(0, 100).map(d => ({
      id: d.id,
      page: d.page,
      type: d.type,
      options: d.options || [],
      comment: d.comment || '',
      createdAt: d.createdAt.toDate().toISOString(),
    }))

    res.json({ totals: { total, likes, dislikes, satisfactionRate }, byPage, byOption, recentEntries })
  } catch (err) {
    console.error('Summary error:', err)
    res.status(500).json({ error: 'Internal server error' })
  }
})

app.listen(port, () => console.log(`Feedback API running on port ${port}`))

Run it:

FIREBASE_CREDENTIALS='{"type":"service_account",...}' node server.js
VariableRequiredDescription
FIREBASE_CREDENTIALSYesThe full JSON contents of your Firebase service account key file.
PORTNoServer port. Defaults to 3002.

Customization

When submission fails, the widget shows a "report the issue" link that opens a GitHub issue. This uses repoUrl from your site config:

config/site.ts
export const siteConfig = {
  repoUrl: 'https://github.com/your-org/your-repo',
  // ...
}

Styling

The widget uses Tailwind utility classes and CSS variables from your design tokens (--primary, --card, --muted, etc.), so it automatically matches your site theme including dark mode. To adjust the appearance, edit components/custom/feedback.tsx directly.

Feedback options

To change the options presented to readers, edit the LikeOptions and DislikeOptions objects at the top of components/custom/feedback.tsx.

Feedback Dashboard

Trellis includes a built-in dashboard for viewing and analyzing collected feedback. It is intended for doc admins and content owners — not site readers — and is accessed directly at /feedback-dashboard/.

Accessing the dashboard

Navigate to your site's /feedback-dashboard/ URL in a browser. For local development:

http://localhost:3000/feedback-dashboard/

What the dashboard shows

SectionDescription
Metric cardsTotal feedback count, likes, dislikes, and overall satisfaction rate.
Feedback by pageSortable table of every page that received feedback, with inline satisfaction bars. Select column headers to sort.
Feedback reasonsHorizontal bar chart showing how often each option was selected, split into positive and negative.
Recent feedbackChronological list of individual entries with option pills, comments, and relative timestamps.

Time period filter

Use the All time, 30 days, or 7 days buttons at the top right to filter the data. The dashboard refetches from the API each time you change the period.

The dashboard uses the GET /api/feedback/summary endpoint from the reference backend. See the summary response format for the expected JSON shape. If no backend is running, the dashboard displays a friendly error message with setup instructions.

Dashboard component structure

The dashboard is composed of modular sub-components in components/custom/feedback-dashboard/:

FileDescription
types.tsShared TypeScript interfaces and option label map.
feedback-dashboard.tsxMain orchestrator — owns fetch, period filter state, composes all sub-components.
metric-cards.tsxFour KPI cards (total, likes, dislikes, satisfaction rate).
page-breakdown-table.tsxSortable table with inline progress bars for satisfaction rate.
option-breakdown.tsxHorizontal bar visualization of selected feedback options.
recent-feedback-list.tsxPaginated list of individual feedback entries with "Show more".

Customization

  • Add to navigation: By default, the dashboard is not in the site nav. To add it, edit config/navigation.ts and add a nav item pointing to /feedback-dashboard/.
  • Change the page size: The recent feedback list shows 20 entries at a time. Edit the PAGE_SIZE constant in recent-feedback-list.tsx to adjust.
  • Adjust satisfaction thresholds: The table uses green (>= 70%), yellow (50–69%), and red (< 50%) for satisfaction bars. Edit rateColor() in page-breakdown-table.tsx to change these thresholds.

Troubleshooting

  • Modal not opening: Verify that the Feedback component is rendered client-side. It uses the 'use client' directive, so it works in any MDX page or layout, but not if imported from a server component at module scope.
  • Feedback not submitting: Check the Network tab in browser DevTools for errors in the POST request. Confirm that your backend is running and the URL in getBaseUrl() matches your setup.
  • CORS errors in development: If your backend runs on a different port (for example, 3002) than the dev server, ensure the backend sets appropriate Access-Control-Allow-Origin headers.
  • API endpoint returning errors: Check your backend logs for exceptions during feedback processing. Confirm the database connection is healthy.
  • "Report the issue" link broken: Check that siteConfig.repoUrl in config/site.ts points to a valid GitHub repository.
  • Dashboard shows "Unable to load feedback data": Your backend must provide a GET /api/feedback/summary endpoint. Check that it is running and returns the expected JSON shape described above.

Was this page helpful?