Skip to main content

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

LayerPitfallE2E fix
Server ActionsuseFormStatus shows pending; DB write failsProbe row after action; don't assert spinner alone
RSC payloadsNo stable client bundle for "API"Navigate or POST to route handler for Assert
MiddlewareAuth cookie missing in request fixtureSeed session via login or test cookie route
CachingStale RSC after mutationrevalidatePath + poll probe, not first paint
Parallel routesWrong slot rendersAssert URL + probe, not layout snapshot
Route handlers200 JSON ≠ business successProbe 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

ScenarioEdge caseWhy tests breakApproach
Server Action formredirect() after mutatePlaywright loses assert targetFollow redirect; probe destination state
Progressive enhancementJS disabled pathUntestedpage.request.post with action body
useActionStateError in state fieldAsserting wrong DOM nodeProbe + optional error role
Route Handler POSTCSRF / origin check403 in CISeed with same-origin request
Middleware authmatcher excludes /api/testSeed routes 401Document matcher; gate E2E_TEST_MODE
Parallel CIShared test@ userSession collisionPer-run user via seed routes
revalidateTag lagUI list stale 1–2sFlaky text assertPoll probe count
Server Action idempotencyDouble submitDuplicate rowsProbe single row per runId
cookies() in actionHttpOnly not in document.cookieFalse "logged out"Probe session route
Streaming RSCSuspense boundary empty firstEarly assert failsexpect.poll or waitForResponse
dynamic = 'force-static'Mutation appears in dev onlyProd-only bugTest against preview env
Edge runtimePrisma incompatibleSkipped actionsDocument runtime split
next/navigation redirect loopBad middlewareTimeoutNegative middleware spec
InstrumentationOTEL noise in CIN/ADisable 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

PatternAssert
<Link href="/dashboard">await expect(page).toHaveURL(/dashboard/)
redirect('/login') in actionURL + probe session null
Parallel route @modalURL 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-patternWhy it failsBetter approach
Mock Server Action in PlaywrightProd action untestedBlack-box form submit + probe
Assert RSC HTML snapshotBreaks on any copy changeProbe business fields
waitForTimeout after submitStill races DBexpect.poll probe
Direct Prisma in specNot E2EHTTP seed/probe routes
Skip middleware in testsAuth bugs shipReal middleware + test user
One global beforeAll seedParallel collisionPer-test runId

External references

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.

  1. Arrange: Seed user and cart via /api/test/seed-* with runId; E2E_TEST_MODE enabled.
  2. Act: Submit Place order form (Server Action).
  3. 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.

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.

Start free on TestChimp · Book a demo