Adapt your design system for React Server Components — identify which components can be RSC, handle the client boundary split, and maintain a consistent theming system across server and client rendering.
Adapt your design system for React Server Components — identify which components can be RSC, handle the client boundary split, and maintain a consistent theming system across server and client rendering.
Not all design system components can be Server Components. Components must be client-only if they use: React hooks (useState, useEffect, useRef, useContext), browser-only APIs (window, document, localStorage, navigator), event handlers (onClick, onChange, onKeyDown), or imperative DOM manipulation. Components that CAN be server-rendered: pure presentational components that accept props and render JSX with no state or side effects.
Classify your entire component library:
1// Component compatibility matrix
2const COMPONENTS: Record<string, 'server' | 'client' | 'both'> = {
3 // Server-compatible (pure rendering, no state/events)
4 Heading: 'server',
5 Text: 'server',
6 Badge: 'server',
7 Avatar: 'server',
8 Divider: 'server',
9 Skeleton: 'server', // Static skeleton with CSS animation only
10 Card: 'server', // If content-only, no interactive state
11
12 // Client-only (interactive, uses hooks or browser APIs)
13 Button: 'client', // onClick event handler required
14 Input: 'client', // onChange + controlled state
15 Dialog: 'client', // focus management, Escape key
16 Combobox: 'client', // keyboard navigation, portal
17 DatePicker: 'client', // complex state + calendar logic
18 ThemeToggle: 'client', // reads/writes localStorage
19
20 // Both (export server and client versions)
21 Select: 'both', // RSC for display, client for interactive
22};Components classified as 'server' carry the biggest performance benefit — they render to HTML on the server with zero client JavaScript, directly improving Time to First Byte and Largest Contentful Paint.
Separate server-compatible and client-only exports so consuming Next.js and Remix apps can use each correctly:
1{
2 "name": "@acme/react",
3 "exports": {
4 ".": {
5 "react-server": "./dist/server/index.js",
6 "import": "./dist/client/index.js",
7 "require": "./dist/client/index.cjs"
8 },
9 "./server": {
10 "react-server": "./dist/server/index.js",
11 "import": "./dist/server/index.js"
12 },
13 "./client": {
14 "import": "./dist/client/index.js",
15 "require": "./dist/client/index.cjs"
16 }
17 }
18}The react-server condition is resolved by bundlers in server component contexts. Server-only components live in ./dist/server/ and do not include any client-side code. Client components in ./dist/client/ all start with 'use client' directive to create the client boundary.
1// packages/react/src/server/Heading.tsx
2// No 'use client' — this is a server component
3import type { ElementType } from 'react';
4
5interface HeadingProps {
6 level?: 1 | 2 | 3 | 4 | 5 | 6;
7 children: React.ReactNode;
8 className?: string;
9}
10
11export function Heading({ level = 2, children, className }: HeadingProps) {
12 const Tag = `h${level}` as ElementType;
13 return <Tag className={className}>{children}</Tag>;
14}CSS custom properties set via data-theme attribute on the document root work seamlessly across the RSC boundary because they are pure CSS — no JavaScript required for server-rendered components to consume them.
1// app/layout.tsx (Next.js App Router)
2import { cookies } from 'next/headers';
3
4export default async function RootLayout({ children }: { children: React.ReactNode }) {
5 // Read theme preference from cookie (available server-side)
6 const cookieStore = await cookies();
7 const colorScheme = cookieStore.get('color-scheme')?.value ?? 'light';
8 const brand = cookieStore.get('brand')?.value ?? 'brand-a';
9
10 return (
11 <html
12 lang="en"
13 data-theme={brand}
14 data-color-scheme={colorScheme}
15 // Suppress hydration warning — client may update this on mount
16 suppressHydrationWarning
17 >
18 <head>
19 <link rel="stylesheet" href="/tokens/global.css" />
20 <link rel="stylesheet" href={`/tokens/themes/${brand}.css`} />
21 </head>
22 <body>{children}</body>
23 </html>
24 );
25}The suppressHydrationWarning on <html> prevents React from complaining when the client updates data-color-scheme based on a user's stored preference that differs from the server default. Server-rendered components using CSS custom properties render correctly without JavaScript.
Server components cannot use browser globals. Test them in a Node.js environment that simulates server rendering:
1// vitest.config.ts — server component tests use node environment
2export default defineConfig({
3 test: {
4 projects: [
5 {
6 // Client components: jsdom environment
7 name: 'client',
8 environment: 'jsdom',
9 include: ['src/client/**/*.test.tsx'],
10 },
11 {
12 // Server components: node environment
13 name: 'server',
14 environment: 'node',
15 include: ['src/server/**/*.test.tsx'],
16 // No window, no document — intentional
17 },
18 ],
19 },
20});1// src/server/Heading.test.tsx
2import { describe, it, expect } from 'vitest';
3import { renderToStaticMarkup } from 'react-dom/server';
4import { Heading } from './Heading';
5
6describe('Heading (RSC compatible)', () => {
7 it('renders the correct heading level', () => {
8 const html = renderToStaticMarkup(<Heading level={1}>Page Title</Heading>);
9 expect(html).toBe('<h1>Page Title</h1>');
10 });
11
12 it('applies className prop', () => {
13 const html = renderToStaticMarkup(
14 <Heading level={2} className="custom">Section</Heading>
15 );
16 expect(html).toContain('class="custom"');
17 });
18
19 it('does not use any browser APIs', () => {
20 // If this renders without error in node environment, it is RSC-compatible
21 expect(() => renderToStaticMarkup(<Heading>Test</Heading>)).not.toThrow();
22 });
23});Storybook requires special configuration to render RSC-compatible components correctly without client polyfills:
1// src/server/Card/Card.stories.tsx
2import type { Meta, StoryObj } from '@storybook/react';
3import { Card } from './Card';
4
5const meta: Meta<typeof Card> = {
6 title: 'Server Components/Card',
7 component: Card,
8 tags: ['autodocs', 'server-compatible'],
9 parameters: {
10 // Hint for documentation: this component does not require JavaScript
11 docs: {
12 description: {
13 component:
14 '**RSC Compatible** — This component renders on the server with zero client JavaScript. ' +
15 'Import from `@acme/react/server` in React Server Component contexts.',
16 },
17 },
18 },
19};
20
21export default meta;
22type Story = StoryObj<typeof Card>;
23
24export const Default: Story = {
25 args: {
26 children: <p>Card content renders on the server.</p>,
27 },
28};Add a visual indicator in Storybook's sidebar for server-compatible components — a 'server' badge next to the component name. Teams scanning the catalog can immediately see which components are free in terms of JavaScript budget.
Audit your 10 most-used design system components for RSC compatibility. Mark each as server or client and restructure your package exports to expose a /server entry point for the compatible ones.
Your progress is saved in this browser, on this device only.