TLDR: when your Vercel function returns a blank 500, the first fix is rarely the bug — it's making the function tell you what went wrong. Surface the error, then debug one layer at a time.
What We Were Building
I was putting a RAG chat (AI search over a vector database) on top of my internal docs site for our Make.com automation blueprints.
The idea: click any node in the blueprint map, ask a question, get an answer grounded in the real Loom walkthroughs.
Stack: Next.js on Vercel, better-sqlite3 for the database, sqlite-vec (a vector-search extension for SQLite) for the embeddings. Built and tested locally. Worked great.
Deployed it. Total silence. Every /api/chat request returned a 500 with zero explanation.
The Wall
No stack trace in the browser. Nothing in the Vercel dashboard "Functions" tab. Just a blank error response.
First instinct: Google the error. Except there was no error. That's the trap.
(And here's a thing I didn't know: vercel logs <deployment-url> streams live runtime logs — but it shows nothing if no requests are actively hitting the function. And vercel inspect --logs only returns BUILD logs, not runtime. So you're not just blind, you're confidently reading the wrong source.)
The Real First Fix
I stopped trying to guess the bug.
Instead I made the function scream when it died — wrap every failure path in an explicit catch, return a 500 with { error: e.message, stack: e.stack } in the body, log it to console.error. Then hit the endpoint with curl while vercel logs was streaming in another terminal.
That single commit (fix(api): make /api/chat survive Vercel's read-only filesystem + surface 500 causes) was more valuable than any actual bug fix I shipped that day.
The Layers (One at a Time)
Error 1 surfaced: module not found — sqlite-vec-linux-x64.
The native binary wasn't making it into the Vercel bundle. Fixed: pin the linux-x64 build in package.json, tell the bundler to trace it in. Done.
Error 2 surfaced: SQLITE_CANTOPEN.
And this one hurt. existsSync(DB_PATH) returned true. The file was RIGHT THERE. And better-sqlite3 still refused to open it.
Turns out Vercel mounts your bundle artifacts at /var/task in a traced overlay filesystem. That overlay satisfies existsSync. It does not satisfy whatever open() syscall SQLite needs underneath. The fix is almost offensively simple:
const TMP_DB = '/tmp/rag.db';
if (!existsSync(TMP_DB)) {
copyFileSync(DB_PATH, TMP_DB);
}
const db = new Database(TMP_DB, { readonly: true });
Copy the file to /tmp on cold start. That's it. /tmp is writable, it persists for the warm-function lifetime (~5–15 min idle), and warm requests skip the copy entirely. Cost: ~50–200ms for my 22MB DB.
Why This Matters to Me
I used to treat opaque 500s as puzzles to brute-force — swap dependencies, redeploy, stare at dashboards.
Now my first commit is always the same: make the error tell me what it is. Surface the cause, get it into the response body, stream the logs while you trigger it. Then the layers reveal themselves, one at a time, in the right order.
You can't fix what you can't see. Everything else follows from that.