NoWaterProgramming

Testing Next.js Route Handlers: Ten Seeded Bugs, Four Methods, and What Each One Missed

We put ten defects into real route handlers and ran four testing methods at them: a direct call, next-test-api-route-handler, next start, and the Cloudflare Workers build we deploy. Two methods pass a handler that is broken, and one fails a handler that is fine.

18 min read
Next.jstesting
Share:

Measured against Next.js 16.1.7 on 2026-08-27.

Next.js has an official guide to testing with Vitest. It sets up jsdom, renders a page component, and asserts on a heading. It never mentions route handlers.1 Neither does the Jest guide, or the Playwright one. The framework ships a first-class way to write an HTTP endpoint and no documented opinion on how to test one.

So we measured it. Ten defects went into route handlers on this site, one defect per handler, and four testing methods ran against all of them.

Two methods pass a handler that is genuinely broken. One method fails a handler that is genuinely fine. And the runtime difference everyone warns you about turned out to affect exactly one of the ten.

The Four Methods

The four ways people actually test a route handler differ in one thing: how much of Next.js is between the test and the handler.

methodwhat runs itwhat is between
Aimport the exported function and call itvitest, Node, in-processnothing
Bnext-test-api-route-handlervitest, Node, in-processNext's own resolvers
CHTTP against next build + next startNodethe whole framework
DHTTP against the deployed buildworkerdthe framework and the runtime adapter

Method D is specific to this site: it is a Next.js app built through OpenNext and deployed to Cloudflare Workers, so production runs on workerd rather than Node.2 If you deploy to Vercel or to a Node container, C is your production runtime and D does not exist for you.

Four testing methods attaching to a vertical request path. The path runs from the deployment runtime, through the production build, through the Next.js resolvers, down to the handler function. Method A attaches directly to the handler and supplies the context itself; method B attaches at the resolvers; method C at the production build; method D at the deployment runtime. Two dashed edges mark the blind spots: method A cannot see force-static or the real params, and method B cannot see fs.readFileSync.
Each method only sees defects below the point where it attaches. The dashed edges are what the two cheap methods cannot reach.

Each method attaches to the request path at a different depth, and catches only the defects that live below the point where it attaches. Every result further down follows from that.

A note on the starting point, because it is the reason this measurement happened. This site runs four route handlers in production, has 17 test files and 150 tests, and had no test that invoked a route handler at all. The gap is easy to arrive at honestly: the guide you follow does not cover it.

What Each Method Caught

Six of the ten probes are ordinary defects, the kind that reach a pull request. Here is which method noticed.

defectA directB NTARHC next startD workerd
200 on the rejection pathcaughtcaughtcaughtcaught
No max length, so a 262-character address is acceptedcaughtcaughtcaughtcaught
params read synchronouslydependscaughtcaughtcaught
force-static on a handler that reads a query parametermissedcaughtcaughtcaught
fs.readFileSync at request timemissedmissedmissedcaught
Handler fetches its own originfalse redfalse redpassespasses

The first two rows are the useful boring result: for defects that live entirely inside your own logic, the cheapest method is as good as the most expensive one. A wrong status code and a missing length check are caught by calling the function directly, and nothing above that adds anything. Most of what you want to test about a route handler is in those two rows.

The other four rows are where the methods disagree, and each one disagrees for a different reason.

A direct call tests your beliefs, not the framework

The params row says "depends" because method A's verdict is decided by the test author, not by the handler. Since Next.js 15, context.params is a promise rather than a plain object.3 A handler that reads params.id without awaiting is broken. But method A supplies the context itself, so:

// Written the way every pre-15 tutorial writes it. Passes.
const res = await GET(new Request('http://localhost/api/items/42'), {
  params: { id: '42' },
});
 
// Written the way Next.js 16 actually calls it. Fails, correctly.
const res = await GET(new Request('http://localhost/api/items/42'), {
  params: Promise.resolve({ id: '42' }),
});
ts

Both are "a test for the handler". The first one is green against broken code. Nothing in the test file is wrong in a way TypeScript or a reviewer would notice, because the mistake is in the test's model of the framework, and the test is the only thing asserting what that model is.

Methods B, C and D all supply the real context and all catch it. That is the whole argument against method A in one row: it does not test the handler against the framework's calling convention, it tests the handler against your memory of the calling convention. If your memory were reliable you would not have written the bug.

