A self-hosted visual feedback tool for agency client reviews. Clients drop a pin anywhere on a staging site, a PDF, or an uploaded image, and the team triages, discusses and resolves that feedback from one dashboard.
Collecting website feedback over email and screenshots is slow and loses context. This app gives each project a small script-tag widget. A client clicks on the page, writes a comment, and the widget sends the pin with its X/Y position, a DOM selector and a screenshot. The agency dashboard groups that feedback by client site and review round, keeps a threaded discussion per pin, and can push new feedback to Slack, Discord, a signed webhook or GitHub Issues.
It is built as a self-hosted alternative to hosted tools like Markup.io, with role-aware teams, workspace branding and a versioned developer API.
Client widget
- One
<script>tag withdata-api-keyanddata-project-id; no iframe - Click-to-pin with X/Y coordinates, a DOM selector for re-anchoring, and a screenshot upload
- Keyboard and touch accessible, tested at 320px in Chromium, Firefox and WebKit
- Optional typed browser SDK (
packages/markup-sdk) withmount(),startFeedback(),on()anddestroy()
Dashboard
- Workspaces, teams and invitations with role enforcement on every API route
- Client sites with review rounds, sign-offs and reversible archiving
- Per-pin comment threads with
@mentions, open/resolved status, and internal issue metadata (assignee, priority and similar fields) - Developer context and structured hand-off on each pin
- PDF review (up to 50 pages, rendered server-side) and image review uploads
- Server-side recapture of a page with headless Chromium, with screenshot version history
- Managed public share links for client review without an account
- Workspace branding and a client review mode
- Live updates and presence over server-sent events
Integrations and API
- Durable outbound delivery queue for Slack, Discord, generic webhooks and GitHub Issues, with bounded retries and a dead-letter state
- Generic webhooks are signed with HMAC-SHA256 (
X-Visual-Feedback-Signature) - GitHub tokens are encrypted at rest with AES-256-GCM and never returned by the API
- Versioned developer API with project-scoped tokens and an OpenAPI 3 spec
- Member email notifications through Mailgun, with per-member, role-aware preferences
Security
- Session auth with CSRF and origin checks for the dashboard, project keys for the widget
- Rate limiting on pin, comment and screenshot status routes
- Strict input validation on IDs and uploads, plus an audit log of dashboard writes
- Next.js 16 (App Router, standalone output), React 19, TypeScript
- PostgreSQL with Prisma 6
- Tailwind CSS 4, Heroicons
- Vite for the standalone widget bundle (
public/widget.js) - npm workspaces:
packages/markup-core(shared server helpers) andpackages/markup-sdk(browser SDK) - Vitest, Testing Library, jsdom and Supertest for unit and integration tests; Playwright for E2E
- Docker multi-stage build and Docker Compose for local development
- GitHub Actions for build, lint and tests
Requirements: Docker with Compose, or Node.js 24 (see .nvmrc) and a PostgreSQL database.
cp .env.example .env # fill in the placeholders
docker compose up --buildThe stack starts PostgreSQL, runs every Prisma migration in a one-shot migrate service, then serves the app at http://localhost:3030 (change it with APP_PORT).
npm install
cp .env.example .env # set DATABASE_URL and the other required values
npx prisma migrate deploy
npm run devAll environment variables are documented in .env.example. Required at runtime: DATABASE_URL, DASHBOARD_HOST, SCREENSHOTS_DIR, DELIVERY_WORKER_SECRET and INTEGRATION_ENCRYPTION_KEY. Mailgun settings are optional; email is a no-op without them.
| Command | What it does |
|---|---|
npm run dev |
Next.js dev server |
npm run build |
Builds the widget, the workspace packages, then the Next.js app |
npm run build:widget |
Builds public/widget.js with Vite |
npm start |
Runs the production build |
npm run lint |
ESLint |
<script
src="https://<your-host>/widget.js"
data-api-key="<project apiKey>"
data-project-id="<project uuid>"></script>The dashboard shows the exact snippet for each project.
- Operations: env vars, deploy flow, pitfalls
- Developer API v1
- OpenAPI spec (also served at
/api/v1/openapi.json) - Browser SDK
- Rate limit notes
Deploy steps live in the deploy runbook.
scripts/install-cron.sh, called by deploy.sh after a healthy deploy, writes
these host cron jobs:
| Cron file | Schedule | Job |
|---|---|---|
/etc/cron.d/markup-clone |
0 3 * * * (daily 03:00 UTC) |
scripts/prune-screenshots.sh — removes website screenshots older than 90 days that have no pins, plus screenshot history versions and unreferenced files older than 90 days. Pinned screenshots, uploaded images and PDF pages are never pruned. It also deletes expired login sessions and stale presence rows. |
/etc/cron.d/markup-integration-delivery |
* * * * * (every minute) |
Claims a bounded delivery batch and calls the protected processor at POST /api/internal/integration-deliveries/process. |
/etc/cron.d/markup-backup |
15 2 * * * (daily 02:15 UTC) |
scripts/nightly-backup.sh — database dump plus an archive of every screenshot, upload, PDF page and comment attachment, with retention. Same-host only; copy off-host for disaster recovery. |
Pin creation writes an immutable visual-feedback.event.v1 envelope and one
delivery per configured target in the same transaction. Generic webhooks are
signed: X-Visual-Feedback-Signature is v1= plus the HMAC-SHA256 of
timestamp.payload. Retryable failures use a bounded schedule of five attempts;
permanent failures or an exhausted budget enter DEAD_LETTER, and project
admins can retry from the delivery log. GitHub Issues delivery needs a
fine-grained token limited to one repository with Metadata: read and
Issues: write; it is encrypted under INTEGRATION_ENCRYPTION_KEY and never
returned by the API.
npm test # Vitest: unit, integration, component and widget tests
npm run test:coverage # same, with V8 coverage
npm run test:e2e # Playwright in Chromium, Firefox and WebKit
npm run test:load:local # local-only pin ingestion load rehearsal (needs the Compose stack)Run npx playwright install chromium firefox webkit once before the E2E suite.
.
├── src/
│ ├── app/ # Next.js App Router pages and API routes
│ ├── components/ # Dashboard UI
│ ├── lib/ # Auth, validation, integrations, review workflow, email
│ └── widget/ # Source for the embeddable widget
├── packages/
│ ├── markup-core/ # Shared server helpers
│ └── markup-sdk/ # Typed browser SDK
├── prisma/ # Schema and migrations
├── tests/ # unit, integration, components, widget, e2e
├── docs/ # API docs and plans
├── Dockerfile
└── docker-compose.yml # Local development stack
MIT, see LICENSE.
Built by Cameron Ashley at Ashbi Design.