SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
Apr 20, 2026·Tutorial·SecureStartKit Team

Next.js Testing: Vitest + Playwright for SaaS Apps [2026]

Vitest for Server Actions and Zod schemas, Playwright for async Server Components and auth flows. The complete Next.js testing setup for SaaS.

Summarize with AI

On this page

  • Table of Contents
  • Why Vitest + Playwright, not Jest
  • Setting up Vitest for Next.js
  • How do you test Server Actions with Vitest?
  • How do you test Zod schemas?
  • Why can't Vitest render async Server Components?
  • Setting up Playwright for E2E tests
  • How do you test auth flows and Stripe checkout?
  • Should you mock Supabase or use a real test project?
  • What to test, what to skip

On this page

  • Table of Contents
  • Why Vitest + Playwright, not Jest
  • Setting up Vitest for Next.js
  • How do you test Server Actions with Vitest?
  • How do you test Zod schemas?
  • Why can't Vitest render async Server Components?
  • Setting up Playwright for E2E tests
  • How do you test auth flows and Stripe checkout?
  • Should you mock Supabase or use a real test project?
  • What to test, what to skip

Testing a Next.js SaaS in 2026 splits cleanly into two layers. Vitest with React Testing Library runs unit tests on Server Actions, Zod schemas, and synchronous components. Playwright runs end-to-end tests on auth flows, Stripe checkout, and async Server Components that Vitest can't render [1]. Jest has been replaced. pages/ testing patterns don't apply. This guide covers the setup, the split, and the exact tests that matter for a production SaaS.

The wrinkle most tutorials skip: Vitest currently cannot render async Server Components because React's async component support isn't stable in the test runner yet [1]. That isn't a Vitest bug you can work around, it's a capability gap. So your testing strategy is forced by the tool, not the other way around. Unit what you can with Vitest, push everything else to Playwright.

This guide uses a real Next.js 15 + Supabase + Stripe codebase as the reference. The patterns are specific, the code examples are copy-pasteable, and every technique has been verified against the current official Next.js testing docs.

Table of Contents

  • Why Vitest + Playwright, not Jest
  • Setting up Vitest for Next.js
  • How do you test Server Actions with Vitest?
  • How do you test Zod schemas?
  • Why can't Vitest render async Server Components?
  • Setting up Playwright for E2E tests
  • How do you test auth flows and Stripe checkout?
  • Should you mock Supabase or use a real test project?
  • What to test, what to skip

Why Vitest + Playwright, not Jest

Jest was the default for Next.js projects through 2024. By 2026 the community has moved. Vitest is the Vite-native replacement: faster cold starts, native ESM support, and a nearly identical API to Jest, so migration is largely a search-and-replace. Every new Next.js testing tutorial from the Next.js team uses Vitest [1]. If you're starting fresh, don't pick Jest.

The split works like this:

  • Vitest handles everything that doesn't need a real browser: Server Actions as plain functions, Zod schema validation, utility functions, synchronous React components, and client components with React Testing Library.
  • Playwright handles everything that does: auth flows, form submissions that hit real endpoints, Stripe checkout redirects, async Server Components, and anything that depends on cookies, middleware, or the Next.js router.

Jest works for the first half but loses to Vitest on speed and tooling. Jest loses to Playwright entirely on the second half, because Jest isn't a browser. That's why the 2026 setup is these two tools, not one.

Setting up Vitest for Next.js

Install the packages [1]:

npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/dom vite-tsconfig-paths

Create vitest.config.mts in the project root:

import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import tsconfigPaths from 'vite-tsconfig-paths'

export default defineConfig({
  plugins: [tsconfigPaths(), react()],
  test: {
    environment: 'jsdom',
    setupFiles: ['./vitest.setup.ts'],
    env: {
      NEXT_PUBLIC_SUPABASE_URL: 'http://localhost:54321',
      NEXT_PUBLIC_SUPABASE_ANON_KEY: 'test-anon-key',
      SUPABASE_SERVICE_ROLE_KEY: 'test-service-role',
      NEXT_PUBLIC_APP_URL: 'http://localhost:3000',
      STRIPE_SECRET_KEY: 'sk_test_placeholder',
    },
  },
})

