E2E Testing React Router v7 Apps with Playwright
End-to-end tests for React Router v7 apps need to account for SSR hydration, client-side navigation, loader data rendering, and form action redirects. This guide sets up Playwright for a v7 app from scratch and covers every major testing scenario with working examples.
Key Takeaways
SSR hydration must be explicitly tested. Disable JavaScript in Playwright to verify server-rendered HTML is correct before React takes over. Hydration mismatches that cause content flicker or missing data won't show up in unit tests.
Client-side navigation needs different assertions than full page loads. After a link click, the URL changes but no new HTML document is fetched. Use page.waitForURL() or expect(page).toHaveURL() instead of waitForLoadState("networkidle").
Form action tests follow a three-step pattern. Fill, submit, then assert the post-redirect state. Never assert on the form page after submission — you want to verify where the action sent the user, not that the form still exists.
Why E2E Tests Are Non-Negotiable for React Router v7
React Router v7 in framework mode has more moving parts than a pure SPA:
- Server renders HTML with loader data embedded
- JavaScript loads and hydrates the React tree
- Client-side router takes over — subsequent navigations don't reload the page
- Form submissions go through the action function, potentially on the server
- Redirects happen after actions, updating the URL and fetching new loader data
Unit tests verify individual loaders and actions. Integration tests verify components with stub data. But only E2E tests verify that these pieces work together correctly when the server is running, the database is real (or seeded), and the browser is executing JavaScript.
Playwright Configuration for React Router v7
// playwright.config.ts
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
screenshot: "only-on-failure",
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
{
name: "firefox",
use: { ...devices["Desktop Firefox"] },
},
// Mobile viewport
{
name: "Mobile Chrome",
use: { ...devices["Pixel 5"] },
},
],
// Start the React Router v7 dev server before tests
webServer: {
command: "npm run dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
stdout: "pipe",
stderr: "pipe",
},
});For a production build test (which exercises SSR more faithfully than dev mode):
webServer: {
command: "npm run build && npm start",
url: "http://localhost:3000",
reuseExistingServer: false,
timeout: 180_000,
},Testing SSR Hydration
React Router v7 in framework mode sends fully-rendered HTML. The most critical E2E tests verify this works correctly — especially for routes that require authentication or load data.
// e2e/ssr-hydration.spec.ts
import { test, expect } from "@playwright/test";
test.describe("SSR hydration", () => {
test("home page renders server HTML before JS loads", async ({ browser }) => {
// Create a context with JavaScript disabled
const context = await browser.newContext({ javaScriptEnabled: false });
const page = await context.newPage();
await page.goto("/");
// Content must be visible from SSR alone
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
await expect(page.getByRole("navigation")).toBeVisible();
await context.close();
});
test("loader data appears in SSR output", async ({ browser }) => {
const context = await browser.newContext({ javaScriptEnabled: false });
const page = await context.newPage();
// Seed or use test data that will appear in loader response
await page.goto("/products/featured");
// These items come from the loader — if they're visible without JS, SSR works
await expect(page.getByTestId("product-list")).toBeVisible();
await expect(page.locator("[data-testid='product-card']").first()).toBeVisible();
await context.close();
});
test("hydration does not cause content flash", async ({ page }) => {
// With JS enabled, content should be present immediately (not appear after delay)
const start = Date.now();
await page.goto("/");
// Heading should be visible within 500ms — no waiting for client render
await expect(page.getByRole("heading", { level: 1 })).toBeVisible({ timeout: 500 });
expect(Date.now() - start).toBeLessThan(500);
});
});Testing Client-Side Navigation
After hydration, React Router v7 handles navigation on the client without full page reloads. Test this separately from the initial SSR load.
// e2e/navigation.spec.ts
import { test, expect } from "@playwright/test";
test.describe("client-side navigation", () => {
test("navigates between routes without page reload", async ({ page }) => {
await page.goto("/");
await page.waitForLoadState("networkidle");
// Track navigation requests — client-side nav should not make a document request
const documentRequests: string[] = [];
page.on("request", (req) => {
if (req.resourceType() === "document") {
documentRequests.push(req.url());
}
});
await page.getByRole("link", { name: "Products" }).click();
await expect(page).toHaveURL("/products");
// Only the initial load should have been a document request
expect(documentRequests).toHaveLength(0);
});
test("back button returns to previous route", async ({ page }) => {
await page.goto("/products");
await page.getByRole("link", { name: /widget/i }).first().click();
await expect(page).toHaveURL(/\/products\//);
await page.goBack();
await expect(page).toHaveURL("/products");
// Verify the product list is still rendered (not blank)
await expect(page.getByTestId("product-list")).toBeVisible();
});
test("active link receives aria-current", async ({ page }) => {
await page.goto("/dashboard");
const dashboardLink = page.getByRole("link", { name: "Dashboard" });
await expect(dashboardLink).toHaveAttribute("aria-current", "page");
});
});Testing Loader Data in the UI
Verify that data returned by loaders actually appears in the rendered output:
// e2e/loader-data.spec.ts
import { test, expect } from "@playwright/test";
test.describe("loader data rendering", () => {
test("product page shows correct product details", async ({ page }) => {
await page.goto("/products/widget-pro");
await expect(page.getByRole("heading", { name: "Widget Pro" })).toBeVisible();
await expect(page.getByText("$29.99")).toBeVisible();
await expect(page.getByText("Widgets")).toBeVisible(); // category name
});
test("loader data updates after navigation", async ({ page }) => {
await page.goto("/products/widget-pro");
await expect(page.getByRole("heading", { name: "Widget Pro" })).toBeVisible();
// Navigate to a different product
await page.getByRole("link", { name: "Super Widget" }).click();
await expect(page).toHaveURL("/products/super-widget");
// Loader should have fetched new data
await expect(page.getByRole("heading", { name: "Super Widget" })).toBeVisible();
// Old product name should not appear
await expect(page.getByRole("heading", { name: "Widget Pro" })).not.toBeVisible();
});
test("pending UI appears during loader fetch", async ({ page }) => {
// Slow down the network to observe loading states
await page.route("**/api/products/**", async (route) => {
await new Promise((resolve) => setTimeout(resolve, 500));
await route.continue();
});
await page.goto("/products");
await page.getByRole("link", { name: "Widget Pro" }).click();
// Loading indicator should appear during the route transition
await expect(page.getByTestId("loading-indicator")).toBeVisible();
// Then data should appear
await expect(page.getByRole("heading", { name: "Widget Pro" })).toBeVisible();
});
});Testing Form Actions
Form action tests follow a consistent pattern: navigate to the form page, fill fields, submit, then assert the post-action state.
// e2e/form-actions.spec.ts
import { test, expect } from "@playwright/test";
test.describe("form actions", () => {
test("contact form submits and shows confirmation", async ({ page }) => {
await page.goto("/contact");
await page.fill('[name="name"]', "Alice Smith");
await page.fill('[name="email"]', "alice@example.com");
await page.fill('[name="message"]', "I have a question about pricing.");
await page.click('[type="submit"]');
// Verify the action redirected to a confirmation page
await expect(page).toHaveURL("/contact/thank-you");
await expect(page.getByText(/thank you/i)).toBeVisible();
});
test("form shows field validation errors inline", async ({ page }) => {
await page.goto("/contact");
// Submit without filling required fields
await page.click('[type="submit"]');
// Action returns validation errors, form stays on same URL
await expect(page).toHaveURL("/contact");
await expect(page.getByText(/name is required/i)).toBeVisible();
await expect(page.getByText(/email is required/i)).toBeVisible();
});
test("form retains values after validation failure", async ({ page }) => {
await page.goto("/contact");
await page.fill('[name="name"]', "Alice Smith");
// Intentionally leave email blank
await page.click('[type="submit"]');
// Name value should be preserved (useActionData shows back the fields)
await expect(page.locator('[name="name"]')).toHaveValue("Alice Smith");
await expect(page.getByText(/email is required/i)).toBeVisible();
});
});Testing Error Boundaries
// e2e/error-boundaries.spec.ts
import { test, expect } from "@playwright/test";
test.describe("error boundaries", () => {
test("404 error boundary renders for unknown routes", async ({ page }) => {
await page.goto("/this-route-does-not-exist");
await expect(page.getByRole("heading", { name: /not found/i })).toBeVisible();
// Page should still have navigation — error didn't break the whole app
await expect(page.getByRole("navigation")).toBeVisible();
});
test("route error boundary catches loader errors", async ({ page }) => {
// Use a route that has a loader that can fail
await page.goto("/products/nonexistent-product-id-12345");
// The route-level error boundary should render
await expect(page.getByTestId("route-error")).toBeVisible();
await expect(page.getByText(/product not found/i)).toBeVisible();
});
test("global error boundary catches unexpected errors", async ({ page }) => {
// Intercept and break a specific API call
await page.route("**/api/critical-data", (route) => {
route.fulfill({ status: 500, body: "Internal Server Error" });
});
await page.goto("/dashboard");
await expect(page.getByTestId("error-boundary")).toBeVisible();
});
});Testing Protected Routes
// e2e/protected-routes.spec.ts
import { test, expect } from "@playwright/test";
test.describe("protected routes", () => {
test("unauthenticated user is redirected to login", async ({ page }) => {
await page.goto("/dashboard");
await expect(page).toHaveURL(/\/login/);
});
test("login redirect preserves the intended destination", async ({ page }) => {
await page.goto("/dashboard/settings");
// Should redirect to login with returnTo param
await expect(page).toHaveURL(/\/login.*returnTo/);
});
test("authenticated user reaches protected route", async ({ page }) => {
// Set up authenticated session via API (not through UI — too slow)
await page.request.post("/api/test/set-session", {
data: { userId: "test-user-1" },
});
await page.goto("/dashboard");
await expect(page).toHaveURL("/dashboard");
await expect(page.getByRole("heading", { name: /dashboard/i })).toBeVisible();
});
});Organizing E2E Tests for React Router v7
e2e/
auth/
login.spec.ts
logout.spec.ts
protected-routes.spec.ts
navigation/
client-side-routing.spec.ts
ssr-hydration.spec.ts
features/
products.spec.ts
checkout.spec.ts
contact-form.spec.ts
errors/
error-boundaries.spec.ts
not-found.spec.ts
fixtures/
auth.ts # authenticated page fixture
seeded-data.ts # test data helpersUse Playwright fixtures to share auth state across tests in the same file:
// e2e/fixtures/auth.ts
import { test as base } from "@playwright/test";
export const test = base.extend({
authenticatedPage: async ({ page }, use) => {
await page.request.post("/api/test/set-session", {
data: { userId: "test-user-1" },
});
await use(page);
},
});Running E2E Tests Without Local Infrastructure
Maintaining Playwright installations, browser binaries, and test environments across developer machines and CI runners is overhead that adds up. HelpMeTest runs your Robot Framework + Playwright tests in the cloud — no local browser dependencies, no CI configuration for browser environments. The CLI installs in seconds:
curl -fsSL https://helpmetest.com/install | bashAt $100/month flat for unlimited test runs, it removes the need to manage Playwright infrastructure entirely. Write the tests, push to the cloud, get results back.