npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

next-smart-hmr

v1.4.0

Published

Route-aware HMR for Next.js — only affected tabs refresh

Downloads

287

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             -> untouched

Quick Start

Install

bun add -d next-smart-hmr

Add 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
  1. File watcher detects saves in src/ and queries the dependency graph
  2. Dependency graph traces imports from the changed file up to route entry points (page.tsx, layout.tsx)
  3. WebSocket broadcast sends affected route patterns to all browser tabs
  4. Client-side interceptor (injected via <SmartHMR />) wraps the native WebSocket constructor. When Next.js sends a serverComponentChanges message, it checks if the current tab's route is affected. If not, the message is silently dropped. addedPage / removedPage messages (which stock Next.js reacts to in every tab) are gated the same way by the route they name.
  5. 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.
  6. 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.tsx edit -> only that route
  • Layout changes: layout.tsx edit -> 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 * from files are tracked transitively
  • CSS files: .css / .module.css are graph nodes (including @import chains), so a style edit refreshes only the routes that import it
  • New files: a page.tsx / route.ts created 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.json paths (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 carries fallback: 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 serverComponentChanges on 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, so use cache dev 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 dev hmrRefresh() 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-hmr to /_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's hmrRefresh() 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 entryJSFiles union when present.
  • Mirrors the result to <distDir>/diagnostics/route-bundle-stats.json as { warning?, routes: [...] }, with each route carrying both firstLoadUncompressedJsBytes and the firstLoadJs alias.
  • Red-flags fabrication: if ≥100 routes share one identical size (the signature of a measurement collapse), a warning field 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 distDir

Environment Variables

SMART_HMR_PORT=3003          # watcher WebSocket port (default: 3002)
SMART_HMR_VERBOSE=1          # enable debug logging

Config 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/recent

Returns 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