Testing Server Components & Actions
Test async Server Components, Server Actions, and Route Handlers by treating them as plain async functions.
Search across all documentation pages
Test async Server Components, Server Actions, and Route Handlers by treating them as plain async functions.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Quick-reference recipe card -- copy-paste ready.
// Test an async Server Component as a function
import { render, screen } from "@testing-library/react";
// Server Components are async functions -- call and render their output
const Component = await ServerComponent({ params: { id: "1" } });
render(Component);
expect(screen.getByText("Alice")).toBeInTheDocument();
// Test a Server Action as a unit function
const formData = new FormData();
formData.set("email", "alice@example.com");
const result = await createUser(formData);
expect(result.success).toBe(true);
// Test a Route Handler as a function
const request = new Request("http://localhost/api/users", { method: "GET" });
const response = await GET(request);
expect(response.status).toBe(200);
const data = await response.json();
expect(data).toHaveLength(3);When to reach for this: When testing Next.js App Router Server Components, Server Actions, or Route Handlers in isolation.
// src/lib/db.ts
export interface User {
id: string;
name: string;
email: string;
}
export async function getUserById(id: string): Promise<User | null> {
// In production, this queries a database
throw new Error("Not implemented -- use mock in tests");
}
export async function createUser(data: Omit<User, "id">): Promise<User> {
throw new Error("Not implemented -- use mock in tests");
}
export async function getAllUsers(): Promise<User[]> {
throw new Error("Not implemented -- use mock in tests");
}// src/app/users/[id]/page.tsx
import { getUserById } from "@/lib/db";
import { notFound } from "next/navigation";
interface Props {
params: Promise<{ id: string }>;
}
export default async function UserPage({ params }: Props) {
const { id } = await params;
const user = await getUserById(id);
if (!user) {
notFound();
}
return (
<article>
<h1>{user.name}</h1>
<p>{user.email}</p>
</article>
);
}// src/app/users/[id]/page.test.tsx
import { render, screen } from "@testing-library/react";
import { vi, describe, it, expect } from "vitest";
import UserPage from "./page";
// Mock the database module
vi.mock("@/lib/db", () => ({
getUserById: vi.fn(),
}));
// Mock next/navigation
const mockNotFound = vi.fn();
vi.mock("next/navigation", () => ({
notFound: () => {
mockNotFound();
throw new Error("NEXT_NOT_FOUND");
},
}));
import { getUserById } from "@/lib/db";
describe("UserPage", () => {
it("renders user data", async () => {
vi.mocked(getUserById).mockResolvedValue({
id: "1",
name: "Alice Johnson",
email: "alice@example.com",
});
// Async Server Components return JSX -- await then render
const jsx = await UserPage({ params: Promise.resolve({ id: "1" }) });
render(jsx);
expect(screen.getByRole("heading")).toHaveTextContent("Alice Johnson");
expect(screen.getByText("alice@example.com")).toBeInTheDocument();
expect(getUserById).toHaveBeenCalledWith("1");
});
it("calls notFound when user does not exist", async () => {
vi.mocked(getUserById).mockResolvedValue(null);
await expect(
UserPage({ params: Promise.resolve({ id: "999" }) })
).rejects.toThrow("NEXT_NOT_FOUND");
expect(mockNotFound).toHaveBeenCalled();
});
});// src/app/actions/user-actions.ts
"use server";
import { z } from "zod";
import { createUser as dbCreateUser } from "@/lib/db";
import { revalidatePath } from "next/cache";
const createUserSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
});
interface ActionState {
success: boolean;
message: string;
errors: Record<string, string>;
}
export async function createUserAction(
_prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const rawData = {
name: formData.get("name"),
email: formData.get("email"),
};
const parsed = createUserSchema.safeParse(rawData);
if (!parsed.success) {
const errors: Record<string, string> = {};
for (const issue of parsed.error.issues) {
errors[issue.path[0] as string] = issue.message;
}
return { success: false, message: "Validation failed", errors };
}
await dbCreateUser(parsed.data);
revalidatePath("/users");
return { success: true, message: "User created", errors: {} };
}// src/app/actions/user-actions.test.ts
import { vi, describe, it, expect, beforeEach } from "vitest";
import { createUserAction } from "./user-actions";
// Mock dependencies
vi.mock("@/lib/db", () => ({
createUser: vi.fn().mockResolvedValue({ id: "new-1", name: "Alice", email: "alice@test.com" }),
}));
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
}));
import { createUser } from "@/lib/db";
import { revalidatePath } from "next/cache";
describe("createUserAction", () => {
const initialState = { success: false, message: "", errors: {} };
beforeEach(() => {
vi.clearAllMocks();
});
it("creates a user with valid data", async () => {
const formData = new FormData();
formData.set("name", "Alice Johnson");
formData.set("email", "alice@example.com");
const result = await createUserAction(initialState, formData);
expect(result).toEqual({
success: true,
message: "User created",
errors: {},
});
expect(createUser).toHaveBeenCalledWith({
name: "Alice Johnson",
email: "alice@example.com",
});
expect(revalidatePath).toHaveBeenCalledWith("/users");
});
it("returns validation errors for invalid data", async () => {
const formData = new FormData();
formData.set("name", "A"); // too short
formData.set("email", "not-an-email");
const result = await createUserAction(initialState, formData);
expect(result.success).toBe(false);
expect(result.errors.name).toBeDefined();
expect(result.errors.email).toBeDefined();
expect(createUser).not.toHaveBeenCalled();
});
it("returns validation error for missing fields", async () => {
const formData = new FormData();
const result = await createUserAction(initialState, formData);
expect(result.success).toBe(false);
expect(Object.keys(result.errors).length).toBeGreaterThan(0);
});
});// src/app/api/users/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getAllUsers, createUser } from "@/lib/db";
export async function GET() {
const users = await getAllUsers();
return NextResponse.json(users);
}
export async function POST(request: NextRequest) {
const body = await request.json();
if (!body.name || !body.email) {
return NextResponse.json(
{ error: "Name and email are required" },
{ status: 400 }
);
}
const user = await createUser(body);
return NextResponse.json(user, { status: 201 });
}// src/app/api/users/route.test.ts
import { vi, describe, it, expect, beforeEach } from "vitest";
import { GET, POST } from "./route";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
getAllUsers: vi.fn().mockResolvedValue([
{ id: "1", name: "Alice", email: "alice@test.com" },
{ id: "2", name: "Bob", email: "bob@test.com" },
]),
createUser: vi.fn().mockResolvedValue({
id: "3",
name: "Charlie",
email: "charlie@test.com",
}),
}));
describe("GET /api/users", () => {
it("returns all users", async () => {
const response = await GET();
const data = await response.json();
expect(response.status).toBe(200);
expect(data).toHaveLength(2);
expect(data[0].name).toBe("Alice");
});
});
describe("POST /api/users", () => {
it("creates a user with valid data", async () => {
const request = new NextRequest("http://localhost/api/users", {
method: "POST",
body: JSON.stringify({ name: "Charlie", email: "charlie@test.com" }),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(201);
expect(data.name).toBe("Charlie");
});
it("returns 400 for missing fields", async () => {
const request = new NextRequest("http://localhost/api/users", {
method: "POST",
body: JSON.stringify({ name: "Charlie" }),
});
const response = await POST(request);
expect(response.status).toBe(400);
});
});What this demonstrates:
notFound() from next/navigationrevalidatePathrender()FormData -- you call them directly with test FormDataGET, POST, etc.) that accept Request and return Response -- standard Web API objectsnotFound() from Next.js throws a special error internally -- mock it to throw an error you can catch in testsrevalidatePath and revalidateTag are Next.js caching APIs that have no effect in tests -- mock them to verify they are calledTesting a Server Component with search params:
// src/app/users/page.tsx
interface Props {
searchParams: Promise<{ q?: string; page?: string }>;
}
export default async function UsersPage({ searchParams }: Props) {
const { q, page } = await searchParams;
// ...
}
// Test
const jsx = await UsersPage({
searchParams: Promise.resolve({ q: "alice", page: "2" }),
});
render(jsx);Testing Server Actions with database transactions:
vi.mock("@/lib/db", () => ({
db: {
transaction: vi.fn((fn) => fn({
insert: vi.fn().mockResolvedValue([{ id: "1" }]),
update: vi.fn().mockResolvedValue([]),
})),
},
}));// Typing Server Component props for tests
import type { Metadata } from "next";
// Server Components can export generateMetadata too
export async function generateMetadata({ params }: Props): Promise<Metadata> {
// Test this as a regular async function
}
// NextRequest construction for route handler tests
const request = new NextRequest("http://localhost/api/users?page=2", {
method: "GET",
headers: { "Content-Type": "application/json" },
});Cannot render() an async component directly -- React's render does not support async components. Fix: Await the Server Component function call, then pass the resulting JSX to render().
"use server" directive in test files -- Vitest treats files as normal modules. The "use server" directive is ignored. Fix: This is fine for testing -- Server Actions are just async functions in test context.
Mocking next/headers -- Functions like cookies() and headers() only work in a server context. Fix: Mock the entire module:
vi.mock("next/headers", () => (\{
cookies: () => (\{
get: vi.fn().mockReturnValue(\{ value: "session-token" \}),
set: vi.fn(),
\}),
headers: () => new Headers(\{ "x-user-id": "123" \}),
\}));Server Components cannot be tested with client-side interactivity -- You can only test their rendered output, not clicks or state changes. Fix: Test the rendered HTML/JSX output. Test interactive client components separately.
FormData in tests -- FormData.get() returns FormDataEntryValue (string or File), which may need casting in typed schemas. Fix: Zod handles the coercion. In tests, formData.set("key", "value") always sets strings.
| Alternative | Use When | Don't Use When |
|---|---|---|
| Playwright E2E | You need to test Server Components with full Next.js rendering | You want fast unit-level tests |
| Storybook RSC | You want to develop and preview Server Components in isolation | You need assertion-based testing |
| Integration tests | You want to test the full request/response cycle | Fast unit tests of individual functions are sufficient |
next/test (experimental) | Next.js releases official testing utilities for RSC | The API is not yet stable |
Call it as a function, await the result, then render:
const jsx = await UserPage({ params: Promise.resolve({ id: "1" }) });
render(jsx);
expect(screen.getByText("Alice")).toBeInTheDocument();React's render does not support async components. You must await the Server Component function call first, then pass the resulting JSX to render().
Treat it as a plain async function. Construct FormData, call the action, and assert the result:
const formData = new FormData();
formData.set("email", "alice@example.com");
const result = await createUserAction(initialState, formData);
expect(result.success).toBe(true);const mockNotFound = vi.fn();
vi.mock("next/navigation", () => ({
notFound: () => {
mockNotFound();
throw new Error("NEXT_NOT_FOUND");
},
}));Then assert with expect(...).rejects.toThrow("NEXT_NOT_FOUND").
No. Vitest treats files as normal modules, so the "use server" directive is ignored. Server Actions are just async functions in test context, which is fine for testing.
vi.mock("next/headers", () => ({
cookies: () => ({
get: vi.fn().mockReturnValue({ value: "session-token" }),
set: vi.fn(),
}),
headers: () => new Headers({ "x-user-id": "123" }),
}));Construct a Request or NextRequest, call the exported function, and assert the response:
const request = new NextRequest("http://localhost/api/users", {
method: "POST",
body: JSON.stringify({ name: "Alice", email: "a@b.com" }),
});
const response = await POST(request);
expect(response.status).toBe(201);Mock next/cache and assert:
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
// ... call your server action
expect(revalidatePath).toHaveBeenCalledWith("/users");No. Server Components only produce HTML/JSX output. You can test the rendered output, but not client-side interactions. Test interactive client components separately.
const jsx = await UsersPage({
searchParams: Promise.resolve({ q: "alice", page: "2" }),
});
render(jsx);const request = new NextRequest("http://localhost/api/users?page=2", {
method: "GET",
headers: { "Content-Type": "application/json" },
});NextRequest extends the standard Request and is fully typed from next/server.
vi.mock("@/lib/db", () => ({
db: {
transaction: vi.fn((fn) => fn({
insert: vi.fn().mockResolvedValue([{ id: "1" }]),
update: vi.fn().mockResolvedValue([]),
})),
},
}));Reviewed by Chris St. John·Last updated Jul 16, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