TLDR: Next.js's file tracer can't follow dynamic import specifiers — so native packages that resolve their binary at runtime get silently excluded from your Vercel function. Two config keys fix it. They solve different problems. Get them both.

What We Were Building

An internal SOP documentation site — a Next.js 16 App Router site that renders a React Flow (visual canvas library) map of flat-file SOPs, with a bottom-drawer RAG chat (Retrieval-Augmented Generation — an AI that answers questions grounded in your own docs) backed by sqlite-vec (a SQLite extension for vector search) and better-sqlite3 (native Node SQLite bindings).

Frozen read-only on Vercel. One password gate. Dead simple in theory.

The Wall

Deployed to Vercel. The /api/chat function threw Cannot find package 'sqlite-vec-linux-x64'.

My first reaction: how? The package was in node_modules. It installed fine locally. It was in the lockfile. Vercel's build logs showed it installing.

And yet — the deployed function couldn't find it.

What I Tried First (Wrong)

I assumed it was a lockfile / platform mismatch thing. Spent time verifying the linux-x64 variant was getting pulled in for Vercel's build environment.

It was. That wasn't the problem.

What Was Actually Happening

Here's the thing about sqlite-vec that I missed completely.

It resolves its native binary at runtime, using a dynamic import specifier:

const platformPkg = `sqlite-vec-${process.platform}-${process.arch}`;
await import(platformPkg);

Next's file tracer follows static import / require calls. It sees sqlite-vec/index.mjs fine — but when that file turns around and does a dynamic import with a runtime-computed string, the tracer hits a dead end. No static reference. It silently excludes the whole package from the function archive.

Silently. No warning. No build error. Just a Cannot find package at runtime that feels totally insane because the package IS there at build time.

The Fix That Actually Worked

Two separate config keys in next.config.ts. They solve different problems — this is the part where it's easy to get confused.

serverExternalPackages tells Next: don't try to bundle these at all — load them as real Node modules at runtime.

Native packages with .node addons or .dylib extensions can't be webpack-bundled anyway. This is the "mark native modules external" step the title promises.

outputFileTracingIncludes tells Next's file tracer: I know you can't see these — trust me and copy them into the function archive anyway.

// next.config.ts
serverExternalPackages: ['better-sqlite3', 'sqlite-vec'],

outputFileTracingIncludes: {
  '/api/chat': [
    './data/rag.db',
    './node_modules/sqlite-vec-linux-x64/**',
  ],
},

Both lines. Both required. One without the other left me with a different broken state each time.

And Then One More Wall…

After the bundling was sorted, better-sqlite3 threw SQLITE_CANTOPEN even though existsSync(DB_PATH) returned true. Turns out Vercel's traced /var/task filesystem layer doesn't satisfy whatever SQLite needs for a raw open() call.

Fix: copy data/rag.db to /tmp on cold start (writable, persists for the warm-function lifetime), open from there. Cold-start cost was ~100ms for a 22 MB DB. Warm requests skip the copy entirely.

That one surprised me the most — the file exists, the path is correct, and sqlite still can't open it. But that's a story for another post.

Why This Matters to Me

This whole pattern generalizes. Any package that uses runtime-computed import specifiers is invisible to static bundle analysis — @swc/core, lightningcss, anything in the sqlite-* family. The bundler can't help you there. You have to name them by hand.

The lesson I'm carrying forward into every RAG app and every native-module deploy: "it's in node_modules" and "it'll be in the function" are two completely different facts. Know which config key closes which gap.

P.S. The commit message for this one was mark native modules external — which sounded like one thing and turned out to be four distinct sequential failures. I love when the fix is three lines and the understanding takes an afternoon.