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
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
childrenwhen a client wrapper is unavoidable. - Render values on the server where you can. A
Valueor aRelativeTimewith 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.tsthat 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
localStorageread during render. suppressHydrationWarningis 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.
Related
- Performance and bundle size covers measuring what crossed the boundary.
- Dark mode covers the one provider, and why the theme is a class.
- Next.js installation has provider placement and CSS import order in an App Router application.
- TypeScript covers typing props that cross the boundary.
Dark mode
One class on the root element, one provider, and the two-line inline script that prevents the flash. Dark mode is a contrast problem before a colour problem.
Internationalisation
RTL, locale formatting, and the hard part. That part is translating a status vocabulary without changing what it means clinically.