Steve Kinney

React Server Components Types

React Server Components fundamentally change how we think about the client-server boundary in React applications. Instead of rendering everything on the client and fetching data through APIs, some components run on the server, some on the client, and some can do both depending on where they’re imported. This flexibility is powerful, but it comes with a catch: you need to be deliberate about what data crosses the network and how you enforce these boundaries with TypeScript.

We’re going to explore how to create type-safe contracts between your server and client components, ensure data serialization works correctly, and catch boundary violations at compile time rather than runtime. By the end, you’ll have patterns for building React Server Components that are both performant and bulletproof.

Understanding the Component Boundary

React Server Components introduce three types of components:

  • Server Components: Run only on the server during rendering
  • Client Components: Run in the browser (marked with "use client")
  • Shared Components: Can run in either environment depending on where they’re imported

The tricky part is that data flowing between these environments must be serializable—no functions, class instances, or other non-JSON values. TypeScript can help enforce these rules, but you need to set up the right constraints.

// ✅ Server Component - runs on server
export default async function ProductList() {
  const products = await fetchProducts(); // Direct database access
  return (
    <div>
      {products.map((product) => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

// ✅ Client Component - runs in browser
('use client');
export function AddToCartButton({ productId }: { productId: string }) {
  const [isLoading, setIsLoading] = useState(false);
  // ... interactive logic
}

The challenge comes when passing data between these boundaries. Let’s make it type-safe.

Creating Serializable Type Contracts

First, we need types that represent only serializable data. Here’s a utility type that strips out non-serializable values:

// Utility type for serializable data
type Serializable =
  | string
  | number
  | boolean
  | null
  | undefined
  | SerializableObject
  | SerializableArray;

type SerializableObject = { [Key in string]: Serializable };
type SerializableArray = ReadonlyArray<Serializable>;

// Helper to ensure a type is serializable
type EnsureSerializable<T> = T extends Serializable ? T : never;

Now let’s define our data contracts:

// Raw database/API types (server-only)
interface ProductEntity {
  id: string;
  name: string;
  price: number;
  createdAt: Date; // ❌ Not serializable
  updateInventory: (count: number) => Promise<void>; // ❌ Not serializable
}

// Serializable contract for client
export interface SerializableProduct {
  id: string;
  name: string;
  price: number;
  createdAt: string; // ✅ Serialized as ISO string
  // Functions are excluded
}

// Ensure our contract is actually serializable
type ValidProduct = EnsureSerializable<SerializableProduct>; // ✅ Compiles

This pattern catches serialization issues at compile time rather than runtime.

Server-Only and Client-Only Type Guards

We can create stronger boundaries using TypeScript’s module resolution and branded types:

// server-only-types.ts
declare const SERVER_ONLY: unique symbol;
export interface ServerOnlyData {
  readonly [SERVER_ONLY]: true;
}

export interface DatabaseProduct extends ServerOnlyData {
  id: string;
  name: string;
  price: number;
  createdAt: Date;
  inventory: {
    count: number;
    updateCount(delta: number): Promise<void>;
  };
}
// client-safe-types.ts
export interface ClientSafeProduct {
  id: string;
  name: string;
  price: number;
  createdAt: string; // Always serialized
  inventoryCount: number; // Flattened, no methods
}

Now create transformation functions with explicit type boundaries:

// server/transforms.ts
import type { DatabaseProduct } from './server-only-types';
import type { ClientSafeProduct } from './client-safe-types';

export function toClientSafeProduct(serverProduct: DatabaseProduct): ClientSafeProduct {
  return {
    id: serverProduct.id,
    name: serverProduct.name,
    price: serverProduct.price,
    createdAt: serverProduct.createdAt.toISOString(),
    inventoryCount: serverProduct.inventory.count,
    // ✅ Methods and complex objects are stripped
  };
}

// This function signature prevents accidental server data leakage
export async function getProductsForClient(): Promise<ClientSafeProduct[]> {
  const serverProducts = await fetchProductsFromDatabase();
  return serverProducts.map(toClientSafeProduct);
}

Enforcing Component Boundaries with Props

Let’s create wrapper types that enforce correct prop usage:

// Component boundary types
type ServerComponentProps<T = {}> = T & {
  children?: React.ReactNode;
};

type ClientComponentProps<T = {}> = EnsureSerializable<T> & {
  children?: React.ReactNode;
};

// Usage in components
export default async function ProductListServer(
  props: ServerComponentProps<{ category?: string }>
) {
  const { category } = props;
  const products = await getProductsForClient(); // Already serialized

  return (
    <div>
      {products.map(product => (
        <ProductClientCard key={product.id} product={product} />
      ))}
    </div>
  );
}

"use client";
export function ProductClientCard(
  props: ClientComponentProps<{ product: ClientSafeProduct }>
) {
  const { product } = props;
  // ✅ TypeScript ensures product is serializable
}

This approach catches prop violations at compile time:

// ❌ TypeScript error - Date is not serializable
<ProductClientCard product={{
  id: "1",
  name: "Widget",
  createdAt: new Date() // Error!
}} />

// ✅ Compiles correctly
<ProductClientCard product={{
  id: "1",
  name: "Widget",
  createdAt: new Date().toISOString()
}} />

Runtime Validation with Zod

For production applications, combine TypeScript with runtime validation using Zod:

import { z } from 'zod';

// Zod schema for serializable data
const SerializableProductSchema = z.object({
  id: z.string(),
  name: z.string(),
  price: z.number().positive(),
  createdAt: z.string().datetime(), // ISO string
  inventoryCount: z.number().int().min(0),
});

// Infer TypeScript type from schema
export type SerializableProduct = z.infer<typeof SerializableProductSchema>;

// Server-to-client boundary with validation
export function validateForClient<T>(
  data: unknown,
  schema: z.ZodSchema<T>
): T {
  const result = schema.safeParse(data);

  if (!result.success) {
    console.error('Serialization validation failed:', result.error);
    throw new Error('Invalid data passed to client component');
  }

  return result.data;
}

// Usage in server component
export default async function ProductList() {
  const rawProducts = await fetchProductsFromDatabase();
  const safeProducts = rawProducts.map(product =>
    validateForClient(toClientSafeProduct(product), SerializableProductSchema)
  );

  return <ProductGrid products={safeProducts} />;
}

This gives you both compile-time and runtime safety for your component boundaries.

Handling Complex Serialization Patterns

Discriminated Unions

When passing complex state, use discriminated unions to maintain type safety:

type ClientSafeState =
  | { status: 'loading' }
  | { status: 'error'; message: string }
  | { status: 'success'; products: SerializableProduct[] };

"use client";
export function ProductDisplay({
  state
}: ClientComponentProps<{ state: ClientSafeState }>) {
  switch (state.status) {
    case 'loading':
      return <Spinner />;
    case 'error':
      return <ErrorMessage message={state.message} />;
    case 'success':
      return <ProductList products={state.products} />;
  }
}

Nested Data Structures

For deeply nested objects, create recursive serialization utilities:

type DeepSerializable<T> = T extends (infer U)[]
  ? DeepSerializable<U>[]
  : T extends Record<string, any>
    ? { [K in keyof T]: DeepSerializable<T[K]> }
    : T extends Date
      ? string
      : T extends Function
        ? never
        : T;

interface ComplexServerData {
  user: {
    profile: {
      createdAt: Date;
      preferences: {
        theme: string;
        notifications: boolean;
      };
    };
  };
}

type SafeComplexData = DeepSerializable<ComplexServerData>;
// Result: { user: { profile: { createdAt: string; preferences: { theme: string; notifications: boolean } } } }

Common Pitfalls and Solutions

Accidentally Passing Server Data

// ❌ Common mistake - passing server objects directly
export default async function BadExample() {
  const user = await fetchUser(); // Returns Date objects, methods
  return <ClientProfile user={user} />; // Runtime serialization error
}

// ✅ Explicit transformation
export default async function GoodExample() {
  const serverUser = await fetchUser();
  const clientSafeUser: ClientSafeUser = {
    id: serverUser.id,
    name: serverUser.name,
    joinedAt: serverUser.createdAt.toISOString(),
  };
  return <ClientProfile user={clientSafeUser} />;
}

Environment-Specific Imports

// server-utils.ts - mark as server-only
import 'server-only'; // Prevents client bundling

export async function fetchUserFromDatabase(id: string) {
  // Database logic here
}

// client-utils.ts - mark as client-only
import 'client-only'; // Prevents server usage

export function trackUserInteraction(event: string) {
  // Browser-only analytics
}
Always install

server-only and client-only packages to enforce environment boundaries at build time.

Real-World Application Pattern

Here’s a complete pattern for a production app:

// types/contracts.ts
export interface UserContract {
  id: string;
  email: string;
  profile: {
    displayName: string;
    avatarUrl: string | null;
    joinedAt: string;
  };
}

// server/user-service.ts
import 'server-only';
import { UserContract } from '@/types/contracts';

export async function getUserForClient(id: string): Promise<UserContract> {
  const user = await db.user.findUnique({ where: { id } });

  return {
    id: user.id,
    email: user.email,
    profile: {
      displayName: user.displayName,
      avatarUrl: user.avatarUrl,
      joinedAt: user.createdAt.toISOString(),
    },
  };
}

// components/UserProfile.server.tsx
import { getUserForClient } from '@/server/user-service';
import { UserProfileClient } from './UserProfile.client';

export default async function UserProfile({ userId }: { userId: string }) {
  const user = await getUserForClient(userId);
  return <UserProfileClient user={user} />;
}

// components/UserProfile.client.tsx
"use client";
import { UserContract } from '@/types/contracts';

export function UserProfileClient({
  user
}: {
  user: UserContract
}) {
  // Interactive client logic here
  return (
    <div>
      <h1>{user.profile.displayName}</h1>
      <p>Joined: {new Date(user.profile.joinedAt).toLocaleDateString()}</p>
    </div>
  );
}

This pattern gives you:

  • ✅ Compile-time safety for data contracts
  • ✅ Clear separation of server and client concerns
  • ✅ Explicit serialization boundaries
  • ✅ Runtime validation when needed
  • ✅ Easy testing of individual components

Next Steps

Now that you have type-safe boundaries between your server and client components, you can:

  1. Add runtime validation with Zod schemas for production safety
  2. Create reusable transformation utilities for common serialization patterns
  3. Set up ESLint rules to catch boundary violations during development
  4. Build testing utilities that validate your serialization contracts

The key is being explicit about your boundaries rather than hoping React’s serialization “just works.” With proper TypeScript contracts, you’ll catch issues at compile time and ship more reliable Server Components.

Last modified on .