Next 16 does give you a way to close this from the type side. RouteContext<'/users/[id]'> is a globally available generated helper that types params from the route literal.3 It does not make method A test the real convention, but it does make the wrong context shape a type error rather than a passing test.

Route config is invisible to a direct call

The force-static row is the clearest case for method B over method A. Route handlers accept the same route segment config as pages,3 and export const dynamic = 'force-static' tells Next to prerender the route at build time. A handler that reads request.nextUrl.searchParams under that config gets nothing, because there was no request when the response was produced.

To a direct call, dynamic is an exported constant that the handler never reads. The test passes. next build puts the route in the route table as static:

├ ○ /api/probe/d6          ○  (Static)  prerendered as static content
├ ƒ /api/probe/d1          ƒ  (Dynamic) server-rendered on demand

and at runtime the query parameter is null.

next-test-api-route-handler catches this, which was the surprise of the measurement. NTARH runs the handler through Next's own resolvers rather than calling the export,4 and route segment config comes along with them. We checked this was really the config and not a quirk of the harness: delete the force-static line and the same NTARH test passes; put it back and it fails. That behaviour is most of the case for installing it.

An in-process test has no origin

The last row is the one that costs you time rather than correctness. The handler is fine. Methods A and B both fail it.

export async function GET(request: NextRequest) {
  const res = await fetch(new URL('/robots.txt', request.url));
  return NextResponse.json({ ok: true, status: res.status });
}
ts

Any handler that calls its own site does this: a health check that hits another route, a warmer, a handler that composes two endpoints. Under C and D there is a server listening and the fetch returns 200. Under A and B there is no server, so the fetch fails, and the test goes red for a reason that has nothing to do with anything the handler got wrong.

A false red is worse than a missing test. A missing test leaves you where you already were. A false red puts a plausible-looking failure in front of someone who will make it green, and the cheapest way to make it green is to change the handler.

The Runtime Gap Is Smaller Than Its Reputation

The reason this measurement was worth running was a hypothesis that turned out to be wrong, so it goes near the top rather than in a footnote.

Every method above except D runs the handler on Node. Production, on this site, is workerd. The standing advice is that this difference will bite you, so three probes went in to find out where. Two of the three came back clean, and the third one did too.

probeexpected on workerdmeasured
process.env.NEWSLETTER_TOKEN_SECRET instead of the Worker bindingundefined, so a 503200, secret present
node:crypto createHmac instead of Web Cryptothrows200, correct MAC
Intl.DateTimeFormat with timeZone: 'Asia/Ashgabat'ICU data missing200, formatted correctly

node:crypto works because nodejs_compat provides it natively.5 The process.env result is more interesting, because the adapter does it deliberately and you can read the code in your own build output. .open-next/cloudflare/init.js, generated by the OpenNext build, contains:

function populateProcessEnv(url, env) {
  for (const [key, value] of Object.entries(env)) {
    if (typeof value === "string") {
      process.env[key] = value;
    }
  }
  // ...
}
js

Every string binding is copied into process.env on the first request the Worker instance handles. So the "wrong" way to read a secret is not wrong here, and a Node-based test that exercises it is telling you the truth.

That is worth publishing precisely because it is unexciting. The alternative to knowing it is writing defensive code against a problem the adapter already solved, and paying for a workerd-based test suite to protect you from it.

Where It Is Not Smaller: The Filesystem

One probe out of ten behaved differently, and it is the one that reads a file at request time.

const file = path.join(process.cwd(), 'content/blog/nextjs-api-routes.mdx');
return NextResponse.json({ bytes: readFileSync(file, 'utf8').length });
ts

Under next start this returns 23,867 bytes. Under workerd, on this site's configuration:

500 {"ok":false,"error":"Error: [unenv] fs.readFileSync is not implemented yet!"}

Methods A, B and C all report the handler healthy. This is the single cell in the whole matrix where the expensive method earns its cost.

And then the interesting part. Cloudflare's Node.js compatibility table lists the file system as supported.5 Both things are true, because which one you get is decided by a date string in your config and not by your code. We ran the same handler, same build, changing only compatibility_date in wrangler.jsonc:

compatibility dateresult
2024-12-30 (ours)[unenv] fs.readFileSync is not implemented yet!
2025-09-14[unenv] fs.readFileSync is not implemented yet!
2025-09-15no such file or directory, readAll '/content/blog/nextjs-api-routes.mdx'
2026-08-01no such file or directory, readAll '/bundle/content/blog/nextjs-api-routes.mdx'

