Foundations
Server and client
@comitor/ui is built one module per source file, so each file keeps its own "use client" directive. That makes "can a React Server Component call this?" a real question with a per-name answer — and it is not the answer most people guess. Seven functions that do nothing but concatenate class strings throw when a server page calls them.
This page is the complete inventory. It is also itself a Server Component, and the class strings further down were computed by it at build time.
Where the line is drawn
The package builds with tsup at bundle: false: every source file is transpiled to its own ESM module rather than merged into a few chunks. That is not a tree-shaking nicety, it is the reason the boundary works at all. Bundled output would push a "use client" line into the middle of a chunk, where it means nothing, and drag static primitives to the client with it.
The rule
A file's exports inherit that file's directive. Anything exported from a "use client" file reaches the server as a client reference — a component, a hook, a class builder, a string constant, all the same.
What it is not
It is not about what the code does. A pure function in a client file is still a client reference. And it is not about rendering: a Server Component may freely render <Dialog> or <Button>. Calling is the blocked verb, not rendering.
Because the cut is per file, one export can be moved to the safe side simply by moving it to a neighbouring file with no directive. That is exactly what every *-constants.ts module in the package is for.
Two failure modes
Crossing the boundary wrongly fails in two different ways, and telling them apart is most of what you need to know. Which one you get depends on whether the thing behind the boundary is a function or a value.
Loud — a function
Calling it throws at render, and names itself in the message:
Attempted to call buttonVariants() from the server but buttonVariants is on the client.
Annoying, but honest. The build stops and tells you the name to fix.
Silent — a value
Reading it gives you a client-reference proxy, so every property reads:
undefined
No error, no warning, and TypeScript still reports the literal type. This is the one that reaches production.
The trap: seven class builders that throw
These seven are the easiest thing in the package to trip over, precisely because nothing about them suggests a boundary. They take an options object and return a string. They touch no state, no DOM, no browser API. They fail anyway, because each one is declared in the same file as its client component.
| Name | Declared in | From a Server Component |
|---|---|---|
| buttonVariants | components/ui/button.tsx | Throws |
| toggleVariants | components/ui/toggle.tsx | Throws |
| fieldVariants | components/ui/field.tsx | Throws |
| inputGroupAddonVariants | components/ui/input-group.tsx | Throws |
| inputGroupButtonVariants | components/ui/input-group.tsx | Throws |
| progressTrackVariants | components/ui/progress.tsx | Throws |
| progressBarVariants | components/ui/progress.tsx | Throws |
// app/pricing/page.tsx — a Server Component. No 'use client' in this file.
import { buttonVariants } from '@comitor/ui'
export default function PricingPage() {
return (
<a href="/get-started" className={buttonVariants({ variant: 'outline' })}>
Get started
</a>
)
}
// Build fails:
// Attempted to call buttonVariants() from the server but buttonVariants is
// on the client.
//
// buttonVariants only concatenates strings. It fails anyway, because it is
// declared in components/ui/button.tsx, and that file starts with 'use client'.Two ways out
// The usual fix. A Server Component may RENDER a client component — that is the
// whole point of the boundary. It just may not CALL a function declared in a
// client file. Button takes asChild, so it hands its classes to your own <a>.
import { Button } from '@comitor/ui'
export default function PricingPage() {
return (
<Button asChild variant="outline">
<a href="/get-started">Get started</a>
</Button>
)
}// When you genuinely need the string, move the call across the boundary once.
// components/link-button.tsx
'use client'
import type { ComponentProps } from 'react'
import { buttonVariants, cn } from '@comitor/ui'
type LinkButtonProps = ComponentProps<'a'> & {
variant?: 'default' | 'outline' | 'ghost' | 'link'
size?: 'sm' | 'md' | 'lg'
}
export function LinkButton({ variant, size, className, ...props }: LinkButtonProps) {
return <a className={cn(buttonVariants({ variant, size }), className)} {...props} />
}
// app/pricing/page.tsx — still a Server Component
import { LinkButton } from '@/components/link-button'
export default function PricingPage() {
return <LinkButton href="/get-started" variant="outline">Get started</LinkButton>
}The nine that are fine
Learning that one class builder is stuck tells you nothing about the next one. Nine more, plus cn, live in files that carry no directive and are callable from a server page. There is no naming convention separating the two lists — this is the list.
| Name | Declared in | From a Server Component |
|---|---|---|
| alertVariants | components/ui/alert.tsx | Yes |
| badgeVariants | components/ui/badge.tsx | Yes |
| buttonGroupVariants | components/ui/button-group.tsx | Yes |
| cardVariants | components/ui/card.tsx | Yes |
| emptyMediaVariants | components/ui/empty.tsx | Yes |
| itemVariants | components/ui/item.tsx | Yes |
| itemMediaVariants | components/ui/item.tsx | Yes |
| navigationMenuTriggerStyle | components/ui/navigation-menu.tsx | Yes |
| statCardIconVariants | components/composite/stat-card.tsx | Yes |
| cn | lib/cn.ts | Yes |
Computed on the server
This page has no "use client". The four strings below were produced by calling the helpers during the server render of this route — they are printed, not transcribed.
- badgeVariants({ variant: 'success', size: 'sm' })
- inline-flex items-center font-medium rounded-md whitespace-nowrap gap-1 [&>svg]:size-3 [&>svg]:pointer-events-none bg-green/10 dark:bg-green/20 text-green-ink px-1.5 py-0.5 text-[10px]
- statCardIconVariants({ tone: 'primary' })
- flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/20 text-primary-ink
- itemMediaVariants({ variant: 'icon' })
- flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5 size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4
- cn('px-3 py-2 text-sm', 'px-4')
- py-2 text-sm px-4
The quiet one: data behind a client file
A constant re-exported through a "use client" file is not blocked — it simply arrives hollow. The RSC boundary cuts by file, so a data object crossing it becomes a client-reference proxy: every property reads undefined, an array reports a length of zero, and no diagnostic fires anywhere.
// The shape of the quiet bug. Same boundary, no error.
// The constant sits in the right module — and still arrives hollow, because the
// only path from the entry to it crosses a client file.
// chart/comitor-chart.tsx
'use client'
export { CHART_COLORS } from './chart-colors.js' // ← re-exported THROUGH a client file
// app/report/page.tsx — a Server Component
import { CHART_COLORS } from '@comitor/ui/chart'
CHART_COLORS.gold // undefined — not 'var(--chart-1)'
Object.keys(CHART_COLORS) // [] — the object arrives with no keys
// Nothing throws. Nothing warns. TypeScript still reports the literal type, so
// the editor stays green too. The chart renders with every series undefined.// What the package does instead. The constant lives in a directive-free module,
// and the barrel exports it STRAIGHT from there — routing it through the client
// file would put it back behind the boundary.
// shell/contrast-constants.ts — no directive, on purpose
export const CONTRAST_STORAGE_KEY = 'comitor-contrast'
// shell/index.ts
export * from './contrast-constants.js' // not via contrast-provider.tsx
export * from './contrast-provider.js'
// app/layout.tsx — a Server Component
import { CONTRAST_STORAGE_KEY } from '@comitor/ui/shell'
CONTRAST_STORAGE_KEY // 'comitor-contrast'Every pure constant in the package lives in a directive-free module for this reason: *-constants.ts beside each client composite, chart/chart-colors.ts, shell/types.ts, shell/contrast-constants.ts, shell/display-axis-constants.ts and components/ui/tabs-variants.ts. Each barrel exports them straight from those files. These are the values a server layout or a server page actually reads:
| Read from a Server Component | Entry | Value |
|---|---|---|
| CHART_COLORS.gold | @comitor/ui/chart | 'var(--chart-1)' |
| CHART_SERIES_COLORS[0] | @comitor/ui/chart | 'var(--chart-1)' |
| CONTRAST_STORAGE_KEY | @comitor/ui/shell | 'comitor-contrast' |
| DENSITY_STORAGE_KEY | @comitor/ui/shell | 'comitor-density' |
| FONT_SIZE_STORAGE_KEY | @comitor/ui/shell | 'comitor-font-size' |
| CONTRAST_ATTRIBUTE | @comitor/ui/shell | 'data-contrast' |
| SELECT_EMPTY_VALUE | @comitor/ui | '__empty__' |
| VI_DATE_FORMAT | @comitor/ui | 'dd/MM/yyyy' |
| DEFAULT_PAGE_SIZE_OPTIONS[0] | @comitor/ui | 20 |
| DEFAULT_DATE_PRESETS.length | @comitor/ui | 3 |
| DEFAULT_DATE_RANGE_PRESETS.length | @comitor/ui | 5 |
| DEFAULT_DATA_TABLE_LABELS.emptyTitle | @comitor/ui | 'Chưa có dữ liệu' |
| DEFAULT_TABLE_PAGINATION_LABELS.region | @comitor/ui | 'Phân trang' |
The storage keys matter most: a root layout is a Server Component, and it renders the anti-flash script for the contrast, density and text-size axes. If those three keys read undefined, the script writes to the wrong key and every display preference is silently forgotten on reload.
Hooks and toast commands
Nothing subtle here, and both groups fail loudly. Every hook in the package is client-only — useShell, useIsMac, useCarousel, useContrast, useDensity, useFontSize, useChart, useFormField, useFileUpload — as are the three imperative toast calls, toast, dismissToast and toastPromise.
They are listed because a complete inventory is the point of this page, not because anybody expects them to work on a server render.
What is safe on a server page
The short version, before the full table.
Types
Every exported type and interface. Types are erased at build, so they never had a runtime side to be stuck on.
Tokens
@comitor/ui/tokens in full — the gold, ash and accent scales, semantic and status colours, layout and typography. tokens.ts has no directive and never will.
Constants
Every DEFAULT_* label set, every storage key and attribute name, CHART_COLORS, VI_DATE_FORMAT, SELECT_EMPTY_VALUE, STATUS_TONES, the Tabs class maps.
Pure helpers
cn, the Vietnamese text utilities, the status helpers, the shell nav and app-launcher utilities, the three display-axis type guards.
Nine class builders
alertVariants, badgeVariants, buttonGroupVariants, cardVariants, emptyMediaVariants, itemVariants, itemMediaVariants, navigationMenuTriggerStyle, statCardIconVariants.
The brand marks
ComitorLogo and ComitorLockup are static SVG with no directive, so a server page renders them without shipping any JavaScript.
Every callable name
Components are omitted: a Server Component can render any of them. What follows is everything you might call or read — built from the re-export graph of all six entries, so it is the complete list rather than a selection.
| Name | Entry | Server? | Note |
|---|---|---|---|
Class builders (cva) The one group where the answer is genuinely mixed. Nothing in the name or the signature tells the two halves apart — only the directive on the file each one happens to live in. | |||
| buttonVariants | @comitor/ui | Throws | Ships beside the Button client component |
| toggleVariants | @comitor/ui | Throws | Ships beside Toggle |
| fieldVariants | @comitor/ui | Throws | Ships beside Field |
| inputGroupAddonVariants | @comitor/ui | Throws | Ships beside InputGroup |
| inputGroupButtonVariants | @comitor/ui | Throws | Ships beside InputGroup |
| progressTrackVariants | @comitor/ui | Throws | Ships beside Progress |
| progressBarVariants | @comitor/ui | Throws | Ships beside Progress |
| alertVariants | @comitor/ui | Yes | alert.tsx carries no directive |
| badgeVariants | @comitor/ui | Yes | badge.tsx carries no directive |
| buttonGroupVariants | @comitor/ui | Yes | button-group.tsx carries no directive |
| cardVariants | @comitor/ui | Yes | card.tsx carries no directive |
| emptyMediaVariants | @comitor/ui | Yes | empty.tsx carries no directive |
| itemVariants | @comitor/ui | Yes | item.tsx carries no directive |
| itemMediaVariants | @comitor/ui | Yes | item.tsx carries no directive |
| navigationMenuTriggerStyle | @comitor/ui | Yes | navigation-menu.tsx carries no directive |
| statCardIconVariants | @comitor/ui | Yes | stat-card.tsx carries no directive |
Hooks Every hook in the package is client-only, and every one of them throws loudly. No surprises here — this group is listed for completeness. | |||
| useIsMac | @comitor/ui | Throws | Reads navigator after mount |
| useCarousel | @comitor/ui | Throws | Embla carousel context |
| useShell | @comitor/ui/shell | Throws | Shell context |
| useContrast | @comitor/ui/shell | Throws | Contrast axis context |
| useDensity | @comitor/ui/shell | Throws | Density axis context |
| useFontSize | @comitor/ui/shell | Throws | Text-size axis context |
| useChart | @comitor/ui/chart | Throws | ChartContainer config context |
| useFormField | @comitor/ui/form | Throws | react-hook-form field context |
| useFileUpload | @comitor/ui/uploader | Throws | Builds an Uppy instance and wires its events |
Toast commands Imperative browser calls. They were never going to work from a server render, and they say so. | |||
| toast | @comitor/ui | Throws | Sonner command |
| dismissToast | @comitor/ui | Throws | Sonner command |
| toastPromise | @comitor/ui | Throws | Sonner command |
Helper functions All pure, all in directive-free modules, all callable from a Server Component — and from a Server Action, and from a route handler. | |||
| cn | @comitor/ui | Yes | clsx + tailwind-merge |
| dateFormatForLocale | @comitor/ui | Yes | Ships with VI_DATE_FORMAT |
| toSelectValue | @comitor/ui | Yes | Ships with SELECT_EMPTY_VALUE |
| fromSelectValue | @comitor/ui | Yes | Ships with SELECT_EMPTY_VALUE |
| getAvatarToneClasses | @comitor/ui | Yes | Deterministic tone from a name |
| getAvatarToneColors | @comitor/ui · @comitor/ui/tokens | Yes | Hex pair for canvas and PDF work |
| getAvatarToneIndex | @comitor/ui · @comitor/ui/tokens | Yes | Deterministic tone from a name |
| getStatusConfig | @comitor/ui | Yes | Status map lookup |
| getStatusClasses | @comitor/ui | Yes | Status map lookup |
| getStatusLabel | @comitor/ui | Yes | Status map lookup |
| sortByStatusPriority | @comitor/ui | Yes | Status map ordering |
| getVietnameseInitials | @comitor/ui · @comitor/ui/shell | Yes | Same function, two entries |
| normalizeVi | @comitor/ui · @comitor/ui/shell | Yes | Same function, two entries |
| removeVietnameseTones | @comitor/ui | Yes | String utility |
| matchesSearch | @comitor/ui | Yes | Tone-insensitive search predicate |
| truncate | @comitor/ui | Yes | String utility |
| toNavGroups | @comitor/ui/shell | Yes | Nav shape normaliser |
| flattenNavItems | @comitor/ui/shell | Yes | Nav shape normaliser |
| toSearchParams | @comitor/ui/shell | Yes | Accepts the searchParams object of a server page |
| isNavItemActive | @comitor/ui/shell | Yes | Active-route matching |
| isNavBranchActive | @comitor/ui/shell | Yes | Active-route matching |
| getEntitledApps | @comitor/ui/shell | Yes | App launcher filtering |
| getLockedApps | @comitor/ui/shell | Yes | App launcher filtering |
| resolveCurrentApp | @comitor/ui/shell | Yes | App launcher lookup |
| appAccentStyle | @comitor/ui/shell | Yes | Inline style object for the app accent |
| isContrastMode | @comitor/ui/shell | Yes | Type guard |
| isDensityMode | @comitor/ui/shell | Yes | Type guard |
| isFontSizeMode | @comitor/ui/shell | Yes | Type guard |
Constants The group that would fail silently if it were placed wrongly. Each of these is declared in a module with no directive and exported straight from the barrel, so a server render reads the real value. | |||
| CHART_COLORS | @comitor/ui/chart | Yes | chart/chart-colors.ts |
| CHART_SERIES_COLORS | @comitor/ui/chart | Yes | chart/chart-colors.ts |
| CONTRAST_MODES | @comitor/ui/shell | Yes | shell/contrast-constants.ts |
| CONTRAST_ATTRIBUTE | @comitor/ui/shell | Yes | shell/contrast-constants.ts |
| CONTRAST_STORAGE_KEY | @comitor/ui/shell | Yes | Read by the root layout |
| DEFAULT_CONTRAST_TOGGLE_LABELS | @comitor/ui/shell | Yes | shell/contrast-constants.ts |
| DENSITY_MODES | @comitor/ui/shell | Yes | shell/display-axis-constants.ts |
| DENSITY_ATTRIBUTE | @comitor/ui/shell | Yes | shell/display-axis-constants.ts |
| DENSITY_STORAGE_KEY | @comitor/ui/shell | Yes | Read by the root layout |
| DEFAULT_DENSITY | @comitor/ui/shell | Yes | shell/display-axis-constants.ts |
| FONT_SIZE_MODES | @comitor/ui/shell | Yes | shell/display-axis-constants.ts |
| FONT_SIZE_ATTRIBUTE | @comitor/ui/shell | Yes | shell/display-axis-constants.ts |
| FONT_SIZE_STORAGE_KEY | @comitor/ui/shell | Yes | Read by the root layout |
| DEFAULT_FONT_SIZE | @comitor/ui/shell | Yes | shell/display-axis-constants.ts |
| DEFAULT_SHELL_LABELS | @comitor/ui/shell | Yes | shell/types.ts |
| DEFAULT_COMMAND_PALETTE_LABELS | @comitor/ui | Yes | command-palette-constants.ts |
| DEFAULT_DATA_TABLE_LABELS | @comitor/ui | Yes | data-table-constants.ts |
| DEFAULT_DATE_PICKER_LABELS | @comitor/ui | Yes | date-picker-constants.ts |
| DEFAULT_DATE_PRESETS | @comitor/ui | Yes | date-picker-constants.ts |
| DEFAULT_DATE_RANGE_PRESETS | @comitor/ui | Yes | date-picker-constants.ts |
| VI_DATE_FORMAT | @comitor/ui | Yes | date-picker-constants.ts |
| DEFAULT_SEARCH_FILTER_BAR_LABELS | @comitor/ui | Yes | search-filter-bar-constants.tsx |
| DEFAULT_TABLE_PAGINATION_LABELS | @comitor/ui | Yes | table-pagination-constants.ts |
| DEFAULT_PAGE_SIZE_OPTIONS | @comitor/ui | Yes | table-pagination-constants.ts |
| SELECT_EMPTY_VALUE | @comitor/ui | Yes | form-field-constants.ts |
| DEFAULT_FILE_UPLOAD_LABELS | @comitor/ui/uploader | Yes | uploader/file-upload-constants.ts |
| DEFAULT_IMAGE_UPLOAD_FIELD_LABELS | @comitor/ui/uploader | Yes | Same file. The barrel imports it from there, not through the client field |
| STATUS_TONES | @comitor/ui | Yes | status-config.ts |
| tabsListVariants | @comitor/ui | Yes | tabs-variants.ts — a plain map, not cva |
| tabsTriggerVariants | @comitor/ui | Yes | tabs-variants.ts — a plain map, not cva |
| TABS_TRIGGER_BASE | @comitor/ui | Yes | tabs-variants.ts |
| gold · brandNeutral · ash · accent · accentInk · accentForeground · semantic · highContrast · status · chart · layout · typography | @comitor/ui · @comitor/ui/tokens | Yes | The whole token mirror. tokens.ts has no directive and never will |
Brand marks Both are deliberately directive-free so a server page renders static SVG and ships no JavaScript for them. | |||
| ComitorLogo | @comitor/ui/shell | Yes | Static SVG, no directive |
| ComitorLockup | @comitor/ui/shell | Yes | Static SVG, no directive |
How the list stays true
A silent boundary bug cannot be caught by review, so the package gates it before publish. Three checks run on every release.
The directive survives the build
Every source file that starts with "use client" must produce a dist file that starts with it too. A dropped directive would make a client component look like a server one, and the failure would only show up in a consuming app after publish.
No pure constant sits in a client file
A data value declared inside a "use client" file fails the gate outright. Components written as cva(…) or forwardRef(…) are exempt: their initialiser is a call, not data.
No constant reaches an entry through a client file
The stricter one. A constant can sit correctly in its own pure module and still be routed to the barrel via a client re-export, which reproduces the bug exactly. The gate walks the re-export graph from all six entries and fails any name whose only path crosses a client file.
Check 3 exists because check 2 was not enough, and that was measured rather than assumed. Put CHART_COLORS in its own pure module — correct on its own, so checks 1 and 2 both stay green — but let the barrel reach it through the client chart file, and a real Next build hands a server page an object with zero keys again.