feat: RSC-native components via `react-server` conditional exports
- Dominant language
- TypeScript
- Stars
- 13k
- Forks
- 1.1k
- Avg merge
- 1d 15h
- Merged PRs (30d)
- 690
Description
## Summary
XDS can become the first design system to offer genuinely server-renderable components — not by wrapping everything in `"use client"`, but by using the [`react-server` conditional export](https://github.com/reactjs/rfcs/pull/227) to let bundlers resolve the right module automatically.
When a consumer writes `import { Badge } from "@xds/core/Badge"` in a Server Component, the bundler resolves the server-safe version (no `"use client"`, pure HTML output). In a Client Component, it resolves the interactive version. Same import, different resolution. Zero friction for consumers.
## Motivation
The current state of the React component library ecosystem:
| Library | RSC approach |
|---------|-------------|
| Chakra UI | All client. `"use client"` everywhere. |
| MUI | Runtime theme via context. All client. |
| Mantine | All client. |
| Radix | All client for interactive primitives. |
| Headless UI | All client. |
| **next-intl** | `react-server` condition at package level ✅ |
| **Payload CMS** | Explicit `./rsc` subpath export ✅ |
| **XDS (proposed)** | `react-server` condition at per-component level 🆕 |
No major design system has solved server-renderable themed components yet. XDS is positioned to be the first because:
1. Themes resolve via CSS (`light-dark()`, CSS layers) not runtime context
2. StyleX compiles to static CSS at build time — no runtime JS for styling
3. 18 of 60 components use zero client APIs (no hooks, no event handlers, no context)
## The `react-server` condition
From [React RFC 227](https://github.com/reactjs/rfcs/pull/227), the `react-server` condition in `package.json` exports tells the bundler to resolve a different file when the import happens inside a React Server Component environment:
```jsonc
// package.json exports
{
"./Badge": {
"react-server": "./dist/Badge/index.mjs",
"default": "./dist/Badge/index.mjs"
},
"./Dialog": {
"react-server": "./dist/Dialog/index.server.mjs",
"default": "./dist/Dialog/index.mjs"
}
}
```
This is the same mechanism used by `server-only` and `client-only` packages, and by `next-intl` for its server/client API split.
## Component tiers
### Tier 1: Server-safe (18 components)
No hooks, no events, no context. Pure HTML + CSS. Can render entirely on the server with zero JavaScript shipped to the client.
`AspectRatio`, `Badge`, `Card`, `Center`, `Divider`, `EmptyState`, `Field`, `Grid`, `Icon`, `Kbd`, `NavIcon`, `NavItem`, `OverflowList`, `ProgressBar`, `Section`, `Skeleton`, `Stack`, `StatusDot`
**Action:** Remove `"use client"` from these. Both `react-server` and `default` conditions point to the same file.
### Tier 2: Events-only (15 components)
Accept event handler props (onClick, onChange) but no state or effects.
`Breadcrumbs`, `Button`, `CheckboxInput`, `FormLayout`, `Link`, `List`, `Pagination`, `RadioList`, `SegmentedControl`, `Slider`, `Switch`, `TabList`, `TextArea`, `TextInput`, `Token`
**Open question — three options:**
- **A)** Keep as client-only (current behavior, safe)
- **B)** Make server-renderable, document that event handlers require a client boundary
- **C)** Dual export: server version omits event handler props from the type signature, TypeScript errors if you try to pass onClick in a Server Component
### Tier 3: Context-only (1 component)
`Layout` — reads context but no state or effects. Could be server-safe with a provider pattern.
### Tier 4: Full client (26 components)
State, effects, refs — genuinely interactive. Stay as `"use client"`.
`AppShell`, `Avatar`, `Banner`, `Calendar`, `Collapsible`, `DateInput`, `Dialog`, `DropdownMenu`, `HoverCard`, `Layer`, `MobileNav`, `MoreMenu`, `NumberInput`, `Popover`, `PowerSearch`, `Selector`, `SideNav`, `Spinner`, `Table`, `Text`, `TimeInput`, `Tokenizer`, `Tooltip`, `TopNav`, `TreeList`, `Typeahead`
## Implementation plan
### Phase 1: Server-safe components (low risk, high signal)
1. Remove `"use client"` from Tier 1 source files
2. Verify via RSC harness (see below)
3. Update `check-use-client.mjs` to allowlist Tier 1 components
### Phase 2: Conditional exports infrastructure
1. Add `react-server` condition to all `package.json` component exports
2. Tier 1: both conditions point to same server-safe file
3. Tier 4: `react-server` condition points to same file (with `"use client"` — auto client boundary)
4. CI check: validate export map matches component tiers
### Phase 3: Events-only components
1. Decide on A/B/C for Tier 2
2. If C: build type-stripping infrastructure
3. Test with real consumer pain points (Jurmarcus's use cases)
### Phase 4: Server stubs for interactive components (stretch)
For Tier 4 components, generate server-side HTML shells that render the visual structure without interactivity.
---
## RSC Test Harness
Testing RSC boundaries requires a real Next.js server runtime — not a static export. `apps/example-nextjs` already runs with a real server (no `output: "export"`) and is the right place.
### Approach
Run locally with `yarn dev` in `apps/example-nextjs`. No deploy needed — Next.js dev server gives real RSC with hot reload so boundary pain points are immediately visible.
### Routes to add
**`/rsc/server-only`** — Tier 1 components only, async Server Component with real data fetching. No `"use client"` anywhere. Validates that Badge, Card, Skeleton etc. render server-side after Phase 1.
**`/rsc/mixed`** — Server-fetched data + client interactive components. A realistic dashboard pattern: server renders the data shell (cards, tables, badges), client handles interactivity (Dialog, DropdownMenu, search). Demonstrates the correct boundary placement.
**`/rsc/pain-points`** — Deliberately triggers RSC errors to document them. Each error case is annotated with the explanation and fix. Useful for understanding what Jurmarcus is hitting.
**`/rsc/boundary-demo`** — Visual markers showing which subtrees are server vs client rendered. Useful for seeing how XDS components compose across the boundary.
### What to measure
- JS bundle size on `/rsc/server-only` — should approach zero for Tier 1 only pages
- Error messages when boundary rules are violated — are they understandable?
- DX friction — how many wrapper components does a typical RSC consumer need?
---
## Prior art
- [React RFC 227: Server Module Conventions](https://github.com/reactjs/rfcs/pull/227)
- [next-intl](https://github.com/amannn/next-intl) — `react-server` condition at package level
- [Payload CMS](https://github.com/payloadcms/payload) — explicit `./rsc` subpath
- [`server-only` / `client-only`](https://www.npmjs.com/package/server-only) — React team reference implementation
- [XDS RSC Compatibility wiki](https://github.com/facebookexperimental/xds/wiki/RSC-Compatibility)
## Success criteria
- A Next.js Server Component can import `Badge`, `Card`, `Skeleton` from `@xds/core` without any `"use client"` boundary
- Zero JavaScript shipped for pages that only use Tier 1 components
- No breaking changes — existing consumers work identically
- TypeScript provides correct guidance about what works in server vs client contexts
- The `/rsc/*` routes in `apps/example-nextjs` serve as living documentation of the boundary
Contributor guide
Assessment
This issue has not been assessed yet.