This package is designed for Next.js Pages Directory, where streaming isn't available. If you're using App Directory, use built-in streaming for better results.
Supercharge your Next.js app performance ⚡️
Load components only when users need them
⚡️ Lower TBT • 📦 Smaller Bundle • 🚀 Improved Performance
View Demo
npm install next-lazy-hydration-on-scroll
# or
yarn add next-lazy-hydration-on-scroll
# or
pnpm add next-lazy-hydration-on-scroll
import { lazyHydrate } from 'next-lazy-hydration-on-scroll'
// Wrap your component with lazyHydrate
const LazyComponent = lazyHydrate(() => import('./components/HeavyComponent'), {
LoadingComponent: () => <div>Loading...</div>, // Optional
})
// Use it in your page
export default function Page() {
return (
<div>
<header>Always hydrated</header>
{/* This will hydrate only when scrolled into view */}
<LazyComponent />
{/* You can wrap multiple components */}
<LazyComponent />
<LazyComponent />
</div>
)
}
When you visit a Next.js website:
- The server sends HTML that you can see immediately (like a static webpage)
- Then it sends JavaScript to make everything interactive:
- Makes buttons clickable
- Enables form submissions
- Activates React hooks (useEffect, useState, etc.)
- Sets up event listeners
Let's look at how this package transforms the traditional hydration process:
By default, Next.js loads and hydrates all components at once, even those not visible on screen. This leads to several issues:
- Slow down initial page load
- Delay page interactivity
- High memory usage during hydration
Traditional hydration process:
Load Page ──► Download All JS ──► Hydrate Everything ──► Page Interactive
(blocking) (blocking)
This package breaks down the hydration process into stages:
- Shows static content instantly
- Detects when you scroll to components
- Loads and hydrates components only when needed
- Keeps your page fast and efficient
Static HTML ──► Scroll Detection ──► Load Component JS ──► Hydrate Component ──► Interactive
🟢 🟡 🔴 🔴 🟢
🟢 Instant/Interactive
🟡 Lightweight operation
🔴 Heavy operation (but only for visible components)
This approach provides many (some not obvious) benefits:
- Reduces Total Blocking Time (TBT) by splitting hydration work
- Reduces initial JavaScript bundle size
- Components with data fetching (useEffect, SWR, React Query) only trigger requests when hydrated
The key improvement is in Total Blocking Time (TBT), which measures how long the main thread is blocked, preventing user interactions. By hydrating components gradually:
-
Initial Load:
- Only essential components are hydrated
- Main thread stays responsive
- Users can interact with visible content faster
-
Resource Usage:
- JavaScript is parsed and executed in smaller chunks
- Memory usage stays lower
- Network requests are distributed over time
-
Data Fetching:
- Components that fetch data only do so when hydrated
- Saves bandwidth for unseen components
With Lazy Hydration ✨ | Without Lazy Hydration |
---|---|
✓ Faster Time to Interactive | × Longer Loading Times |
✓ Lower Memory Usage | × Higher Memory Usage |
✓ Better User Experience | × Potential UI Freezes |
-
Keep components in separate files
-
Avoid barrel files (index.ts that re-exports components):
// ❌ Don't use barrel files like this: // components/index.ts export { ComponentA } from './ComponentA' export { ComponentB } from './ComponentB' // This will bundle all components together, defeating lazy loading
// ✅ Instead, import directly: import { ComponentA } from './components/ComponentA' import { ComponentB } from './components/ComponentB'
This is important because webpack code splits on file boundaries. When using barrel files, all re-exported components get bundled into a single chunk, defeating the purpose of lazy loading.
-
next/dynamic alone isn't enough:
// ❌ This still executes on initial load: const Component = dynamic(() => import('./Component')) function Page() { return <Component /> // Component is loaded immediately } // ✅ This works as expected: function Page() { const [show, setShow] = useState(false) return show && <Component /> // Component loads only when show is true }
Unless the component is conditionally rendered,
next/dynamic
will still load and hydrate it on initial page load. This package uses IntersectionObserver and a few tweaks to enable pre-rendering while deferring hydration.
- Perfect for Pages Router
- For App Router, use built-in streaming
- Works with Next.js 12 and above
A: No! Content is still pre-rendered, so search engines see everything immediately.
A: Works in all modern browsers that support IntersectionObserver (IE11+ with polyfill).
A: The <section>
wrapper is crucial - without it, several problems would occur:
-
No stable reference point - IntersectionObserver needs a consistent DOM element to observe. Without the section wrapper, we couldn't reliably track when components enter the viewport.
-
Hydration mismatches - React would throw hydration mismatch errors because server would return full component markup while client would try to hydrate empty content. That's why we need the section with
suppressHydrationWarning
- it tells React to ignore this intentional mismatch.
A: The process happens in several steps:
-
Server-Side Rendering (SSR):
<section> <Component {...props} /> </section>
The first render occurs on the server, where your component is fully server-side rendered with all its props.
-
Client-Side Hydration Setup:
<section ref={rootRef} dangerouslySetInnerHTML={{ __html: '' }} suppressHydrationWarning />
On the client side,
hydrateClientSide
takes over. It renders an empty section with a ref andsuppressHydrationWarning
. This prop is crucial as it tells React to ignore the hydration mismatch between server and client content (For more detailed explanation see below). -
Hydration Control:
- A
useEffect
hook runs after React renders your component and the rootRef is created - If
rootRef.current
is not available or the IntersectionObserver fails to initialize,isHydrated
is immediately set to true - This triggers the "shouldHydrate" condition, replacing the empty section with the full component:
<section> <Component {...props} /> </section>
- If everything is working correctly, an IntersectionObserver is attached to
rootRef.current
- A
-
On-Demand Hydration: When the section intersects with the viewport:
isHydrated
is set to true- The component's JS chunk is dynamically imported and executed
- The empty section is replaced with the fully hydrated component:
<section> <Component {...props} /> </section>
This approach is similar to using dynamic imports with conditional rendering, but with a crucial difference: it works during SSR. While next/dynamic
with ssr: true
requires client-side triggers (like useState) to render, this package preserves SSR while optimizing client-side hydration.
When you use dangerouslySetHTML as an empty string in a div (or any dom element), it stops react from walking down the tree. We can use dangerouslySetHTML to signal to react to stop hydrating this subtree.
Made with ❤️ for better web performance
Questions? Issues? Visit the GitHub repository