React 19 finally landed with full stability, and the TypeScript story around Server Components and Server Actions is… better than it was, but still full of traps. If you’ve been guessing your way through "use server" directives, praying that FormData gives you what you expect, and copy-pasting Awaited<ReturnType<...>> without fully understanding it — this article is for you.
We’ll cover how to properly type Server Components, how to lock down Server Actions so TypeScript actually catches your mistakes before the browser does, and how to model the server/client data boundary without losing type safety. No hand-waving, no "it depends" non-answers.
The official React docs are at https://react.dev. For framework-level integration (file conventions, "use server" semantics), you’ll mostly be working through Next.js — its source and type definitions live at https://github.com/vercel/next.js.
Why This Is Harder Than It Looks
Server Components and Server Actions introduce a concept TypeScript wasn’t designed for: a hard runtime boundary between two execution environments that share type definitions. On the server you can read from the database; on the client you can’t. The types need to accurately represent this split, or you end up with a false sense of safety.
The core problems:
- Props crossing the boundary must be serializable. Functions, class instances,
Map,Set,Date(partially) — none of these survive the serialization to the client. TypeScript won’t stop you from passing them. - Server Actions look like async functions but have different call semantics. Calling one from a Client Component enqueues a network request, not a direct function call. The type signature tells you nothing about this.
FormDatais a black box. You get aFormDataobject and you’re on your own — no typed fields unless you build the plumbing yourself.- Error handling has no standard shape. Return types are whatever you make them, so you end up with inconsistent patterns across a codebase.
Let’s fix all of this.
Typing Server Components
A Server Component is just an async function that returns JSX. TypeScript is fine with this:
// app/users/page.tsx
import { db } from "@/lib/db";
// This is a Server Component — no "use client", no hooks, no event handlers.
// TypeScript sees it as: () => Promise<JSX.Element>
export default async function UsersPage() {
const users = await db.user.findMany();
return (
<ul>
{users.map((u) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}
Boring so far. The typing problems show up when you pass props to Server Components, especially when mixing them with Client Components.
Typing Props in Server Components
Props work exactly like in any React component, but you should be deliberate about what you put in them. Mark your prop types with serializable constraints in mind:
// components/UserCard.tsx
// This is a Server Component.
type UserCardProps = {
userId: string;
// Don't do this — functions can't cross the boundary:
// onDelete: (id: string) => void; ← this would blow up at runtime
};
export async function UserCard({ userId }: UserCardProps) {
const user = await db.user.findUnique({ where: { id: userId } });
if (!user) return null;
return <div>{user.name}</div>;
}
The compiler won’t catch the "unserializable prop" mistake. That’s a gap React’s type system simply doesn’t cover right now. You have to enforce it by convention and code review.
Passing Data to Client Components
When a Server Component renders a Client Component, it’s passing serialized data over a wire (conceptually). The Client Component’s props must only contain JSON-safe types.
// components/UserActions.tsx
"use client";
// Only plain serializable types here.
type UserActionsProps = {
userId: string;
userName: string;
role: "admin" | "viewer";
};
export function UserActions({ userId, userName, role }: UserActionsProps) {
return (
<button onClick={() => console.log(`Acting on ${userId}`)}>
{userName}
</button>
);
}
// app/users/[id]/page.tsx — Server Component
import { UserActions } from "@/components/UserActions";
export default async function UserPage({ params }: { params: { id: string } }) {
const user = await db.user.findUnique({ where: { id: params.id } });
if (!user) notFound();
// TypeScript ensures you're passing the right shape.
// Runtime ensures it's serializable — TypeScript does NOT.
return <UserActions userId={user.id} userName={user.name} role={user.role} />;
}
Gotcha: React 19 serializes props via RSC (React Server Components) protocol, not plain JSON.stringify. So Date objects survive — but only as Date, not as custom class instances. Don’t rely on this for complex objects.
Typing Server Actions
This is where most people run into trouble. A Server Action is an async function marked with "use server". When called from a Client Component, it executes on the server. But from TypeScript’s perspective, it’s just a function.
Basic Server Action Typing
// app/actions/user.ts
"use server";
import { db } from "@/lib/db";
import { revalidatePath } from "next/cache";
// The return type matters — this is what the client gets back.
export async function deleteUser(userId: string): Promise<void> {
await db.user.delete({ where: { id: userId } });
revalidatePath("/users");
}
Calling it from a Client Component:
"use client";
import { deleteUser } from "@/app/actions/user";
export function DeleteButton({ userId }: { userId: string }) {
return (
<button
onClick={async () => {
// TypeScript knows this returns Promise<void>
await deleteUser(userId);
}}
>
Delete
</button>
);
}
Clean and type-safe for the happy path. But real applications need structured errors.
The Result Pattern for Server Actions
Throwing errors from Server Actions works, but it’s a terrible user experience — the client gets an opaque error boundary. The production-ready approach is to return a typed result object.
// lib/types/action-result.ts
export type ActionSuccess<T> = {
success: true;
data: T;
};
export type ActionError = {
success: false;
error: string;
// Optional: field-level validation errors
fieldErrors?: Record<string, string[]>;
};
export type ActionResult<T = void> = ActionSuccess<T> | ActionError;
Now your Server Actions have a contract:
// app/actions/user.ts
"use server";
import { z } from "zod";
import type { ActionResult } from "@/lib/types/action-result";
const CreateUserSchema = z.object({
name: z.string().min(2).max(100),
email: z.string().email(),
});
export async function createUser(
formData: FormData
): Promise<ActionResult<{ id: string }>> {
const parsed = CreateUserSchema.safeParse({
name: formData.get("name"),
email: formData.get("email"),
});
if (!parsed.success) {
return {
success: false,
error: "Validation failed",
fieldErrors: parsed.error.flatten().fieldErrors,
};
}
try {
const user = await db.user.create({ data: parsed.data });
return { success: true, data: { id: user.id } };
} catch {
return { success: false, error: "Database error" };
}
}
The discriminated union on success means TypeScript will enforce the narrowing:
"use client";
const result = await createUser(formData);
if (result.success) {
// TypeScript knows result.data exists here
console.log(result.data.id);
} else {
// TypeScript knows result.error exists here
console.error(result.error, result.fieldErrors);
}
useActionState — The React 19 Way
React 19 replaced useFormState (from the React DOM canary) with useActionState. This is the idiomatic way to wire a Server Action to a form with state feedback.
"use client";
import { useActionState } from "react";
import { createUser } from "@/app/actions/user";
import type { ActionResult } from "@/lib/types/action-result";
// useActionState signature:
// useActionState<State, Payload>(
// action: (state: State, payload: Payload) => Promise<State>,
// initialState: State,
// permalink?: string
// )
// The action passed to useActionState must accept (prevState, formData).
// We need to wrap our action to fit this shape.
type CreateUserState = ActionResult<{ id: string }> | null;
// The wrapper: prevState is required by useActionState, even if you ignore it.
async function createUserAction(
_prevState: CreateUserState,
formData: FormData
): Promise<CreateUserState> {
return createUser(formData);
}
export function CreateUserForm() {
const [state, formAction, isPending] = useActionState(
createUserAction,
null // initial state
);
return (
<form action={formAction}>
<input name="name" required />
<input name="email" type="email" required />
{state && !state.success && (
<p style={{ color: "red" }}>{state.error}</p>
)}
{state && state.success && (
<p style={{ color: "green" }}>Created: {state.data.id}</p>
)}
<button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create"}
</button>
</form>
);
}
Gotcha: The action passed to useActionState must have the signature (prevState: State, payload: Payload) => Promise<State>. If you try to pass your Server Action directly without wrapping, TypeScript will complain because the _prevState parameter is missing.
Gotcha: isPending is new in React 19’s useActionState. If your types are from an older @types/react, you might not see it. Run npm install react@19 @types/react@19 and make sure your tsconfig.json has "moduleResolution": "bundler" or "node16".
Extracting Inferred Types from Server Actions
The Awaited<ReturnType<...>> pattern is your friend when you want the return type of a Server Action without duplicating the type definition:
import { createUser } from "@/app/actions/user";
// Infer the resolved return type of the async function
type CreateUserResult = Awaited<ReturnType<typeof createUser>>;
// → ActionResult<{ id: string }>
This is useful for typing state in components or for building reusable UI components that accept generic action results:
"use client";
import type { ActionResult } from "@/lib/types/action-result";
type FormFeedbackProps<T> = {
state: ActionResult<T> | null;
successMessage: (data: T) => string;
};
// Generic component that works with any typed action result
export function FormFeedback<T>({ state, successMessage }: FormFeedbackProps<T>) {
if (!state) return null;
if (state.success) return <p className="success">{successMessage(state.data)}</p>;
return <p className="error">{state.error}</p>;
}
Typing FormData Properly
FormData.get() returns string | File | null. You’re dealing with an untyped bag of values. Zod (or Valibot, or yup) is the right tool to validate and narrow the types in one step — don’t try to cast manually.
But sometimes you want a typed helper without pulling in a full schema library. Here’s a lightweight approach:
// lib/form-data.ts
// Parse FormData into a plain object with typed keys.
// This is NOT validation — it just extracts strings.
export function formDataToObject<T extends Record<string, string>>(
formData: FormData,
keys: (keyof T)[]
): Partial<T> {
const result: Partial<T> = {};
for (const key of keys) {
const value = formData.get(key as string);
if (typeof value === "string") {
result[key] = value as T[typeof key];
}
}
return result;
}
Usage:
const raw = formDataToObject<{ name: string; email: string }>(formData, [
"name",
"email",
]);
// raw is Partial<{ name: string; email: string }>
// Still need to validate — but at least you have typed keys
The use() Hook and Async Data
React 19’s use() hook lets Client Components consume promises and context. Typing it is straightforward — use() is generic:
"use client";
import { use } from "react";
type User = { id: string; name: string };
// Promise comes from a Server Component as a prop
export function UserName({ userPromise }: { userPromise: Promise<User> }) {
// use() unwraps the promise — TypeScript infers User
const user = use(userPromise);
return <span>{user.name}</span>;
}
// Server Component passing a promise as a prop
export default function Page() {
const userPromise = db.user.findFirst(); // Promise<User | null>
return <UserName userPromise={userPromise} />;
}
Gotcha: This pattern requires a Suspense boundary above UserName. If the promise rejects, you need an error boundary. TypeScript won’t tell you either of these things — you’ll just get a runtime crash.
Gotcha: Passing promises as props from Server to Client is powerful but leaks server-side references into client code. If the promise resolves to something that includes non-serializable data (e.g., Prisma model instances with methods), you’ll hit a runtime serialization error. Always map to plain objects before passing.
Production-Ready Patterns
Keep Server Actions thin. The action itself should do validation and call a service function. Don’t write business logic inside "use server" files — they’re entry points, not domain logic.
app/actions/user.ts ← thin: validate, call service, return result
lib/services/user.ts ← thick: business logic, db calls
Type your database layer separately. If you’re using Prisma, don’t use the generated User type directly in component props. Create explicit DTO types that represent what the UI actually needs:
// lib/types/dto.ts
export type UserDTO = {
id: string;
name: string;
email: string;
role: "admin" | "viewer";
};
This way, changing the DB schema doesn’t automatically break your component types — you update the mapping layer deliberately.
Co-locate action types with actions. Don’t scatter your ActionResult<T> generics across the codebase. Export the state type from the same file as the action:
// app/actions/user.ts
"use server";
export type CreateUserState = ActionResult<{ id: string }> | null;
export async function createUser(
_prev: CreateUserState,
formData: FormData
): Promise<CreateUserState> { ... }
The component imports both from the same place — no disconnected type drift.
Use satisfies for action config objects. If you have a registry of actions or config objects, satisfies gives you inference without widening:
const actionConfig = {
createUser,
deleteUser,
} satisfies Record<string, (...args: never[]) => Promise<ActionResult>>;
Gotchas Summary
useActionStatevsuseFormState: The olduseFormStateis deprecated in React 19. If you see it in a tutorial, it’s outdated. UseuseActionState.- The
_prevStateparameter is not optional.useActionStatealways passes it as the first argument. Your action function must accept it, even if it ignores it. - Server Actions can’t be called during render. They’re async side effects, not data loaders. For data fetching, use
asyncServer Components oruse()with a promise. "use server"at the file level marks every export as a Server Action. One accidental export of a helper function means it’s now a public HTTP endpoint. Be deliberate.- Type-only imports across the boundary. You can share types between Server and Client Components, but only type imports (
import type) — not runtime values. If you import a server-side module (database client, secret config) into a Client Component file, it’ll blow up. - Next.js
paramsandsearchParamsare now Promises in Next.js 15+. If you’re on the latest Next.js with React 19,paramsin page components isPromise<{ id: string }>, not{ id: string }. Await it.
Wrapping Up
The TypeScript experience with React 19 Server Components and Server Actions is genuinely good once you understand the model. The boundary between server and client is a first-class concept — lean into it by typing both sides explicitly, using discriminated unions for action results, and keeping your "use server" files as thin entry points rather than logic dumping grounds.
The biggest productivity unlock is the ActionResult<T> discriminated union pattern. Once you have that in place, useActionState becomes predictable, form error handling gets typed end-to-end, and you stop guessing what came back from the server.