TLDR:
serverExternalPackagesis 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.