TLDR: serverExternalPackages is step one. There are three more after it.

The Setup

I was building a RAG docs chat interface — a RAG (retrieval-augmented generation, a pattern for letting AI answer questions from a document corpus) chat interface on top of 25 Loom walkthrough transcripts.

Stack: Next.js 16, better-sqlite3 (a native Node binding for SQLite), and sqlite-vec (a SQLite extension for vector search). Works in seconds locally.

Then I tried to deploy it to Vercel.

Four Walls, Four Deploys

I honestly expected one error. I got four. And the brutal part — each one hid the next.

Wall 1: native addons get destroyed by bundling.

better-sqlite3 loads a .node binary addon. When Next.js bundles your serverless function, it inlines everything. That breaks native modules completely.

Fix: tell Next to leave them alone and load from node_modules at runtime.

serverExternalPackages: ['better-sqlite3', 'sqlite-vec']

Shipped. Thought I was done.

Wall 2: wrong platform binary.

sqlite-vec ships as platform-specific sub-packages. My lockfile had the darwin-arm64 variant (my MacBook). Vercel runs linux-x64. The right package wasn't even installed in CI.

Fix: explicitly pin the linux binary.

npm install sqlite-vec-linux-x64 --save

Deployed again.

Wall 3: the tracer can't follow dynamic imports. (This one is WILD.)

The package was in node_modules. Vercel logs still said Cannot find package 'sqlite-vec-linux-x64'.

How?

sqlite-vec resolves its platform sub-package at runtime using a computed string:

await import(`sqlite-vec-${process.platform}-${process.arch}`)

Next's file tracer follows static imports. It sees no static reference, silently excludes the package from the function archive. It's present at build time. Gone by runtime.

Fix: force the tracer to include it explicitly — and bundle rag.db the same way, since fs.readFileSync is invisible to the tracer too.

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

Wall 4: the filesystem is read-only.

Vercel mounts your function at /var/task — read-only. better-sqlite3 tries to open rag.db there and throws.

Fix: copy it to /tmp on cold start, where you actually have write access.

const DB_PATH = '/tmp/rag.db'
if (!fs.existsSync(DB_PATH)) {
  fs.copyFileSync(path.join(process.cwd(), 'data/rag.db'), DB_PATH)
}

Why This Matters

Native modules don't fail once in serverless. They fail in layers. Each layer conceals the next until you push a fix.

The sequence on this RAG chat interface: bundle strategy → wrong platform binary → dynamic import the tracer can't follow → read-only filesystem. Four distinct deploys. Four distinct error messages. Zero indication of what was coming next.

If you're shipping anything with native bindings to Vercel, treat these four as a checklist you run top-to-bottom before your first deploy — not four deploys after it.