facebook / facebook/astryx

feat: RSC-native components via `react-server` conditional exports

Open
#823 0 comments 0 reactions 1 assignee Claimed by @cixzhang View on GitHub
enhancement
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.