How to Test TanStack Start Applications
TanStack Start is a full-stack React framework built on TanStack Router. It gives you type-safe file-based routing, server functions (like Next.js Server Actions but with explicit createServerFn), streaming SSR, and RPC-style end-to-end type safety. The testing story is still young — the framework is relatively new — so most teams are figuring out what to test and how.
This guide covers what actually matters: unit testing route components, integration testing server functions, and E2E testing the full stack with Playwright.
Project Structure
A typical TanStack Start app looks like this:
src/
routes/
__root.tsx # Root layout
index.tsx # / route
posts/
index.tsx # /posts
$postId.tsx # /posts/:postId
functions/
posts.ts # Server functions
lib/
db.ts # Database clientTests live alongside the source or in a __tests__ directory. Vitest is the natural choice — TanStack Start uses Vite under the hood.
Unit Testing Route Components
Route components in TanStack Start receive loaderData from their loader. Test the component in isolation by mocking the loader context:
// src/routes/posts/$postId.tsx
import { createFileRoute } from "@tanstack/react-router";
import { fetchPost } from "../../functions/posts";
export const Route = createFileRoute("/posts/$postId")({
loader: ({ params }) => fetchPost({ data: { id: params.postId } }),
component: PostPage,
});
function PostPage() {
const post = Route.useLoaderData();
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
</article>
);
}Test the component by providing mock loader data through TanStack Router's test utilities:
// src/routes/posts/__tests__/$postId.test.tsx
import { render, screen } from "@testing-library/react";
import { createMemoryHistory, createRouter, RouterProvider } from "@tanstack/react-router";
import { routeTree } from "../../../routeTree.gen";
function createTestRouter(initialPath: string) {
const router = createRouter({
routeTree,
history: createMemoryHistory({ initialEntries: [initialPath] }),
});
return router;
}
describe("PostPage", () => {
it("renders post title and body from loader data", async () => {
// Mock the server function
vi.mock("../../../functions/posts", () => ({
fetchPost: vi.fn().mockResolvedValue({
id: "1",
title: "Test Post",
body: "Test body content",
}),
}));
const router = createTestRouter("/posts/1");
await router.load();
render(<RouterProvider router={router} />);
expect(await screen.findByText("Test Post")).toBeInTheDocument();
expect(screen.getByText("Test body content")).toBeInTheDocument();
});
it("shows 404 message for missing post", async () => {
vi.mock("../../../functions/posts", () => ({
fetchPost: vi.fn().mockRejectedValue(
Object.assign(new Error("Not found"), { status: 404 })
),
}));
const router = createTestRouter("/posts/nonexistent");
await router.load();
render(<RouterProvider router={router} />);
expect(await screen.findByText(/not found/i)).toBeInTheDocument();
});
});Testing Server Functions
createServerFn is the TanStack Start equivalent of a Server Action. It runs on the server, accepts validated input via a schema, and returns data to the client. Test server functions directly — they're just async functions:
// src/functions/posts.ts
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { db } from "../lib/db";
const GetPostInput = z.object({ id: z.string() });
export const fetchPost = createServerFn({ method: "GET" })
.validator(GetPostInput)
.handler(async ({ data }) => {
const post = await db.post.findUnique({ where: { id: data.id } });
if (!post) throw new Error("Post not found");
return post;
});
export const createPost = createServerFn({ method: "POST" })
.validator(z.object({ title: z.string().min(1), body: z.string().min(1) }))
.handler(async ({ data }) => {
return db.post.create({ data });
});Test the handler directly, bypassing the RPC layer:
// src/functions/__tests__/posts.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest";
import { db } from "../../lib/db";
// Mock the database
vi.mock("../../lib/db", () => ({
db: {
post: {
findUnique: vi.fn(),
create: vi.fn(),
},
},
}));
describe("fetchPost", () => {
beforeEach(() => vi.clearAllMocks());
it("returns post when found", async () => {
const mockPost = { id: "1", title: "Hello", body: "World" };
vi.mocked(db.post.findUnique).mockResolvedValue(mockPost);
// Import the handler — createServerFn exposes .handler for testing
const { fetchPost } = await import("../posts");
const result = await fetchPost({ data: { id: "1" } });
expect(result).toEqual(mockPost);
expect(db.post.findUnique).toHaveBeenCalledWith({
where: { id: "1" },
});
});
it("throws when post not found", async () => {
vi.mocked(db.post.findUnique).mockResolvedValue(null);
const { fetchPost } = await import("../posts");
await expect(fetchPost({ data: { id: "missing" } })).rejects.toThrow(
"Post not found"
);
});
});
describe("createPost", () => {
it("creates and returns a new post", async () => {
const newPost = { id: "new-1", title: "New Post", body: "Content" };
vi.mocked(db.post.create).mockResolvedValue(newPost);
const { createPost } = await import("../posts");
const result = await createPost({
data: { title: "New Post", body: "Content" },
});
expect(result).toEqual(newPost);
expect(db.post.create).toHaveBeenCalledWith({
data: { title: "New Post", body: "Content" },
});
});
it("propagates database errors", async () => {
vi.mocked(db.post.create).mockRejectedValue(
new Error("Unique constraint violation")
);
const { createPost } = await import("../posts");
await expect(
createPost({ data: { title: "Dupe", body: "Body" } })
).rejects.toThrow("Unique constraint violation");
});
});Integration Testing with a Real Database
For database integration, use Vitest with Testcontainers to spin up a real Postgres:
// src/functions/__tests__/posts.integration.test.ts
import { PostgreSqlContainer } from "@testcontainers/postgresql";
import { PrismaClient } from "@prisma/client";
let prisma: PrismaClient;
let containerConnectionString: string;
beforeAll(async () => {
const container = await new PostgreSqlContainer("postgres:16").start();
containerConnectionString = container.getConnectionUri();
prisma = new PrismaClient({
datasources: { db: { url: containerConnectionString } },
});
// Run migrations
await prisma.$executeRaw`CREATE TABLE posts (
id TEXT PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
body TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)`;
});
afterAll(() => prisma.$disconnect());
describe("fetchPost (integration)", () => {
it("retrieves a real post from the database", async () => {
const created = await prisma.post.create({
data: { title: "Integration Post", body: "Real body" },
});
// Override the db module to use the test Prisma instance
vi.doMock("../../lib/db", () => ({ db: prisma }));
const { fetchPost } = await import("../posts");
const result = await fetchPost({ data: { id: created.id } });
expect(result.title).toBe("Integration Post");
});
});Testing Search Params and Route Context
TanStack Router's search params are type-safe and validated. Test that components read and write search params correctly:
// src/routes/posts/index.tsx
export const Route = createFileRoute("/posts/")({
validateSearch: z.object({
page: z.number().default(1),
q: z.string().optional(),
}),
component: PostsIndex,
});
function PostsIndex() {
const { page, q } = Route.useSearch();
const navigate = Route.useNavigate();
return (
<div>
<input
value={q ?? ""}
onChange={(e) =>
navigate({ search: { page: 1, q: e.target.value || undefined } })
}
placeholder="Search"
/>
<span>Page {page}</span>
</div>
);
}Test:
describe("PostsIndex search params", () => {
it("reads page from search params", async () => {
const router = createTestRouter("/posts/?page=3");
await router.load();
render(<RouterProvider router={router} />);
expect(await screen.findByText("Page 3")).toBeInTheDocument();
});
it("updates search on input change", async () => {
const router = createTestRouter("/posts/");
await router.load();
render(<RouterProvider router={router} />);
const input = await screen.findByPlaceholderText("Search");
await userEvent.type(input, "hello");
await waitFor(() => {
expect(router.state.location.search).toMatchObject({ q: "hello" });
});
});
});E2E Testing with Playwright
Start TanStack Start in test mode and run Playwright against it:
// playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
webServer: {
command: "NODE_ENV=test bun run start",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
use: {
baseURL: "http://localhost:3000",
},
});// tests/posts.e2e.ts
import { test, expect } from "@playwright/test";
test.describe("Posts", () => {
test("navigates to post detail", async ({ page }) => {
await page.goto("/posts");
const firstPost = page.getByRole("listitem").first();
const title = await firstPost.getByRole("heading").textContent();
await firstPost.getByRole("link").click();
await expect(page).toHaveURL(/\/posts\/.+/);
await expect(page.getByRole("heading", { level: 1 })).toHaveText(title!);
});
test("search filters the post list", async ({ page }) => {
await page.goto("/posts");
await page.getByPlaceholder("Search").fill("unique-search-term");
await expect(page).toHaveURL(/q=unique-search-term/);
// Assert results reflect the filter
});
test("server function creates post and redirects", async ({ page }) => {
await page.goto("/posts/new");
await page.getByLabel("Title").fill("E2E Created Post");
await page.getByLabel("Body").fill("E2E body content");
await page.getByRole("button", { name: "Create" }).click();
await expect(page).toHaveURL(/\/posts\/.+/);
await expect(
page.getByRole("heading", { name: "E2E Created Post" })
).toBeVisible();
});
});What to Focus On
TanStack Start's value is type safety through the full stack — from database to route params to component props. Your tests should verify that this chain holds:
- Server functions return the shape your components expect — test these directly with mocked DB
- Loader data flows correctly to components — test with RouterProvider and mocked server functions
- Search param validation rejects bad input — test the
validateSearchschema directly - E2E tests cover the happy path — server function → loader → component → user interaction
Don't mock at the HTTP layer. TanStack Start server functions are just async functions — call them directly in tests and mock the database. That's faster, more reliable, and catches the errors that actually matter.