Test React Server and Client Boundaries in Next.js
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.

server-only 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.Here is a suite that is green and wrong. The admin categories page checks the session and sends editors to /forbidden. A test renders the page with next/navigation mocked and asserts that redirect 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.
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."
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 how to choose between Node, jsdom, browser mode and Playwright; this is that decision applied to the one boundary a framework draws for you.
async 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 @vitest/browser-playwright 5.0.2 and vitest-browser-react 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.One admin page, four entry points
Each place where a request or a value crosses a trust boundary has a different cheapest test.
| Entry point | What can go wrong there | Layer that reaches it |
|---|---|---|
| The page render: session check and redirect | An editor sees admin data | A plain function in Vitest (Node) for the decision; Playwright for the redirect itself |
| Props passed from the server component to the form | Private fields or non-serializable values cross into the client | A plain function in Vitest (Node) |
| The client form: file preview, pending state, result | A preview that lies, a double submit, a success message after a refusal | Vitest browser mode |
| The Server Action | Anyone who can send the POST can import | A plain function call in Vitest (Node), then a replay in Playwright |
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."
The action that trusted its page
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:
// proxy.ts: an optimistic check, reading only the cookie
export default function proxy(req: NextRequest) {
if (req.nextUrl.pathname.startsWith('/admin') && !req.cookies.get('session')) {
return NextResponse.redirect(new URL('/login', req.nextUrl))
}
return NextResponse.next()
}
// app/admin/categories/actions.ts
'use server'
// WRONG: the page already called requireAdmin(), so this trusts whoever calls it.
export async function importCategoriesAction(_prev: ImportResult, formData: FormData): Promise<ImportResult> {
const file = formData.get('file') as File
const rows = (await file.text()).trim().split('\n').slice(1).map((line) => {
const [name, slug] = line.split(',')
return { name, slug }
})
const count = await categoryRepo.insertMany(rows, 'admin')
revalidatePath('/admin/categories')
return { status: 'imported', count }
}
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 'admin', and passes unvalidated rows to the repository.
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:
// app/admin/categories/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { importCategories, type ImportResult } from '@/lib/categories'
import { categoryRepo } from '@/lib/db'
import { getSession } from '@/lib/session'
export async function importCategoriesAction(_prev: ImportResult, formData: FormData): Promise<ImportResult> {
const result = await importCategories(await getSession(), formData.get('file'), categoryRepo)
if (result.status === 'imported') revalidatePath('/admin/categories')
return result
}
That is the pattern the data security guide recommends for mutations: authentication, authorization and database logic live "in a dedicated server-only module, while "use server" actions stay thin." It also makes the rules testable: the function takes its session and repository as arguments.
Authorization as a function you can call
Split the page's check into a decision and an effect. The decision is a pure function over the session. The effect, calling redirect(), is three lines that the route contract will cover later:
// lib/admin-access.ts
import 'server-only'
import { redirect } from 'next/navigation'
import { getSession, type Session } from './session'
export type AdminAccess =
| { ok: true; userId: string }
| { ok: false; redirectTo: '/login' | '/forbidden' }
export function decideAdminAccess(session: Session): AdminAccess {
if (!session) return { ok: false, redirectTo: '/login' }
if (session.role !== 'admin') return { ok: false, redirectTo: '/forbidden' }
return { ok: true, userId: session.userId }
}
export async function requireAdmin(): Promise<{ userId: string }> {
const access = decideAdminAccess(await getSession())
if (!access.ok) redirect(access.redirectTo)
return access
}
The page calls requireAdmin(). The import function calls decideAdminAccess() with the session it was given. Both paths share one rule, which is what makes a table test of that rule worth writing.
The first run fails on an import
Point a Node-environment Vitest project at this file and the suite fails before any test runs:
FAIL tests/server/admin-access.test.ts
Error: This module cannot be imported from a Client Component module. It should only be used from a Server Component.
❯ Object.<anonymous> node_modules/server-only/index.js:1:7
The message is misleading. server-only 0.0.1 maps the react-server export condition to an empty file and everything else to an index.js 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.
Adding the react-server resolve condition trades one failure for another: applied to externalised dependencies, it resolved React to its server build as well, and importing next/navigation failed with TypeError: _react.default.createContext is not a function. 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:
// vitest.config.ts
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import { playwright } from '@vitest/browser-playwright'
const root = fileURLToPath(new URL('.', import.meta.url))
export default defineConfig({
resolve: { alias: { '@': root } },
test: {
projects: [
{
resolve: {
alias: { 'server-only': fileURLToPath(new URL('./node_modules/server-only/empty.js', import.meta.url)) },
},
test: { name: 'server', environment: 'node', include: ['tests/server/**/*.test.ts'] },
},
{
plugins: [react()],
test: {
name: 'client',
include: ['tests/client/**/*.test.tsx'],
browser: { enabled: true, provider: playwright(), headless: true, instances: [{ browser: 'chromium' }] },
},
},
],
},
})
No extends: true 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 next build. Importing lib/admin-access.ts into the form fails the production build with 'server-only' cannot be imported from a Client Component module, and a Next.js build or dev server is where that error appears.
With the alias in place, the decision becomes a three-row table:
// tests/server/admin-access.test.ts
import { expect, test } from 'vitest'
import { decideAdminAccess } from '@/lib/admin-access'
test.each([
{ who: 'anonymous', session: null, expected: { ok: false, redirectTo: '/login' } },
{ who: 'editor', session: { userId: 'u_ed', role: 'editor' as const }, expected: { ok: false, redirectTo: '/forbidden' } },
{ who: 'admin', session: { userId: 'u_ad', role: 'admin' as const }, expected: { ok: true, userId: 'u_ad' } },
])('$who', ({ session, expected }) => {
expect(decideAdminAccess(session)).toEqual(expected)
})
What redirect() looks like from a test
Calling requireAdmin() directly fails differently: `cookies` was called outside a request scope. Request-time APIs need a request, which is why getSession sits behind its own module. Replace that module and the wrapper can run:
// tests/server/require-admin.test.ts
import { expect, test, vi } from 'vitest'
vi.mock('@/lib/session', () => ({ getSession: async () => ({ userId: 'u_ed', role: 'editor' }) }))
import { requireAdmin } from '@/lib/admin-access'
test('requireAdmin interrupts rendering for an editor', async () => {
await expect(requireAdmin()).rejects.toThrow('NEXT_REDIRECT')
})
The redirect reference says it "throws a NEXT_REDIRECT error and terminates rendering of the route segment", and that is the part the test asserts. The thrown error also carries a digest property, which in 16.3.7 read NEXT_REDIRECT;replace;/forbidden;307;. 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 vi.mock ordering or cleanup surprises you, part 3 covers how the module cache and mock hoisting interact.
Because it throws, a try/catch around redirect swallows it, and notFound(), which throws NEXT_HTTP_ERROR_FALLBACK;404, has the same trap. unstable_rethrow(err) at the top of a catch block lets both through, though it is marked "not recommended for production".
The import, tested without a page
The order of the checks inside importCategories is the security property, so it is worth seeing before its tests:
// lib/categories.ts
import 'server-only'
import * as z from 'zod'
const Row = z.strictObject({
name: z.string().trim().min(1).max(80),
slug: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
})
export async function importCategories(session: Session, file: FormDataEntryValue | null, repo: CategoryRepo): Promise<ImportResult> {
const access = decideAdminAccess(session)
if (!access.ok) return { status: 'rejected', reason: 'forbidden' }
if (!(file instanceof File) || file.size === 0) return { status: 'rejected', reason: 'no-file' }
if (file.size > MAX_BYTES) return { status: 'rejected', reason: 'too-large' }
const [header, ...lines] = (await file.text()).trim().split(/\r?\n/)
if (header !== 'name,slug') return { status: 'rejected', reason: 'invalid-rows', rows: [{ line: 1, field: '(header)' }] }
const parsed = lines.map((line, i) => {
const [name, slug] = line.split(',')
return { line: i + 2, result: Row.safeParse({ name, slug }) }
})
const bad = parsed.flatMap(({ line, result }) =>
result.success ? [] : result.error.issues.map((issue) => ({ line, field: String(issue.path[0]) })),
)
if (bad.length) return { status: 'rejected', reason: 'invalid-rows', rows: bad }
const count = await repo.insertMany(parsed.map(({ result }) => result.data!), access.userId)
return { status: 'imported', count }
}
Authorization comes first, before the file is touched. The writer is access.userId, 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 keeping raw input out of boundary errors.
Its tests read as a list of what a caller cannot do. The fixtures are real File objects, a class Node has shipped as no longer experimental since v20.0.0:
// tests/server/import-categories.test.ts
import { describe, expect, test, vi } from 'vitest'
import { importCategories, type CategoryRepo } from '@/lib/categories'
const admin = { userId: 'u_ad', role: 'admin' as const }
const editor = { userId: 'u_ed', role: 'editor' as const }
const csv = (body: string) => new File([body], 'categories.csv', { type: 'text/csv' })
function fakeRepo(): CategoryRepo {
return {
list: vi.fn(async () => [{ id: 'c1', name: 'Tools', slug: 'tools', createdBy: 'seed', createdAt: new Date() }]),
insertMany: vi.fn(async (rows) => rows.length),
}
}
describe('importCategories', () => {
test('an editor is refused before the file is read', async () => {
const repo = fakeRepo()
const file = csv('name,slug\nAPI,api')
const text = vi.spyOn(file, 'text')
expect(await importCategories(editor, file, repo)).toEqual({ status: 'rejected', reason: 'forbidden' })
expect(text).not.toHaveBeenCalled()
expect(repo.insertMany).not.toHaveBeenCalled()
})
test('a string where a file should be is rejected', async () => {
const repo = fakeRepo()
expect(await importCategories(admin, 'name,slug\nAPI,api', repo)).toMatchObject({ reason: 'no-file' })
expect(repo.insertMany).not.toHaveBeenCalled()
})
test('bad rows are reported by line and field, and nothing is written', async () => {
const repo = fakeRepo()
const result = await importCategories(admin, csv('name,slug\nAPI,api\n,empty-name\nLoad,Load Testing'), repo)
expect(result).toEqual({
status: 'rejected', reason: 'invalid-rows',
rows: [{ line: 3, field: 'name' }, { line: 4, field: 'slug' }],
})
expect(repo.insertMany).not.toHaveBeenCalled()
})
test('the writer is the session user, not anything in the request', async () => {
const repo = fakeRepo()
expect(await importCategories(admin, csv('name,slug\nAPI,api\nMobile,mobile'), repo)).toEqual({ status: 'imported', count: 2 })
expect(repo.insertMany).toHaveBeenCalledWith([{ name: 'API', slug: 'api' }, { name: 'Mobile', slug: 'mobile' }], 'u_ad')
})
})
The spy on file.text 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 FormData.get() returns File | string | null, and a crafted POST can send a text field under the file's name.
One more test calls the real exported action with only the session module replaced:
// tests/server/import-action.test.ts
const session = vi.hoisted(() => ({ current: null as null | { userId: string; role: 'admin' | 'editor' } }))
vi.mock('@/lib/session', () => ({ getSession: async () => session.current }))
vi.mock('next/cache', () => ({ revalidatePath: vi.fn() }))
import { importCategoriesAction } from '@/app/admin/categories/actions'
import { categoryRepo } from '@/lib/db'
test.each([
{ who: 'anonymous', current: null },
{ who: 'editor', current: { userId: 'u_ed', role: 'editor' as const } },
])('the action refuses $who callers on its own', async ({ current }) => {
session.current = current
const before = (await categoryRepo.list()).length
const fd = new FormData()
fd.set('file', new File(['name,slug\nAPI,api'], 'c.csv', { type: 'text/csv' }))
expect(await importCategoriesAction({ status: 'idle' }, fd)).toEqual({ status: 'rejected', reason: 'forbidden' })
expect((await categoryRepo.list()).length).toBe(before)
})
Swap in the trusting action from earlier and both cases fail with expected { status: 'imported', count: 1 } to deeply equal { status: 'rejected', …(1) }. That failure is the one the original suite could not produce, because it never called the action without the page in front of it.
Refusing with a return value rather than a throw is a choice with a visible consequence. The useActionState 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.
Props that cross into the client
The server component passes the form existing category names so it can flag duplicates. Two different things can go wrong with that prop.
The first is exposure. The repository returns rows with createdBy and createdAt; 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 toEqual on the list function's output catches it, where a toMatchObject would not.
The second is serializability, and the rule is narrower than "JSON". React's list of props a Server Component may pass includes Date, Map, Set, 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:
// Mirrors React's list of props a Server Component may pass to a Client Component.
function assertSerializableProps(value: unknown, path = 'props'): void {
if (value === null || ['string', 'number', 'bigint', 'boolean', 'undefined'].includes(typeof value)) return
if (typeof value === 'symbol' && Symbol.keyFor(value) !== undefined) return
if (value instanceof Date) return
if (Array.isArray(value)) return value.forEach((v, i) => assertSerializableProps(v, `${path}[${i}]`))
if (typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
return Object.entries(value).forEach(([k, v]) => assertSerializableProps(v, `${path}.${k}`))
}
throw new TypeError(`${path} is not serializable across the client boundary`)
}
test('the list DTO is exactly what the form renders, and serializable', async () => {
const dto = await listCategoriesForAdmin(admin, fakeRepo())
expect(dto).toEqual([{ name: 'Tools', slug: 'tools' }])
expect(() => assertSerializableProps({ existing: dto.map((c) => c.name) })).not.toThrow()
})
test('a value object in the DTO fails here instead of in the browser', () => {
class Slug { constructor(readonly value: string) {} }
expect(() => assertSerializableProps({ categories: [{ name: 'Tools', slug: new Slug('tools') }] }))
.toThrow('props.categories[0].slug is not serializable')
})
The form in a real browser, with its action as a prop
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:
// app/admin/categories/page.tsx
export default async function CategoriesAdminPage() {
await requireAdmin()
const categories = await listCategoriesForAdmin(await getSession(), categoryRepo)
return (
<main>
<h1>Categories</h1>
<ul aria-label="Existing categories">{categories.map((c) => <li key={c.slug}>{c.name}</li>)}</ul>
<ImportForm existing={categories.map((c) => c.name)} action={importCategoriesAction} />
</main>
)
}
Inside, the form calls useActionState(action, { status: 'idle' }), reads the chosen file with file.text() to show a row count and flag duplicates, and disables its button while the action is pending. That is real file input and real Blob reading, which is why this test belongs in browser mode:
// tests/client/import-form.test.tsx
import { expect, test, vi } from 'vitest'
import { render } from 'vitest-browser-react'
import { ImportForm } from '@/app/admin/categories/import-form'
import type { ImportResult } from '@/lib/categories'
const csv = (body: string) => new File([body], 'categories.csv', { type: 'text/csv' })
test('previews the file, flags duplicates, and submits it as FormData', async () => {
let release!: (r: ImportResult) => void
const action = vi.fn((_prev: ImportResult, _fd: FormData) => new Promise<ImportResult>((r) => { release = r }))
const screen = await render(<ImportForm existing={['Tools']} action={action} />)
const submit = screen.getByRole('button', { name: 'Import' })
await expect.element(submit).toBeDisabled()
await screen.getByLabelText('Categories CSV').upload(csv('name,slug\nTools,tools\nAPI,api'))
await expect.element(screen.getByRole('status')).toHaveTextContent('2 rows ready')
await expect.element(screen.getByRole('alert')).toHaveTextContent('Already exists: Tools')
await submit.click()
await expect.element(screen.getByRole('button', { name: 'Importing…' })).toBeDisabled()
const sent = action.mock.calls[0][1].get('file')
expect(sent).toBeInstanceOf(File)
expect(await (sent as File).text()).toBe('name,slug\nTools,tools\nAPI,api')
release({ status: 'imported', count: 2 })
await expect.element(screen.getByText('Imported 2 categories')).toBeVisible()
})
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 { status: 'rejected', reason: 'forbidden' } and asserts the alert, so the form cannot show success after a refusal. render from vitest-browser-react must be awaited, "to support Suspense correctly".
One route contract against a production build
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.
// playwright.config.ts
export default defineConfig({
testDir: './e2e',
use: { baseURL: 'http://localhost:3917', trace: 'on-first-retry' },
webServer: { command: 'npm run start', url: 'http://localhost:3917/login', reuseExistingServer: false },
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
})
reuseExistingServer: false is deliberate on a machine that runs more than one project. With true, 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 false it "will throw if an existing process is listening".
Three tests make up the contract. Two are plain: anonymous and editor sessions land on /login and /forbidden, 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:
// e2e/admin-categories.spec.ts
test('the action refuses an editor who replays the admin request', async ({ page, context, playwright }) => {
await context.addCookies([{ name: 'session', value: 'tok-admin', url: 'http://localhost:3917' }])
await page.goto('/admin/categories')
await page.getByLabel('Categories CSV').setInputFiles({
name: 'categories.csv', mimeType: 'text/csv', buffer: Buffer.from('name,slug\nReplay,replay'),
})
// Capture the admin's action call and stop it reaching the server.
const captured = new Promise<{ url: string; headers: Record<string, string>; body: Buffer }>((resolve) =>
page.route('**/admin/categories', async (route) => {
const req = route.request()
if (req.method() !== 'POST') return route.continue()
resolve({ url: req.url(), headers: await req.allHeaders(), body: req.postDataBuffer()! })
await route.abort()
}),
)
await page.getByRole('button', { name: 'Import' }).click()
const { url, headers, body } = await captured
// Send the identical request with an editor's session.
const { cookie: _admin, ...replayHeaders } = headers
const editor = await playwright.request.newContext({ extraHTTPHeaders: { cookie: 'session=tok-editor' } })
const response = await editor.post(url, { headers: replayHeaders, data: body })
expect(response.status()).toBeLessThan(500)
await editor.dispose()
await page.unroute('**/admin/categories')
await page.reload()
await expect(page.getByRole('list', { name: 'Existing categories' })).not.toContainText('Replay')
})
The test copies whatever the browser sent, minus the cookie, without naming the framework's headers. Use allHeaders() for the copy: Playwright's plain headers() "does not return security-related headers, including cookie-related ones", and the Origin header must survive, because Next.js compares it with Host and aborts the request on a mismatch.
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 "forbidden" as a key in the route tree even when the import succeeds. A test that asserts toContain('forbidden') on the body passes against the trusting action. Against that same action the state assertion fails with Received string: "ToolsReplay".
Name the three suites for the entry point they cover so a CI summary says which contract broke; the Test Naming Linter checks test names for that.
What the split costs, and when to skip it
It adds seams: decideAdminAccess exists partly so a test can call it, and the action is a prop partly so a test can replace it.
The alias removes a guard from the test run. That is only safe if next build runs on every change, because it is the one step that reports a server-only module pulled into a client bundle.
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.
For CSV import, the Server Actions guide caps action requests "at 1MB by default". Upload a realistic file in the route contract; raise serverActions.bodySizeLimit deliberately or keep the file-size check in importCategories below it, and test whichever you chose.
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.
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.
Replay one Server Action as the wrong user today
Search for the files that define actions and read each exported function's first lines:
grep -rlE --include='*.ts' --include='*.tsx' "^['\"]use server['\"]" app lib
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.
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.
Questions about testing across the Next.js boundary
Could I mark the page 'use client' so it renders in a unit test?
You would be testing a different program. 'use client' 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 server-only import. Keep the page thin and test what it calls.
Is a Proxy check on /admin enough on its own?
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.
Sources for the boundary tests
- Next.js — How to set up Vitest with Next.js. 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.
- Next.js — Testing. The same recommendation at guide level: E2E over unit testing for async components, because "some tools do not fully support them".
- Next.js — How to set up Playwright with Next.js. The recommendation to run E2E tests against production code with
npm run buildandnpm run start, and thewebServeralternative. - Next.js — How to think about data security. 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.
- Next.js — Server Actions. The action ID plus dispatcher that POSTs back, "reachable to anyone who can send the same POST", the 1MB default body limit and
bodySizeLimit, action IDs rotated at most every 14 days, and thatredirectis a control-flow exception. - Next.js — How to implement authentication. 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".
- Next.js — redirect. That
redirect()throws aNEXT_REDIRECTerror, terminates the route segment's rendering, uses thenevertype, and must be called outsidetry; 307 by default and 303 for progressive-enhancement action submissions. - Next.js — notFound. That
notFound()throwsNEXT_HTTP_ERROR_FALLBACK;404and that a surroundingtry/catchsuppresses it. - Next.js — unstable_rethrow. Rethrowing framework interrupts from a catch block, the APIs that rely on throwing, and the unstable status.
- Next.js — Server and Client Components. That
"use client"declares a boundary between module graphs, that the RSC payload includes props passed to Client Components, thatserver-onlyimports cause a build-time error in client code, and that the npm package contents "are not used by Next.js". - Next.js — proxy.js. That Next.js 16.0.0 deprecated Middleware and renamed it Proxy.
- React — 'use server'. 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
FormData, and the unsupported ones. - React — 'use client'. That the directive marks "the module and its transitive dependencies as client code", and the list of serializable props, including
Dateand Server Functions, and excluding class instances and null-prototype objects. - React — Server Functions. 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.
- React — useActionState. The
[state, dispatchAction, isPending]return value, sequential queueing, and that a throwing action cancels queued actions and shows the nearest Error Boundary. React 19.3.0. - server-only on npm. Version 0.0.1 exports an empty module under the
react-servercondition and, by default, anindex.jsthat throws the error quoted in the article; confirmed by installing the package. - Vitest — Test Projects. Inline projects in one config, and that inheriting root options "is enabled by default since Vitest 5.0". Vitest 5.0.2.
- Vitest — Interactivity API. That
userEventuses CDP or WebDriver "instead of faking events", and theuploadmethod on a locator. - Vitest — vitest-browser-react. The asynchronous
render, awaited "to supportSuspensecorrectly", and its locator-based render result. - Vite — Shared options: resolve.alias and resolve.conditions. That file-system aliases should use absolute paths, and the default client resolve conditions.
- Node.js — Buffer: Class File. That
Filehas been no longer experimental since v20.0.0, so Node-environment tests can build real file fixtures. - Playwright — Web server. The
webServeroptions, and thatreuseExistingServer: truereuses whatever serves the URL whilefalsethrows if a process is listening. Playwright 1.63.0. - Playwright — Request.
allHeaders()versusheaders(), which "does not return security-related headers, including cookie-related ones", andpostDataBuffer(). - Playwright — Route.
route.continue()androute.abort()for intercepting the admin's action request. - Playwright — APIRequest.
newContext()withextraHTTPHeadersfor sending the replay as a different user. - Playwright — Locator.
setInputFiles()with an in-memory buffer.
TypeScript Quality Engineering
TypeScript Quality Engineering
Get part 6: Test TypeScript Error Handling Without Losing Causes
It is live now: read part 6. The form below subscribes you to The Automation Brief, the site newsletter.
No spam, no sending cadence promised. Unsubscribe any time.