The env block matters. Supabase clients throw at import time if their env vars are missing, and that crashes your test runner before a single assertion runs. Inject placeholder values at the Vitest config level so every test file starts with a valid environment.

Add a vitest.setup.ts that mocks next/headers, next/navigation, and next/cache:

import { vi } from 'vitest'

vi.mock('next/headers', () => ({
  cookies: vi.fn(() => ({
    get: vi.fn(),
    set: vi.fn(),
    delete: vi.fn(),
    getAll: vi.fn(() => []),
  })),
}))

vi.mock('next/navigation', () => ({
  redirect: vi.fn((url: string) => {
    throw new Error(`REDIRECT: ${url}`)
  }),
  notFound: vi.fn(),
}))

vi.mock('next/cache', () => ({
  revalidatePath: vi.fn(),
  revalidateTag: vi.fn(),
}))

Two details here. The redirect mock throws on purpose, because redirect() in real Next.js throws a NEXT_REDIRECT signal that unwinds the call stack. If you return a plain value, code after redirect() keeps running in your test and you get false positives. Throwing a recognizable error lets you assert on the exact redirect target.

The cookies() mock is the one that bites new projects. Without it, any Server Action that builds a createServerClientWithCookies() crashes with a next/headers error the moment you import it in a test file.

Add the test script to package.json:

{
  "scripts": {
    "test": "vitest",
    "test:ci": "vitest run"
  }
}

How do you test Server Actions with Vitest?

A Server Action is a 'use server' function. It's async. It reads from a database, mutates state, and sometimes calls redirect(). Test it by mocking the boundary: the Supabase client, Stripe, and any other external call. Then assert on the return value, the side effects, and the control flow.

Here's a test for a real login Server Action that uses Zod validation, rate limiting, and Supabase auth:

// actions/__tests__/auth.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { login } from '../auth'

vi.mock('@/lib/supabase/server', () => ({
  createServerClientWithCookies: vi.fn(),
}))

vi.mock('@/lib/rate-limit', () => ({
  rateLimit: vi.fn(),
}))

import { createServerClientWithCookies } from '@/lib/supabase/server'
import { rateLimit } from '@/lib/rate-limit'

describe('login', () => {
  beforeEach(() => {
    vi.clearAllMocks()
  })

  it('rejects invalid email with a validation error', async () => {
    vi.mocked(rateLimit).mockResolvedValue({ success: true })

    const result = await login({ email: 'not-an-email', password: 'longenough' })

    expect(result).toEqual({ error: 'Invalid email address' })
  })

  it('blocks the 6th attempt within the rate window', async () => {
    vi.mocked(rateLimit).mockResolvedValue({ success: false })

    const result = await login({ email: 'u@test.com', password: 'longenough' })

    expect(result?.error).toMatch(/Too many attempts/)
  })

  it('returns the Supabase error when credentials are wrong', async () => {
    vi.mocked(rateLimit).mockResolvedValue({ success: true })
    vi.mocked(createServerClientWithCookies).mockResolvedValue({
      auth: {
        signInWithPassword: vi.fn().mockResolvedValue({
          error: { message: 'Invalid login credentials' },
        }),
      },
    } as never)

    const result = await login({ email: 'u@test.com', password: 'wrongpass' })

    expect(result).toEqual({ error: 'Invalid login credentials' })
  })

  it('redirects to /dashboard on success', async () => {
    vi.mocked(rateLimit).mockResolvedValue({ success: true })
    vi.mocked(createServerClientWithCookies).mockResolvedValue({
      auth: {
        signInWithPassword: vi.fn().mockResolvedValue({ error: null }),
      },
    } as never)

    await expect(login({ email: 'u@test.com', password: 'longenough' }))
      .rejects.toThrow('REDIRECT: /dashboard')
  })
})

