There is a class of bug that only exists in production, produces no build error, and is impossible to reproduce locally no matter how carefully you run the same command. This is one of them, and the cause is a single sentence: Next.js works out what to deploy by following your imports.
CogniPrep has a dynamic API route that serves content data. The data is a directory of JSON files, about 3.9MB across 102 of them, and the route reads one file per request with fs. It does not import them.
In next dev this is perfect. Every file in the repository is on disk, fs finds whatever it is pointed at, every request works.
In production every request 404s.
Why the files were not there
When Next.js builds a route for a serverless target, it does not ship your repository. It runs a dependency trace from the route's entry point, follows the import and require graph, and copies exactly the files that graph reaches into the function bundle. Anything the trace cannot see does not exist at runtime.
A string passed to fs.readFile is not part of an import graph. No bundler can know that a path built at runtime from a request parameter will resolve to a real file, let alone which of 102 it will be. So the route deployed with its code and none of its data, and the first request in production got an ENOENT that the handler correctly turned into a 404.
The reason to read from disk rather than import in the first place is cold starts. Importing a directory of JSON means the bundler inlines all 3.9MB into the route's JavaScript, and every cold invocation pays to parse all of it in order to answer a request that needs one file. Reading one file per request keeps the function small and the work proportional.
The fix is one key, and it should be a glob
// next.config.mjs
outputFileTracingIncludes: {
'/api/content/[id]': ['./lib/content/data/**/*.json'],
},
Three details in that, each of which cost me a try:
The key is a route, not a file path. It is the route as Next names it internally, dynamic segment in brackets and all. Getting it wrong is silent: you get no warning that the key matched nothing, and the deploy still 404s.
The glob is relative to the project root, not to the config file or the route.
It has to be a glob, not a list. This is the part I would argue for even where a list would work today. A new data file that lands next week is included the day it is written, with no config change, and nobody has to know this trap exists to add one. Config that needs updating whenever content is added is config that will be out of date the first time somebody who did not write it adds content.
Check it without deploying
This is the part I wish I had known first, because the loop of "deploy, test, guess" is miserable.
next build writes a trace manifest next to every compiled route:
.next/server/app/api/content/[id]/route.js.nft.json
It is JSON, with a files array listing every path the tracer decided the route needs. For our route:
total traced files: 259
of those, data JSON: 102
102 is the whole directory, so the include worked. Without that config key the count is 0, and the other 259 entries, the OpenTelemetry pieces and the rest of the runtime, looked exactly the same. The manifest is where a deploy-time problem becomes a build-time check: grep it for one file you expect, and you know before you push.
It is also a good way to find the opposite problem. If a route's trace is enormous, something in its import graph is dragging a dependency you did not mean to ship, and outputFileTracingExcludes is the matching key.
The rule this generalises to
Static analysis sees imports. It does not see:
-
fs.readFilewith a computed path - dynamic
require()where the specifier is a variable - a template, locale file or dataset resolved from a request parameter
- anything fetched by a worker or a child process that the bundler never visited
Every one of these works in development, because development is your whole repository sitting on a disk. Every one of these is a production-only failure, because production is a tarball someone else decided the contents of.
So when a route reads a file at runtime, the include belongs in the same commit as the read. Not after the first 404.
See it
- cogniprep.app/games lists the practice tests. Sign in, start one, and keep the Network tab open: the content for that test arrives in a single request after the page, rather than being baked into the page bundle. That request is the route this whole post is about, and the file it reads was invisible to the build until the config key above existed.
- On your own project: run
next build, then open any.nft.jsonunder.next/server/app/and read thefilesarray. It is the most direct answer available to "what actually gets deployed", and most people never look at it.
Top comments (0)