snuggly-nudger
v0.1.0
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.
snuggly-nudger/client
useVersionPoll(currentVersion, options?)UpdateAvailableToastFeedbackModalModalCloseButtonuseIsMobile(breakpoint?)
Type exports:
UseVersionPollOptionsUpdateAvailableToastPropsFeedbackModalPropsModalCloseButtonProps
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; 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.
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 }
);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
