Vercel /api functions return 404 under vite dev

Updated Oct 2026 · 5 min read

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 checklist

Already 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.

Get the starter — $29 Try the live demo