{"title":"Test React Server and Client Boundaries in Next.js","excerpt":"An admin page that redirects editors can still let them import data, because its Server Action is a separate POST endpoint. Using a Next.js 16 page with a client CSV import form, this guide covers testing the access decision and the action as plain functions, why server-only throws under Vitest and how to alias it, checking props for React's serialization rules, testing the form in Vitest browser mode with the action passed as a prop, and a Playwright contract that replays the admin's action request with an editor's session.","canonicalUrl":"https://automationtester.in/blog/automation-tutorials/test-nextjs-server-client-component-boundaries","category":{"name":"Automation Tutorials","slug":"automation-tutorials"},"tags":["nextjs","react-server-components","vitest","playwright","typescript","authorization-testing"],"author":{"name":"Shashank Rawlani","url":"https://shashank.rawlani.com","linkedin":"https://www.linkedin.com/in/shashankrawlani/"},"datePublished":"2026-10-08T13:30:00.000Z","dateModified":"2026-10-14T11:09:52.936Z","coverImage":{"url":"https://ik.imagekit.io/automationtester/automationtester-in/blog/covers/v3/test-nextjs-server-client-component-boundaries.webp","alt":"Dark technical illustration over a faint dashed vertical grid. Two grey rails enter from the left edge and end at ringed dots, a green one above and an orange one below. The green dot's curve reaches a ringed green gate and continues with an arrowhead into a large green panel that holds a heading pill, four short rows, and on its right a dashed inner container with a small file panel, three short pills and a wide solid pill. The orange dot also curves up to that gate, where a dashed orange line drops to a crossed-out orange ring. From the bottom of the dashed inner container a green curve falls to a second ringed green gate lower down, and the orange dot's other path runs straight along the bottom of the frame, under the panel, to the same gate. From that gate a green line with an arrowhead enters a tall green panel of stacked pills on the right, and a grey rail carries on to the right edge, while a dashed orange line drops from the gate to a second crossed-out orange ring."},"contentHtml":"<div class=\"callout callout-info\"><strong>Quick answer:</strong> An App Router page has several entry points; test each in the layer that reaches it. Put the authorization decision and data shaping in server-only functions and test them in a Node project, with <code class=\"language-text\">server-only</code> aliased to its empty module. Make the Server Action authorize and validate through that same code, and call it directly as the public endpoint it is. Pass the action to the form as a prop and test the form in Vitest browser mode against a fake. Then prove the redirect, the happy path, and a replayed admin POST sent with an editor's session in one Playwright contract against a production build.</div>\n\n<p>Here is a suite that is green and wrong. The admin categories page checks the session and sends editors to <code class=\"language-text\">/forbidden</code>. A test renders the page with <code class=\"language-text\">next/navigation</code> mocked and asserts that <code class=\"language-text\">redirect</code> was called for an editor. Another test renders the CSV import form and asserts that the submit handler fires. Both pass. In production an editor who never sees the form sends the same POST the form would have sent, and the categories table grows.</p>\n\n<p>Nothing in that suite was false; it tested the page, and the page never stood between an editor and the database. The Next.js data security guide: \"A page-level authentication check does not extend to the Server Actions defined within it.\" The page's redirect \"controls which UI is rendered, but the Server Action is a separate entry point and must verify the caller on its own.\"</p>\n\n<p>So ask of each test which entry point it exercises. The worked example is one admin page: a server component that redirects non-admins and renders a client CSV import form calling a Server Action. Part 2 of this series covers <a href=\"/blog/choose-vitest-browser-mode-playwright-by-risk\">how to choose between Node, jsdom, browser mode and Playwright</a>; this is that decision applied to the one boundary a framework draws for you.</p>\n\n<div class=\"callout callout-warning\"><strong>Vitest cannot render the page itself.</strong> The Next.js Vitest guide states that \"since <code class=\"language-text\">async</code> Server Components are new to the React ecosystem, Vitest currently does not support them\", and recommends E2E tests for async components. Versions checked on 2026-09-30: Next.js 16.3.7, React 19.3.0, Vitest 5.0.2 with <code class=\"language-text\">@vitest/browser-playwright</code> 5.0.2 and <code class=\"language-text\">vitest-browser-react</code> 2.3.0, Playwright 1.63.0, Zod 4.6.5. Next.js 16.0.0 renamed Middleware to Proxy. Every test in this article was run against those versions, against a production build of the example app.</div>\n\n<h2 id=\"one-admin-page-four-entry-points\">One admin page, four entry points</h2>\n\n<p>Each place where a request or a value crosses a trust boundary has a different cheapest test.</p>\n\n<table>\n<thead><tr><th>Entry point</th><th>What can go wrong there</th><th>Layer that reaches it</th></tr></thead>\n<tbody>\n<tr><td>The page render: session check and redirect</td><td>An editor sees admin data</td><td>A plain function in Vitest (Node) for the decision; Playwright for the redirect itself</td></tr>\n<tr><td>Props passed from the server component to the form</td><td>Private fields or non-serializable values cross into the client</td><td>A plain function in Vitest (Node)</td></tr>\n<tr><td>The client form: file preview, pending state, result</td><td>A preview that lies, a double submit, a success message after a refusal</td><td>Vitest browser mode</td></tr>\n<tr><td>The Server Action</td><td>Anyone who can send the POST can import</td><td>A plain function call in Vitest (Node), then a replay in Playwright</td></tr>\n</tbody>\n</table>\n\n<p>The React documentation describes the fourth row without softening it: \"Arguments to Server Functions are fully client-controlled.\" The Next.js Server Actions guide describes the mechanism: the compiler swaps the implementation in client bundles for \"an action ID plus a dispatcher\" that POSTs back, so \"the route is reachable to anyone who can send the same POST.\"</p>\n\n<h2 id=\"the-action-that-trusted-its-page\">The action that trusted its page</h2>\n\n<p>This is the shape the green-and-wrong suite was testing. A Proxy does an optimistic cookie check, the page checks the role, and the action assumes both already happened:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">// proxy.ts: an optimistic check, reading only the cookie\nexport default function proxy(req: NextRequest) {\n  if (req.nextUrl.pathname.startsWith('/admin') &amp;&amp; !req.cookies.get('session')) {\n    return NextResponse.redirect(new URL('/login', req.nextUrl))\n  }\n  return NextResponse.next()\n}\n\n// app/admin/categories/actions.ts\n'use server'\n// WRONG: the page already called requireAdmin(), so this trusts whoever calls it.\nexport async function importCategoriesAction(_prev: ImportResult, formData: FormData): Promise&lt;ImportResult&gt; {\n  const file = formData.get('file') as File\n  const rows = (await file.text()).trim().split('\\n').slice(1).map((line) =&gt; {\n    const [name, slug] = line.split(',')\n    return { name, slug }\n  })\n  const count = await categoryRepo.insertMany(rows, 'admin')\n  revalidatePath('/admin/categories')\n  return { status: 'imported', count }\n}</code></pre>\n\n<p>The Proxy lets any signed-in editor through, and that is its documented job: because it runs on every route, the authentication guide says it should \"only read the session from the cookie\" and \"should not be your only line of defense\". The action checks nothing, records every row as written by <code class=\"language-text\">'admin'</code>, and passes unvalidated rows to the repository.</p>\n\n<p>The fixed action is thin on purpose. It reads the session itself and hands everything else to a server-only function that owns authorization and validation:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">// app/admin/categories/actions.ts\n'use server'\n\nimport { revalidatePath } from 'next/cache'\nimport { importCategories, type ImportResult } from '@/lib/categories'\nimport { categoryRepo } from '@/lib/db'\nimport { getSession } from '@/lib/session'\n\nexport async function importCategoriesAction(_prev: ImportResult, formData: FormData): Promise&lt;ImportResult&gt; {\n  const result = await importCategories(await getSession(), formData.get('file'), categoryRepo)\n  if (result.status === 'imported') revalidatePath('/admin/categories')\n  return result\n}</code></pre>\n\n<p>That is the pattern the data security guide recommends for mutations: authentication, authorization and database logic live \"in a dedicated <code class=\"language-text\">server-only</code> module, while <code class=\"language-text\">\"use server\"</code> actions stay thin.\" It also makes the rules testable: the function takes its session and repository as arguments.</p>\n\n<h2 id=\"authorization-as-a-function-you-can-call\">Authorization as a function you can call</h2>\n\n<p>Split the page's check into a decision and an effect. The decision is a pure function over the session. The effect, calling <code class=\"language-text\">redirect()</code>, is three lines that the route contract will cover later:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">// lib/admin-access.ts\nimport 'server-only'\nimport { redirect } from 'next/navigation'\nimport { getSession, type Session } from './session'\n\nexport type AdminAccess =\n  | { ok: true; userId: string }\n  | { ok: false; redirectTo: '/login' | '/forbidden' }\n\nexport function decideAdminAccess(session: Session): AdminAccess {\n  if (!session) return { ok: false, redirectTo: '/login' }\n  if (session.role !== 'admin') return { ok: false, redirectTo: '/forbidden' }\n  return { ok: true, userId: session.userId }\n}\n\nexport async function requireAdmin(): Promise&lt;{ userId: string }&gt; {\n  const access = decideAdminAccess(await getSession())\n  if (!access.ok) redirect(access.redirectTo)\n  return access\n}</code></pre>\n\n<p>The page calls <code class=\"language-text\">requireAdmin()</code>. The import function calls <code class=\"language-text\">decideAdminAccess()</code> with the session it was given. Both paths share one rule, which is what makes a table test of that rule worth writing.</p>\n\n<h3 id=\"server-only-throws-under-vitest\">The first run fails on an import</h3>\n\n<p>Point a Node-environment Vitest project at this file and the suite fails before any test runs:</p>\n\n<pre class=\"language-text\"><code class=\"language-text\">FAIL  tests/server/admin-access.test.ts\nError: This module cannot be imported from a Client Component module. It should only be used from a Server Component.\n ❯ Object.&lt;anonymous&gt; node_modules/server-only/index.js:1:7</code></pre>\n\n<p>The message is misleading. <code class=\"language-text\">server-only</code> 0.0.1 maps the <code class=\"language-text\">react-server</code> export condition to an empty file and everything else to an <code class=\"language-text\">index.js</code> whose only statement throws. Next.js does not use either file: its docs say it handles these imports internally and \"the contents of these packages from NPM are not used.\" Vitest resolves with ordinary conditions, so it gets the file that throws.</p>\n\n<p>Adding the <code class=\"language-text\">react-server</code> resolve condition trades one failure for another: applied to externalised dependencies, it resolved React to its server build as well, and importing <code class=\"language-text\">next/navigation</code> failed with <code class=\"language-text\">TypeError: _react.default.createContext is not a function</code>. Alias the one package instead, and only in the server project. Vite's documentation asks for absolute paths when an alias points at the file system:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">// vitest.config.ts\nimport { fileURLToPath } from 'node:url'\nimport { defineConfig } from 'vitest/config'\nimport react from '@vitejs/plugin-react'\nimport { playwright } from '@vitest/browser-playwright'\n\nconst root = fileURLToPath(new URL('.', import.meta.url))\n\nexport default defineConfig({\n  resolve: { alias: { '@': root } },\n  test: {\n    projects: [\n      {\n        resolve: {\n          alias: { 'server-only': fileURLToPath(new URL('./node_modules/server-only/empty.js', import.meta.url)) },\n        },\n        test: { name: 'server', environment: 'node', include: ['tests/server/**/*.test.ts'] },\n      },\n      {\n        plugins: [react()],\n        test: {\n          name: 'client',\n          include: ['tests/client/**/*.test.tsx'],\n          browser: { enabled: true, provider: playwright(), headless: true, instances: [{ browser: 'chromium' }] },\n        },\n      },\n    ],\n  },\n})</code></pre>\n\n<p>No <code class=\"language-text\">extends: true</code> is needed on either project: the Vitest projects guide notes that inheriting the root configuration \"is enabled by default since Vitest 5.0\". The alias does switch off the guard in tests, so something else has to enforce it. That is <code class=\"language-text\">next build</code>. Importing <code class=\"language-text\">lib/admin-access.ts</code> into the form fails the production build with <code class=\"language-text\">'server-only' cannot be imported from a Client Component module</code>, and a Next.js build or dev server is where that error appears.</p>\n\n<p>With the alias in place, the decision becomes a three-row table:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">// tests/server/admin-access.test.ts\nimport { expect, test } from 'vitest'\nimport { decideAdminAccess } from '@/lib/admin-access'\n\ntest.each([\n  { who: 'anonymous', session: null, expected: { ok: false, redirectTo: '/login' } },\n  { who: 'editor', session: { userId: 'u_ed', role: 'editor' as const }, expected: { ok: false, redirectTo: '/forbidden' } },\n  { who: 'admin', session: { userId: 'u_ad', role: 'admin' as const }, expected: { ok: true, userId: 'u_ad' } },\n])('$who', ({ session, expected }) =&gt; {\n  expect(decideAdminAccess(session)).toEqual(expected)\n})</code></pre>\n\n<h3 id=\"what-redirect-throws-in-a-test\">What <code class=\"language-text\">redirect()</code> looks like from a test</h3>\n\n<p>Calling <code class=\"language-text\">requireAdmin()</code> directly fails differently: <code class=\"language-text\">`cookies` was called outside a request scope</code>. Request-time APIs need a request, which is why <code class=\"language-text\">getSession</code> sits behind its own module. Replace that module and the wrapper can run:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">// tests/server/require-admin.test.ts\nimport { expect, test, vi } from 'vitest'\n\nvi.mock('@/lib/session', () =&gt; ({ getSession: async () =&gt; ({ userId: 'u_ed', role: 'editor' }) }))\n\nimport { requireAdmin } from '@/lib/admin-access'\n\ntest('requireAdmin interrupts rendering for an editor', async () =&gt; {\n  await expect(requireAdmin()).rejects.toThrow('NEXT_REDIRECT')\n})</code></pre>\n\n<p>The <code class=\"language-text\">redirect</code> reference says it \"throws a <code class=\"language-text\">NEXT_REDIRECT</code> error and terminates rendering of the route segment\", and that is the part the test asserts. The thrown error also carries a <code class=\"language-text\">digest</code> property, which in 16.3.7 read <code class=\"language-text\">NEXT_REDIRECT;replace;/forbidden;307;</code>. That format is not documented, so do not assert on it. The destination is already covered by the decision table, and the browser-visible redirect by Playwright. If <code class=\"language-text\">vi.mock</code> ordering or cleanup surprises you, part 3 covers <a href=\"/blog/debug-vitest-module-mocking-cache-leaks\">how the module cache and mock hoisting interact</a>.</p>\n\n<p>Because it throws, a <code class=\"language-text\">try/catch</code> around <code class=\"language-text\">redirect</code> swallows it, and <code class=\"language-text\">notFound()</code>, which throws <code class=\"language-text\">NEXT_HTTP_ERROR_FALLBACK;404</code>, has the same trap. <code class=\"language-text\">unstable_rethrow(err)</code> at the top of a catch block lets both through, though it is marked \"not recommended for production\".</p>\n\n<h2 id=\"the-import-without-a-page\">The import, tested without a page</h2>\n\n<p>The order of the checks inside <code class=\"language-text\">importCategories</code> is the security property, so it is worth seeing before its tests:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">// lib/categories.ts\nimport 'server-only'\nimport * as z from 'zod'\n\nconst Row = z.strictObject({\n  name: z.string().trim().min(1).max(80),\n  slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),\n})\n\nexport async function importCategories(session: Session, file: FormDataEntryValue | null, repo: CategoryRepo): Promise&lt;ImportResult&gt; {\n  const access = decideAdminAccess(session)\n  if (!access.ok) return { status: 'rejected', reason: 'forbidden' }\n  if (!(file instanceof File) || file.size === 0) return { status: 'rejected', reason: 'no-file' }\n  if (file.size &gt; MAX_BYTES) return { status: 'rejected', reason: 'too-large' }\n\n  const [header, ...lines] = (await file.text()).trim().split(/\\r?\\n/)\n  if (header !== 'name,slug') return { status: 'rejected', reason: 'invalid-rows', rows: [{ line: 1, field: '(header)' }] }\n\n  const parsed = lines.map((line, i) =&gt; {\n    const [name, slug] = line.split(',')\n    return { line: i + 2, result: Row.safeParse({ name, slug }) }\n  })\n  const bad = parsed.flatMap(({ line, result }) =&gt;\n    result.success ? [] : result.error.issues.map((issue) =&gt; ({ line, field: String(issue.path[0]) })),\n  )\n  if (bad.length) return { status: 'rejected', reason: 'invalid-rows', rows: bad }\n\n  const count = await repo.insertMany(parsed.map(({ result }) =&gt; result.data!), access.userId)\n  return { status: 'imported', count }\n}</code></pre>\n\n<p>Authorization comes first, before the file is touched. The writer is <code class=\"language-text\">access.userId</code>, taken from the session, never from the request. Row errors report a line and a field name and never echo the value, for the reasons part 1 gives about <a href=\"/blog/typescript-runtime-boundary-validation-testing\">keeping raw input out of boundary errors</a>.</p>\n\n<p>Its tests read as a list of what a caller cannot do. The fixtures are real <code class=\"language-text\">File</code> objects, a class Node has shipped as no longer experimental since v20.0.0:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">// tests/server/import-categories.test.ts\nimport { describe, expect, test, vi } from 'vitest'\nimport { importCategories, type CategoryRepo } from '@/lib/categories'\n\nconst admin = { userId: 'u_ad', role: 'admin' as const }\nconst editor = { userId: 'u_ed', role: 'editor' as const }\nconst csv = (body: string) =&gt; new File([body], 'categories.csv', { type: 'text/csv' })\n\nfunction fakeRepo(): CategoryRepo {\n  return {\n    list: vi.fn(async () =&gt; [{ id: 'c1', name: 'Tools', slug: 'tools', createdBy: 'seed', createdAt: new Date() }]),\n    insertMany: vi.fn(async (rows) =&gt; rows.length),\n  }\n}\n\ndescribe('importCategories', () =&gt; {\n  test('an editor is refused before the file is read', async () =&gt; {\n    const repo = fakeRepo()\n    const file = csv('name,slug\\nAPI,api')\n    const text = vi.spyOn(file, 'text')\n    expect(await importCategories(editor, file, repo)).toEqual({ status: 'rejected', reason: 'forbidden' })\n    expect(text).not.toHaveBeenCalled()\n    expect(repo.insertMany).not.toHaveBeenCalled()\n  })\n\n  test('a string where a file should be is rejected', async () =&gt; {\n    const repo = fakeRepo()\n    expect(await importCategories(admin, 'name,slug\\nAPI,api', repo)).toMatchObject({ reason: 'no-file' })\n    expect(repo.insertMany).not.toHaveBeenCalled()\n  })\n\n  test('bad rows are reported by line and field, and nothing is written', async () =&gt; {\n    const repo = fakeRepo()\n    const result = await importCategories(admin, csv('name,slug\\nAPI,api\\n,empty-name\\nLoad,Load Testing'), repo)\n    expect(result).toEqual({\n      status: 'rejected', reason: 'invalid-rows',\n      rows: [{ line: 3, field: 'name' }, { line: 4, field: 'slug' }],\n    })\n    expect(repo.insertMany).not.toHaveBeenCalled()\n  })\n\n  test('the writer is the session user, not anything in the request', async () =&gt; {\n    const repo = fakeRepo()\n    expect(await importCategories(admin, csv('name,slug\\nAPI,api\\nMobile,mobile'), repo)).toEqual({ status: 'imported', count: 2 })\n    expect(repo.insertMany).toHaveBeenCalledWith([{ name: 'API', slug: 'api' }, { name: 'Mobile', slug: 'mobile' }], 'u_ad')\n  })\n})</code></pre>\n\n<p>The spy on <code class=\"language-text\">file.text</code> is the detail worth copying. It proves the refusal happens before any work, which matters when the work is parsing a file an attacker chose. The string case exists because <code class=\"language-text\">FormData.get()</code> returns <code class=\"language-text\">File | string | null</code>, and a crafted POST can send a text field under the file's name.</p>\n\n<p>One more test calls the real exported action with only the session module replaced:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">// tests/server/import-action.test.ts\nconst session = vi.hoisted(() =&gt; ({ current: null as null | { userId: string; role: 'admin' | 'editor' } }))\nvi.mock('@/lib/session', () =&gt; ({ getSession: async () =&gt; session.current }))\nvi.mock('next/cache', () =&gt; ({ revalidatePath: vi.fn() }))\n\nimport { importCategoriesAction } from '@/app/admin/categories/actions'\nimport { categoryRepo } from '@/lib/db'\n\ntest.each([\n  { who: 'anonymous', current: null },\n  { who: 'editor', current: { userId: 'u_ed', role: 'editor' as const } },\n])('the action refuses $who callers on its own', async ({ current }) =&gt; {\n  session.current = current\n  const before = (await categoryRepo.list()).length\n  const fd = new FormData()\n  fd.set('file', new File(['name,slug\\nAPI,api'], 'c.csv', { type: 'text/csv' }))\n  expect(await importCategoriesAction({ status: 'idle' }, fd)).toEqual({ status: 'rejected', reason: 'forbidden' })\n  expect((await categoryRepo.list()).length).toBe(before)\n})</code></pre>\n\n<p>Swap in the trusting action from earlier and both cases fail with <code class=\"language-text\">expected { status: 'imported', count: 1 } to deeply equal { status: 'rejected', …(1) }</code>. That failure is the one the original suite could not produce, because it never called the action without the page in front of it.</p>\n\n<p>Refusing with a return value rather than a throw is a choice with a visible consequence. The <code class=\"language-text\">useActionState</code> reference says that if the action throws, \"React cancels all queued actions and shows the nearest Error Boundary\". A returned refusal lets the form say what happened.</p>\n\n<h2 id=\"props-that-cross-into-the-client\">Props that cross into the client</h2>\n\n<p>The server component passes the form existing category names so it can flag duplicates. Two different things can go wrong with that prop.</p>\n\n<p>The first is exposure. The repository returns rows with <code class=\"language-text\">createdBy</code> and <code class=\"language-text\">createdAt</code>; the form needs names. Passing rows through leaks every column, because the RSC payload carries \"any props passed from a Server Component to a Client Component\". An exact <code class=\"language-text\">toEqual</code> on the list function's output catches it, where a <code class=\"language-text\">toMatchObject</code> would not.</p>\n\n<p>The second is serializability, and the rule is narrower than \"JSON\". React's list of props a Server Component may pass includes <code class=\"language-text\">Date</code>, <code class=\"language-text\">Map</code>, <code class=\"language-text\">Set</code>, typed arrays, promises and Server Functions, and excludes classes, \"objects that are instances of any class (other than the built-ins mentioned)\", null-prototype objects and unregistered symbols. A domain value object in a DTO is the usual way this breaks. A small checker that mirrors the documented list turns a runtime failure into a unit test with a path in its message:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">// Mirrors React's list of props a Server Component may pass to a Client Component.\nfunction assertSerializableProps(value: unknown, path = 'props'): void {\n  if (value === null || ['string', 'number', 'bigint', 'boolean', 'undefined'].includes(typeof value)) return\n  if (typeof value === 'symbol' &amp;&amp; Symbol.keyFor(value) !== undefined) return\n  if (value instanceof Date) return\n  if (Array.isArray(value)) return value.forEach((v, i) =&gt; assertSerializableProps(v, `${path}[${i}]`))\n  if (typeof value === 'object' &amp;&amp; Object.getPrototypeOf(value) === Object.prototype) {\n    return Object.entries(value).forEach(([k, v]) =&gt; assertSerializableProps(v, `${path}.${k}`))\n  }\n  throw new TypeError(`${path} is not serializable across the client boundary`)\n}\n\ntest('the list DTO is exactly what the form renders, and serializable', async () =&gt; {\n  const dto = await listCategoriesForAdmin(admin, fakeRepo())\n  expect(dto).toEqual([{ name: 'Tools', slug: 'tools' }])\n  expect(() =&gt; assertSerializableProps({ existing: dto.map((c) =&gt; c.name) })).not.toThrow()\n})\n\ntest('a value object in the DTO fails here instead of in the browser', () =&gt; {\n  class Slug { constructor(readonly value: string) {} }\n  expect(() =&gt; assertSerializableProps({ categories: [{ name: 'Tools', slug: new Slug('tools') }] }))\n    .toThrow('props.categories[0].slug is not serializable')\n})</code></pre>\n\n\n<h2 id=\"the-form-with-its-action-as-a-prop\">The form in a real browser, with its action as a prop</h2>\n\n<p>The form could import the action directly. Passing it as a prop instead costs one line in the page, and it is supported: React's documentation says Server Functions \"can be created in Server Components and passed as props to Client Components\", and Server Functions are on the list of serializable props. The test can then hand the form an ordinary async function without mocking a module:</p>\n\n<pre class=\"language-tsx\"><code class=\"language-tsx\">// app/admin/categories/page.tsx\nexport default async function CategoriesAdminPage() {\n  await requireAdmin()\n  const categories = await listCategoriesForAdmin(await getSession(), categoryRepo)\n  return (\n    &lt;main&gt;\n      &lt;h1&gt;Categories&lt;/h1&gt;\n      &lt;ul aria-label=\"Existing categories\"&gt;{categories.map((c) =&gt; &lt;li key={c.slug}&gt;{c.name}&lt;/li&gt;)}&lt;/ul&gt;\n      &lt;ImportForm existing={categories.map((c) =&gt; c.name)} action={importCategoriesAction} /&gt;\n    &lt;/main&gt;\n  )\n}</code></pre>\n\n<p>Inside, the form calls <code class=\"language-text\">useActionState(action, { status: 'idle' })</code>, reads the chosen file with <code class=\"language-text\">file.text()</code> to show a row count and flag duplicates, and disables its button while the action is pending. That is real file input and real <code class=\"language-text\">Blob</code> reading, which is why this test belongs in browser mode:</p>\n\n<pre class=\"language-tsx\"><code class=\"language-tsx\">// tests/client/import-form.test.tsx\nimport { expect, test, vi } from 'vitest'\nimport { render } from 'vitest-browser-react'\nimport { ImportForm } from '@/app/admin/categories/import-form'\nimport type { ImportResult } from '@/lib/categories'\n\nconst csv = (body: string) =&gt; new File([body], 'categories.csv', { type: 'text/csv' })\n\ntest('previews the file, flags duplicates, and submits it as FormData', async () =&gt; {\n  let release!: (r: ImportResult) =&gt; void\n  const action = vi.fn((_prev: ImportResult, _fd: FormData) =&gt; new Promise&lt;ImportResult&gt;((r) =&gt; { release = r }))\n\n  const screen = await render(&lt;ImportForm existing={['Tools']} action={action} /&gt;)\n  const submit = screen.getByRole('button', { name: 'Import' })\n  await expect.element(submit).toBeDisabled()\n\n  await screen.getByLabelText('Categories CSV').upload(csv('name,slug\\nTools,tools\\nAPI,api'))\n  await expect.element(screen.getByRole('status')).toHaveTextContent('2 rows ready')\n  await expect.element(screen.getByRole('alert')).toHaveTextContent('Already exists: Tools')\n\n  await submit.click()\n  await expect.element(screen.getByRole('button', { name: 'Importing…' })).toBeDisabled()\n\n  const sent = action.mock.calls[0][1].get('file')\n  expect(sent).toBeInstanceOf(File)\n  expect(await (sent as File).text()).toBe('name,slug\\nTools,tools\\nAPI,api')\n\n  release({ status: 'imported', count: 2 })\n  await expect.element(screen.getByText('Imported 2 categories')).toBeVisible()\n})</code></pre>\n\n<p>The fake action returns a promise the test resolves by hand, which holds the form in its pending state long enough to assert on. A second test returns <code class=\"language-text\">{ status: 'rejected', reason: 'forbidden' }</code> and asserts the alert, so the form cannot show success after a refusal. <code class=\"language-text\">render</code> from <code class=\"language-text\">vitest-browser-react</code> must be awaited, \"to support <code class=\"language-text\">Suspense</code> correctly\".</p>\n\n\n<h2 id=\"one-route-contract-against-a-production-build\">One route contract against a production build</h2>\n\n<p>The Next.js Playwright guide recommends \"running your tests against your production code to more closely resemble how your application will behave\". Only a running server can show the redirect as a browser sees it, and only a running server has real action IDs.</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">// playwright.config.ts\nexport default defineConfig({\n  testDir: './e2e',\n  use: { baseURL: 'http://localhost:3917', trace: 'on-first-retry' },\n  webServer: { command: 'npm run start', url: 'http://localhost:3917/login', reuseExistingServer: false },\n  projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],\n})</code></pre>\n\n<p><code class=\"language-text\">reuseExistingServer: false</code> is deliberate on a machine that runs more than one project. With <code class=\"language-text\">true</code>, Playwright \"will re-use an existing server on the port or url when available\", and nothing checks that the server is yours: if another app already answers on that port, the suite runs against it, redirects and all. With <code class=\"language-text\">false</code> it \"will throw if an existing process is listening\".</p>\n\n<p>Three tests make up the contract. Two are plain: anonymous and editor sessions land on <code class=\"language-text\">/login</code> and <code class=\"language-text\">/forbidden</code>, and an admin who uploads a file sees the new category in the server-rendered list. The third is the one the original suite was missing:</p>\n\n<pre class=\"language-typescript\"><code class=\"language-typescript\">// e2e/admin-categories.spec.ts\ntest('the action refuses an editor who replays the admin request', async ({ page, context, playwright }) =&gt; {\n  await context.addCookies([{ name: 'session', value: 'tok-admin', url: 'http://localhost:3917' }])\n  await page.goto('/admin/categories')\n  await page.getByLabel('Categories CSV').setInputFiles({\n    name: 'categories.csv', mimeType: 'text/csv', buffer: Buffer.from('name,slug\\nReplay,replay'),\n  })\n\n  // Capture the admin's action call and stop it reaching the server.\n  const captured = new Promise&lt;{ url: string; headers: Record&lt;string, string&gt;; body: Buffer }&gt;((resolve) =&gt;\n    page.route('**/admin/categories', async (route) =&gt; {\n      const req = route.request()\n      if (req.method() !== 'POST') return route.continue()\n      resolve({ url: req.url(), headers: await req.allHeaders(), body: req.postDataBuffer()! })\n      await route.abort()\n    }),\n  )\n  await page.getByRole('button', { name: 'Import' }).click()\n  const { url, headers, body } = await captured\n\n  // Send the identical request with an editor's session.\n  const { cookie: _admin, ...replayHeaders } = headers\n  const editor = await playwright.request.newContext({ extraHTTPHeaders: { cookie: 'session=tok-editor' } })\n  const response = await editor.post(url, { headers: replayHeaders, data: body })\n  expect(response.status()).toBeLessThan(500)\n  await editor.dispose()\n\n  await page.unroute('**/admin/categories')\n  await page.reload()\n  await expect(page.getByRole('list', { name: 'Existing categories' })).not.toContainText('Replay')\n})</code></pre>\n\n<p>The test copies whatever the browser sent, minus the cookie, without naming the framework's headers. Use <code class=\"language-text\">allHeaders()</code> for the copy: Playwright's plain <code class=\"language-text\">headers()</code> \"does not return security-related headers, including cookie-related ones\", and the <code class=\"language-text\">Origin</code> header must survive, because Next.js compares it with <code class=\"language-text\">Host</code> and aborts the request on a mismatch.</p>\n\n<p>The assertion is on state, re-read as the admin, and that is not a stylistic choice. The response body is React's serialized payload, and in 16.3.7 it contains the string <code class=\"language-text\">\"forbidden\"</code> as a key in the route tree even when the import succeeds. A test that asserts <code class=\"language-text\">toContain('forbidden')</code> on the body passes against the trusting action. Against that same action the state assertion fails with <code class=\"language-text\">Received string: \"ToolsReplay\"</code>.</p>\n\n<p>Name the three suites for the entry point they cover so a CI summary says which contract broke; the <a href=\"/tools/test-naming-linter\">Test Naming Linter</a> checks test names for that.</p>\n\n<h2 id=\"what-the-split-costs\">What the split costs, and when to skip it</h2>\n\n<p>It adds seams: <code class=\"language-text\">decideAdminAccess</code> exists partly so a test can call it, and the action is a prop partly so a test can replace it.</p>\n\n<p>The alias removes a guard from the test run. That is only safe if <code class=\"language-text\">next build</code> runs on every change, because it is the one step that reports a server-only module pulled into a client bundle.</p>\n\n<p>The replay test depends on the action ID being valid for the build under test. Action IDs are generated at build time and, per the Server Actions guide, rotated \"at most every 14 days, even when the source is unchanged\", so capture and replay must happen in the same run, against the same build. Never store a captured request as a fixture.</p>\n\n<p>For CSV import, the Server Actions guide caps action requests \"at 1MB by default\". Upload a realistic file in the route contract; raise <code class=\"language-text\">serverActions.bodySizeLimit</code> deliberately or keep the file-size check in <code class=\"language-text\">importCategories</code> below it, and test whichever you chose.</p>\n\n<p>Skip most of this for a page with no session and no mutation. The split pays when a role check and an action sit on one page, because they are two entry points that can disagree.</p>\n\n<p>Sorting files by their directives and hook imports is mechanical, and an AI assistant drafts a test placement from it well. Whether an editor may import categories is policy the draft cannot decide, so whoever owns that policy should read the refused-caller rows and the replay test.</p>\n\n<h2 id=\"replay-one-action-as-the-wrong-user\">Replay one Server Action as the wrong user today</h2>\n\n<p>Search for the files that define actions and read each exported function's first lines:</p>\n\n<pre class=\"language-bash\"><code class=\"language-bash\">grep -rlE --include='*.ts' --include='*.tsx' \"^['\\\"]use server['\\\"]\" app lib</code></pre>\n\n<p>Any action that does not read the session itself, directly or through a function that does, is trusting its page. Pick the one guarding the most sensitive mutation. Add the refused-caller test that calls it with an anonymous and an under-privileged session, then the Playwright replay against a production build. If either passes before you change anything, you have a finding, not a test.</p>\n\n<p>Keep three reports named for their entry point: server decisions and refused callers, form behaviour in the browser, and the route contract whose replay case shows nothing changed.</p>\n\n<h2 id=\"questions-about-nextjs-boundary-tests\">Questions about testing across the Next.js boundary</h2>\n\n<h3 id=\"faq-mark-page-use-client\">Could I mark the page <code class=\"language-text\">'use client'</code> so it renders in a unit test?</h3>\n\n<p>You would be testing a different program. <code class=\"language-text\">'use client'</code> puts a module \"and its transitive dependencies\" in the client bundle, so the page could no longer import the session or the data layer, and the build would fail on the <code class=\"language-text\">server-only</code> import. Keep the page thin and test what it calls.</p>\n\n<h3 id=\"faq-proxy-for-admin\">Is a Proxy check on <code class=\"language-text\">/admin</code> enough on its own?</h3>\n\n<p>No. The authentication guide calls Proxy checks optimistic, says they should read only the cookie, and says Proxy \"should not be your only line of defense\". Checking in a shared layout has its own gap: the same guide notes layouts \"don't re-render on navigation\", and a layout that hides its children does not stop them rendering into the payload.</p>\n\n\n<h2 id=\"nextjs-boundary-references\">Sources for the boundary tests</h2>\n\n<ul>\n<li><a href=\"https://nextjs.org/docs/app/guides/testing/vitest\">Next.js — How to set up Vitest with Next.js</a>. That \"Vitest currently does not support\" async Server Components and E2E tests are recommended for them, while synchronous Server and Client Components can be unit tested. Next.js 16.3.7.</li>\n<li><a href=\"https://nextjs.org/docs/app/guides/testing\">Next.js — Testing</a>. The same recommendation at guide level: E2E over unit testing for async components, because \"some tools do not fully support them\".</li>\n<li><a href=\"https://nextjs.org/docs/app/guides/testing/playwright\">Next.js — How to set up Playwright with Next.js</a>. The recommendation to run E2E tests against production code with <code class=\"language-text\">npm run build</code> and <code class=\"language-text\">npm run start</code>, and the <code class=\"language-text\">webServer</code> alternative.</li>\n<li><a href=\"https://nextjs.org/docs/app/guides/data-security\">Next.js — How to think about data security</a>. That \"a page-level authentication check does not extend to the Server Actions defined within it\", the Data Access Layer's duties (server-only, authorization, minimal DTOs), the thin-action-over-DAL pattern for mutations, that actions are reachable by direct POST, the Origin and Host comparison, and returning only what the UI needs.</li>\n<li><a href=\"https://nextjs.org/docs/app/guides/server-actions\">Next.js — Server Actions</a>. The action ID plus dispatcher that POSTs back, \"reachable to anyone who can send the same POST\", the 1MB default body limit and <code class=\"language-text\">bodySizeLimit</code>, action IDs rotated at most every 14 days, and that <code class=\"language-text\">redirect</code> is a control-flow exception.</li>\n<li><a href=\"https://nextjs.org/docs/app/guides/authentication\">Next.js — How to implement authentication</a>. Optimistic versus secure checks, that Proxy should read only the cookie and \"should not be your only line of defense\", that layouts do not re-render on navigation, and that Server Actions get \"the same security considerations as public-facing API endpoints\".</li>\n<li><a href=\"https://nextjs.org/docs/app/api-reference/functions/redirect\">Next.js — redirect</a>. That <code class=\"language-text\">redirect()</code> throws a <code class=\"language-text\">NEXT_REDIRECT</code> error, terminates the route segment's rendering, uses the <code class=\"language-text\">never</code> type, and must be called outside <code class=\"language-text\">try</code>; 307 by default and 303 for progressive-enhancement action submissions.</li>\n<li><a href=\"https://nextjs.org/docs/app/api-reference/functions/not-found\">Next.js — notFound</a>. That <code class=\"language-text\">notFound()</code> throws <code class=\"language-text\">NEXT_HTTP_ERROR_FALLBACK;404</code> and that a surrounding <code class=\"language-text\">try/catch</code> suppresses it.</li>\n<li><a href=\"https://nextjs.org/docs/app/api-reference/functions/unstable_rethrow\">Next.js — unstable_rethrow</a>. Rethrowing framework interrupts from a catch block, the APIs that rely on throwing, and the unstable status.</li>\n<li><a href=\"https://nextjs.org/docs/app/getting-started/server-and-client-components\">Next.js — Server and Client Components</a>. That <code class=\"language-text\">\"use client\"</code> declares a boundary between module graphs, that the RSC payload includes props passed to Client Components, that <code class=\"language-text\">server-only</code> imports cause a build-time error in client code, and that the npm package contents \"are not used by Next.js\".</li>\n<li><a href=\"https://nextjs.org/docs/app/api-reference/file-conventions/proxy\">Next.js — proxy.js</a>. That Next.js 16.0.0 deprecated Middleware and renamed it Proxy.</li>\n<li><a href=\"https://react.dev/reference/rsc/use-server\">React — 'use server'</a>. That \"arguments to Server Functions are fully client-controlled\", that every Server Function must check the user is allowed to act, the supported argument types including <code class=\"language-text\">FormData</code>, and the unsupported ones.</li>\n<li><a href=\"https://react.dev/reference/rsc/use-client\">React — 'use client'</a>. That the directive marks \"the module and its transitive dependencies as client code\", and the list of serializable props, including <code class=\"language-text\">Date</code> and Server Functions, and excluding class instances and null-prototype objects.</li>\n<li><a href=\"https://react.dev/reference/rsc/server-functions\">React — Server Functions</a>. That Server Functions can be created in Server Components and passed to Client Components as props, and the naming change from Server Actions in September 2024.</li>\n<li><a href=\"https://react.dev/reference/react/useActionState\">React — useActionState</a>. The <code class=\"language-text\">[state, dispatchAction, isPending]</code> return value, sequential queueing, and that a throwing action cancels queued actions and shows the nearest Error Boundary. React 19.3.0.</li>\n<li><a href=\"https://www.npmjs.com/package/server-only\">server-only on npm</a>. Version 0.0.1 exports an empty module under the <code class=\"language-text\">react-server</code> condition and, by default, an <code class=\"language-text\">index.js</code> that throws the error quoted in the article; confirmed by installing the package.</li>\n<li><a href=\"https://vitest.dev/guide/projects\">Vitest — Test Projects</a>. Inline projects in one config, and that inheriting root options \"is enabled by default since Vitest 5.0\". Vitest 5.0.2.</li>\n<li><a href=\"https://vitest.dev/api/browser/interactivity\">Vitest — Interactivity API</a>. That <code class=\"language-text\">userEvent</code> uses CDP or WebDriver \"instead of faking events\", and the <code class=\"language-text\">upload</code> method on a locator.</li>\n<li><a href=\"https://vitest.dev/api/browser/react\">Vitest — vitest-browser-react</a>. The asynchronous <code class=\"language-text\">render</code>, awaited \"to support <code class=\"language-text\">Suspense</code> correctly\", and its locator-based render result.</li>\n<li><a href=\"https://vite.dev/config/shared-options#resolve-alias\">Vite — Shared options: resolve.alias and resolve.conditions</a>. That file-system aliases should use absolute paths, and the default client resolve conditions.</li>\n<li><a href=\"https://nodejs.org/api/buffer.html#class-file\">Node.js — Buffer: Class File</a>. That <code class=\"language-text\">File</code> has been no longer experimental since v20.0.0, so Node-environment tests can build real file fixtures.</li>\n<li><a href=\"https://playwright.dev/docs/test-webserver\">Playwright — Web server</a>. The <code class=\"language-text\">webServer</code> options, and that <code class=\"language-text\">reuseExistingServer: true</code> reuses whatever serves the URL while <code class=\"language-text\">false</code> throws if a process is listening. Playwright 1.63.0.</li>\n<li><a href=\"https://playwright.dev/docs/api/class-request\">Playwright — Request</a>. <code class=\"language-text\">allHeaders()</code> versus <code class=\"language-text\">headers()</code>, which \"does not return security-related headers, including cookie-related ones\", and <code class=\"language-text\">postDataBuffer()</code>.</li>\n<li><a href=\"https://playwright.dev/docs/api/class-route\">Playwright — Route</a>. <code class=\"language-text\">route.continue()</code> and <code class=\"language-text\">route.abort()</code> for intercepting the admin's action request.</li>\n<li><a href=\"https://playwright.dev/docs/api/class-apirequest\">Playwright — APIRequest</a>. <code class=\"language-text\">newContext()</code> with <code class=\"language-text\">extraHTTPHeaders</code> for sending the replay as a different user.</li>\n<li><a href=\"https://playwright.dev/docs/api/class-locator\">Playwright — Locator</a>. <code class=\"language-text\">setInputFiles()</code> with an in-memory buffer.</li>\n</ul>\n"}