Vercel /api functions return 404 under vite dev
You added api/create-checkout-session.ts, deployed it, and it works on Vercel. Locally:
POST http://localhost:5173/api/create-checkout-session 404 (Not Found)
vite dev is a static dev server plus HMR — it has no idea what a serverless function is. Your options are vercel dev, or a small shim that runs the same handlers in plain Node and proxying /api to it.
Option A: vercel dev
npm i -g vercel
vercel link
vercel dev
Works, but it wants a linked project and a login, and it can be slow to boot. If you only need two endpoints, Option B is faster.
Option B: a 100-line Node shim + Vite proxy
The shim mimics what @vercel/node gives a handler: req.body, req.rawBody, and a chainable res.status().json().
// scripts/dev-api.ts
import http from 'node:http'
import { fileURLToPath } from 'node:url'
// handlers read env at import time — load .env first
process.loadEnvFile(fileURLToPath(new URL('../.env', import.meta.url)))
const PORT = 5175
const routes: Record<string, () => Promise<{ default: (req: any, res: any) => unknown }>> = {
'/api/create-checkout-session': () => import('../api/create-checkout-session.ts'),
'/api/webhook': () => import('../api/webhook.ts'),
}
function readRawBody(req: http.IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = []
req.on('data', (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c)))
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
req.on('error', reject)
})
}
const server = http.createServer(async (nodeReq, nodeRes) => {
const pathname = new URL(nodeReq.url ?? '/', 'http://localhost').pathname
const loader = routes[pathname]
if (!loader) { nodeRes.writeHead(404); nodeRes.end('no dev route'); return }
const rawBody = await readRawBody(nodeReq)
const req: any = {
method: nodeReq.method,
url: nodeReq.url,
headers: nodeReq.headers,
rawBody, // Stripe signature needs this
body: nodeReq.headers['content-type']?.includes('application/json')
? JSON.parse(rawBody)
: undefined,
}
const mod = await loader()
await mod.default(req, nodeRes)
})
server.listen(PORT, () => console.log(`dev-api on http://localhost:${PORT}`))
Then point Vite at it:
// vite.config.ts
server: {
proxy: {
'/api': { target: 'http://localhost:5175', changeOrigin: true },
},
},
Run both terminals:
npm run dev # Vite on 5173
npm run dev:api # handlers on 5175
Two details that bite people
1. Env vars are read at import time
If a handler reads process.env.STRIPE_SECRET_KEY at module load, loading .env after the import gives you undefined. Call process.loadEnvFile() before importing handlers (the shim above does).
2. The webhook needs the raw body
A shim that only passes a parsed object breaks Stripe signature verification. Keep rawBody on the request — the webhook guide explains why.
Free: the Supabase + Stripe launch checklist — all 23 checks in one page, no email required to read the guides.
Get the free checklistAlready wired in the starter
npm run dev:api, the Vite proxy and the raw-body shim ship together, so checkout works locally without vercel dev.