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 type ReactNode) 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.

Boundary at the Page Versus at the Observed ElementTwo columns. Putting use client at the top of the article page turns the page and everything it imports into client code, including the markdown renderer and highlighter, adding a large bundle. Putting it on a small observer wrapper keeps the article a Server Component, passes its rendered output through as children, and ships only the wrapper's few hundred bytes plus the hook.'use client' on the pagePage and all its imports become client codeMarkdown renderer, highlighter ship to the browser~180 KB more JavaScript'use client' on a small wrapperArticle stays a Server ComponentContent passed through as childrenOnly the wrapper + hook ship (~1 KB)

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

TSX
// 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

TSX
// 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>;
}
TSX
// 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" />; }
TSX
// 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.

What Crosses the Client BoundaryFour boxes. The Server Component fetches data and renders markdown on the server. It passes rendered sections as children and plain numbers as props to small client wrappers. The wrappers attach observers in the browser and toggle attributes or mount lazy chunks. Only the wrappers, the hook and deferred chunks ship to the browser.Server Componentfetch, rendermarkdownProps + childrenserialisable onlyClient wrapperref + observerBrowser JSwrapper, hook, lazychunks

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.

Where Should This Observer Code Go?A decision chain. If the component only renders content, keep it a Server Component. Otherwise, if it needs to observe an element, make a small client wrapper that takes children. Otherwise, if many components share an observer, add a client provider that takes children as low in the tree as possible. Never mark layouts or pages as client components just to use a hook.Does it only render content?Server ComponentyesnoDoes it observe one element?Small 'use client' wrapper taking childrenyesnoDo many components share an observer?Client provider taking children, low in the treeyesnoNever mark a page or layout 'use client' just to use a hook.

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.


↑ Back to React Observer Hooks