Right, let’s talk about the great divide. You’ve got your Server Components, living their best life on the server, churning out HTML and doing database things without a care in the world. And you’ve got your Client Components, over in the browser, handling user events and making things feel alive. They’re two different countries with different languages. Your job, as a TypeScript diplomat, is to broker a peace treaty between them so data can cross the border without getting strip-searched.

The core of the issue is this: Server Components can’t import Client Components. It’s a one-way street. The server renders the client component’s spot in the tree, but the client hydrates it later. This means you can’t pass server-side data (like the result of a prisma.user.findMany()) directly as a prop to a client component from its server component parent. Why? Because that data isn’t JSON-serializable by default. It might have functions, circular references, or even JavaScript’s Date objects—all of which will cause the app to blow up on its way through the network.

The Golden Rule: Serialize Everything

The only things that can cross the server-client boundary are plain old JavaScript objects (POJOs) that are trivially serializable. Think strings, numbers, booleans, arrays of those things, and simple objects. No functions, no Maps, no Sets, and for the love of all that is holy, no Promises.

This is where you, the brilliant developer, step in. You need to transform your fancy server-side data into something the client can safely consume. The most common and robust way to do this is to lean on your ORM or database client’s built-in serialization. Let’s say you’re using Prisma.

// app/users/page.tsx (Server Component)
import { PrismaClient } from '@prisma/client';
import UserTable from './UserTable'; // This is a Client Component

const prisma = new PrismaClient();

export default async function UsersPage() {
  // This is a full User object from Prisma, with all its methods. Can't send this.
  const unsafeUsers = await prisma.user.findMany();

  // The correct way: JSON.stringify and JSON.parse strip all non-serializable properties.
  // This creates a plain array of plain objects.
  const safeUsers = JSON.parse(JSON.stringify(unsafeUsers));

  return (
    <div>
      <h1>User List</h1>
      {/* We pass the safe, serialized data to the Client Component */}
      <UserTable users={safeUsers} />
    </div>
  );
}
// app/users/UserTable.tsx (Client Component - has 'use client' directive)
'use client';

// We define what the serialized data looks like on the client.
// Notice the `createdAt` is now a string, not a Date object.
interface SerializedUser {
  id: string;
  email: string;
  name: string | null;
  createdAt: string; // ISO string
  updatedAt: string; // ISO string
}

interface UserTableProps {
  users: SerializedUser[];
}

export default function UserTable({ users }: UserTableProps) {
  // Now you can use this `users` array in client-side state, effects, etc.
  return (
    <table>
      {/* ... render the users ... */}
    </table>
  );
}

Taming Dates and Other Problem Children

See that createdAt field? On the server, it was a Date object. After its trip through JSON.parse(JSON.stringify()), it’s now a string. This is the single most common “gotcha.” You must be vigilant about this transformation in your types.

A best practice is to create a type utility specifically for this purpose. This tells everyone on your team, “This type is meant to be sent to the client.”

// types/index.ts

// This takes a TypeScript type and transforms all Date objects into strings.
// It also handles nulls and arrays.
export type Serialized<T> = {
  [P in keyof T]: T[P] extends Date
    ? string
    : T[P] extends object | null
    ? Serialized<T[P]>
    : T[P];
};

// Now, use it to define your client-safe props.
interface UserTableProps {
  users: Serialized<User>[]; // Much clearer intent!
}

The tRPC Shortcut (Because They’re Not Savages)

If you’re using tRPC, congratulations, you’ve already won half this battle. The tRPC router is built around this exact problem. It uses superjson under the hood, which is a library smart enough to serialize and deserialize things like Date objects, BigInts, and even circular references. It magically turns them back into their proper types on the client.

When you define a router procedure and call it from the client, tRPC handles all the serialization boundary for you. The data you get back in your client-side useQuery will have proper Date objects. It’s honestly a cheat code.

// server/routers/user.ts
export const userRouter = router({
  list: publicProcedure.query(async ({ ctx }) => {
    // This returns a Prisma User object with Date objects
    return ctx.prisma.user.findMany();
  }),
});

// app/users/UserTable.tsx (Client Component)
'use client';
import { trpc } from '@/utils/trpc';

export default function UserTable() {
  // `data` here will be an array of objects where `createdAt` is a Date object.
  // No manual type transformation needed. tRPC + superjson did it.
  const { data: users, isLoading } = trpc.user.list.useQuery();

  if (isLoading) return <div>Loading...</div>;

  return (
    <table>
      {/* ... happily use user.createdAt.getFullYear() ... */}
    </table>
  );
}

The moral of the story? Be intentional. Every time data moves from server to client, ask yourself: “Is this a plain object?” If you’re not using tRPC, you must do the manual work of serialization and defining corresponding types. It’s a bit tedious, but it’s the price we pay for a type-safe full-stack application. And trust me, the alternative—runtime errors and any types—is far, far worse.