next-smart-hmr
v1.4.0
Published
Route-aware HMR for Next.js — only affected tabs refresh
Downloads
287
Maintainers
Readme
next-smart-hmr
Route-aware Hot Module Replacement for Next.js. When you edit a file, only the browser tabs viewing affected routes refresh. All other tabs stay untouched.
The package has grown a second job: honest per-route First Load JS numbers for Turbopack builds (smart-hmr bundle-stats / smart-hmr next-smart-build) — see Per-route First Load JS.
Supports Next 16 across both dev-protocol generations (≤16.2 /_next/webpack-hmr, 16.3+ /_next/hmr) — see Protocol coupling.
The Problem
Next.js broadcasts HMR updates to every connected browser tab. If you have 10 tabs open while developing, editing a single component causes all 10 tabs to hard-refresh. This loses component state, triggers redundant API calls, and wastes time.
The Solution
next-smart-hmr builds a dependency graph of your project and intercepts HMR messages on each tab. When a file changes, it traces the import chain to determine which routes are affected, then only those tabs refresh. Everything else stays untouched.
Edit accounting/page.tsx
Tab 1: /dashboard/accounting -> refreshes (affected)
Tab 2: /dashboard/employees -> untouched
Tab 3: /dashboard/settings -> untouched
Tab 4: /auth/login -> untouchedQuick Start
Install
bun add -d next-smart-hmrAdd to Root Layout
// src/app/layout.tsx
import { SmartHMR } from 'next-smart-hmr/react'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
{children}
<SmartHMR />
</body>
</html>
)
}<SmartHMR /> renders nothing in production. In development, it injects a lightweight WebSocket interceptor that filters HMR messages per-tab.
Update Dev Script
{
"scripts": {
"dev": "smart-hmr next dev"
}
}The smart-hmr CLI wraps your Next.js dev command. It starts a file watcher alongside Next.js that builds the dependency graph and broadcasts affected routes to browser tabs.
Works with Turbopack, Webpack, and wrappers like next-smart-runner:
{
"scripts": {
"dev": "smart-hmr next dev --turbopack",
"dev:webpack": "smart-hmr next dev --webpack"
}
}That's it. Selective HMR is active.
How It Works
next-smart-hmr watcher (Bun)
┌────────────────────────────┐
fs.watch detects file save -> │ Dependency Graph │
│ (2000+ files, <25ms build) │
│ │
│ Maps changed file -> routes │
│ via import chain traversal │
└──────────┬─────────────────┘
│ WebSocket (port 3002)
│ { affectedRoutes: ["/dashboard/accounting"] }
│
┌─────────────────────────────────┼─────────────────────────────────┐
│ │ │
Tab 1: /dashboard/accounting Tab 2: /dashboard/employees Tab 3: /auth
Route matches -> REFRESH Route doesn't match -> SKIP SKIP- File watcher detects saves in
src/and queries the dependency graph - Dependency graph traces imports from the changed file up to route entry points (
page.tsx,layout.tsx) - WebSocket broadcast sends affected route patterns to all browser tabs
- Client-side interceptor (injected via
<SmartHMR />) wraps the native WebSocket constructor. When Next.js sends aserverComponentChangesmessage, it checks if the current tab's route is affected. If not, the message is silently dropped.addedPage/removedPagemessages (which stock Next.js reacts to in every tab) are gated the same way by the route they name. - Rolling-union matching: the interceptor matches against the union of all broadcasts from the last 45s (configurable via
unionWindowMs), not just the latest one. The watcher socket and Next's HMR socket are independent — a compile can take seconds while broadcasts arrive in milliseconds, so under concurrent editing the push for edit A can arrive after the broadcast for edit B. Last-broadcast-wins mis-filtered exactly that case. - Visibility catch-up: hidden tabs suppress all updates. When you switch back to a tab, it refreshes once only if a suppressed update was actually relevant to its route — tabs that only suppressed other routes' churn stay untouched.
Dependency Resolution
The graph handles:
- Direct page changes:
page.tsxedit -> only that route - Layout changes:
layout.tsxedit -> all child routes under that layout - Shared dependencies: traces through the full import chain (e.g.,
page.tsx -> client.tsx -> useHook.ts -> graphql-client.ts) - Barrel re-exports:
export * fromfiles are tracked transitively - CSS files:
.css/.module.cssare graph nodes (including@importchains), so a style edit refreshes only the routes that import it - New files: a
page.tsx/route.tscreated while the watcher runs registers its route immediately (derived from its path); other new files join the graph via their imports - Deleted / renamed files: map to their former importers' routes (captured before the graph forgets them)
- Dynamic routes:
[param]/[...catchall]patterns match concrete pathnames in the client (e.g./items/[id]matches/items/abc123) - Path aliases: reads
tsconfig.jsonpaths (e.g.,@/*->src/*) - Smart collapsing: when many routes under a prefix are all affected, collapses to
"/dashboard/**"patterns;'*'(all page routes) only when ≥75% of page routes are affected - mtime-only bumps: a rewrite with byte-identical content (e.g. a codegen step calling
utimesSync) is detected by content hash and broadcasts nothing - Conservative fallback: only a file the graph knows nothing about broadcasts
'*'to all tabs (the message carriesfallback: true). A known file whose import walk reaches zero routes broadcasts an empty route set — "this change affects nothing" — instead of refreshing the world.
Built for multi-agent development
The 1.2.0 semantics exist because of a real workload: one shared next dev
server with several AI agents editing the tree concurrently while a human
watches other tabs. In that mode, file creations, deletions, css edits, and
codegen mtime storms are constant — under 1.1.0 each of those broadcast '*'
(25% of all broadcasts in a live capture), and switching to any tab
force-refreshed it because the catch-up keyed on a nonzero suppressed count
rather than relevance. All of those paths are now scoped, and the only
remaining full-tab refreshes are edits that genuinely fan out (root layout,
shared providers).
Graceful Degradation
If the watcher isn't running or crashes, the client-side interceptor stays inactive. All HMR messages pass through unfiltered. Zero breakage — identical to stock Next.js. The client reconnects forever with capped backoff, and a crashed watcher self-heals without ever taking the dev server down with it.
Never-stale guarantees (v1.3.0)
The failure direction that matters is a tab going SILENTLY STALE (suppressing an update it needed). v1.3.0 closes every audited path to it:
- Reconciliation heartbeat: the watcher periodically sweeps the filesystem against its graph (
reconcileIntervalMs, default 60s) and immediately on suspicious events (directory renames, null-filename events) — missed fs events (Windows buffer overflows under codegen storms, editor quirks, upstream watcher bugs) heal instead of persisting. - Hold window: an unmatched
serverComponentChangeson a visible tab is held briefly (holdMs, default 300ms) awaiting a fresher broadcast — Turbopack's push can beat the watcher for fast recompiles; deciding instantly wrong-suppressed the edited route's own tab. - Sequence numbers: broadcasts carry a monotonic
seq; a gap tells the client its knowledge is incomplete and filtering degrades to pass-through until fresh knowledge arrives. - Unresolved-import retry: files referenced before they exist (same-batch scaffolds, delete-then-recreate) reconnect their edges the moment they appear.
- Root config files (
.env*,next.config.*,tsconfig.json) broadcast'*'— Next global-invalidates on them and every tab must react. - Refresh-hash forwarding (Next ≤16.2): suppressed updates record Next's HMR refresh hash and re-apply the
__next_hmr_refresh_hash__cookie before the catch-up refresh, souse cachedev content can't stay keyed to a stale hash. On Next 16.3+ that cookie mechanism is gone — the catch-up instead calls the router's devhmrRefresh()action (exactly what the suppressed update would have run), which carries the cache-bust itself.
One Next behavior to know about: experimental.serverComponentsHmrCache (default on) caches fetches across HMR refreshes until a navigation/full reload. If your dev workflow needs every refresh to hit live data, disable it in next.config — the tool cannot compensate for it.
Protocol coupling (and the drift canary)
The interceptor is coupled to an undocumented Next.js dev protocol: the HMR WebSocket path and the HMR_MESSAGE_SENT_TO_BROWSER message types. Next can change either in any release, and the failure mode is silent (filtering just stops — fail-open — or a new reload-triggering type passes unfiltered). So tests/protocol-contract.test.ts enumerates the protocol from the installed next package and fails on any member that hasn't been explicitly triaged. Upgrading next re-audits the protocol for free: red means a human re-verifies before shipping.
The Next 16.3 upgrade exercised this exactly as designed (3 canaries went red):
- The socket path moved from
/_next/webpack-hmrto/_next/hmr?id=<requestId>. The interceptor recognizes both generations. - The
__next_hmr_refresh_hash__cookie side-effect is gone; the dev cache-bust now rides the router'shmrRefresh()action, which the visibility catch-up now calls. - Two new message types (
staticParamsChanged,requestInsightsUpdate) were triaged.
Is the package still needed? Re-audited against 16.3.3: yes, unchanged. Next still broadcasts serverComponentChanges to every connected client with no route payload, and every client responds with a full page refetch — its own source says "Each announcement makes every client refetch its page." There is no route-scoping config knob. A canary pins this premise, so if Next ever starts scoping updates itself, the suite goes red and the package gets re-evaluated instead of double-filtering.
Per-route First Load JS (bundle stats)
Next 16 stopped printing per-route First Load JS for Turbopack builds. smart-hmr bundle-stats [distDir] (or smart-hmr next-smart-build, which builds first) reconstructs the numbers from the build manifests:
- Reconstructs each route's First Load JS from
build-manifest+ client-reference manifests (chunk paths are URL-decoded before stat-ing;/_next/-prefixed spellings are stripped segment-anchored and set-unioned so a chunk counted under both spellings counts once). - Prefers Next's native
entryJSFilesunion when present. - Mirrors the result to
<distDir>/diagnostics/route-bundle-stats.jsonas{ warning?, routes: [...] }, with each route carrying bothfirstLoadUncompressedJsBytesand thefirstLoadJsalias. - Red-flags fabrication: if ≥100 routes share one identical size (the signature of a measurement collapse), a
warningfield is written into the JSON and printed to stderr instead of letting a consumer read the collapse as truth. - Numbers are uncompressed bytes (gzip is roughly a quarter of that).
Configuration
CLI Options
smart-hmr [options] <next-command> [next-options]
smart-hmr next dev # zero config
smart-hmr --verbose next dev # debug logging
smart-hmr --port 3003 next dev # custom watcher port
smart-hmr next-smart-dev # with next-smart-runner
smart-hmr next-smart-build # build, then print per-route First Load JS
smart-hmr bundle-stats # print First Load JS for the last build
smart-hmr bundle-stats .next-test # sizes for a specific distDirEnvironment Variables
SMART_HMR_PORT=3003 # watcher WebSocket port (default: 3002)
SMART_HMR_VERBOSE=1 # enable debug loggingConfig File (Optional)
Create smart-hmr.config.ts in your project root:
import { defineConfig } from 'next-smart-hmr'
export default defineConfig({
port: 3002, // watcher WebSocket port
debounce: 50, // ms to batch rapid edits (1s max-wait cap)
verbose: false, // debug logging
include: ['src/**', 'app/**'], // directories to watch
exclude: ['**/*.test.*', '**/__tests__/**'],
// Manual overrides for files the graph can't trace
routeOverrides: {
'src/lib/theme.ts': ['*'], // theme changes affect all routes
},
})Note: debounce batches — a larger value groups more concurrent
edits into one broadcast (the affected routes of the whole batch are
unioned). A 1s max-wait guarantees a sustained edit stream still flushes.
Component Props
<SmartHMR
port={3002} // watcher WebSocket port (default: 3002)
debug={false} // enable console logging in browser (default: false)
unionWindowMs={45000} // how long a broadcast stays in the matching union (default: 45000)
/>API
Programmatic Usage
import { startWatcher, RouteMapper, DependencyGraph } from 'next-smart-hmr'
// Start the watcher programmatically
const watcher = await startWatcher(process.cwd(), { port: 3002, verbose: true })
// Or use the graph directly
const mapper = new RouteMapper(rootDir, appDir, config)
await mapper.build()
const result = mapper.getAffectedRoutes(['src/lib/auth.ts'])
console.log(result.routes) // ["/dashboard/company/**", "/dashboard/employee/**"]Health Check
While the watcher is running:
curl http://localhost:3002/health
# {"status":"ok","totalFiles":2301,"totalRoutes":362,"totalEdges":7785,"buildTimeMs":23}Recent Broadcasts (why did my tab refresh?)
curl http://localhost:3002/recentReturns the last 100 broadcasts — changed files, the routes they mapped to,
whether the batch was a '*' fallback (and which file was untraceable), and
which files were skipped as content-unchanged. This is the first place to
look when a tab refreshes unexpectedly.
Performance
Tested on a production Next.js 16 app with 2,301 source files and 362 routes:
| Metric | Value | |--------|-------| | Full graph build | ~25ms | | Incremental update (file change) | <10ms | | Memory overhead | ~15MB | | Dependency edges tracked | 7,785 |
Requirements
- Runtime: Bun >= 1.0 (for the watcher CLI)
- Next.js: 16+ — both dev-protocol generations supported (≤16.2 and 16.3+)
- React: 19+
- App Router (Pages Router is not supported)
Browser Verification
Open DevTools console on any page and check:
window.__SMART_HMR_STATE__
// { enabled: true, pathname: "/dashboard/...", affectedRoutes: [...],
// recent: [{ routes: [...], ts: 1699... }], suppressedCount: 0,
// pendingRelevant: false }If enabled is true, the interceptor is active and filtering HMR messages.
recent is the rolling broadcast union used for matching; pendingRelevant
means a relevant update was suppressed while hidden and the tab will catch up
once on focus.
Troubleshooting
Watcher not starting: Check if port 3002 is already in use. Use --port 3003 or set SMART_HMR_PORT.
All tabs still refreshing: Check window.__SMART_HMR_STATE__.enabled in DevTools. If false, the watcher WebSocket isn't connecting. Verify the watcher is running (curl http://localhost:3002/health).
A tab refreshed and you don't know why: curl http://localhost:3002/recent — every broadcast in the last 100 is listed with the files that caused it and the routes it mapped to. Broadcasts flagged fallback: true name the untraceable file; give it a routeOverrides entry or fix its imports.
Wrong routes refreshing: Run with --verbose to see which files map to which routes. Use routeOverrides in the config file for files the graph can't trace.
A tab refreshes every time you focus it: window.__SMART_HMR_STATE__.pendingRelevant should be false for an idle tab on an untouched route. If it flips true constantly, something in that route's import graph really is being edited — /recent will show what.
No effect in production: Correct. <SmartHMR /> renders nothing when NODE_ENV !== 'development'. The watcher CLI is only for dev.
Contributing
git clone https://github.com/Technologies-Unlimited/next-smart-hmr.git
cd next-smart-hmr
bun install
bun test # unit tests
bun run build # compile to dist/License
MIT - Technologies Unlimited
