Typing Server Components
Recipe
Type React Server Components (RSC), async components, and Server Actions in Next.js. Understand how TypeScript works with the server/client boundary.
Working Example
// app/users/page.tsx - Async Server Component
type User = {
id: string;
name: string;
email: string;
};
async function getUsers(): Promise<User[]> {
const res = await fetch("https://api.example.com/users", {
cache: "force-cache",
});
if (!res.ok) throw new Error("Failed to fetch users");
return res.json() as Promise<User[]>;
}
export default async function UsersPage() {
const users = await getUsers();
return (
<main>
<h1>Users</h1>
<ul>
{users.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</main>
);
}// Server Action with typed params
"use server";
type CreateUserInput = {
name: string;
email: string;
};
type ActionResult = {
success: boolean;
message: string;
};
export async function createUser(input: CreateUserInput): Promise<ActionResult> {
// Validate and save to database
if (!input.name || !input.email) {
return { success: false, message: "Name and email are required" };
}
// ... database operation
return { success: true, message: "User created" };
}// Client component consuming the Server Action
"use client";
import { useActionState } from "react";
import { createUser } from "./actions";
export function CreateUserForm() {
const [state, formAction, isPending] = useActionState(
async (_prevState: ActionResult | null, formData: FormData) => {
const result = await createUser({
name: formData.get("name") as string,
email: formData.get("email") as string,
});
return result;
},
null
);
return (
<form action={formAction}>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit" disabled={isPending}>
{isPending ? "Creating..." : "Create User"}
</button>
{state?.message && <p>{state.message}</p>}
</form>
);
}Deep Dive
How It Works
- Server Components can be
asyncfunctions. TypeScript allowsasync function Page()that returnsPromise<JSX.Element>. This is unique to RSC -- client components cannot be async. - Server Actions are async functions marked with
"use server". They run on the server but can be called from client components. TypeScript enforces the parameter and return types across the boundary. - The
"use client"and"use server"directives create serialization boundaries. Only serializable types (strings, numbers, plain objects, arrays, Date, FormData, etc.) can cross these boundaries. useActionState(React 19) replaces the olderuseFormState. It returns[state, formAction, isPending]with full type inference from the action function.
Variations
Typed page params (Next.js 15):
// app/users/[id]/page.tsx
type PageProps = {
params: Promise<{ id: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
};
export default async function UserPage({ params, searchParams }: PageProps) {
const { id } = await params;
const { tab } = await searchParams;
const user = await getUser(id);
return <div>{user.name}</div>;
}Server Action with FormData:
"use server";
export async function submitForm(formData: FormData): Promise<ActionResult> {
const name = formData.get("name");
const email = formData.get("email");
if (typeof name !== "string" || typeof email !== "string") {
return { success: false, message: "Invalid form data" };
}
// ... process
return { success: true, message: "Submitted" };
}Typed layout component:
// app/layout.tsx
type RootLayoutProps = {
children: React.ReactNode;
};
export default function RootLayout({ children }: RootLayoutProps) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}TypeScript Notes
- In Next.js 15,
paramsandsearchParamsarePromisetypes that must be awaited. This is a breaking change from Next.js 14. - Server Actions can only accept and return serializable values. Functions, class instances, and Symbols cannot cross the boundary.
- The
React.FCtype does not support async components. Use regular function declarations for Server Components.
Gotchas
- Importing a Server Action into a client component works, but passing a client function to a Server Component does not. Functions are not serializable.
formData.get()returnsFormDataEntryValue | null(which isstring | File | null). Always validate and narrow the type.- Forgetting
"use server"on an action file means the function runs on the client, which will fail if it uses server-only APIs like database access. - Server Components cannot use hooks (
useState,useEffect, etc.). TypeScript will not catch this -- it is a runtime error.
Alternatives
| Approach | Pros | Cons |
|---|---|---|
| Async Server Components | Direct data fetching, zero client JS | Cannot use hooks or browser APIs |
| Server Actions | Type-safe mutations, progressive enhancement | Only serializable params and returns |
| API Route Handlers | Full HTTP control, any client can call | Manual fetch + typing on client |
| tRPC | End-to-end type safety | Additional dependency and setup |
Server-side getServerSideProps (Pages Router) | Familiar pattern | Pages Router only, being phased out |
FAQs
Can a Server Component be an async function?
- Yes. Server Components can be
asyncand returnPromise<JSX.Element>. - This is unique to RSC -- client components cannot be async.
What is the "use server" directive and where does it go?
- It marks a file (or individual function) as a Server Action.
- Place it at the top of the file or at the beginning of the function body.
- Server Actions run on the server but can be called from client components.
What types can cross the server/client boundary?
- Only serializable types: strings, numbers, booleans, plain objects, arrays, Date, FormData.
- Functions, class instances, Symbols, and Maps cannot cross the boundary.
How do I type page params in Next.js 15?
type PageProps = {
params: Promise<{ id: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
};
export default async function Page({ params, searchParams }: PageProps) {
const { id } = await params;
}What is useActionState and how does it replace useFormState?
useActionState(React 19) returns[state, formAction, isPending].- It replaces the older
useFormStatewith full type inference and a built-in pending flag.
Gotcha: What happens if I forget the "use server" directive on an action file?
- The function will run on the client instead of the server.
- If it uses server-only APIs (database, secrets), it will fail at runtime.
Can I use React.FC for Server Components?
- No.
React.FCdoes not support async components. - Use regular function declarations:
async function Page() { ... }.
Gotcha: Can Server Components use hooks like useState or useEffect?
- No. Server Components cannot use any hooks.
- TypeScript will not catch this -- it produces a runtime error, not a compile-time error.
How should I type a layout component?
type RootLayoutProps = {
children: React.ReactNode;
};
export default function RootLayout({ children }: RootLayoutProps) {
return <html><body>{children}</body></html>;
}What does formData.get() return and how do I narrow it?
- It returns
FormDataEntryValue | null, which isstring | File | null. - Always check
typeof value === "string"before using it as a string.
Can I pass a client function to a Server Component?
- No. Functions are not serializable and cannot cross the client-to-server boundary.
- Importing a Server Action into a client component works, but the reverse does not.
How do I type a Server Action that accepts FormData?
"use server";
export async function submitForm(formData: FormData): Promise<ActionResult> {
const name = formData.get("name");
if (typeof name !== "string") {
return { success: false, message: "Invalid" };
}
// process...
return { success: true, message: "Done" };
}