Standalone embeddable user feedback & ticket management system
REST API + Admin Dashboard + Lightweight Widget
- Overview
- Key Features
- Tech Stack
- Getting Started
- Widget Integration
- API Reference
- Project Structure
- Environment Variables
- Testing
- FAQ
- Contributing
- License
User Feedback is a self-hosted feedback collection and ticket management system designed to be embedded into any website. Users submit feedback (bug reports, feature requests, general inquiries) via a zero-dependency widget or a standalone page. Admins manage tickets, track analytics, and receive real-time updates through a dedicated dashboard.
The system communicates exclusively through a versioned REST API, making it interoperable with any frontend framework or static site.
| Category | Feature | Description |
|---|---|---|
| Widget | Embeddable JS widget | Zero-dependency vanilla TS bundle (~27 kB, ~7 kB gzipped) with Shadow DOM style isolation |
| Widget | Configurable via data-* attributes |
Theme (auto/light/dark), position, button label, button color, z-index |
| Widget | 3-step submission flow | Select type → Write feedback → Submit with instant tracking ID |
| API | Versioned REST API (/api/v1/) |
Public feedback submission & tracking, admin ticket management |
| API | Consistent response envelope | { success, data, error, meta } on every endpoint |
| API | Zod validation | Schema-based request validation on all endpoints |
| Admin | Ticket dashboard | List view with filtering by status, type, priority, and assignee |
| Admin | Ticket detail view | Full status history timeline, priority management, assignee panel |
| Admin | Analytics dashboard | Summary cards, status funnel chart, type breakdown, trend chart |
| Admin | Real-time updates (SSE) | Server-Sent Events stream for live ticket creation, updates, and deletions |
| Admin | Role-based access | ADMIN and MANAGER roles with JWT-based authentication (NextAuth v5) |
| Notifications | Email notifications | Status change alerts to submitters, new feedback alerts to admins (Resend) |
| Security | Rate limiting | Upstash Redis sliding window: public (5/10min), tracking (30/10min), admin (120/min) |
| Security | CORS management | Configurable origin allowlist with optional public open mode for widget embedding |
| Security | Soft delete | Tickets are soft-deleted (deletedAt field), never permanently removed |
| Accessibility | WCAG 2.1 AA | Skip navigation, keyboard navigation, ARIA labels, focus trap, axe-core tests |
| Layer | Technology | Purpose |
|---|---|---|
| Framework | Next.js 16 (App Router) | SSR for admin, Route Handlers for REST API |
| Language | TypeScript 5 (strict) | End-to-end type safety |
| ORM | Prisma 5 | Type-safe database access + migrations |
| Database | PostgreSQL (Supabase) | Primary data store with connection pooling |
| Auth | NextAuth v5 (JWT) | Admin dashboard authentication |
| Validation | Zod | Request/response schema validation |
| UI | shadcn/ui + Tailwind CSS v4 | Accessible, composable admin components |
| Charts | Recharts | Analytics dashboard visualizations |
| State | Zustand | Client-side state management |
| Resend | Transactional email notifications | |
| Rate Limiting | Upstash Redis | Sliding window rate limiting |
| Widget | Vanilla TypeScript (Vite IIFE bundle) | Zero-dependency embeddable widget |
| Testing | Vitest + Testing Library + jest-axe | Unit, integration, and accessibility tests |
| Deploy | Vercel | Serverless, edge-ready deployment |
- Node.js >= 18
- PostgreSQL database (or a Supabase project)
- npm (included with Node.js)
# Clone the repository
git clone https://github.com/Sadonim/user-feedback.git
cd user-feedback
# Install dependencies
npm install
# Copy environment variables
cp .env.example .env# Generate Prisma client
npx prisma generate
# Run database migrations
npx prisma migrate deploy
# (Optional) Seed with sample data
npm run seedThe seed script creates two admin accounts and seven sample tickets:
| Account | Default Password | Role | |
|---|---|---|---|
| Admin | admin@example.com |
admin1234 |
ADMIN |
| Manager | manager@example.com |
manager1234 |
MANAGER |
Warning: Change default seed passwords before any non-local deployment. Override via
SEED_ADMIN_PASSWORDandSEED_MANAGER_PASSWORDenvironment variables.
# Start the Next.js dev server
npm run dev
# Build the embeddable widget (outputs to public/widget.js)
npm run widget:build
# Watch mode for widget development
npm run widget:dev# Build for production
npm run build
# Start production server
npm startEmbed the feedback widget on any website with a single <script> tag:
<script
src="https://your-domain.com/widget.js"
data-api-url="https://your-domain.com"
></script>| Attribute | Default | Description |
|---|---|---|
data-api-url |
(required) | Base URL of your User Feedback instance |
data-theme |
auto |
Widget theme: auto, light, or dark |
data-position |
bottom-right |
Widget position: bottom-right, bottom-left, top-right, top-left |
data-button-label |
Feedback |
Trigger button label (max 50 characters) |
data-button-color |
#4F46E5 |
Trigger button hex color |
data-z-index |
9999 |
CSS z-index of the widget |
<script
src="https://your-domain.com/widget.js"
data-api-url="https://your-domain.com"
data-theme="dark"
data-position="bottom-left"
data-button-label="Send Feedback"
data-button-color="#3182f6"
></script>For single-page applications, the widget exposes a global API for teardown:
// Remove the widget programmatically
window.UserFeedbackWidget.destroy();All endpoints return a consistent JSON envelope:
{
"success": true,
"data": { ... },
"error": null,
"meta": { "total": 100, "page": 1, "limit": 20, "hasNextPage": true }
}| Method | Endpoint | Description |
|---|---|---|
POST |
/api/v1/feedback |
Submit new feedback |
GET |
/api/v1/feedback/:trackingId |
Track feedback status by tracking ID |
GET |
/api/v1/status |
Health check |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/v1/tickets |
List tickets (paginated, filterable) |
GET |
/api/v1/tickets/:id |
Ticket detail with status history |
PATCH |
/api/v1/tickets/:id |
Update status or priority |
DELETE |
/api/v1/tickets/:id |
Soft-delete a ticket |
POST |
/api/v1/tickets/:id/assign |
Assign/unassign a ticket |
GET |
/api/v1/tickets/stats |
Ticket statistics (count by status, type) |
GET |
/api/v1/tickets/stream |
SSE stream for real-time updates |
GET |
/api/v1/analytics/summary |
Aggregated analytics (funnel, distribution, rates) |
GET |
/api/v1/analytics/timeseries |
Daily ticket counts (7/14/30/90 days) |
GET |
/api/v1/admin/users |
List admin users (for assignee dropdown) |
| Parameter | Type | Default | Options |
|---|---|---|---|
status |
string | all | OPEN, IN_PROGRESS, RESOLVED, CLOSED |
type |
string | all | BUG, FEATURE, GENERAL |
priority |
string | all | LOW, MEDIUM, HIGH, CRITICAL |
assigneeId |
string | all | CUID or unassigned |
page |
number | 1 |
>= 1 |
limit |
number | 20 |
1-100 |
sort |
string | createdAt |
createdAt, updatedAt |
order |
string | desc |
asc, desc |
user-feedback/
├── prisma/
│ ├── schema.prisma # Database schema (Feedback, User, AdminUser, StatusHistory)
│ ├── seed.ts # Dev seed script
│ └── migrations/ # SQL migration files
├── public/
│ └── widget.js # Built widget bundle (generated)
├── src/
│ ├── app/
│ │ ├── api/
│ │ │ ├── v1/
│ │ │ │ ├── feedback/ # Public: submit + track
│ │ │ │ ├── tickets/ # Admin: CRUD + stats + SSE stream
│ │ │ │ ├── analytics/ # Admin: summary + timeseries
│ │ │ │ ├── admin/users/ # Admin: user list for assignee
│ │ │ │ └── status/ # Health check
│ │ │ └── auth/[...nextauth]/ # NextAuth route handler
│ │ ├── admin/
│ │ │ ├── (protected)/ # Dashboard, tickets, analytics (auth required)
│ │ │ └── login/ # Admin login page
│ │ └── (public)/
│ │ ├── submit/ # Feedback submission page
│ │ └── track/ # Ticket tracking page
│ ├── components/
│ │ ├── admin/ # Dashboard components + charts
│ │ ├── auth/ # Login form
│ │ ├── feedback/ # Submission form, tracking view
│ │ ├── layout/ # Skip navigation
│ │ └── ui/ # shadcn/ui base components
│ ├── lib/
│ │ ├── api/ # Response helpers, CORS, auth middleware
│ │ ├── sse/ # SSE format utilities
│ │ ├── validators/ # Zod schemas
│ │ ├── rate-limit.ts # Upstash Redis rate limiting
│ │ └── tracking.ts # Tracking ID generator (nanoid)
│ ├── server/
│ │ ├── db/prisma.ts # Prisma client singleton
│ │ └── services/
│ │ ├── analytics.ts # Analytics aggregation queries
│ │ ├── ticket-stats.ts # Ticket statistics service
│ │ └── email/ # Email service (adapter pattern)
│ │ ├── adapters/ # Resend + Null (dev/test) adapters
│ │ └── templates/ # Email templates
│ ├── widget/ # Embeddable widget (vanilla TS)
│ │ ├── index.ts # Entry point + SPA destroy API
│ │ ├── config.ts # data-* attribute parser + validation
│ │ ├── api.ts # Widget API client
│ │ ├── state.ts # Immutable state machine
│ │ ├── styles.ts # Scoped CSS-in-JS
│ │ ├── utils/focus-trap.ts # Accessibility focus trap
│ │ └── ui/ # DOM rendering (button, popup, overlay)
│ ├── types/ # Shared TypeScript types
│ └── __tests__/
│ ├── unit/ # 19 unit test files
│ ├── integration/ # 12 integration test files
│ └── a11y/ # 8 accessibility test files (jest-axe)
├── auth.ts # NextAuth configuration
├── middleware.ts # Admin route protection
├── next.config.ts # CORS headers + widget serving
├── vite.widget.config.ts # Widget build config (IIFE bundle)
├── vitest.config.ts # Test configuration (80% coverage threshold)
└── package.json
Copy .env.example and fill in the values:
cp .env.example .env| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
Yes | PostgreSQL connection string (pooled) |
DIRECT_URL |
Yes | PostgreSQL direct connection (for migrations) |
AUTH_SECRET |
Yes | NextAuth secret key (generate with openssl rand -base64 32) |
NEXTAUTH_URL |
Yes | Application URL (e.g., http://localhost:3000) |
NEXT_PUBLIC_APP_URL |
Yes | Public-facing application URL |
CORS_ALLOWED_ORIGINS |
Prod | Comma-separated allowed origins for CORS |
CORS_PUBLIC_OPEN |
No | Set to true to allow all origins on public feedback endpoint (default: false) |
UPSTASH_REDIS_REST_URL |
No | Upstash Redis URL for rate limiting (disabled if absent) |
UPSTASH_REDIS_REST_TOKEN |
No | Upstash Redis token |
RESEND_API_KEY |
No | Resend API key for email notifications (NullAdapter used if absent) |
EMAIL_FROM |
No | Sender address (e.g., Feedback <noreply@yourapp.com>) |
ADMIN_NOTIFICATION_EMAILS |
No | Comma-separated admin emails for new feedback alerts |
Rate limiting and email notifications gracefully degrade when their respective environment variables are not set. The system remains fully functional without them.
The project uses Vitest with an 80% coverage threshold enforced across lines, functions, branches, and statements.
# Run all tests
npm test
# Run unit tests only
npm run test:unit
# Run integration tests only
npm run test:integration
# Run tests in watch mode
npm run test:watch
# Run with coverage report
npm run test:coverage| Type | Count | Location | Scope |
|---|---|---|---|
| Unit | 19 files | src/__tests__/unit/ |
Validators, API helpers, widget logic, email templates |
| Integration | 12 files | src/__tests__/integration/ |
API endpoints, CORS, notifications, SSE stream |
| Accessibility | 8 files | src/__tests__/a11y/ |
WCAG 2.1 AA compliance (jest-axe) |
How do tracking IDs work?
Each submitted feedback receives a unique tracking ID in the format FB-xxxxxxxx (8 random alphanumeric characters generated with nanoid). Users can look up their submission status using this ID on the tracking page or via the GET /api/v1/feedback/:trackingId endpoint. Collision handling retries up to 3 times.
Can I use a database other than Supabase?
Yes. The system uses Prisma with a standard PostgreSQL datasource. Any PostgreSQL-compatible database works. Update DATABASE_URL and DIRECT_URL in your .env file. No Supabase-specific features are used beyond the connection string.
What happens if Redis (Upstash) is not configured?
Rate limiting is disabled gracefully. All requests are allowed through. A console warning is logged on first use. This is the expected behavior for local development.
What happens if the Resend API key is not set?
The email service falls back to a NullAdapter that silently accepts all send calls without delivering emails. The API continues to function normally. This is intended for development and testing environments.
How does the SSE real-time stream work?
The GET /api/v1/tickets/stream endpoint opens a Server-Sent Events connection that polls the database every 3 seconds for changes. It emits ticket.created, ticket.updated, and ticket.deleted events. A keepalive comment is sent every 15 seconds to prevent proxy timeouts. The stream supports automatic reconnection via the standard Last-Event-ID header.
Is the widget safe from CSS conflicts?
Yes. The widget renders inside a Shadow DOM, which provides full style isolation from the host page. The host page's CSS cannot leak into the widget, and the widget's styles cannot affect the host page.
How does soft delete work?
When a ticket is deleted via the admin API, its deletedAt field is set to the current timestamp instead of removing the row. All queries automatically filter out soft-deleted records (WHERE deletedAt IS NULL). The SSE stream detects soft deletions and notifies connected clients.
Contributions are welcome. Please follow these steps:
- Fork the repository
- Create a feature branch (
git checkout -b feat/your-feature) - Write tests for your changes
- Ensure all tests pass (
npm test) and lint is clean (npm run lint) - Commit using conventional commits (e.g.,
feat:,fix:,refactor:) - Open a pull request
This project is open source. See the repository for license details.