snuggly-nudger
v1.0.1
Published
Reusable client and server utilities for update notifications and in-app feedback reporting.
Maintainers
Readme
snuggly-nudger
snuggly-nudger is a lightweight React + server utility package extracted from https://catacombs.9lives.quest for a snuggly approach to:
- polling your deployed app version and showing update notifications
- collecting feedback/bug reports in a reusable modal
- providing server handlers for
/api/versionand/api/report
It is designed for modern full-stack apps (for example, Next.js App Router on Vercel), while staying framework-agnostic on the server side through standard Web Request/Response handlers.
The flow the original is built on uses Vercel serverless functions to interact with a Neon psql database (Neon is super cute, btw) on a droplet to post bug reports to a private channel in my discord server so I can click a button to create a github issue in the offending repository. Since I have a bunch of projects I'm prototyping at once, this is how I chose to centralize feedback from all of them. I'm moving it part of it here for re-use in other apps.
Install
npm install snuggly-nudger reactPackage Exports
The main entry snuggly-nudger/client re-exports everything for convenience. If you are the kind of person who still side-eyes bundle size reports because you've been hurt before, import only what you need from granular subpaths instead (your bundler should tree-shake the barrel too when configured correctly):
import { useVersionPoll } from 'snuggly-nudger/client/useVersionPoll';
import { UpdateAvailableToast } from 'snuggly-nudger/client/UpdateAvailableToast';Available subpaths: client/useVersionPoll, client/UpdateAvailableToast, client/FeedbackModal, client/ModalCloseButton, client/useIsMobile, client/useFeedbackReport.
snuggly-nudger/client
useVersionPoll(currentVersion, options?)UpdateAvailableToastFeedbackModaluseFeedbackReport(options)— headless state and submit for building your own modal UIModalCloseButtonuseIsMobile(breakpoint?)FEEDBACK_REPORT_TYPES,getBrowserMetadata,getDeviceMetadata,sanitizeClientInput(helpers used by the report flow)
Type exports:
UseVersionPollOptions,UseVersionPollSystemNotificationOptionsUpdateAvailableToastPropsFeedbackModalPropsUseFeedbackReportOptions,UseFeedbackReportResult,FeedbackReportTypeModalCloseButtonProps
snuggly-nudger/server
createVersionHandler({ version })createReportHandler({ projectId, reportsApiUrl? })
Type exports:
VersionHandlerConfigReportHandlerConfig
Client Usage
import { useState } from 'react';
import { FeedbackModal, UpdateAvailableToast, useVersionPoll } from 'snuggly-nudger/client';
declare const __APP_VERSION__: string;
export function App() {
const [showFeedback, setShowFeedback] = useState(false);
const { updateAvailable, dismissUpdate } = useVersionPoll(__APP_VERSION__);
return (
<>
{updateAvailable && (
<UpdateAvailableToast
deployedVersion={updateAvailable}
onRefresh={() => window.location.reload()}
onDismiss={dismissUpdate}
/>
)}
<button type="button" onClick={() => setShowFeedback(true)}>
Send Feedback
</button>
{showFeedback && (
<FeedbackModal
version={__APP_VERSION__}
appMetadata={{ route: window.location.pathname }}
appMetadataLabel="App State"
onClose={() => setShowFeedback(false)}
/>
)}
</>
);
}useVersionPoll options
useVersionPoll(currentVersion, {
apiPath: '/api/version',
pollIntervalMs: 5 * 60 * 1000,
});While the tab is hidden, scheduled interval polls are skipped by default; an extra check still runs when the tab becomes visible again. No need to wake up the network just because someone left a tab open next to twelve others.
Headless update notification (onUpdateAvailable and system notifications)
useVersionPoll already exposes updateAvailable / dismissUpdate for your own UI. For a push-style hook without UpdateAvailableToast, pass onUpdateAvailable — it runs once per newly detected deployed version (not on every poll tick):
useVersionPoll(__APP_VERSION__, {
onUpdateAvailable: (deployed) => {
// e.g. show your design-system toast, Sonner, etc.
toast.message(`v${deployed} is live — refresh to update.`);
},
});To also fire a browser/OS notification while the tab is in the background, enable systemNotification. That turns on background polling automatically (so a new deploy can be noticed when the tab is hidden). You must obtain permission first (usually from a user gesture):
// e.g. after the user clicks "Enable notifications"
await Notification.requestPermission();
useVersionPoll(__APP_VERSION__, {
systemNotification: {
title: 'Update available',
body: (v) => `v${v} is deployed. Refresh when you are ready.`,
when: 'hidden', // default: only notify when the tab is not focused (avoids doubling your in-app toast)
},
});Use systemNotification: true for the same defaults as the pre-built toast copy. Set when: 'always' if you want a system notification even when the tab is visible.
To poll in the background without system notifications (for example to run onUpdateAvailable while hidden), set pollWhileHidden: true.
Lazy-loading FeedbackModal (Next.js)
To avoid loading the modal until it is actually needed, because not every page load needs the whole kitchen sink:
import dynamic from 'next/dynamic';
const FeedbackModal = dynamic(
() => import('snuggly-nudger/client/FeedbackModal').then((m) => m.FeedbackModal),
{ ssr: false }
);Composable / headless useFeedbackReport
FeedbackModal is a pre-styled default. When you want the same fields, metadata toggles, and POST payload behavior inside your own dialog (design system, Radix, MUI, etc.), use useFeedbackReport:
import type { FeedbackReportType } from 'snuggly-nudger/client';
import { useFeedbackReport } from 'snuggly-nudger/client/useFeedbackReport';
declare const __APP_VERSION__: string;
export function MyFeedbackDialog({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
}) {
const fr = useFeedbackReport({
version: __APP_VERSION__,
appMetadata: { route: typeof window !== 'undefined' ? window.location.pathname : '' },
onSubmitSuccess: () => onOpenChange(false),
});
if (!open) return null;
return (
<div role="dialog" aria-modal="true" aria-labelledby={fr.titleId}>
<h2 id={fr.titleId}>Send report</h2>
<select value={fr.type} onChange={(e) => fr.setType(e.target.value as FeedbackReportType)}>
<option value="feedback">Feedback</option>
<option value="bug">Bug</option>
<option value="other">Other</option>
</select>
<textarea
value={fr.message}
onChange={(e) => fr.setMessage(e.target.value)}
maxLength={1000}
/>
{fr.hasAppMetadata && (
<label>
<input
type="checkbox"
checked={fr.includeApp}
onChange={(e) => fr.setIncludeApp(e.target.checked)}
/>
Include app context
</label>
)}
<label>
<input
type="checkbox"
checked={fr.includeBrowser}
onChange={(e) => fr.setIncludeBrowser(e.target.checked)}
/>
Browser
</label>
<label>
<input
type="checkbox"
checked={fr.includeDevice}
onChange={(e) => fr.setIncludeDevice(e.target.checked)}
/>
Device
</label>
<button
type="button"
disabled={fr.submitting || !fr.canSubmit}
onClick={() => void fr.submit()}
>
{fr.submitting ? 'Sending…' : 'Send'}
</button>
{fr.statusText && <p role="status">{fr.statusText}</p>}
</div>
);
}Wire reportEndpoint if your app uses something other than the default /api/report. Use fr.previewJson, fr.showPreview, and fr.togglePreview if you want the same JSON preview behavior as the built-in modal.
Server Usage
/api/version
import { createVersionHandler } from 'snuggly-nudger/server';
import pkg from '../../../package.json';
export const { GET } = createVersionHandler({ version: pkg.version });/api/report
import { createReportHandler } from 'snuggly-nudger/server';
export const { POST } = createReportHandler({
projectId: 'my-app',
});Report Handler Architecture
createReportHandler validates and sanitizes client payloads before proxying to your reports service. In other words: it tries to keep the useful signal and filter out the nonsense before your backend has to deal with it.
Request bodies are limited to 64 KiB by default (reject with 413). The upstream fetch uses a 15s timeout by default (response 502 with Upstream request timed out). Override via maxBodyBytes / upstreamTimeoutMs on the handler config if needed.
Resolution order for destination base URL:
reportsApiUrlpassed tocreateReportHandler(...)process.env.REPORTS_API_URL
Final upstream URL:
{reportsApiUrl}/api/reports
Request payload forwarded upstream:
{
"projectId": "my-app",
"description": "sanitized message",
"version": "safe-version-string",
"category": "bug",
"metadata": {
"app": {},
"browser": {},
"device": {}
}
}Only app, browser, and device metadata objects are forwarded.
Environment Variables
See .env.example for expected values. It is brief, on purpose, and does not require a decoding ring:
REPORTS_API_URL- Base URL for your reports ingestion service
Notes
FeedbackModalsends metadata keys asapp,browser, anddevice.createReportHandlervalidatestype(feedback,bug,other) and sanitizes message + version before proxying.- The package does not implement app-specific state restoration on refresh; pass your own
onRefreshcallback. We provide the nudge, not a full state-management philosophy.
Demo App
A runnable demo is available in demo/ and shows the whole thing working end-to-end, without requiring interpretive dance:
- update polling UI
- feedback modal submission
/api/version,/api/report, and a mock/api/reportsupstream endpoint
