opsinjs
HandbookEnvironment

Server and client components

Where the client boundary falls, what is safe to render on the server, and why importing one interactive component can turn a whole page into client code.

The short version

NOT IMPLEMENTED. This component does not exist in any released version of opsinjs. There is no package to install, no module to import and no props interface to generate code against. Everything on this page is a specification of intended behaviour and may change without notice. Do not write code against it.

This component is not built yet

There is nothing to render because there is nothing to install. What you can read on this page is the specification the implementation will have to satisfy.

PlannedRoadmapWhat “planned” means

Presentational opsinjs components are intended to be server-renderable, as a ResultCard, a Value, a StatusPill and a RangeBar with a fixed value all are. Anything with state, a listener or a portal is a client component and will carry its own "use client", as Dialog, Sheet, LogSheet and ReadingInput all do.

The rule that matters: "use client" marks a boundary, not a file. Every module imported from a client component becomes part of the client bundle, and a page that imports one interactive component has not made that component client-only. It has made everything below it client code.

How it works

A React Server Component renders on the server and ships HTML, not JavaScript. A client component ships both. The boundary is wherever a "use client" module is imported from a server module, and it applies to the whole import subgraph beneath it.

Two consequences that account for most of the confusion:

  • A server component can render a client component. This is the normal case and it is cheap.

  • A client component cannot import a server component, but it can receive one as a prop or as children. That is the escape hatch, and it is how you keep an expensive server-rendered subtree out of the client bundle even though a client component wraps it:

    // page.tsx, running on the server
    <ClientSheet>
      <ExpensiveServerRenderedSummary />
    </ClientSheet>

Providers

A provider is a client component by definition. It holds context. Put it as high as it needs to be and no higher. In this documentation site there is exactly one, fumadocs' RootProvider, mounted in the root layout; see Dark mode.

The temptation is to wrap the application in a provider for every capability. Each one you add makes the root layout a client component's parent and pulls another dependency into the shared bundle for every route, including the ones that never use it.

Do this

  • Keep "use client" as low in the tree as possible. Push it onto the interactive leaf rather than onto the page.
  • Pass server-rendered content as children when a client wrapper is unavoidable.
  • Render values on the server where you can. A Value or a RelativeTime with a known input has no reason to run in the browser.
  • Keep data fetching in server components and pass the result down. A client component that fetches on mount produces a loading state that the server could have avoided entirely.
  • Check what actually crossed the boundary with a bundle analysis before assuming; see Performance and bundle size.

Not this

  • Do not put "use client" at the top of a layout to make an error go away. It will make the error go away and take the whole route's server rendering with it.
  • Do not pass a function as a prop across the boundary. Functions are not serialisable; the error message points at the prop and not at the boundary that made it a problem.
  • Do not render a time-dependent value identically on both sides and hope. A relative time computed on the server and again on the client will disagree, and the hydration mismatch is reported as a generic text-content error.
  • Do not wrap everything in one provider "for consistency". Consistency here costs bundle size on every route.
  • Do not use typeof window === "undefined" to branch rendering. It produces different markup on the two sides by construction, which is exactly what hydration checks for.

Gotchas

  • A barrel file poisons the boundary. Importing one component from an index.ts that re-exports fifty pulls the client ones in with it. Import from the specific module.
  • A hydration mismatch reports the symptom, not the cause. "Text content did not match" usually means a date, a random value, or a localStorage read during render.
  • suppressHydrationWarning is not a fix. It is correct for exactly one thing, and that thing is the theme class set by the pre-paint script. Everywhere else it silences real bugs.
  • Portals render outside your layout but inside your client boundary. A dialog's content is still client code even though it appears at the end of the body.
  • Context does not cross the boundary upward. A server component cannot read a client context, which is why theme-dependent server rendering is not possible and why the theme is applied as a class rather than through context.
  • Third-party components are frequently client components without saying so in their docs. The first sign is a bundle that grew for no reason you can identify.

On this page