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.

NameDeclared inFrom a Server Component
buttonVariantscomponents/ui/button.tsxThrows
toggleVariantscomponents/ui/toggle.tsxThrows
fieldVariantscomponents/ui/field.tsxThrows
inputGroupAddonVariantscomponents/ui/input-group.tsxThrows
inputGroupButtonVariantscomponents/ui/input-group.tsxThrows
progressTrackVariantscomponents/ui/progress.tsxThrows
progressBarVariantscomponents/ui/progress.tsxThrows
app/pricing/page.tsx
// 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

asChild
// 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>
  )
}
client wrapper
// 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.

NameDeclared inFrom a Server Component
alertVariantscomponents/ui/alert.tsxYes
badgeVariantscomponents/ui/badge.tsxYes
buttonGroupVariantscomponents/ui/button-group.tsxYes
cardVariantscomponents/ui/card.tsxYes
emptyMediaVariantscomponents/ui/empty.tsxYes
itemVariantscomponents/ui/item.tsxYes
itemMediaVariantscomponents/ui/item.tsxYes
navigationMenuTriggerStylecomponents/ui/navigation-menu.tsxYes
statCardIconVariantscomponents/composite/stat-card.tsxYes
cnlib/cn.tsYes

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 bug
// 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.
the fix
// 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 ComponentEntryValue
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/ui20
DEFAULT_DATE_PRESETS.length@comitor/ui3
DEFAULT_DATE_RANGE_PRESETS.length@comitor/ui5
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.

