The Uncomfortable Secret: A Single Serializer

The promise of React Server Components (RSCs) is powerful: run rendering logic on the server, stream components to the client, and reduce JavaScript bundle size. Developers can sprinkle "use client" directives liberally, but the real challenge lies in understanding what actually survives the journey from the server to the client. The uncomfortable truth is that the boundary is a single serializer. React inspects every prop passed to a client component, attempts to encode it, and crashes on the first value it cannot serialize. This post dives into the source code of React 19's Flight serializer to reveal exactly what crosses this boundary and the common pitfalls that lead to build failures, even after code review.

The contract is simple: a prop is legal if the serializer has a specific branch for it. Anything outside these branches triggers a prototype check and ultimately, a build-time error. Understanding these branches is key to avoiding the dreaded next build failures that offer obscure error messages, no clear import chain, and a prop buried deep within an object named options.

What Actually Crosses the Boundary

At its core, the RSC boundary is defined by what the React Flight serializer can handle. This serializer is designed to transmit component trees and their props from the server to the client. It operates by traversing the props of a component marked with "use client". For each prop, it checks if a serialization strategy exists for its type. If a strategy exists, the prop is encoded and passed along. If not, React throws an error, halting the build process.

The types of data that successfully cross the boundary are those that have a corresponding serialization path within React's Flight serializer. These are primarily JSON-serializable types. Think of it like sending a package: only items that fit within a standard, pre-approved shipping container can be sent. Anything else, no matter how useful, is rejected at the loading dock.

The core types that are serializable include:

  • Primitive JavaScript types: strings, numbers, booleans, null.
  • Plain JavaScript objects: objects whose properties are themselves serializable.
  • Arrays: arrays whose elements are serializable.
  • React elements and fragments.
  • Special React types like React.Fragment, React.Suspense, and React.memo components.
  • Functions: While functions themselves cannot be directly serialized and sent to the client, their presence is handled in a specific way. In RSCs, functions passed as props are generally not sent to the client. If a client component receives a function as a prop from a server component, it will likely result in an error unless it's a specific type of function handled by the framework (e.g., event handlers that are implicitly handled).

The Pitfalls: What Fails

The most common cause of build failures stems from attempting to pass non-serializable data. This includes:

  • Functions: As mentioned, most JavaScript functions cannot be serialized and sent across the boundary. This is a frequent culprit, especially when passing callbacks or complex object methods.
  • Classes and Class Instances: Instances of JavaScript classes, including custom class instances, are not directly serializable.
  • DOM Nodes: You cannot pass actual DOM elements or nodes.
  • Non-plain objects: While plain objects are fine, objects with complex prototypes, such as Date objects, RegExps, or custom class instances, will fail.
  • Circular references: Any data structure with circular references will also cause serialization to fail.
  • Large or complex data structures: While not strictly a serialization failure, extremely large or deeply nested structures can lead to performance issues or memory limits during serialization, though the primary failure mode is type incompatibility.

Two Common Traps

Even with an understanding of serializability, two common patterns can trip up developers, leading to build failures that appear out of nowhere:

Trap 1: Undefined as an Object Property

Consider a scenario where you construct an options object on the server. If a property within this object is intentionally left undefined, the serializer might struggle. While undefined as a top-level prop is often handled, when it's nested within an object that's being serialized, it can lead to unexpected behavior or errors depending on the specific traversal logic of the serializer. The serializer expects a defined value or a type it can handle. If it encounters an undefined property during its traversal of an object, it might not have a specific branch for it, leading to a fallback prototype check that fails.

For example:

// On the server
const serverData = {
  user: {
    id: 123,
    name: 'Alice',
    // Imagine settings might be undefined if not configured
    settings: undefined
  }
};

// In a Server Component passing to a Client Component
<ClientComponent data={serverData} />

The issue isn't that undefined itself is un-serializable in all contexts, but how the serializer handles it when nested. It's safer to explicitly omit the key or set it to null if the downstream client component can handle null.

Trap 2: Arrow Functions in Nested Objects

This is perhaps the most insidious trap, as highlighted in the excerpt. Passing an arrow function, even indirectly within a nested object, is a guaranteed way to break the build. The serializer has no branch for functions. When it encounters one, it attempts a generic check that fails. The error message often points to the containing object (like options) or the prop name, but not directly to the function itself, making debugging difficult.

A common mistake is creating utility functions or callbacks on the server and embedding them within configuration objects passed down. For instance:


// On the server
const serverConfig = {
  // ... other config
  actions: {
    // This arrow function will break the build
    deleteItem: (itemId) => {
      console.log('Deleting item:', itemId);
      // ... actual delete logic
    }
  }
};

// In a Server Component passing to a Client Component
<ClientComponent config={serverConfig} />

The error might manifest as a build failure with an opaque message, potentially pointing to the config prop or even the actions object, but the root cause is the function. Developers must ensure that any functions intended for client-side execution are defined within client components themselves, or that server-side logic doesn't attempt to pass executable code.

The Flight Serializer in React 19

React 19's Flight serializer is the gatekeeper. It's a piece of code that lives within React itself, not a framework-specific abstraction like Next.js's routing or data fetching utilities. This means the rules are fundamental to React's server component implementation. By examining the source code (specifically files related to the React Flight format), one can trace the logic that handles different data types.

The serializer's logic can be broadly understood as a series of checks:

  1. Type Check: Is this a primitive, object, array, or React element?
  2. Specific Branch Check: Is this a known serializable type like a string, number, boolean, null, plain object, array, or a special React type?
  3. Fallback/Error: If none of the above, attempt a generic JSON-like serialization. If this fails (which it will for most non-plain types), throw an error indicating that the value could not be serialized.

The key takeaway is that React Server Components are designed to pass data, not arbitrary code or complex object instances. The serialization process ensures that only data that can be safely and efficiently transmitted and understood by the client is passed. This aligns with the goal of reducing client-side JavaScript and separating server concerns from client concerns.

Conclusion: Embrace Data, Not Code

The boundary between React Server Components and Client Components is strictly enforced by a serializer that favors JSON-compatible data. Developers must treat props passed from server to client as data payloads. Avoid passing functions, class instances, or complex non-plain objects. Pay close attention to nested object structures and ensure all properties are serializable. By understanding and respecting the limitations of the Flight serializer, developers can avoid frustrating build errors and leverage the full power of React Server Components effectively.