The boundary is 2025-09-15, which is exactly the date Cloudflare documents as the default for enable_nodejs_fs_module.6 Before it, fs.readFileSync does not exist and unenv throws a stub error.5 After it, a real filesystem exists and the failure becomes an ordinary missing file, at a root that itself moves as the date advances.

Both are failures, but they need different fixes, and someone reading the second error will spend their afternoon on a path when the first error would have sent them straight to the architecture. Nothing in the handler changed between those rows.

Why You Cannot Just Detect The Runtime

The obvious defence is to branch: check what you are running on and behave accordingly. It does not work, because the runtime reports itself as Node.

The same handler, returning what it can see about itself:

Node (A, B, C)workerd (D)
process.cwd()the project path/
process.versionv24.18.0v22.14.0
process.platformdarwinlinux
typeof Bufferdefineddefined
navigator.userAgentNode.js/24Cloudflare-Workers

process.version returning a Node version on a Worker is not workerd being evasive. It is the adapter, on purpose, in the same generated file as the env code:

function initRuntime() {
  Object.assign(process, { version: process.version || "v22.14.0" });
  Object.assign(process.versions, { node: "22.14.0", ...process.versions });
  // ...
}
js

This is a sensible thing for the adapter to do, since a great deal of library code branches on process.version and would take the wrong path otherwise. It also means the first guard most people reach for is the one that cannot work. Of the five signals above, navigator.userAgent is the only one that tells the truth.

What Each Method Costs

The cost difference is large enough to decide the shape of your suite on its own. Warm repeat runs, on an M3 Pro:

methodbuildserver readytotal
A vitest, direct callnonenone0.9s
B vitest, NTARHnonenone0.6s
C next build + next start11.3s1.1s12.4s
D opennext build + wrangler dev17.7s1.4s19.1s

Method D costs 21 times method A and catches one defect out of ten that the others do not. It still earns a place in the suite. It does not earn a place in the loop you run on every save.

What We Did With This

The suite this produced is B for everything, plus D once before a deploy.

Method B costs the same as method A, is the fastest thing measured, and catches two defect classes that A does not. There is no case for A once NTARH is installed, other than not wanting a dependency. Method C is dominated: everything it catches, B also catches, and it costs twenty times more.

Method D catches one thing and it is a thing you cannot fix quickly under pressure, because the fix is architectural rather than a patch. That makes it a gate rather than a test: run the deployed build once before shipping, hit the routes, and read the statuses. On this site the deploy chain already builds the Worker, so the cost of doing it is the cost of a handful of curls.

If you deploy to Node, this collapses further: install NTARH, write your assertions there, and stop. Everything above about runtimes belongs to your deployment target, and Next.js has no part in it.

Running It On Your Own Repository

The matrix above is one stack. Reproducing it on yours takes about twenty minutes, and the answer for your repository is the only one that decides your suite.

Put one deliberately broken handler in app/api/probe/route.ts, then run the same request four ways:

# A: call the export directly
npx vitest run tests/probe-direct.test.ts
 
# B: through Next's resolvers
npm i -D next-test-api-route-handler
npx vitest run tests/probe-ntarh.test.ts
 
# C: against a production build on Node
npm run build && npx next start -p 3100 &
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:3100/api/probe
 
# D: against the runtime you deploy to
npx opennextjs-cloudflare build && npx wrangler dev --port 8788 --local &
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8788/api/probe
bash

The probe worth writing first is the runtime one, because it is the only result that cannot be reasoned out from the docs. Have the handler return what it can see about itself:

export async function GET() {
  return Response.json({
    cwd: process.cwd(),
    version: process.version,
    platform: process.platform,
    userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : null,
  });
}
ts

Run it under C and under D and compare the two payloads. Every difference is a thing your Node-based tests cannot tell you, and every line that matches is a thing you do not have to build a second suite for.

Where This Measurement Is Weak

Five things bound what you should take from it.

Ten defects is not a sample, and the selection is the weakest thing here. Each probe was written to sit on a specific seam between the test and the framework, which is the opposite of drawing defects at random from real bugs. So the matrix answers "can this method see this seam", not "what fraction of your bugs will this method catch", and the second question is the one you actually have. The strongest reason to distrust the shape of it is that the most useful result in the whole exercise was the one that proved the starting hypothesis wrong. Two probes went in expecting a Node-versus-workerd failure and neither failed. If the hypothesis that motivated the measurement was wrong, the intuitions that chose the other eight probes have no special claim either, and the defect that would have changed the recommendation is most likely one nobody here thought to write down.

