feedback-tracker
v0.2.1
Published
A universal embeddable feedback widget with pluggable backend adapters.
Maintainers
Readme
feedback-tracker
A universal, embeddable feedback widget. One Web Component, three reference adapters, GDPR-friendly defaults. No framework, no build step, no vendor lock-in.
Live demo: https://feedback.api.mpeters.dev/ · CDN: https://feedback.api.mpeters.dev/feedback-tracker.js
<script type="module" src="https://feedback.api.mpeters.dev/feedback-tracker.js"></script>
<feedback-tracker
adapter="pocketbase"
endpoint="https://feedback.api.mpeters.dev"
collection="feedback"
collect-email
></feedback-tracker>That's it. Floating button bottom-right, click → form, submit → the hosted PocketBase instance (or any backend you wire up via the adapters).
Why another one?
Existing widgets either lock you to one SaaS, require a backend you don't have, or aren't backend-agnostic. This one is a Web Component (works in any framework or none) with a pluggable adapter system so the backend is your choice. A couple are bundled out of the box; writing another is ~30 lines.
Install
Three options, pick one:
1. Use the hosted CDN (zero setup — production deployment at feedback.api.mpeters.dev):
<script type="module" src="https://feedback.api.mpeters.dev/feedback-tracker.js"></script>2. Self-host the static file: copy src/ to any static host and load it as an ES module:
<script type="module" src="/path/to/feedback-tracker.js"></script>3. Via npm (once published):
npm install feedback-trackerimport 'feedback-tracker';Bundled adapters
| Adapter | Use when | Setup |
|---|---|---|
| webhook | You want to POST JSON anywhere — your own server, a Cloudflare Worker, a Discord/Slack webhook | endpoint="..." |
| pocketbase | You want a self-hosted single-binary backend on your homeserver | endpoint, collection |
Webhook (universal)
<feedback-tracker
adapter="webhook"
endpoint="https://example.com/feedback"
></feedback-tracker>POSTs JSON like:
{
"type": "idea",
"message": "...",
"email": "[email protected]",
"meta": {
"url": "https://yoursite.com/page",
"userAgent": "...",
"locale": "en-US",
"timestamp": "2026-04-12T15:14:22.000Z"
}
}Discord and Slack webhooks need a slightly different shape — pass data-format="discord" or data-format="slack" and the adapter formats the body for you.
PocketBase (self-host or hosted)
PocketBase is a single Go binary (~30 MB) with SQLite, an admin UI, file storage, and rule-based permissions. The hosted instance at https://feedback.api.mpeters.dev is a PocketBase deployment configured exactly like the steps below — see Self-hosting for how it's set up.
To run your own:
- Download and run PocketBase:
./pocketbase serve - Open the admin UI at
http://127.0.0.1:8090/_/and create an admin account. - Create a new collection called
feedbackwith fields:type(Plain text, required)message(Plain text, required, max length 5000)email(Email, optional)url(URL)userAgent(Plain text)locale(Plain text)screenshot(File, optional, max 5 MB, MIME typesimage/png,image/jpeg,image/webp) — required if you want to receive screenshots
- In the collection's API Rules tab, set the Create rule to an empty string (just save it blank). Leave List/View/Update/Delete rules unset so the public can write but not read.
- Embed the widget:
<feedback-tracker
adapter="pocketbase"
endpoint="https://pb.example.com"
collection="feedback"
></feedback-tracker>The PocketBase admin UI is your triage dashboard. Pair with Cloudflare Tunnel to expose it from a homeserver without opening ports — or follow the Caddy recipe below for a public droplet.
Configuration
| Attribute | Description |
|---|---|
| adapter | Which adapter to use (webhook, pocketbase, or any registered) |
| accent | CSS color for the button and accents (default #6366f1) |
| label | Trigger button text (default Feedback) |
| collect-email | Show an email field |
| position | bottom-right (default) or bottom-left |
| hidden-trigger | Hide the floating button (control programmatically) |
Adapter-specific attributes (endpoint, project, etc.) are passed through to the adapter's config object. Anything starting with data- is also passed through.
Programmatic control
import { FeedbackTracker, registerAdapter } from 'feedback-tracker';
// Register a custom adapter
registerAdapter('mybackend', {
async submit(payload, config) {
const res = await fetch(config.endpoint, {
method: 'POST',
body: JSON.stringify(payload),
});
return res.ok ? { ok: true } : { ok: false, error: await res.text() };
},
});<feedback-tracker adapter="mybackend" endpoint="..."></feedback-tracker>Events
The widget dispatches feedback-sent and feedback-error custom events that bubble through the shadow root:
document.querySelector('feedback-tracker').addEventListener('feedback-sent', (e) => {
console.log('sent', e.detail.payload, e.detail.result);
});Spam protection
The widget ships with a honeypot field (catches naive bots for free). For real spam protection, put a Cloudflare Turnstile verifier in front of your endpoint — it's free, unlimited, and invisible. The webhook adapter is the natural place to add it: send the Turnstile token in the payload and verify server-side before storing.
React / Next.js
A thin React wrapper ships at feedback-tracker/react. It registers the Web Component on mount (SSR-safe — registration happens in useEffect) and forwards camelCase props as kebab-case attributes:
'use client';
import { FeedbackTracker } from 'feedback-tracker/react';
export default function Page() {
return (
<FeedbackTracker
adapter="pocketbase"
endpoint="https://feedback.api.mpeters.dev"
collection="feedback"
collectEmail
accent="#6366f1"
label="Send feedback"
onSent={(d) => console.log('sent', d.payload, d.result)}
onError={(d) => console.warn('failed', d.error)}
/>
);
}TypeScript types ship with the package (src/react/index.d.ts). All adapter-specific props (endpoint, collection, accessKey) and widget options (accent, label, position, collectEmail, hiddenTrigger, noScreenshot, dataFormat) are typed. onSent / onError receive the same detail payload as the underlying feedback-sent / feedback-error custom events.
To register a custom adapter from React code:
import { registerAdapter } from 'feedback-tracker/react';
await registerAdapter('mybackend', {
async submit(payload, config) { /* ... */ },
});Demo
Live: https://feedback.api.mpeters.dev/ — a Next.js static export using the React wrapper, deployed to the same droplet that hosts the widget files. Click the floating button in the bottom-right to submit to the hosted PocketBase instance.
Local development:
cd demo-next
npm install
npm run dev
# open http://localhost:3000Build the static export:
npm run demo:build # → demo-next/out/
npm run demo:preview # → http://localhost:3000 (built output)Edit demo-next/app/page.tsx to change adapters, endpoints, or layout. The demo imports the React wrapper from the workspace via a file:.. dependency, so changes to src/react/index.jsx are picked up after npm install.
Self-hosting (Caddy + PocketBase)
This is the exact recipe used to deploy the hosted instance at https://feedback.api.mpeters.dev on a $4/mo DigitalOcean droplet (Ubuntu, 1 vCPU, 512 MB). Replace the domain with your own.
1. DNS. Point an A record at your droplet's IP:
feedback.example.com A YOUR.DROPLET.IP 602. Install Caddy (terminates TLS, serves static files, reverse-proxies to PocketBase):
apt install -y debian-keyring debian-archive-keyring apt-transport-https curl gnupg
curl -fsSL https://dl.cloudsmith.io/public/caddy/stable/gpg.key \
| gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -fsSL https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt \
> /etc/apt/sources.list.d/caddy-stable.list
apt update && apt install -y caddy3. Install PocketBase as a systemd service:
PB_VER=$(curl -fsSL https://api.github.com/repos/pocketbase/pocketbase/releases/latest \
| grep tag_name | head -1 | sed 's/.*"v\([^"]*\)".*/\1/')
curl -fsSL -o /tmp/pb.zip \
"https://github.com/pocketbase/pocketbase/releases/download/v${PB_VER}/pocketbase_${PB_VER}_linux_amd64.zip"
apt install -y unzip
unzip -o /tmp/pb.zip pocketbase -d /usr/local/bin/
chmod +x /usr/local/bin/pocketbase
useradd --system --home /var/lib/pocketbase --shell /usr/sbin/nologin pocketbase
mkdir -p /var/lib/pocketbase && chown -R pocketbase:pocketbase /var/lib/pocketbase
cat > /etc/systemd/system/pocketbase.service <<'EOF'
[Unit]
Description=PocketBase
After=network.target
[Service]
Type=simple
User=pocketbase
Group=pocketbase
ExecStart=/usr/local/bin/pocketbase serve --http=127.0.0.1:8090 --dir=/var/lib/pocketbase/pb_data --publicDir=/var/lib/pocketbase/pb_public
Restart=always
RestartSec=5
LimitNOFILE=4096
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now pocketbase4. Deploy the widget files:
mkdir -p /var/www/feedback-tracker
# from your dev machine:
rsync -az src/ [email protected]:/var/www/feedback-tracker/
rsync -az README.md LICENSE package.json [email protected]:/var/www/feedback-tracker/
rsync -az demo/index.html [email protected]:/var/www/feedback-tracker/index.html5. Configure Caddy (/etc/caddy/Caddyfile):
feedback.example.com {
encode gzip zstd
@cors_preflight method OPTIONS
handle @cors_preflight {
header Access-Control-Allow-Origin "*"
header Access-Control-Allow-Methods "GET, POST, OPTIONS"
header Access-Control-Allow-Headers "Content-Type, Authorization"
header Access-Control-Max-Age "86400"
respond 204
}
handle /api/* {
reverse_proxy 127.0.0.1:8090
header Access-Control-Allow-Origin "*"
}
handle /_/* {
reverse_proxy 127.0.0.1:8090
}
handle {
root * /var/www/feedback-tracker
header {
Access-Control-Allow-Origin "*"
Cache-Control "public, max-age=300"
?X-Content-Type-Options "nosniff"
}
@js path *.js
header @js Content-Type "application/javascript; charset=utf-8"
file_server
}
}systemctl reload caddyCaddy provisions a Let's Encrypt cert automatically on first request.
6. Bootstrap the PocketBase superuser & feedback collection:
sudo -u pocketbase /usr/local/bin/pocketbase superuser create \
[email protected] 'GENERATE-A-STRONG-PASSWORD' \
--dir=/var/lib/pocketbase/pb_dataThen log into https://feedback.example.com/_/ and create the feedback collection per the PocketBase adapter section above. Or do it via API:
TOKEN=$(curl -sS -X POST https://feedback.example.com/api/collections/_superusers/auth-with-password \
-H "Content-Type: application/json" \
-d '{"identity":"[email protected]","password":"YOUR_PASSWORD"}' | jq -r .token)
curl -sS -X POST https://feedback.example.com/api/collections \
-H "Authorization: $TOKEN" -H "Content-Type: application/json" \
-d '{
"name": "feedback",
"type": "base",
"fields": [
{"name": "type", "type": "text", "required": true, "max": 50},
{"name": "message", "type": "text", "required": true, "max": 5000},
{"name": "email", "type": "email", "required": false},
{"name": "url", "type": "url", "required": false},
{"name": "userAgent", "type": "text", "required": false, "max": 500},
{"name": "locale", "type": "text", "required": false, "max": 20},
{"name": "screenshot", "type": "file", "required": false, "maxSelect": 1, "maxSize": 5242880,
"mimeTypes": ["image/png","image/jpeg","image/webp"]}
],
"createRule": "",
"listRule": null, "viewRule": null, "updateRule": null, "deleteRule": null
}'7. Embed the widget elsewhere:
<script type="module" src="https://feedback.example.com/feedback-tracker.js"></script>
<feedback-tracker
adapter="pocketbase"
endpoint="https://feedback.example.com"
collection="feedback"
collect-email
></feedback-tracker>Updating after code changes:
rsync -az src/ [email protected]:/var/www/feedback-tracker/No reload needed — Caddy serves new files immediately (the Cache-Control: max-age=300 means embedders may take up to 5 minutes to pick up changes).
Resource use (observed on the live droplet): Caddy ~17 MB RAM, PocketBase ~10 MB RAM. Comfortably fits a 512 MB droplet.
Adapter interface
To write your own adapter:
export const myAdapter = {
async submit(payload, config) {
// payload = { type, message, email?, meta }
// config = attributes from the <feedback-tracker> tag
// return = { ok: true, id?: string } | { ok: false, error: string }
},
};Then register it before the element is connected:
import { registerAdapter } from 'feedback-tracker';
import { myAdapter } from './my-adapter.js';
registerAdapter('mine', myAdapter);Roadmap
- Built-in Cloudflare Turnstile support
- More adapters: Supabase, GitHub Issues (via Worker proxy), Brevo (via Worker proxy)
- i18n
- Custom field schemas
Recently shipped: Lucide icon set, screenshot capture + redaction, hosted PocketBase deployment, React wrapper, Next.js static-export demo.
License
MIT
