//
Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Type React Server Components (RSC), async components, and Server Actions in Next.js. Understand how TypeScript works with the server/client boundary.
// 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>
);
}async functions. TypeScript allows async function Page() that returns Promise<JSX.Element>. This is unique to RSC -- client components cannot be async."use server". They run on the server but can be called from client components. TypeScript enforces the parameter and return types across the boundary."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 older useFormState. It returns [state, formAction, isPending] with full type inference from the action function.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>
);
}params and searchParams are Promise types that must be awaited. This is a breaking change from Next.js 14.React.FC type does not support async components. Use regular function declarations for Server Components.formData.get() returns FormDataEntryValue | null (which is string | File | null). Always validate and narrow the type."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.useState, useEffect, etc.). TypeScript will not catch this -- it is a runtime error.
| 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 |
async and return Promise<JSX.Element>.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;
}useActionState (React 19) returns [state, formAction, isPending].useFormState with full type inference and a built-in pending flag.React.FC does not support async components.async function Page() { ... }.type RootLayoutProps = {
children: React.ReactNode;
};
export default function RootLayout({ children }: RootLayoutProps) {
return <html><body>{children}</body></html>;
}FormDataEntryValue | null, which is string | File | null.typeof value === "string" before using it as a string."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" };
}Reviewed by Chris St. John·Last updated Jul 19, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