Four tests, four of the paths that matter. Notice what's NOT being tested: the actual behavior of signInWithPassword, Supabase's network response, or the rate limiter's Redis connection. Those belong in Playwright, against a real staging Supabase project. Here we're testing the decision logic of the Server Action: does it validate first? Does it bail on a rate limit? Does it propagate Supabase errors? Does it redirect on success?

For the full architecture behind these Server Actions, see the complete guide to type-safe form validation with Zod.

The open-redirect cases are worth adding too. The real login action accepts an optional redirectTo and guards against protocol-relative URLs like //evil.com. A test that passes redirectTo: '//evil.com' and asserts the redirect still lands on /dashboard is the kind of regression test that would have caught half the open-redirect CVEs of the last five years.

How do you test Zod schemas?

Zod schemas are plain objects. They have no dependencies on Next.js, Supabase, or anything else. They're the easiest thing in the codebase to test and the most valuable tests you can write, because every form in your app depends on them.

// lib/schemas/__tests__/auth.test.ts
import { describe, it, expect } from 'vitest'
import { loginSchema, signupSchema } from '../auth'

describe('loginSchema', () => {
  it('accepts a valid email + 8-char password', () => {
    const result = loginSchema.safeParse({
      email: 'u@test.com',
      password: 'longenough',
    })
    expect(result.success).toBe(true)
  })

  it('rejects passwords under 8 characters', () => {
    const result = loginSchema.safeParse({
      email: 'u@test.com',
      password: 'short',
    })
    expect(result.success).toBe(false)
    expect(result.error?.issues[0].message).toBe(
      'Password must be at least 8 characters'
    )
  })

  it('rejects malformed emails', () => {
    const result = loginSchema.safeParse({
      email: 'no-at-sign',
      password: 'longenough',
    })
    expect(result.success).toBe(false)
    expect(result.error?.issues[0].path).toEqual(['email'])
  })
})

describe('signupSchema', () => {
  it('requires a full name of at least 2 characters', () => {
    const result = signupSchema.safeParse({
      email: 'u@test.com',
      password: 'longenough',
      fullName: 'A',
    })
    expect(result.success).toBe(false)
    expect(result.error?.issues[0].path).toEqual(['fullName'])
  })
})

These tests run in under 50ms total. They catch every case where somebody bumps the minimum password length and forgets to update the error message, or loosens the email validation and breaks downstream code that assumed the .toLowerCase() had already happened in the schema. Schema tests are boring to write and disproportionately useful in production.

Why can't Vitest render async Server Components?

Next.js is explicit about this in the official docs [1]:

Since async Server Components are new to the React ecosystem, Vitest currently does not support them. While you can still run unit tests for synchronous Server and Client Components, we recommend using E2E tests for async components.

The mechanical reason: React's server component runtime expects an async execution environment that Vitest's test runner doesn't provide. You can render a synchronous Server Component (one that doesn't use await) with @testing-library/react and it'll work. The moment you add const data = await fetch(...) or const user = await getUser(), the render throws.

