Summary
I added one tool, which AI agents call from outside, to a service I develop at my own company. When this tool is called, one page selected from a PDF document stored in the service is converted into a JPEG image and included in the response as an image. Before deploying, the AI in charge of development had passed the type check and every unit test. It had also called the tool’s function directly from a script and visually checked the generated image. Even so, starting right after the deployment to the verification environment, the response contained no image, no matter how many times the tool was called.
The cause lay in the production build of Next.js 16. In Next.js 16, the default bundler is Turbopack. The require.resolve function returns the location of a file in a package. In the Turbopack build, this call had been resolved at build time and replaced in the build output with an integer module ID. That integer was passed to path.dirname, and a TypeError occurred. Because type checking, unit tests, and direct calls do not pass through this build step, none of the three can detect this replacement.
The AI resolved this defect by fixing one place. The fix changes the base for resolving the package location to the process’s working directory. This fix works with Turbopack but not with webpack. In addition, the minimal reproduction built for this article showed that the diagnosis at the time was off in one place. The records at the time explained that import.meta.url had been replaced with a number. In the reproduction, however, import.meta.url remained a string. The value that became an integer was the return value of resolve. After the failure, the development side recorded, as a lesson, a check that starts the same build as production and calls it from outside. Ten days later, the development side also added the same procedure to the service’s operations document.
In the body, I first show how the replacement works using values from the reproduction, and then compare the results across four execution methods. After that, I compare two ways to fix the code along with the conditions under which each works, and summarize the procedure for checking before deployment. Finally, I explain the checking step the AI had not run, the failure right after deployment, and the discrepancy in the diagnosis at the time. I confirmed the behavior on 2026-09-25 with Next.js 16.3.6 and 16.2.2 on Node 25.7.0.
What you can take away
For those whose Next.js server code reads files inside a package at runtime, I explain the following three things.
- You will understand why type checking, unit tests, and direct calls cannot detect a value being replaced by the bundler, and you will be able to check whether the same replacement happens in your own environment
- You will be able to choose a fix for code that resolves a package location at runtime, either using the working directory as the base or adding a turbopackIgnore annotation, knowing the conditions under which each one works
- You will be able to incorporate into your pre-deployment steps a check that starts the same build as production and calls it from outside
How Turbopack replaces a require.resolve call with an integer
In this section, I explain how the values in code that resolves a package location change in the production build of Next.js 16. According to the Turbopack API reference in the official Next.js documentation, next build in Next.js 16 uses Turbopack by default. It uses webpack only when –webpack is passed. The same page also states that type checking is not part of Turbopack’s processing.
For the reproduction, I prepared the following single function and called it from a Next.js route handler. Inside this function, createRequire creates a require function from each of two bases. Then the resolve of each one resolves the location of next/package.json. The viaImportMeta form uses import.meta.url, the URL of the module itself, as the base. The viaCwd form uses the path of a hypothetical file under the process’s working directory as the base.
import path from 'node:path';
import { createRequire } from 'node:module';
export function describeBase() {
const out: Record<string, unknown> = { importMetaUrl: import.meta.url, cwd: process.cwd() };
try {
const req = createRequire(import.meta.url);
out.viaImportMeta = path.dirname(req.resolve('next/package.json'));
} catch (e) { out.viaImportMetaError = String(e); }
try {
const req = createRequire(path.join(process.cwd(), 'index.js'));
out.viaCwd = path.dirname(req.resolve('next/package.json'));
} catch (e) { out.viaCwdError = String(e); }
return out;
}
I built the project with the default bundler of Next.js 16.3.6, started the build output, and called this function. The viaImportMeta form failed with a Node TypeError. The error code was ERR_INVALID_ARG_TYPE, and the message said that the argument named path had received a number instead of a string. The parentheses at the end of the message contained 39897. The viaCwd form returned a path under a node_modules directory that actually exists. The result was the same with Next.js 16.2.2.
Reading the code in the build output showed where the replacement had happened. The call resolve(‘next/package.json’) was gone from the build output. In its place, the integer 39897 was written directly, and the expression had become path.dirname(39897). The number 39897 is the module ID of next/package.json, which was pulled into the build output, and it is a number assigned in the Turbopack build.
On the other hand, import.meta.url had not become a number. In the Turbopack build, import.meta.url had been replaced with an expression that calls a function at runtime. Inside that function, a file URL is built from the root path of the deployment environment, so the value stayed a string. The resolve in viaCwd also remained in the build output as a runtime call.
The function in which the exception occurred is path.dirname. In Node 25.7.0, passing a number to path.dirname results in an ERR_INVALID_ARG_TYPE exception. Passing a number to createRequire results in a different exception, ERR_INVALID_ARG_VALUE. Its message says that the filename argument is invalid.
The number in the parentheses is a module ID. According to the table of settings in the Turbopack API reference of the official Next.js documentation, the default value of turbopackModuleIds in production builds is deterministic, that is, module IDs are assigned deterministically. In the reproduction with 16.2.2, I changed the target of resolve to a different public package. That package is listed in the setting that keeps packages out of the app bundle. Even so, the replacement did not stop, and the number in the parentheses matched the number in the service’s logs. I have not checked whether the value is the same when the project is structured differently. Both 39897 and the 5607 from the webpack build are values that may change if you reproduce this with another version.
The type of resolve is declared as returning a string. The replacement with a number happens only in the build output after the build has finished. That is why type checking cannot detect this failure. In the reproduction as well, the TypeScript check included in the next build step succeeded, and then the code failed at runtime.
Resolving the resolve call at build time is legitimate static analysis for pulling dependent files into the build output. The cause of the defect is that the service’s implementation had been written without taking this static analysis into account.
Comparing the results of resolve by execution method
The following table summarizes the results of calling the same function with four execution methods. In the Bundle column, I wrote whether the app’s code goes through the step that combines it into a single build output.
| Execution method | Bundle | Value of import.meta.url | viaImportMeta | viaCwd |
|---|---|---|---|---|
| Run the source directly with tsx | No | The source file’s file URL | Existing path | Existing path |
| Run inside a Vitest 5.0.1 test | No | The source file’s file URL | Existing path, test passed | Existing path |
| next build, Turbopack, 16.3.6 and 16.2.2 | Yes | A string assembled at runtime | TypeError, the number 39897 in 16.3.6 | Existing path |
| next build –webpack, 16.3.6 | Yes | A file URL pointing to an absolute path on the machine that did the build | TypeError, the number 5607 | Warning at build time, TypeError at runtime |
With tsx and Vitest, the code does not go through the bundling step, so the resolve calls are evaluated at runtime just as written in the source. That is why both forms succeed. According to the official tsx documentation, tsx transforms TypeScript and ESM with esbuild and does not check types. It says nothing about bundling. According to the Common Errors page of Vitest, source files are run with Vite’s module runner by default. According to the deps page in the Vitest configuration reference, in version 5.0.1, externalized modules in node_modules are loaded by Node itself by default. Neither page describes a step that combines the app’s code into a single build output.
With webpack too, the viaImportMeta form failed with a number. So the replacement of the resolve call is not behavior unique to Turbopack. In the webpack build, the absolute path of the source file on the machine that did the build was written directly into the build output as a file URL.
The Next.js 16.3 Turbopack announcement from 2026-06-29 lists two compatibility improvements. They are improved handling of createRequire combined with new URL, and a fix for import.meta.url on Windows. Even so, the replacement of the resolve call remained in 16.3.6. The behavior described in this article covers only what was confirmed with the versions stated.
Comparing a fix that uses the working directory as the base with a fix that uses the turbopackIgnore annotation
In this section, I compare two ways to fix code that resolves a package location at runtime. One is the fix adopted in the service, which uses the working directory as the base. The other is a fix that uses an annotation described in the Turbopack API reference in the official Next.js documentation.
According to the table of annotations on the same page, the webpack-compatible annotations can also be used on require.resolve() expressions. The applicable expressions are these four: dynamic import(), require(), require.resolve(), and new Worker(). The table explains that turbopackIgnore: true is interpreted only by Turbopack and that the call is excluded from bundling. It states that webpackIgnore: true is interpreted by both webpack and Turbopack. In the reproduction, with Turbopack in 16.3.6, I placed /* turbopackIgnore: true */ right before the argument passed to resolve in viaImportMeta, the form that uses import.meta.url as the base. Then an existing path came back, even with import.meta.url still as the base.
The following table summarizes the results and conditions of the two fixes.
| Fix | Turbopack 16.3.6 | webpack 16.3.6 | Conditions for it to work | Not measured |
|---|---|---|---|---|
| Use the working directory as the base, the viaCwd form | Existing path | Warning at build time, TypeError at runtime | There is a node_modules under the working directory at runtime. The resolve call remains without being resolved at build time | Distributing it as Next.js standalone output |
| Keep the viaImportMeta form and put the turbopackIgnore annotation right before the argument to resolve | Existing path | Not measured | The annotation is interpreted by Turbopack | Behavior under webpack, behavior in 16.2.2 |
The fix that uses the working directory as the base depends on that form not being resolved statically by the bundler. With webpack, a build of the same form produced the warning “module.createRequire failed parsing argument.” At runtime, it failed with a TypeError from trying to read resolve of undefined. I have not measured whether this fix works when distributed as Next.js standalone output. The service’s configuration does not specify standalone.
The fix that uses the annotation lets you keep the form that uses import.meta.url as the base. In exchange, whether this fix works depends on whether the annotation is interpreted by Turbopack. Which fix to adopt depends on how the code is distributed and which bundler is used. As of 2026-09-25, the main branch of the service is still written with the working directory as the base.
Steps for a check that starts the same build as production and calls it from outside
In this section, I describe the procedure for checking before deployment. The development side wrote this procedure in a record in response to the failure right after deployment, which I explain in the last section. On the same day as the failure, the development side wrote the following practice as a lesson in a repository for personal memory and the history of rule changes. The record covers two kinds of changes. One is adding a new tool. The other is adding processing that reads files at runtime, where what is read is a data file shipped with a package or a native module. For these changes, just calling the function directly does not count as having checked it. The practice is to create the same build as production locally, start it on a separate port, call it from outside, and go as far as checking the response before deploying.
The same record also includes two cautions. One is that defects caused by the bundler do not reproduce in unit tests. The other is that when developers write code that computes a path at runtime, they should suspect that it can break after it goes through bundling. The record frames this practice as the same idea as a rule that has been in the service’s rule documents since before this failure. That rule is that even if a server is running, it is not necessarily running the latest code. Ten days later, the development side also added the same procedure to the service’s operations document. It is a procedure that starts the same build output as the deployment with next build and next start, and then calls the tool.
Below, I summarize the procedure before deployment by adding the checks the AI actually ran after the fix to the steps in the record and the operations document. Items 1 and 2 are the steps in the record and the operations document, and items 3 and 4 are the AI’s checks.
- Create the build output in the local repository with next build, the same command as in production
- Start the created output with next start on a separate port that is free
- From outside, call over HTTP the endpoint for AI agents on the same route the callers use, and check the response down to its contents
- Also check that passing input that does not exist returns a response that reports an error
What this procedure checks is the bundling step, which direct function calls and unit tests do not go through.
What the AI checked before deploying and the step it had not run
In this section, I explain what the AI in charge of development checked when the tool was added to the service. When the feature was added, the AI ran the following three checks before deploying.
- Passed lint, the type check, and every unit test
- Called the tool’s function directly from a tsx script, had it create an image from real data, and looked at that image
- Checked the list of tools and the shape of the responses through a relay that connects to the AI client over standard input and output
The AI put off a fourth check, having a model read the image, until after deployment.
The rendering unit test had not replaced the PDF rendering library with a fake. This test passes a small PDF built by hand to the real rendering library. A function written the same way as viaImportMeta, which uses import.meta.url as the base, was also actually run inside the test and succeeded. The only tests that swapped in a fake were the tests for the part that assembles the tool’s response. As the table in the section comparing execution methods shows, the app’s code is not bundled in Vitest tests. So even in a test that went through the real library, the resolve call was not replaced, and the failure did not reproduce.
The AI had also paid attention to the shape of the build output it would deploy. In the same commit, the AI added two items to the Next.js configuration. One is a setting that keeps the rendering library out of the app bundle and has it loaded from node_modules. The other is a file tracing setting. With this setting, the AI included in the build output the data files shipped with the rendering library, such as fonts, and the native binaries. However, as shown in the section on how the replacement works, the resolve call is replaced with an integer even for a package listed in the setting that keeps it out of the bundle.
The check the AI had never run was to start the next build output and call the tool from outside it. The AI visually checked the image generated by calling the tool’s function directly. However, that check does not go through the bundling step, so the AI had not verified how the build output it would deploy behaves. In the body of the fix PR it created later, the AI wrote this missing check down as one of the causes. A failure in which the range verified by tests did not match the range of code that actually runs was also covered in an earlier article, About 1,300 tests were passing, and the screen still said saved when the save had failed.
The failure right after deployment and the discrepancy in the diagnosis at the time
In this section, I explain in chronological order what happened after the deployment on the day the feature was added. Right after merging the PR that added the feature, the AI deployed to the verification environment and called the tool. The response was text saying that the PDF page could not be turned into an image, and it was the same no matter how many times the tool was called. The server logs recorded a TypeError with ERR_INVALID_ARG_TYPE and the same message as in the reproduction. The parentheses at the end contained a five-digit number.
The AI changed one place. It moved the code that resolves where the shipped data files are located from the viaImportMeta form, which uses import.meta.url as the base, to the viaCwd form, which uses the working directory as the base. The AI gave the following three reasons.
- In the deployment environment too, node_modules is under the process’s working directory
- Other data in the same repository is already loaded with the working directory as the base
- The value of the working directory is not subject to Turbopack’s replacement
After the fix, the AI ran next build in the local repository where it had made the fix, and started the output with next start on a free port. It then sent an HTTP request to the endpoint for AI agents and called the tool. The response contained two things, text and an image, and the image was a JPEG in which the text on the page could be read. The AI also confirmed that passing a page number that does not exist returned a response reporting an error. After merging the fix PR and deploying again, the AI sent the same call to the same endpoint in the deployment environment. The AI confirmed that an image came back and left the result in a comment on the PR.
The diagnosis at the time was off in one place. The fix commit and the records from that time explain the cause this way: import.meta.url was replaced with a numeric ID, and that number was passed to createRequire and caused an exception. In the reproduction built for this article, import.meta.url remained a string. The value that became a number was the return value of resolve, and the exception occurred in path.dirname. The error code in the logs also matches path.dirname’s ERR_INVALID_ARG_TYPE, not createRequire’s ERR_INVALID_ARG_VALUE. The resolve in viaCwd stays a runtime call even in the build output. That is why the fix resolved the defect even though the diagnosis was off in one place.
Materials I referred to
Here I list again the materials I referred to at various points in the body.
- The Turbopack API reference in the official Next.js documentation, updated 2026-08-03. I confirmed the default bundler, that it does not do type checking, the default value of turbopackModuleIds, and the table of supported annotations https://nextjs.org/docs/app/api-reference/turbopack
- The Next.js 16.3 Turbopack announcement, 2026-06-29. I confirmed the compatibility improvements related to createRequire and import.meta.url https://nextjs.org/blog/next-16-3-turbopack
- The TypeScript page of the official tsx documentation. I confirmed that tsx transforms code with esbuild and does not check types https://tsx.hirok.io/typescript
- Common Errors in the official Vitest documentation. I confirmed that source files are run with Vite’s module runner https://vitest.dev/guide/common-errors
- The deps configuration in the official Vitest documentation, version 5.0.1. I confirmed that externalized modules are loaded by Node itself https://vitest.dev/config/deps