← All posts
Build Notes

Three small Vercel mistakes that cost me a weekend

Standing up a clients site sounded simple: a static site, a contact form, and a small serverless function to send the emails. I had built plenty of APIs before, so I didn't expect to lose a weekend to configuration details. But three separate mistakes, each small on its own, added up to a lot of quiet debugging. Writing them down here mostly so future-me doesn't repeat them.

1. The serverless folder has to be named exactly api/

Vercel auto-detects serverless functions by folder convention, not by config. I had my contact-form handler sitting in a folder called functions/, which is a perfectly reasonable name and one that works fine in plenty of other environments. On Vercel it does nothing. No error, no warning, the deploy just succeeds and the endpoint quietly doesn't exist.

The fix was almost embarrassingly small:

project/
  api/
    contact.js
  index.html
  style.css

Anything under api/ becomes a route automatically, so api/contact.js becomes /api/contact. No routing config needed, which is nice once you know it, but not something I'd have guessed from the docs alone. I spent a good hour checking my fetch call and CORS headers before I even considered the folder name was the problem.

2. "type": "module" in package.json breaks CommonJS functions

The contact form calls the Resend API to send the email. I wrote the handler the way I write most small Node scripts now, using import and export. It worked locally. On Vercel it failed with an error that didn't obviously point at the real cause.

My package.json had:

{
  "type": "module"
}

which tells Node to treat every .js file in the project as an ES module. That's fine until you mix in a function written with CommonJS syntax, or a dependency that expects require(). Vercel's function runtime didn't like the mismatch. I had two options: rename every function file to .mjs and keep ESM everywhere, or drop the "type": "module" line and write the functions in plain CommonJS:

// api/contact.js
const { Resend } = require('resend');

module.exports = async (req, res) => {
  const resend = new Resend(process.env.RESEND_API_KEY);
  // ...
};

I went with the second option since the rest of the project didn't depend on ESM-only packages. The lesson isn't "avoid ESM," it's that the module type is a project-wide switch, and a serverless function folder inherits it whether you meant it to or not.

3. SPF records don't stack, they merge

This one had nothing to do with code. Once the site was live, I set up Zoho Mail for the domain so I could send from an actual domain address instead of a personal Gmail. Zoho asks you to add an SPF TXT record. I already had one from a previous integration, so I added a second SPF TXT record alongside it, assuming DNS would treat them as additive.

It doesn't. A domain is only supposed to have one SPF record. Two records makes the SPF check ambiguous, and mail providers are within their rights to treat that as a fail, which is exactly what started happening to outgoing mail. The fix was to combine both into a single TXT record with both providers listed:

v=spf1 include:zoho.com include:_spf.resend.com ~all

One record, every sending source listed with include:, one ~all at the end. Obvious in hindsight, but SPF failures don't always announce themselves loudly. Some mail just gets quietly filtered, which makes this the kind of bug you only notice once someone says "I never got your email."

What actually helped

None of these three issues were hard to fix once identified. What made them expensive was that each one failed silently or with a misleading error, so the actual debugging time went into ruling out the wrong causes first. If I were doing this again, I'd check the boring infrastructure assumptions before touching application code: folder names, project-wide config flags, and DNS records that are supposed to be singular. It's rarely the interesting bug, but it's often the actual one.

← All posts