Feedback Widget
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:
import { Feedback } from '@/components/custom/feedback'Then place it inside the <article> element, after the closing </div> of the prose block:
<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.metafor 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
- The reader selects thumbs up or thumbs down.
- A modal appears with contextual options (for example, "Easy to follow" for likes, "Hard to understand" for dislikes).
- If the reader selects "Provide details", a free-text field appears.
- On submit, the widget POSTs the data to your feedback API endpoint.
- 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:
| Part | Description |
|---|---|
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 modal | Displays contextual checkbox options and an optional text field after the reader selects a thumb button. |
| Thank-you modal | Confirms successful submission. |
| Error modal | Shown on submission failure, with a link to report the issue on GitHub. |
Dependencies
- React (with
useState,useMemo,useEffect,useRefhooks) - Next.js (
usePathnamefor the current page path) - lucide-react (
ThumbsUp,ThumbsDown,Xicons) - Tailwind CSS with design-token CSS variables for theming
cn()utility from@/lib/utilsfor 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:
| Method | Path | Used by | Description |
|---|---|---|---|
POST | /api/feedback/submit | Feedback widget | Receives and stores a single feedback submission. |
GET | /api/feedback/summary?period=all|30d|7d | Feedback Dashboard | Returns aggregated feedback data for the dashboard. |
Base URL configuration
The getBaseUrl() function in lib/feedback-api.ts determines where requests are sent:
| Environment | URL |
|---|---|
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:
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"
}| Field | Type | Description |
|---|---|---|
page | string | The pathname of the page where feedback was submitted. |
type | "like" | "dislike" | Whether the reader found the page helpful. |
options | string[] | Zero or more option keys the reader selected (see tables below). |
comment | string | Free-text comment, present only when the reader selects "Provide details". |
Option keys
Like options:
| Key | Label shown to reader |
|---|---|
ACCURATE | Accurately describes the platform |
RESOLVE_ISSUE | Helped me resolve an issue |
EASY_TO_FOLLOW | Easy to follow and comprehend |
CLEAR_CODE_SAMPLES | Code samples were clear |
ADOPT_PLATFORM | Convinced me to adopt the platform |
POSITIVE_ANOTHER_REASON | Provide details |
Dislike options:
| Key | Label shown to reader |
|---|---|
INACCURATE | Doesn't accurately describe the platform |
NOT_FOUND | Couldn't find what I was looking for |
MISSING_INFO | Missing important information |
HARD_TO_UNDERSTAND | Hard to understand |
COMPLICATED | Too complicated or unclear |
CODE_ERRORS | Code sample errors |
NEGATIVE_ANOTHER_REASON | Provide 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
| Platform | Best for | Free tier | Notes |
|---|---|---|---|
| MongoDB Atlas | General purpose, flexible schema | 512 MB | Familiar driver, large ecosystem |
| Azure Cosmos DB | Azure-hosted sites, global distribution | 1000 RU/s | Native SQL API or MongoDB-compatible API |
| PostgreSQL | SQL queries, analytics, structured data | Varies by host | Supabase and Neon offer generous free tiers |
| Firebase Firestore | Serverless, minimal backend code | 1 GiB storage | Can 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 installOr set up manually:
Manual setup
mkdir api && cd api
npm init -y
npm install express cors mongodbServer code:
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| Variable | Required | Description |
|---|---|---|
MONGODB_URI | Yes | MongoDB connection string (for example, mongodb://localhost:27017 or your Atlas URI). |
PORT | No | Server 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):
- Create a Cosmos DB account (choose NoSQL API).
- Create a database named
trellis. - Create a container named
feedbackwith/pageas the partition key. - Copy the URI and Primary Key from the Keys blade.
Quick start:
node scripts/setup-feedback-api.js cosmos
cd api && npm installOr set up manually:
Manual setup
mkdir api && cd api
npm init -y
npm install express cors @azure/cosmosServer code:
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| Variable | Required | Description |
|---|---|---|
COSMOS_ENDPOINT | Yes | Cosmos DB account URI (for example, https://your-account.documents.azure.com:443/). |
COSMOS_KEY | Yes | Cosmos DB primary or secondary key. |
PORT | No | Server 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_URIwith 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 installThe 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 pgCreate 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:
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| Variable | Required | Description |
|---|---|---|
DATABASE_URL | Yes | PostgreSQL connection string. |
PORT | No | Server 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:
- Create a Firebase project.
- Enable Firestore in the Firebase Console.
- Go to Project Settings > Service accounts and generate a new private key JSON file.
- Set the contents of that JSON file as the
FIREBASE_CREDENTIALSenvironment variable.
Quick start:
node scripts/setup-feedback-api.js firebase
cd api && npm installOr set up manually:
Manual setup
mkdir api && cd api
npm init -y
npm install express cors firebase-adminServer code:
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| Variable | Required | Description |
|---|---|---|
FIREBASE_CREDENTIALS | Yes | The full JSON contents of your Firebase service account key file. |
PORT | No | Server port. Defaults to 3002. |
Customization
Error reporting link
When submission fails, the widget shows a "report the issue" link that opens a GitHub issue. This uses repoUrl from your site config:
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
| Section | Description |
|---|---|
| Metric cards | Total feedback count, likes, dislikes, and overall satisfaction rate. |
| Feedback by page | Sortable table of every page that received feedback, with inline satisfaction bars. Select column headers to sort. |
| Feedback reasons | Horizontal bar chart showing how often each option was selected, split into positive and negative. |
| Recent feedback | Chronological 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/:
| File | Description |
|---|---|
types.ts | Shared TypeScript interfaces and option label map. |
feedback-dashboard.tsx | Main orchestrator — owns fetch, period filter state, composes all sub-components. |
metric-cards.tsx | Four KPI cards (total, likes, dislikes, satisfaction rate). |
page-breakdown-table.tsx | Sortable table with inline progress bars for satisfaction rate. |
option-breakdown.tsx | Horizontal bar visualization of selected feedback options. |
recent-feedback-list.tsx | Paginated 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.tsand 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_SIZEconstant inrecent-feedback-list.tsxto adjust. - Adjust satisfaction thresholds: The table uses green (>= 70%), yellow (50–69%), and red (< 50%) for satisfaction bars. Edit
rateColor()inpage-breakdown-table.tsxto change these thresholds.
Troubleshooting
- Modal not opening: Verify that the
Feedbackcomponent 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
POSTrequest. Confirm that your backend is running and the URL ingetBaseUrl()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 appropriateAccess-Control-Allow-Originheaders. - 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.repoUrlinconfig/site.tspoints to a valid GitHub repository. - Dashboard shows "Unable to load feedback data": Your backend must provide a
GET /api/feedback/summaryendpoint. Check that it is running and returns the expected JSON shape described above.