Server-Side Rendering
How @chassis-ui/react works inside React Server Components, and what its single 'use client' directive means for a Next.js app.
Every @chassis-ui/react entry point — the root @chassis-ui/react and each component subpath
like @chassis-ui/react/button — carries a 'use client' directive. Nearly every component reads
state or uses a ref internally, so each entry behaves as a client boundary, and you never need a
'use client' wrapper of your own just to render one.
In Server Components
Import and render any component directly inside a Server Component — no local 'use client'
wrapper needed on the consuming side:
// app/page.tsx — a Server Component, no directive of its own
import { Button, Switch } from '@chassis-ui/react'
export default function Page() {
return (
<main>
<Button>Click me</Button>
<Switch defaultSelected>Works fine here</Switch>
</main>
)
}A React Server Components-aware bundler sees the directive, registers every export from
@chassis-ui/react as a client reference, and renders/hydrates it correctly with no explicit
client-component boundary in application code.
Crossing the boundary
The usual Server Components rule still applies: whatever a Server Component passes down to a
@chassis-ui/react component — an inline function to onClick, a non-primitive value to another
prop — has to be serializable across the boundary, or itself be defined inside a Client Component
higher up the tree. That constraint comes from React and the bundler in use, not from anything
specific to this package.
Polymorphic components
A component reference is a function, so it can't be passed from a Server Component to one of this
package's components either. <Button component={Link} href="/login"> in a Server Component fails
next build with "Functions cannot be passed directly to Client Components". It worked with older
versions of next/link only because Link wasn't a plain function.
Render the link as a child with asChild instead. An element crosses the boundary fine:
// app/page.tsx — a Server Component
import Link from 'next/link'
import { Button } from '@chassis-ui/react/button'
export default function Page() {
return (
<Button asChild>
<Link href="/login">Log in</Link>
</Button>
)
}Every component with a component prop supports asChild. A string tag also crosses the boundary
fine (component="section"); only component references need asChild.
Compound components
A compound component reads its children before it renders them: Tabs finds its TabList, List
checks whether an item is a link, Combobox builds its options. Children written in a Server
Component work for all of these, with your own Client Components anywhere inside them:
// app/settings/page.tsx — a Server Component
import { Tab, TabList, TabPanel, Tabs } from '@chassis-ui/react/tabs'
import { ProfileForm } from './ProfileForm' // 'use client'
export default function Settings() {
return (
<Tabs defaultSelectedKey="profile">
<TabList aria-label="Settings">
<Tab id="profile">Profile</Tab>
<Tab id="billing">Billing</Tab>
</TabList>
<TabPanel id="profile">
<ProfileForm />
</TabPanel>
<TabPanel id="billing">Billing details</TabPanel>
</Tabs>
)
}| Component | From a Server Component |
|---|---|
Tabs, List, Stepper | Yes |
Combobox, Autocomplete | Yes, with items written as children |
Tooltip, Popover | Yes. The trigger is the child element |
DataGrid | With columns and rows written as children. items with a render function needs a Client Component |
Table | No. Use StaticTable, or render Table from a Client Component |
Table reads its children as data to build a collection, and that data has to be in hand when it
renders. StaticTable renders plain markup and is itself a Server Component.
A component that reads a child waits for it when the child is still loading, the way a lazy
component does, so put a <Suspense> boundary above it if it shouldn't hold back the rest of the
page.
What the server renders
The server's HTML is the page until its JavaScript has loaded, so each component renders there what
it settles to: Tabs with its selected panel, a Toast or Notification already shown, a
Carousel with its indicators and active slide. What only the browser can know appears once the
page has hydrated:
-
Portaled overlays.
Popover,Tooltip, the lists ofComboboxandAutocomplete, and aMenuwithcontainerrender nothing on the server. A trigger'saria-controlsoraria-describedbyfor its overlay is added with it. -
A
Menuopen on its first render. Its list is in the HTML, and shown once it has a position. -
Modal,AlertandDraweropen on their first render. A<dialog>opens as modal only from script. For a dialog that should be open in the server's HTML, such as a static preview, render it withopen: it is then open from the first paint, as a non-modal dialog. -
Today in a calendar. Which day is today depends on the time zone, and a server's is rarely the viewer's.
Calendar,RangeCalendarand the date pickers mark today after hydration, in the browser's time zone, instead of taking atimeZoneprop you would have to know on the server. Two things still come from the server's zone: react-aria's accessible name for the day it considers today ("Today, …"), and the month a calendar with no value opens on, which can differ near the turn of a month. Give a server-rendered calendar avalueordefaultValueto fix the month. -
What fits in a
NavOverflow. Which items fit depends on a width the server doesn't have, so it renders every item, with the toggle hidden. Until the page has hydrated, the items that don't fit are clipped at the wrapper's edge and the row scrolls, so every link can still be reached. The first render in the browser then measures and moves them into the menu before it paints. -
The section a
Scrollspymarks. Which section is being read depends on a scroll position the server doesn't have, so no link is marked in its HTML. The first observation after hydration marks the active one. -
A
NumberField's keyboard on a phone. Which keyboard has a minus sign or a decimal separator depends on the phone, so the server's HTML asks for the numeric one. After hydration the field picks the keyboard that has the keys it needs.
A slide rendered by a component of your own, rather than a CarouselItem written directly inside
CarouselInner, is found after hydration too: the carousel counts its slides from the page then.
Bundle size
A 'use client' module is a client-reference boundary, and Turbopack (the default bundler in
Next.js 16) doesn't tree-shake unused exports out of one. A page that imports a single Button
from the root @chassis-ui/react entry therefore ships every component in the package to the
browser. Import from the component's subpath instead and it ships only what that page uses:
// ~13 KB gzip of client JS for this page, instead of ~220 KB from the root entry
import { Button } from '@chassis-ui/react/button'Under webpack (next build --webpack), Vite, and other bundlers that honor package.json's
sideEffects field, root imports are tree-shaken too, but subpaths work the same everywhere.