Skip to main contentSkip to docs navigation

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:

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

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

TSX
// 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>
  )
}
ComponentFrom a Server Component
Tabs, List, StepperYes
Combobox, AutocompleteYes, with items written as children
Tooltip, PopoverYes. The trigger is the child element
DataGridWith columns and rows written as children. items with a render function needs a Client Component
TableNo. 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 of Combobox and Autocomplete, and a Menu with container render nothing on the server. A trigger's aria-controls or aria-describedby for its overlay is added with it.

  • A Menu open on its first render. Its list is in the HTML, and shown once it has a position.

  • Modal, Alert and Drawer open 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 with open: 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, RangeCalendar and the date pickers mark today after hydration, in the browser's time zone, instead of taking a timeZone prop 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 a value or defaultValue to 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 Scrollspy marks. 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:

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