There are workarounds (shimming React's async hooks, manually awaiting the component function before passing it to render), but they're brittle and break on every React minor version. The official recommendation is the right one: don't fight it. Use Playwright for the component's rendered output, and unit test the data-fetching logic separately.

A pragmatic pattern: pull the async data fetch out of the component and into a plain async function. Unit test that function with Vitest. The component becomes a thin wrapper that's barely worth rendering at all.

// Before: can't unit test
export default async function UserPage({ id }: { id: string }) {
  const supabase = createAdminClient()
  const { data } = await supabase.from('users').select('*').eq('id', id).single()
  return <div>{data.name}</div>
}

// After: testable
export async function getUserById(id: string) {
  const supabase = createAdminClient()
  const { data } = await supabase.from('users').select('*').eq('id', id).single()
  return data
}

export default async function UserPage({ id }: { id: string }) {
  const user = await getUserById(id)
  return <div>{user.name}</div>
}

Now getUserById is a plain function you can mock Supabase against, and the component itself is a one-liner you can verify with a single Playwright test.

Setting up Playwright for E2E tests

Initialize Playwright [2]:

npm init playwright

The CLI walks you through setup. Accept the defaults (TypeScript, tests/ directory, GitHub Actions workflow). This creates a playwright.config.ts with sensible defaults.

Open it and make two changes. First, set baseURL so tests can use page.goto('/') instead of a full URL. Second, configure webServer so Playwright can start the dev server automatically:

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'

export default defineConfig({
  testDir: './tests',
  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',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  ],
  webServer: {
    command: 'npm run build && npm run start',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
    timeout: 120_000,
  },
})

Two things worth flagging. In CI, run against npm run start (production build) not npm run dev. Dev mode has slower renders, different caching, and occasional hot-reload flakes that'll turn your CI red for reasons unrelated to your code. Run tests against what you actually ship [2].

Set workers: 1 in CI if your tests touch a shared test database. Playwright parallelizes by default, which is great locally and a foreign-key-violation nightmare against one Supabase project.

How do you test auth flows and Stripe checkout?

The most valuable Playwright tests are the flows that would cost you real money if they broke: signup, login, and checkout.

Here's a signup flow test:

// tests/auth.spec.ts
import { test, expect } from '@playwright/test'

test('user can sign up and reach dashboard', async ({ page }) => {
  const email = `test-${Date.now()}@test.local`

  await page.goto('/signup')
  await page.fill('input[name="fullName"]', 'Test User')
  await page.fill('input[name="email"]', email)
  await page.fill('input[name="password"]', 'correctpassword')
  await page.click('button[type="submit"]')

  await expect(page.getByText(/Check your email/i)).toBeVisible()
})

test('invalid password shows inline error', async ({ page }) => {
  await page.goto('/signup')
  await page.fill('input[name="fullName"]', 'Test User')
  await page.fill('input[name="email"]', 'u@test.com')
  await page.fill('input[name="password"]', 'short')
  await page.click('button[type="submit"]')

  await expect(page.getByText(/at least 8 characters/i)).toBeVisible()
  await expect(page).toHaveURL(/\/signup/)
})

Every new email uses a timestamp to avoid collisions across test runs. If your Supabase project has email confirmation on, the test verifies the confirmation-email message rather than the full click-the-link flow, which would require an email inbox mock (not worth the complexity for a smoke test).

For login flows where you need an already-confirmed user, pre-create the account via the admin API in a beforeAll hook, then log in through the UI. The complete guide to Supabase auth in App Router covers the session-management side that these tests exercise.

Stripe checkout tests require two pieces: a test-mode Stripe account, and test card numbers. Use 4242 4242 4242 4242 for successful charges and 4000 0000 0000 9995 for insufficient-funds rejections. The Stripe Checkout page is hosted on checkout.stripe.com, so Playwright navigates across origins. That's fine, it's built for this:

// tests/checkout.spec.ts
import { test, expect } from '@playwright/test'

test('user can complete a purchase with a test card', async ({ page }) => {
  await loginAsTestUser(page)

  await page.goto('/#pricing')
  await page.getByRole('button', { name: /Get Starter/i }).click()

  await expect(page).toHaveURL(/checkout\.stripe\.com/)

  await page.fill('input[name="cardNumber"]', '4242 4242 4242 4242')
  await page.fill('input[name="cardExpiry"]', '12/30')
  await page.fill('input[name="cardCvc"]', '123')
  await page.fill('input[name="billingName"]', 'Test User')

  await page.getByRole('button', { name: /Pay/i }).click()

  await expect(page).toHaveURL(/\/purchase\/success/, { timeout: 30_000 })
})

Stripe occasionally changes the DOM structure of the Checkout page, so these tests are the flakiest in the suite. Run them, but don't block CI on them. The complete guide to Stripe payments with Server Actions covers the server side, which is far more stable and worth deeper unit coverage.

Should you mock Supabase or use a real test project?

This is the decision that defines your testing strategy. Both are valid. Picking the wrong one will either slow your test suite to a crawl or give you tests that pass while production breaks.

Mock Supabase for Vitest unit tests. You're testing decision logic, not Supabase's networking. Mocking the client keeps unit tests at millisecond speeds and lets you simulate error paths you couldn't reliably trigger against a real project (network timeouts, rate limits, stale sessions). A mocking library like vi.fn() with chained .mockResolvedValue() is enough for 95% of cases. For the remaining 5% (complex chained .from().select().eq().single() queries), Mock Service Worker (MSW) intercepts the HTTP calls under the Supabase client and is cleaner than mocking the client directly [4].

Use a real Supabase project for Playwright E2E tests. Create a dedicated test project (free tier is fine), populate it with seed data before each test run, and tear down after. This catches the 10% of bugs that only appear when a real row-level-security policy meets a real authenticated session. Those are the exact bugs that hurt most in production, and a mock can't catch them because a mock has no RLS.

If you're worried about RLS policies specifically (and you should be: the RLS policies guide covers why), add a dedicated smoke test that creates two users, seeds a row for one, and asserts the other can't read it. Run it against the real test project, after every schema change.

Do NOT test RLS policies from the Supabase SQL editor. The SQL editor runs as the postgres superuser, which bypasses RLS entirely. You can have completely broken policies and SQL-editor tests that all pass.

What to test, what to skip

There's a version of testing where you chase 100% coverage and end up with 400 tests that all assert the same Zod validation in slightly different ways. Don't do that. Test the things that would cost you money or trust if they silently broke:

  • Test every Server Action's decision logic. Validation, rate limiting, auth checks, redirect targets, error propagation. These are pure logic tests and they run fast.
  • Test every Zod schema. Each happy path and at least one failure case per field.
  • Test auth flows end to end. Signup, login, logout, password reset. If any of these break, you lose users.
  • Test the payment flow end to end. Checkout, success page, webhook delivery. Flaky but essential.
  • Test the RLS smoke. Two users, one row, one assertion. Runs against staging.
  • Skip synchronous Server Components that render static content. A Playwright test on the page that contains them already covers this.
  • Skip third-party SDKs. You're not testing Stripe's code. Trust their SDK and test your integration code.
  • Skip CSS and layout. Playwright's visual-regression tooling exists, but it's a separate investment and flakes on font rendering. Not worth it for most SaaS.

Before any of this gets to production, run through the pre-deploy items in the SaaS Security Checklist. Testing verifies that your code does what you think. The security checklist verifies that your code does what a hostile user can't make it do. Both matter, and tests alone will miss the second category. For the monitoring side that catches bugs your tests didn't anticipate, the error handling and Sentry setup guide walks through production instrumentation.

In SecureStartKit the tested surfaces are every Server Action, every Zod schema, the signup/login/checkout flow, and the RLS smoke. That's enough coverage to catch the regressions that matter without turning the test suite into a second codebase. The template ships without a pre-built test suite so you can pick the layers that match your app, but the Server Actions and schemas are structured to be testable in exactly the way this post describes.

Built for developers who care about security

SecureStartKit ships with these patterns out of the box.

Backend-only data access, Zod validation on every input, RLS enabled, Stripe webhooks verified. One purchase, lifetime updates.

View PricingSee the template in action

References

  1. How to set up Vitest with Next.js— nextjs.org
  2. How to set up Playwright with Next.js— nextjs.org
  3. Next.js Testing Guide: Unit and E2E Tests with Vitest & Playwright— strapi.io
  4. Testing React and Supabase with React Testing Library and Mock Service Worker— nygaard.dev

Related Posts

Mar 23, 2026·Tutorial

Rate Limit Next.js Server Actions Before Abuse

Server Actions are public HTTP endpoints anyone can call. Here's how to add rate limiting to login, checkout, and contact forms.

Mar 1, 2026·Tutorial

Send Emails in Next.js with React Email + Resend

Stop writing HTML strings for emails. Learn how to build type-safe, component-based email workflows in Next.js using Resend and React Email.

Feb 22, 2026·Tutorial

Add Stripe Payments to Next.js with Server Actions

A production-ready guide to integrating Stripe one-time payments in Next.js 15 with Server Actions, Zod validation, webhooks, and automated email delivery.