NameEntryServer?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/uiThrowsShips beside the Button client component
toggleVariants@comitor/uiThrowsShips beside Toggle
fieldVariants@comitor/uiThrowsShips beside Field
inputGroupAddonVariants@comitor/uiThrowsShips beside InputGroup
inputGroupButtonVariants@comitor/uiThrowsShips beside InputGroup
progressTrackVariants@comitor/uiThrowsShips beside Progress
progressBarVariants@comitor/uiThrowsShips beside Progress
alertVariants@comitor/uiYesalert.tsx carries no directive
badgeVariants@comitor/uiYesbadge.tsx carries no directive
buttonGroupVariants@comitor/uiYesbutton-group.tsx carries no directive
cardVariants@comitor/uiYescard.tsx carries no directive
emptyMediaVariants@comitor/uiYesempty.tsx carries no directive
itemVariants@comitor/uiYesitem.tsx carries no directive
itemMediaVariants@comitor/uiYesitem.tsx carries no directive
navigationMenuTriggerStyle@comitor/uiYesnavigation-menu.tsx carries no directive
statCardIconVariants@comitor/uiYesstat-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/uiThrowsReads navigator after mount
useCarousel@comitor/uiThrowsEmbla carousel context
useShell@comitor/ui/shellThrowsShell context
useContrast@comitor/ui/shellThrowsContrast axis context
useDensity@comitor/ui/shellThrowsDensity axis context
useFontSize@comitor/ui/shellThrowsText-size axis context
useChart@comitor/ui/chartThrowsChartContainer config context
useFormField@comitor/ui/formThrowsreact-hook-form field context
useFileUpload@comitor/ui/uploaderThrowsBuilds 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/uiThrowsSonner command
dismissToast@comitor/uiThrowsSonner command
toastPromise@comitor/uiThrowsSonner 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/uiYesclsx + tailwind-merge
dateFormatForLocale@comitor/uiYesShips with VI_DATE_FORMAT
toSelectValue@comitor/uiYesShips with SELECT_EMPTY_VALUE
fromSelectValue@comitor/uiYesShips with SELECT_EMPTY_VALUE
getAvatarToneClasses@comitor/uiYesDeterministic tone from a name
getAvatarToneColors@comitor/ui · @comitor/ui/tokensYesHex pair for canvas and PDF work
getAvatarToneIndex@comitor/ui · @comitor/ui/tokensYesDeterministic tone from a name
getStatusConfig@comitor/uiYesStatus map lookup
getStatusClasses@comitor/uiYesStatus map lookup
getStatusLabel@comitor/uiYesStatus map lookup
sortByStatusPriority@comitor/uiYesStatus map ordering
getVietnameseInitials@comitor/ui · @comitor/ui/shellYesSame function, two entries
normalizeVi@comitor/ui · @comitor/ui/shellYesSame function, two entries
removeVietnameseTones@comitor/uiYesString utility
matchesSearch@comitor/uiYesTone-insensitive search predicate
truncate@comitor/uiYesString utility
toNavGroups@comitor/ui/shellYesNav shape normaliser
flattenNavItems@comitor/ui/shellYesNav shape normaliser
toSearchParams@comitor/ui/shellYesAccepts the searchParams object of a server page
isNavItemActive@comitor/ui/shellYesActive-route matching
isNavBranchActive@comitor/ui/shellYesActive-route matching
getEntitledApps@comitor/ui/shellYesApp launcher filtering
getLockedApps@comitor/ui/shellYesApp launcher filtering
resolveCurrentApp@comitor/ui/shellYesApp launcher lookup
appAccentStyle@comitor/ui/shellYesInline style object for the app accent
isContrastMode@comitor/ui/shellYesType guard
isDensityMode@comitor/ui/shellYesType guard
isFontSizeMode@comitor/ui/shellYesType 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/chartYeschart/chart-colors.ts
CHART_SERIES_COLORS@comitor/ui/chartYeschart/chart-colors.ts
CONTRAST_MODES@comitor/ui/shellYesshell/contrast-constants.ts
CONTRAST_ATTRIBUTE@comitor/ui/shellYesshell/contrast-constants.ts
CONTRAST_STORAGE_KEY@comitor/ui/shellYesRead by the root layout
DEFAULT_CONTRAST_TOGGLE_LABELS@comitor/ui/shellYesshell/contrast-constants.ts
DENSITY_MODES@comitor/ui/shellYesshell/display-axis-constants.ts
DENSITY_ATTRIBUTE@comitor/ui/shellYesshell/display-axis-constants.ts
DENSITY_STORAGE_KEY@comitor/ui/shellYesRead by the root layout
DEFAULT_DENSITY@comitor/ui/shellYesshell/display-axis-constants.ts
FONT_SIZE_MODES@comitor/ui/shellYesshell/display-axis-constants.ts
FONT_SIZE_ATTRIBUTE@comitor/ui/shellYesshell/display-axis-constants.ts
FONT_SIZE_STORAGE_KEY@comitor/ui/shellYesRead by the root layout
DEFAULT_FONT_SIZE@comitor/ui/shellYesshell/display-axis-constants.ts
DEFAULT_SHELL_LABELS@comitor/ui/shellYesshell/types.ts
DEFAULT_COMMAND_PALETTE_LABELS@comitor/uiYescommand-palette-constants.ts
DEFAULT_DATA_TABLE_LABELS@comitor/uiYesdata-table-constants.ts
DEFAULT_DATE_PICKER_LABELS@comitor/uiYesdate-picker-constants.ts
DEFAULT_DATE_PRESETS@comitor/uiYesdate-picker-constants.ts
DEFAULT_DATE_RANGE_PRESETS@comitor/uiYesdate-picker-constants.ts
VI_DATE_FORMAT@comitor/uiYesdate-picker-constants.ts
DEFAULT_SEARCH_FILTER_BAR_LABELS@comitor/uiYessearch-filter-bar-constants.tsx
DEFAULT_TABLE_PAGINATION_LABELS@comitor/uiYestable-pagination-constants.ts
DEFAULT_PAGE_SIZE_OPTIONS@comitor/uiYestable-pagination-constants.ts
SELECT_EMPTY_VALUE@comitor/uiYesform-field-constants.ts
DEFAULT_FILE_UPLOAD_LABELS@comitor/ui/uploaderYesuploader/file-upload-constants.ts
DEFAULT_IMAGE_UPLOAD_FIELD_LABELS@comitor/ui/uploaderYesSame file. The barrel imports it from there, not through the client field
STATUS_TONES@comitor/uiYesstatus-config.ts
tabsListVariants@comitor/uiYestabs-variants.ts — a plain map, not cva
tabsTriggerVariants@comitor/uiYestabs-variants.ts — a plain map, not cva
TABS_TRIGGER_BASE@comitor/uiYestabs-variants.ts
gold · brandNeutral · ash · accent · accentInk · accentForeground · semantic · highContrast · status · chart · layout · typography@comitor/ui · @comitor/ui/tokensYesThe 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/shellYesStatic SVG, no directive
ComitorLockup@comitor/ui/shellYesStatic 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.

1

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.

2

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.

3

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.