Server Components cannot run observers, so wrap only the observed element in a small 'use client' component and pass the server-rendered content through as children — the observer logic ships to the browser, while the content inside it stays a Server Component with no client JavaScript.
Problem / Scenario Context
A Next.js App Router site renders articles with Server Components. The team adds lazy-loaded embeds and reveal animations with a useInView hook — and the build fails: hooks cannot be used in Server Components. The quick fix is 'use client' at the top of the article page, which works, but the page's JavaScript bundle grows by 180 KB because the markdown renderer, syntax highlighter and every component the article uses are now client components.
The boundary was placed too high. The React Observer Hooks topic covers the hooks themselves, and fixing "IntersectionObserver is not defined" in Next.js covers the runtime error; this page is about where the client boundary goes.
Mechanics Explanation
In the React Server Components model:
- Server Components run only on the server (at build or request time). They can be async, read data directly, and render to a serialised description. They cannot use state, effects, refs or browser APIs, and their code never ships to the browser.
- Client Components, marked with
'use client', are rendered on the server for the initial HTML and hydrated in the browser, where hooks and observers run. Their code, and every module they import, ships to the browser. - The boundary is transitive for imports, not for children. A module imported by a client component becomes client code. But a Server Component passed as
children(or any prop of typeReactNode) to a client component stays a Server Component: it is rendered on the server and passed as already-rendered output.
So an observer wrapper that takes children can sit around arbitrarily large server-rendered content without turning it into client code. The only client code is the wrapper.
Comparison Table: Where Observer Code Can Live
| Location | Can use observers? | Ships JS? | Typical use |
|---|---|---|---|
| Server Component | no | no | content, data fetching |
| Client Component (leaf) | yes | yes, small | the observed element's wrapper |
Client Component with children from server |
yes | wrapper only | reveal, lazy mount around server content |
| Client provider (context) | yes | yes | shared observer registry for a subtree |
app/layout.tsx marked client |
yes | everything below imports | avoid |
Minimal Reproducible Example
// app/articles/[slug]/page.tsx
'use client'; // makes the whole page client code
import { useInView } from '@/hooks/useInView';
import { renderMarkdown } from '@/lib/markdown'; // now bundled for the browser
import { Highlighter } from '@/components/Highlighter';
export default function Article({ params }: { params: { slug: string } }) {
const [ref, inView] = useInView<HTMLDivElement>();
// …renders the whole article
return <div ref={ref}>{/* … */}</div>;
}
Production-Safe Solution
// components/Reveal.tsx — the only client code
'use client';
import { useInView } from '@/hooks/useInView';
import type { ReactNode } from 'react';
export function Reveal({ children, as: Tag = 'div' }: { children: ReactNode; as?: 'div' | 'section' }) {
const [ref, inView] = useInView<HTMLDivElement>({ once: true });
return <Tag ref={ref} className="reveal" data-in-view={inView || undefined}>{children}</Tag>;
}
// components/LazyEmbed.tsx — client wrapper that mounts heavy client code only when near
'use client';
import dynamic from 'next/dynamic';
import { useInView } from '@/hooks/useInView';
const Map = dynamic(() => import('./Map'), { ssr: false });
export function LazyEmbed({ lat, lng }: { lat: number; lng: number }) {
const [ref, inView] = useInView<HTMLDivElement>({ once: true, rootMargin: '400px' });
return <div ref={ref} style={ { aspectRatio: '16 / 9' } }>{inView ? <Map lat={lat} lng={lng} /> : <MapPreview />}</div>;
}
function MapPreview() { return <div className="map-preview" aria-label="Map loading" />; }
// app/articles/[slug]/page.tsx — stays a Server Component
import { Reveal } from '@/components/Reveal';
import { LazyEmbed } from '@/components/LazyEmbed';
import { getArticle } from '@/lib/data';
import { renderMarkdown } from '@/lib/markdown'; // server-only
export default async function Article({ params }: { params: Promise<{ slug: string }> }) {
const article = await getArticle((await params).slug);
return (
<article>
<h1>{article.title}</h1>
{article.sections.map((s) => (
<Reveal key={s.id} as="section">{renderMarkdown(s.body)}</Reveal> // server-rendered children
))}
{article.map && <LazyEmbed lat={article.map.lat} lng={article.map.lng} />}
</article>
);
}
The article, the markdown renderer and the highlighter stay on the server. Reveal receives already-rendered sections as children and only adds a ref and an attribute. LazyEmbed defers the heavy map chunk until the placeholder is near the viewport, following lazy loading components with dynamic import. Only serialisable props (numbers, strings, plain objects, server-rendered nodes) cross the boundary — not functions.
Providers and Layouts
Shared observer registries, such as the context provider in sharing one observer across React components with context, are client components too. Placing a provider in app/layout.tsx is fine as long as the provider takes children: the layout remains a Server Component that renders <InViewProvider>{children}</InViewProvider>, and the pages below stay server-rendered. What must be avoided is marking the layout file itself 'use client', which turns every import in it into client code.
Keep providers as low as their consumers allow. A provider that exists only for a carousel belongs around the carousel, where its scroll-container root is, not at the app root.
Verification Steps
- Check the bundle analyser: markdown, highlighter and data libraries must not appear in client chunks.
- View source: the article HTML is fully server-rendered, including content inside
Reveal. - Disable JavaScript: content remains readable (reveal styles must not hide content by default).
- Scroll to the embed and confirm the map chunk loads only then.
- Confirm no hydration warnings caused by observer-dependent attributes (render the default state on the server).
Common Mistakes to Avoid
'use client'on pages or layouts to use a hook.- Importing server-only modules from client wrappers. Pass rendered output as children instead.
- Passing functions as props from Server to Client Components. Only serialisable values cross.
- Rendering observer-dependent state on the server. The server cannot know visibility; render the default.
FAQ
Why can't Server Components use IntersectionObserver?
They run only on the server, where there is no DOM, no layout and no viewport. Observers need a browser, so they must live in client components.
Do children of a client component become client components?
Not when they are passed in as children from a Server Component. They are rendered on the server and passed as output. Only modules imported by the client component become client code.
Is a tiny client wrapper around every section expensive?
The wrapper's code ships once, however many times it is used. Each instance adds a small amount of hydration work; with a shared observer registry, the per-instance runtime cost is minimal.
What should the server render for observer-driven state?
The default state: not in view, not loaded. The client updates it after hydration. That keeps server and client HTML identical and avoids hydration mismatches.
Can a Server Component pass a callback to the wrapper?
No, functions are not serialisable across the boundary. Pass data and rendered content, and keep behaviour inside the client component. Server Actions are the exception for server-side mutations.
Does this apply outside Next.js?
Yes. Any React Server Components setup — other frameworks or custom bundler integrations — follows the same boundary rules.
Related
- Deferring Observer Setup Until After Hydration — timing on the client side
- Hydration Mismatch from Observer-Driven Class Toggles — rendering the right default
- Animating on Visibility in React Without Re-Renders — making the wrapper cheaper
↑ Back to React Observer Hooks