How to Test Next.js App Router and Server Actions in E2E
Short answer
App Router E2E fails when tests treat Server Actions like client fetch—asserting optimistic UI while the mutation never committed—or when parallel workers collide on shared users. Use route handlers for Arrange/Assert probes, per-run runId in seeds, and expect.poll on authoritative DB state. Server Components render HTML you cannot "click through" without navigation; Server Actions need form submits or page.request with action IDs when isolating handler logic.
Part of Testing Guides by integrations.
Who this is for
Teams shipping Next.js 13+ App Router with Server Components, Server Actions, and Route Handlers (app/api/.../route.ts) who need Playwright E2E that survives parallel CI, revalidatePath timing, and middleware—not Jest-only unit tests of action functions.
Typical stacks: Next.js + Prisma/Drizzle, Next.js + Supabase (Supabase guide), Vercel deployments, or Lovable exports migrated to App Router.
Why Next.js App Router needs different Arrange/Assert
| Layer | Pitfall | E2E fix |
|---|---|---|
| Server Actions | useFormStatus shows pending; DB write fails | Probe row after action; don't assert spinner alone |
| RSC payloads | No stable client bundle for "API" | Navigate or POST to route handler for Assert |
| Middleware | Auth cookie missing in request fixture | Seed session via login or test cookie route |
| Caching | Stale RSC after mutation | revalidatePath + poll probe, not first paint |
| Parallel routes | Wrong slot renders | Assert URL + probe, not layout snapshot |
| Route handlers | 200 JSON ≠ business success | Probe schema fields (status, runId) |
UI-only asserts miss the classic App Router bug: action returns { error } serialized into the form while a toast library still shows success from a client-side branch.
Complexity map
| Scenario | Edge case | Why tests break | Approach |
|---|---|---|---|
| Server Action form | redirect() after mutate | Playwright loses assert target | Follow redirect; probe destination state |
| Progressive enhancement | JS disabled path | Untested | page.request.post with action body |
useActionState | Error in state field | Asserting wrong DOM node | Probe + optional error role |
| Route Handler POST | CSRF / origin check | 403 in CI | Seed with same-origin request |
| Middleware auth | matcher excludes /api/test | Seed routes 401 | Document matcher; gate E2E_TEST_MODE |
| Parallel CI | Shared test@ user | Session collision | Per-run user via seed routes |
revalidateTag lag | UI list stale 1–2s | Flaky text assert | Poll probe count |
| Server Action idempotency | Double submit | Duplicate rows | Probe single row per runId |
cookies() in action | HttpOnly not in document.cookie | False "logged out" | Probe session route |
| Streaming RSC | Suspense boundary empty first | Early assert fails | expect.poll or waitForResponse |
dynamic = 'force-static' | Mutation appears in dev only | Prod-only bug | Test against preview env |
| Edge runtime | Prisma incompatible | Skipped actions | Document runtime split |
next/navigation redirect loop | Bad middleware | Timeout | Negative middleware spec |
| Instrumentation | OTEL noise in CI | N/A | Disable in E2E_TEST_MODE |
Seed and probe route handlers
Keep privileged DB access server-side—same pattern as seed routes and probe Assert:
// app/api/test/seed-order/route.ts
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
export async function POST(req: Request) {
if (process.env.E2E_TEST_MODE !== 'true') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const { runId, userId, status = 'draft' } = await req.json();
const order = await db.order.create({
data: { runId, userId, status },
});
return NextResponse.json({ orderId: order.id });
}
// app/api/test/probe-order/route.ts
export async function GET(req: Request) {
if (process.env.E2E_TEST_MODE !== 'true') {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
const orderId = new URL(req.url).searchParams.get('orderId');
const row = await db.order.findUnique({ where: { id: orderId! } });
return NextResponse.json({ status: row?.status ?? null, runId: row?.runId });
}
Gate with E2E_TEST_MODE and exclude from production middleware matchers.
Testing Server Actions via UI
Server Actions bind to <form action={submitOrder}> or button formAction. Playwright should submit the form like a user:
// @Scenario: checkout/server-action-submit
import { test, expect } from '../fixtures/run';
test('server action creates paid order', async ({ page, request, runId }) => {
await request.post('/api/test/seed-user', { data: { runId } });
await page.goto('/checkout');
await page.getByLabel('Quantity').fill('2');
await page.getByRole('button', { name: 'Place order' }).click();
await expect.poll(async () => {
const res = await request.get(`/api/test/probe-order?orderId=${runId}`);
return (await res.json()).status;
}, { timeout: 15_000 }).toBe('paid');
});
Do not rely on useFormStatus().pending returning false—that fires before your transaction commits.
Isolating Server Actions without full UI
When debugging handler logic, POST the action payload directly (Next.js encodes action IDs in production builds—prefer test-only route that calls the same service layer):
// app/api/test/invoke-submit-order/route.ts — calls shared service, not duplicated business rules
export async function POST(req: Request) {
if (process.env.E2E_TEST_MODE !== 'true') return NextResponse.json({}, { status: 403 });
const body = await req.json();
const result = await submitOrderService(body);
return NextResponse.json(result);
}
Share one implementation between Server Action and test invoke route to avoid drift.
App Router navigation asserts
| Pattern | Assert |
|---|---|
<Link href="/dashboard"> | await expect(page).toHaveURL(/dashboard/) |
redirect('/login') in action | URL + probe session null |
Parallel route @modal | URL query + dialog role |
notFound() | 404 page or custom not-found.tsx |
Use page.waitForURL after actions that call redirect()—Playwright may still be on the form route for a frame.
Middleware and auth cookies
// middleware.ts — ensure test routes bypass or accept service token
export const config = {
matcher: ['/((?!api/test|_next/static|favicon.ico).*)'],
};
For Clerk/Auth0/Supabase session cookies, log in via UI once per worker or use provider-specific testing helpers (Clerk guide).
Anti-patterns
| Anti-pattern | Why it fails | Better approach |
|---|---|---|
| Mock Server Action in Playwright | Prod action untested | Black-box form submit + probe |
| Assert RSC HTML snapshot | Breaks on any copy change | Probe business fields |
waitForTimeout after submit | Still races DB | expect.poll probe |
| Direct Prisma in spec | Not E2E | HTTP seed/probe routes |
| Skip middleware in tests | Auth bugs ship | Real middleware + test user |
One global beforeAll seed | Parallel collision | Per-test runId |
External references
- Next.js App Router
- Server Actions and Mutations
- Route Handlers
- Forms with Server Actions
- Playwright
requestfixture - Playwright polling assertions
Example scenario
Situation: User submits checkout form; Server Action charges card and calls revalidatePath before DB transaction commits.
Expected outcome: Order status paid; inventory decremented exactly once.
Why UI-only automation breaks: Success toast from client onSubmit while action returned serialized error.
- Arrange: Seed user and cart via /api/test/seed-* with runId; E2E_TEST_MODE enabled.
- Act: Submit Place order form (Server Action).
- Assert: expect.poll probe-order status paid; optional UI confirmation.
TestChimp workflow: // @Scenario: links App Router spec to markdown; /testchimp test preserves probe when RSC layout regen changes selectors.
Same Arrange/Act/Assert pattern as expired-coupon checkout.
Related
- Seed routes and probe Assert
- Supabase + Next.js
- Playwright GitHub Actions parallel
- UI-only assertions gotcha
- Stripe webhooks
Frequently asked questions
Can Playwright call Server Actions directly?
You can POST form-encoded action payloads, but action IDs change between builds. Prefer user-realistic form submits for E2E, or a test-only route handler that calls the same service layer as the action.
How do I assert Server Action errors?
Check returned form state for error messages AND probe that no row was created. Client toasts alone are unreliable when optimistic UI is enabled.
Do Server Components need special Playwright setup?
No extra config—Playwright drives the browser like any app. Challenge is Assert: use probe routes because you cannot import server-only modules in tests.
How do I test revalidatePath / caching?
After mutation, poll probe until authoritative state updates, then optionally assert UI. Do not assert list UI on first paint after navigation.
Where should seed routes live in App Router?
app/api/test/seed-* and probe-* route handlers, gated by E2E_TEST_MODE and excluded from auth middleware. Never import service role keys into Playwright files.
App Router vs Pages Router for E2E?
Pages Router API routes and getServerSideProps are familiar; App Router needs probe Assert for Server Actions and RSC cache timing. Same runId fixture pattern applies to both.
How does TestChimp help Next.js teams?
/testchimp init scaffolds seed/probe route handlers and Playwright fixtures; /testchimp test links SmartTests to markdown scenarios when agents refactor RSC layouts.
Should I use MSW to mock Server Actions?
MSW suits client fetch. Server Actions execute on the server—mocking them hides integration bugs. Use test env DB and probes instead.
Apply these patterns in your repo
Run `/testchimp init` to connect TestChimp to your repo, then `/testchimp test` on PRs to turn these patterns into maintained SmartTests. Use `/testchimp evolve` when you want to expand coverage as your app grows.