Client feedback, pinned to the pixel.
A lightweight feedback widget that lets your clients annotate websites during development. Draw rectangles, leave comments, track bugs — directly on the live site.
Getting Started · Configuration · API Reference · CLI · Architecture
See SitePing in action → — Draw annotations, leave feedback, track bugs directly on the live site.
Stop chasing client feedback across Slack threads, email chains, and Notion docs. SitePing gives your clients a contextual way to leave feedback — anchored to the exact element they're looking at.
| SitePing | Marker.io | BugHerd | |
|---|---|---|---|
| Self-hosted | Yes — your DB, your data | No (SaaS) | No (SaaS) |
| npm package | npm install and go |
npm + script tag | Script tag only |
| Framework-native | First-class Next.js support | Framework-agnostic | Framework-agnostic |
| Pricing | Free & open source | From $39/mo | From $42/mo |
| DOM-anchored annotations | Multi-selector (CSS + XPath + text) | Screenshot-based | Pin-based |
| Annotations survive layout changes | Yes (percentage-relative rects) | No (pixel coordinates) | Partially |
| Customizable | Full control (accent color, position, events) | Limited | Limited |
- Rectangle annotations — Clients draw directly on the page, with category + message
- DOM-anchored persistence — Annotations are tied to elements, not pixels. They survive layout changes
- Shadow DOM isolation — Widget CSS never leaks into your site, and your site CSS never breaks the widget
- Radial menu — Clean FAB with expandable actions (chat, annotate, toggle)
- Feedback panel — Searchable, filterable history with type chips and resolve/unresolve
- Smart tooltips — Hover a marker to preview, click to open the panel
- Retry with backoff — Failed submissions are queued in localStorage and retried automatically
- Zero config auth — Clients identify once (name + email), persisted locally
- Full event system —
onOpen,onClose,onFeedbackSent,onError,onAnnotationStart,onAnnotationEnd - CLI scaffold —
npx @siteping/cli initsets up Prisma schema + API route - Monorepo — Split into independent packages (
widget,adapter-prisma,adapter-memory,adapter-localstorage,cli) - Dev-only by default — Widget auto-hides in production unless
forceShow: true - Lightweight — ~49 KB gzipped today; after the upcoming bundle split (in progress), target is ~30 KB gzipped on first paint
npm install @siteping/widget
# or
bun add @siteping/widgetnpx @siteping/cli initThis will:
- Add
SitepingFeedbackandSitepingAnnotationmodels to yourprisma/schema.prisma - Generate an API route at
app/api/siteping/route.ts
Then push the schema:
npx prisma db push"use client"
import { useSiteping } from "@siteping/widget/react"
export default function Layout({ children }: { children: React.ReactNode }) {
useSiteping({
endpoint: "/api/siteping",
projectName: "my-app",
})
return <>{children}</>
}The useSiteping hook handles StrictMode double-mounts safely and tears the widget down on unmount. It returns the live instance so you can drive it programmatically from anywhere:
"use client"
import { useSiteping } from "@siteping/widget/react"
export function HelpButton() {
const widget = useSiteping({ endpoint: "/api/siteping", projectName: "my-app" })
return <button onClick={() => widget?.open()}>Need help?</button>
}React is declared as an optional peer dep — installing @siteping/widget alone won't pull React in, and the /react entry only resolves when you actually import it.
<script type="module">
import { initSiteping } from '@siteping/widget'
const widget = initSiteping({
endpoint: '/api/siteping',
projectName: 'my-project',
forceShow: true,
})
// Clean up when needed
// widget.destroy()
</script>The widget is framework-agnostic — it works with React, Vue, Svelte, Astro, or plain HTML.
That's it. Your clients can now draw rectangles on the site and leave feedback.
initSiteping({
// Required (one of endpoint or store)
endpoint: '/api/siteping', // Your API route (HTTP mode)
// OR
store: new LocalStorageStore(), // Direct store (client-side mode, no server)
projectName: 'my-project', // Scopes feedbacks to this project
// Optional
position: 'bottom-right', // 'bottom-right' | 'bottom-left'
accentColor: '#0066ff', // Widget accent color
theme: 'light', // 'light' | 'dark' | 'auto'
locale: 'en', // 'en' | 'fr' (default: 'en')
forceShow: false, // Always render — bypasses BOTH the
// production guard and the mobile-viewport
// guard. Default: false
minViewportWidth: 768, // Minimum viewport width (px) to render;
// below it onSkip('mobile') fires. Set 0 to
// allow mobile viewports. Default: 768
debug: false, // Enable debug logging
showAnnotationsToggle: true, // Show the "toggle markers visibility" item
// in the FAB radial menu. Default: true. Set
// to false for hosts that always want markers
// visible or find the eye icon redundant.
enableScreenshot: false, // Capture a JPEG of the annotated area on
// submit. Off by default — see "Screenshot
// capture" below for the masking story.
identity: { // Pre-fill author from the host (SSO apps).
name: 'Alice', // When set, skips localStorage + modal.
email: 'alice@example.com',
},
// Events
onOpen: () => {},
onClose: () => {},
onFeedbackSent: (feedback) => {},
onError: (error) => {},
onAnnotationStart: () => {},
onAnnotationEnd: () => {},
onSkip: (reason) => {}, // Called when widget is skipped (production/mobile)
})const widget = initSiteping({ ... })
widget.open() // Open the feedback panel
widget.close() // Close the feedback panel
widget.refresh() // Refresh feedbacks from the server
widget.destroy() // Remove the widget and clean up all DOM elements + listeners
// Event listeners (alternative to config callbacks)
const unsub = widget.on('feedback:sent', (feedback) => { ... })
unsub() // Unsubscribe
widget.off('feedback:sent', handler)
// All public events:
// 'feedback:sent' — fired after a feedback is successfully submitted
// 'feedback:deleted' — fired after a feedback is deleted (receives feedback id)
// 'panel:open' — fired when the feedback panel opens
// 'panel:close' — fired when the feedback panel closesApps with their own authentication (SSO, NextAuth, Symfony Security, …) can pre-fill the feedback author directly:
initSiteping({
endpoint: '/api/siteping',
projectName: 'my-app',
identity: { name: currentUser.fullName, email: currentUser.email },
})When identity is set, the widget skips both the localStorage lookup and the identity modal — useful when the host already knows who the user is. The value is not persisted and is read at widget init time, not on every render. Hosts that need to react to live sign-in/sign-out changes should currently remount the widget (e.g. via a React key on the wrapping component). See #85 for tracking a future enhancement that propagates identity updates without a remount.
When identity is unset (default), the widget falls back to the existing behavior — localStorage first, modal as a last resort.
When a feedback is created, downstream tools (Zammad tickets, Slack notifications, email digests, internal dashboards) usually want to link back to the exact pixel — not just the page. Set deepLink: true and the widget will pick up a ?siteping=<feedbackId> query parameter on initial load, scroll the matching annotation into view, pin its highlight, and pulse the marker.
initSiteping({
endpoint: '/api/siteping',
projectName: 'my-app',
deepLink: true,
})// On the receiving side (Zammad webhook, Slack handler, …), build the link:
const url = `${feedback.url}?siteping=${feedback.id}`When the recipient clicks the link, the widget opens on the original page with the annotation already in focus. Use a custom query key if siteping clashes with a host-app parameter:
deepLink: { param: 'fb' } // → ?fb=<feedbackId>Only the initial page load triggers focus. SPA navigations and history.pushState updates never trigger an automatic re-scroll — re-scrolling during normal browsing would be surprising. (The feedback data does follow route changes automatically: the widget re-fetches the new page's feedbacks on SPA navigation by default — see watchNavigation. It's only the deep-link focus/scroll that stays initial-load only.) Hosts that need to drive focus after a route change can call the imperative counterpart instead:
const widget = initSiteping({ endpoint: '/api/siteping', projectName: 'my-app' })
// Inside a notification handler, after navigating to feedback.url:
const matched = widget.focusFeedback(feedback.id)
// Returns false when no visible marker matches (unknown ID, filtered by
// scopeAnnotationsByUrl, or markers not yet loaded — initial fetch is async).Both entry points share the same focus implementation — only the trigger differs.
Turn on enableScreenshot and every submitted feedback ships with a JPEG of the annotated rectangle, embedded inline as a data: URL on the feedback record. The reviewer panel shows a dedicated Screenshot section on each feedback that has one.
initSiteping({
endpoint: '/api/siteping',
projectName: 'my-project',
enableScreenshot: true,
})html2canvas ships as a regular dependency of @siteping/widget — no separate install. The widget dynamic-imports it on the first capture (≈ 40 KB gzip), so installs that leave the option off pay only disk space.
Masking sensitive elements. Add data-siteping-ignore="true" to any element you do not want captured. The capture predicate skips matching elements and their descendants, so a single attribute on a wrapper is enough:
<input type="password" data-siteping-ignore="true">
<section data-siteping-ignore="true">
<!-- credit-card form, API tokens, PII … -->
</section>Set masks before turning the option on in production — once a feedback is saved, the screenshot is in your database (or object storage) regardless of what was on the page when it was captured.
Privacy. Screenshots embed page content. Inform end users in your widget host UI when enabling, and treat the feature as an opt-in for environments where casual page content might be confidential.
Output. The widget produces JPEG at quality 0.85, downscaled to a max width of 1200 CSS pixels (≈ 50–150 KB for a typical annotated area). Both numbers are currently fixed; if a use case appears that needs different settings, the option could grow into enableScreenshot: true | { quality?, maxWidth? } — file an issue if you'd find that useful.
Failure handling. If html2canvas fails to load or the capture throws (CORS-tainted canvas, missing 2D context, …), the widget returns null and the feedback still submits without the screenshot — the option never blocks a submission.
Turn on captureDiagnostics and every feedback ships with the last few console.* messages and failed network requests (HTTP ≥ 400 or network error). Closes the loop on the dreaded "this page just doesn't work" report.
initSiteping({
endpoint: '/api/siteping',
projectName: 'my-project',
captureDiagnostics: true,
})Or with per-channel control:
captureDiagnostics: {
console: true,
network: true,
maxConsoleEntries: 50, // default
maxNetworkEntries: 20, // default
}The reviewer panel shows a collapsible Diagnostics section on each feedback that has captured data — colour-coded console levels and a status/method/URL line per failed request.
Storage migration. The Prisma schema gains a diagnostics Json? column. Existing installs need to re-run the sync:
npx siteping sync
npx prisma db pushThe column is nullable so existing rows are unaffected. Hosts who don't want the migration can leave captureDiagnostics off — the widget never sends the field and the adapter never asks Prisma to write it.
Privacy. Console messages may contain whatever your app logs (user emails, IDs, …). Failed network URLs include the query string but never the response body. Inform end users in environments where they might log sensitive values, or restrict capture to internal builds.
Fire a Slack, Discord, or generic HTTP webhook every time a feedback is created — useful for surfacing client feedback in the team channel without leaving the chat.
// app/api/siteping/route.ts
import { createSitepingHandler } from '@siteping/adapter-prisma'
import { prisma } from '@/lib/prisma'
export const { GET, POST, PATCH, DELETE, OPTIONS } = createSitepingHandler({
prisma,
webhooks: [
{
url: process.env.SLACK_WEBHOOK_URL!,
type: 'slack',
},
{
url: process.env.DISCORD_WEBHOOK_URL!,
type: 'discord',
},
],
})You can pass a single config or an array. Each entry accepts:
| Option | Type | Description |
|---|---|---|
url |
string |
Required. Endpoint to POST to (Slack/Discord incoming webhook, or your own). |
type |
'slack' | 'discord' | 'generic' |
Payload format. Defaults to 'generic' (raw FeedbackRecord JSON). |
headers |
Record<string, string> |
Extra headers merged on top of Content-Type: application/json (sign payloads, add bearer tokens, …). |
timeoutMs |
number |
Abort after this many ms. Defaults to 5000. |
onError |
(err, feedbackId) => void |
Observe delivery failures (network error, non-2xx, timeout). Without it, errors land in console.warn. |
Behaviour
- Fire-and-forget. The widget gets its
201response before any webhook is awaited — a slow Slack response never blocks the user. - Per-webhook isolation. One failing receiver does not stop the others.
- Generic payload. When
typeis omitted, the body is the full feedback record — wire your own formatter (Microsoft Teams, Mattermost, custom dashboard, …).
// Generic receiver — verify signatures, persist to your own queue, etc.
webhooks: [
{
url: 'https://my-server.example.com/notify',
headers: { 'X-Signature': process.env.WEBHOOK_SECRET! },
onError: (err, id) => logger.warn({ err, feedbackId: id }, 'webhook failed'),
},
]The adapter handles all API logic — validation, persistence, error handling.
// app/api/siteping/route.ts
import { createSitepingHandler } from '@siteping/adapter-prisma'
import { prisma } from '@/lib/prisma'
export const { GET, POST, PATCH, DELETE, OPTIONS } = createSitepingHandler({ prisma })Note: The handler does not include rate limiting. Consider adding rate limiting middleware in production.
| Method | Description | Status |
|---|---|---|
POST |
Create a feedback with annotations | 201 with full feedback object |
GET |
List feedbacks (filterable by type, status, search) | 200 with { feedbacks, total } |
PATCH |
Resolve or unresolve a feedback | 200 with updated feedback |
DELETE |
Delete a feedback or all feedbacks for a project | 200 with { deleted: true } |
| Param | Type | Description |
|---|---|---|
projectName |
string |
Required. Filter by project |
type |
string |
Filter: question, change, bug, other |
status |
string |
Filter: open, resolved |
search |
string |
Full-text search on message content |
page |
number |
Pagination (default: 1) |
limit |
number |
Items per page (default: 50, max: 100) |
The CLI generates these models automatically. If you prefer manual setup:
model SitepingFeedback {
id String @id @default(cuid())
projectName String
type String // question | change | bug | other
message String
status String @default("open")
url String
diagnostics Json? // captureDiagnostics snapshot — optional
viewport String
userAgent String
authorName String
authorEmail String
clientId String @unique
resolvedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
annotations SitepingAnnotation[]
}
model SitepingAnnotation {
id String @id @default(cuid())
feedbackId String
feedback SitepingFeedback @relation(fields: [feedbackId], references: [id], onDelete: Cascade)
cssSelector String
xpath String
textSnippet String
elementTag String
elementId String?
textPrefix String
textSuffix String
fingerprint String
neighborText String
xPct Float
yPct Float
wPct Float
hPct Float
scrollX Float
scrollY Float
viewportW Int
viewportH Int
devicePixelRatio Float @default(1)
createdAt DateTime @default(now())
}npx @siteping/cli initInteractive setup that:
- Detects your
prisma/schema.prismafile - Merges the Siteping models (idempotent — safe to run multiple times)
- Generates the Next.js App Router API route
HTTP mode (endpoint) Client-side mode (store)
Browser Server Browser
| | |
| initSiteping() | | initSiteping({ store })
| Widget ────────> | | Widget ── StoreClient
| | | |
| POST /api/siteping| | LocalStorageStore
| ───────────────> | | or MemoryStore
| | |
| Handler | | No server needed
| Zod validation | |
| Prisma / Store | |
| <── 201 ──────── | |
- Shadow DOM (closed) — Widget styles are fully isolated from the host page
- Overlay outside Shadow DOM — The annotation overlay and markers live in the main DOM to avoid clipping from
overflow:hiddencontainers - Multi-selector anchoring — Each annotation stores a CSS selector (
@medv/finder), XPath, and text snippet. Re-anchoring tries all three in order, inspired by Hypothesis - Percentage-relative rectangles — Annotation positions are stored as fractions of the anchor element's bounding box, so they survive responsive layout changes
- Event bus with error isolation — User callbacks (
onError, etc.) cannot crash internal widget logic
| Package | Platform | Description |
|---|---|---|
@siteping/widget |
Browser | Widget: initSiteping() |
@siteping/adapter-prisma |
Node.js | Server: createSitepingHandler() |
@siteping/adapter-memory |
Any | In-memory store (testing, demos, serverless) |
@siteping/adapter-localstorage |
Browser | Client-side localStorage store (demos, prototyping) |
@siteping/cli |
CLI | Setup: init, sync, status, doctor |
Each package is independently published and tree-shakeable. The widget bundle never includes Prisma or Zod. The adapter never includes DOM code.
All adapters implement the SitepingStore interface — swap adapters without changing any other code.
- What the widget collects: author name, email, feedback message, page URL, viewport dimensions, user agent, and DOM anchoring data (CSS selector, XPath, text snippet, element coordinates).
- No screenshots or full DOM snapshots are captured by default — only the minimal data needed to re-anchor annotations. Hosts can opt in to per-annotation screenshot capture via
enableScreenshot: true; see Screenshot capture for the masking story and the privacy caveat. - Self-hosted — all data is stored in your own database. Nothing is sent to third-party servers.
- Sensitive URL parameters are automatically stripped before submission to prevent accidental data leakage.
Full type definitions are included. Key exported types:
import type {
SitepingConfig,
SitepingInstance,
SitepingPublicEvents,
FeedbackType, // 'question' | 'change' | 'bug' | 'other'
FeedbackStatus, // 'open' | 'resolved'
FeedbackPayload,
FeedbackResponse,
AnnotationPayload,
AnnotationResponse,
AnchorData,
RectData,
} from '@siteping/widget'# Unit tests (Vitest)
bun run test:run
# E2E tests (Playwright + Chromium)
bun run test:e2e
# Type check
bun run check| Suite | Tests | What it covers |
|---|---|---|
| Unit (Vitest) | ~1300 | Zod validation, API handlers, store conformance, adapter tests, EventBus, API client retry, identity persistence, theme normalization, DOM anchoring, resolver, fuzzy matching, fingerprinting, XPath, text context, i18n |
| E2E (Playwright) | 29 (×3 browsers) | Full browser: widget injection, FAB, panel, annotation draw, popup submit, marker creation, API persistence, i18n, search, touch, event delegation, cleanup |
The widget is dev-only by default. It auto-hides when NODE_ENV=production or import.meta.env.MODE === 'production'.
- Fix: Pass
forceShow: truein the config to show it in production. Note thatforceShowalso bypasses the mobile-viewport guard below. - The widget also hides on viewports narrower than 768px (mobile). This is by design — annotation drawing works best with a pointer device. Tune the threshold with
minViewportWidth(set0to allow mobile viewports), or bypass it entirely withforceShow: true.
If you see errors like The table does not exist in the current database, the schema hasn't been pushed yet.
npx prisma db pushIf you changed your schema manually, ensure the SitepingFeedback and SitepingAnnotation models match the expected structure. Run npx @siteping/cli doctor to verify.
- By default, the API has no authentication. Anyone who knows the endpoint URL can read, create, and delete feedbacks. For production, always set the
apiKeyoption increateSitepingHandler({ prisma, apiKey: process.env.SITEPING_API_KEY }). - The widget sends feedback without authentication (it runs in the browser). The
apiKeyprotects admin operations (GET, PATCH, DELETE). - Add rate limiting at your framework/infrastructure level to prevent abuse.
The widget renders inside a closed Shadow DOM, so host page styles cannot leak in. If you see style issues:
- Ensure no script is removing the
<siteping-widget>element from the DOM. - The annotation overlay and markers live outside the Shadow DOM (in the main DOM) to avoid
overflow: hiddenclipping. This is expected behavior. - If a CSS reset targets
*with!important, it may affect the overlay elements. Scope your reset to avoidsiteping-*elements.
See CHANGELOG.md for version history.
Recently shipped
- ✅ Multi-language support — 7 built-in locales (
en,fr,de,es,it,pt,ru) - ✅ Client-side store mode (no server needed)
- ✅ In-memory + localStorage adapters
- ✅ Adapter conformance test suite (22 tests, shared across adapters)
- ✅ Webhook notifications (Slack, Discord, generic HTTP) — see Notifications
Up next
- 🚧 Drizzle adapter — help wanted
- 🚧 Framework example apps (Astro, SvelteKit, Nuxt) — good first issues
- 🚧 MutationObserver for SPA re-anchoring
Planned
- 📋 Dashboard UI for reviewing feedbacks
- 📋 Screenshot fallback when re-anchoring fails
Want to help ship these? Browse
good first issueandhelp wanted.
Contributions are welcome — first-time contributors especially.
Where to start
- 🌱
good first issue— small, well-scoped tasks (locales, examples, docs, CLI flags) - 🎯
help wanted— meatier features (new adapters, dashboard) - 📖 Contributing guide — local setup, architecture, release flow
- 🌍 Adding a new locale takes three small steps — see Adding a Locale
Quick setup
git clone https://github.com/NeosiaNexus/SitePing.git
cd SitePing
bun install
bun run build # Build all packages
bun run test # Tests in watch mode
bun run test:e2e # E2E testsA big thank you to the Trade-su/SitePing fork — Valerii (@Trade-su) and grizodubov (@grizodubov) — for the work that inspired several upstream features. Forks that ship real ideas back are the best kind.
Specifically:
- Page-scoped annotations + semantic anchors (
data-feedback-anchor) — solves the cross-page leak when CSS selectors accidentally match unrelated elements. - Screenshot capture via
html2canvas— adapted upstream with a pluggableScreenshotStorageso production users can offload to S3/R2 instead of bloating Postgres. - Popup positioning fix — clamps inside the viewport when the drawn rect leaves no room above or below.
If you maintain a fork that adds features, opening an upstream PR or even just an issue describing what you've built is incredibly valuable — it lets the community benefit beyond your fork's user base.
Every line of code, locale, doc fix, and bug report makes SitePing better. Huge thanks to everyone who has shown up — first-time contributors especially.
This project follows the all-contributors specification — every kind of contribution counts (emoji key).
Olsen Matheo 💻 🚧 📖 🎨 🚇 🤔 👀 |
Alce 💻 🌍 |
Jörn Bungartz 🐛 💻 |
Andi Keßler 💻 |
Valerii 💻 |
JinHyuk Sung 💻 |
Marian Steinbach 🐛 🤔 |
LIghtJUNction 💻 |
Want to be in this list? Code, docs, translations, bug reports, design ideas — all count. See CONTRIBUTING.md to get started. The @all-contributors bot can add you automatically when a maintainer comments
@all-contributors please add @your-username for code, doc.
Activity graph powered by Repobeats.