The runtime results are ours, not general. They are OpenNext 1.17.1 on workerd with nodejs_compat. A Worker written without the adapter has no populateProcessEnv and would fail the env probe. Deno, Bun and Vercel's edge runtime were not tested at all.

Method D was measured locally. wrangler dev --local runs the real workerd binary, but a deployed Worker is not identical to a local one, particularly around outbound fetch. This site sets global_fetch_strictly_public, which changes what fetching your own origin means in production, and the origin probe passed locally partly for that reason.

The compatibility-date table is one handler and one method. fs.readFileSync is the one call we walked across the boundary. Other stubs move on other dates, and the same technique would give a different table for each.

Timings are warm, on one machine. A cold CI build is several times the 11.3s here.

FAQ

How do I test a Next.js route handler?

Import the handler and call it with a Request for pure logic, or use next-test-api-route-handler if you want Next's own resolvers between the test and the handler. The second one costs the same to run and catches route segment config and async params, so it is the better default. Next.js itself documents neither.1

Does the official Next.js testing guide cover API routes?

No. The Vitest, Jest, Playwright and Cypress guides all cover component and end-to-end testing. The Vitest guide's only stated limitation is about server components: "Since async Server Components are new to the React ecosystem, Vitest currently does not support them."1 Route handlers are not mentioned.

Why does my route handler test pass but the endpoint break in production?

The three causes measured here are async params supplied as a plain object by the test, route segment config the test never applies, and a runtime API that exists in Node and not in your deployment target. The first two are caught by testing through the framework rather than around it; the third needs a request against the built artifact.

Is params a promise in Next.js 16?

Yes. context.params became a promise in 15.0.0-RC and a codemod shipped with it.3 A handler that reads params.id without awaiting gets undefined rather than an error, so it fails quietly.

Do I need to test against Cloudflare Workers if I deploy there?

Once per deploy, not once per save. In ten probes only one behaved differently between Node and workerd, and the OpenNext adapter closes the two gaps most people expect. One run against the built Worker before shipping buys the coverage at a cost you pay once.

Why does process.version say Node on a Worker?

Because the OpenNext adapter sets it. Its generated initRuntime assigns v22.14.0 to process.version so that library code branching on the Node version takes the Node path. Use navigator.userAgent, which returns Cloudflare-Workers, if you need to know where you are.

Is node:fs available on Cloudflare Workers?

It depends on your compatibility_date, not on your code. enable_nodejs_fs_module is on by default from 2025-09-15.6 Before that date fs.readFileSync throws an unenv stub error; after it you get a real filesystem that does not contain your repository.

Disclosure

The codebase measured here is nowaterprogramming.com, which is our own site. Every defect in the tables is one we put there on purpose, and the stack it ran on is one configuration rather than a survey. The commands are published above for that reason: the version of this measurement that answers the question for your repository is the one you run on it.

For the handlers themselves rather than their tests, the route handlers guide covers caching, async params and the server actions decision, and the App Router walkthrough covers where they sit in a project.

Sources

Sources

  1. Next.js: How to set up Vitest with Next.js - that the official Vitest guide covers component testing only and never mentions route handlers, and the quoted limitation about async Server Components.

  2. OpenNext: Cloudflare adapter - that a Next.js app built through this adapter runs on the Cloudflare Workers runtime rather than Node.

  3. Next.js: route.js file convention - that context.params is a promise and became one in 15.0.0-RC, that route handlers take the same route segment config as pages, and the RouteContext typing helper.

  4. next-test-api-route-handler - that it uses Next.js internal resolvers to emulate route handling rather than calling the exported function, and that it must be the first import in a test file.

  5. Cloudflare Workers: Node.js compatibility - that nodejs_compat provides node:crypto natively, that the file system is listed as supported, and that unimplemented shims throw the [unenv] <method> is not implemented yet! error seen here.

  6. Cloudflare Workers: compatibility flags - that enable_nodejs_fs_module is enabled by default from compatibility date 2025-09-15, which is the boundary measured above.

Related Posts

17 min read
How route handlers actually behave in Next.js 16: GET is no longer cached by default, params are async, and most of what you need is a server action instead. With a real deployment that is not on Vercel.
By NoWaterProgramming Team
13 min read
The App Router explained through a site that actually runs on it: server components, async request APIs, generateStaticParams, and the three things that break when you deploy somewhere other than Vercel.
By NoWaterProgramming Team