Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild

Handling POST Requests in Vercel Serverless Functions

Sean

Platform Writer

Aug 20, 2026
8 min read

Export a function named for the HTTP method and Vercel routes to it — export function POST(request) handles POST and nothing else. The 405 you are seeing usually means the request arrived as something other than the method you exported.

Handling POST Requests in Vercel Serverless Functions

The modern function signature is a plain Web Request in, a Web Response out. That is a genuinely nice API, and it is different enough from the older Node-style handler that half the examples you find online will not match your project. Knowing which model you are on is the first step.

Table of contents

The two handler shapes

Current Vercel functions use the Web standard. Named exports map to HTTP methods:

// api/contact.js
export async function POST(request) {
  const body = await request.json();

  return Response.json({ received: body.email }, { status: 201 });
}

export async function GET() {
  return new Response('Use POST', { status: 405 });
}

The older Node-style handler takes a request and response pair and does its own method routing:

// api/contact.js -- legacy shape
export default function handler(req, res) {
  if (req.method !== 'POST') {
    res.setHeader('Allow', 'POST');
    return res.status(405).json({ error: 'Method not allowed' });
  }

  const { email } = req.body;
  res.status(201).json({ received: email });
}

One difference bites immediately. In the legacy shape req.body is already parsed for you. In the Web-standard shape it is a stream you must await — await request.json() — and forgetting the await gives you a Promise where you expected an object, which fails somewhere confusing rather than at the point of the mistake.

The other difference: with named method exports, a request using a method you did not export gets an automatic 405. That is usually helpful and occasionally the reason for a 405 you cannot explain.

Why your POST arrives as a GET, or an OPTIONS

Two mechanisms turn a POST into something else in transit, and both look like your handler is broken.

The redirect. A 301 or 302 from a URL redirect — trailing slash normalisation, a www-to-apex rule — causes most clients to re-issue the request as a GET, dropping the body. Your handler exists, the request reaches the right file, and it arrives as the wrong method with nothing in it. Post to the exact final URL rather than one that redirects.

The preflight. Any cross-origin POST with a JSON content type triggers an OPTIONS request first. If nothing answers that OPTIONS, the browser never sends the POST at all, and devtools shows a CORS error rather than anything about methods.

// api/contact.js
const CORS = {
  'Access-Control-Allow-Origin': 'https://example.com',
  'Access-Control-Allow-Methods': 'POST, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type, Authorization',
  'Access-Control-Max-Age': '86400'
};

export async function OPTIONS() {
  return new Response(null, { status: 204, headers: CORS });
}

export async function POST(request) {
  const body = await request.json();
  return Response.json({ ok: true }, { headers: CORS });
}

Two details are load-bearing. The CORS headers must be on the actual POST response too, not only on the preflight — a preflight that passes followed by a response without the headers still fails. And a wildcard origin is incompatible with credentialed requests; if you send cookies, you must name the origin explicitly.

The Max-Age header is worth setting. Without it, browsers re-preflight frequently, doubling your request count for no benefit.

The best fix for CORS, where you can take it, is not to need it: serve the API under the same origin as the site, on a path like /api/*. Same-origin requests skip preflight entirely.

Content types, and the bodies that are not JSON

await request.json() throws on anything that is not valid JSON, including an empty body. An unhandled throw in a serverless function is a 500, so a client sending a slightly wrong content type gets a server error instead of a useful message.

export async function POST(request) {
  const type = request.headers.get('content-type') || '';
  let data;

  try {
    if (type.includes('application/json')) {
      data = await request.json();
    } else if (type.includes('application/x-www-form-urlencoded') ||
               type.includes('multipart/form-data')) {
      data = Object.fromEntries(await request.formData());
    } else {
      return Response.json({ error: 'Unsupported content type' }, { status: 415 });
    }
  } catch {
    return Response.json({ error: 'Malformed request body' }, { status: 400 });
  }

  if (!data.email || !data.message) {
    return Response.json({ error: 'email and message are required' }, { status: 400 });
  }

  return Response.json({ ok: true }, { status: 201 });
}

The formData() branch matters for a plain HTML form posting without JavaScript, which is a perfectly good thing to support and sends application/x-www-form-urlencoded.

One case needs the raw body rather than the parsed one: webhook signature verification. Signatures are computed over the exact bytes, so parsing and re-serialising will not match. Read await request.text(), verify against that, and parse afterwards.

The constraints that shape what you can put in a POST handler

Everything above is syntax. These are the ones that decide whether the function is the right place for the work at all.

  • Execution time is capped, and the ceiling depends on your plan. A POST that generates a report, transcodes a file, or calls a slow third party will hit it. Accept the request, queue the work, return 202, and let something else do the job.
  • No shared state between invocations. In-memory rate limiting counts nothing, because the next request may run in a different instance. Rate limiting needs an external store.
  • Database connections do not pool across invocations. Each concurrent invocation opens its own, which is how a modest traffic spike exhausts a Postgres connection limit. Use a pooler.
  • The response body is size-limited. Large payloads need object storage and a signed URL rather than an inline response.
  • Cold starts add latency to the first request after idle — usually fine, occasionally not, and hardest to predict on spiky traffic.

The queue pattern is worth internalising, because it is the answer to the first constraint and it generalises:

export async function POST(request) {
  const job = await request.json();

  const id = await queue.enqueue('generate-report', job);

  return Response.json(
    { jobId: id, status: 'queued', poll: `/api/jobs/${id}` },
    { status: 202 }
  );
}

Which raises the obvious question of what consumes the queue — and the answer is not another serverless function, because that one has the same time limit. It is a process that keeps running.

How this fits the rest of the stack

Serverless POST handlers are excellent for the work they are shaped for: validate, transform, forward, respond, in under a second. The friction shows up when a handler needs to do something long, remember something between calls, or hold a database connection open — and every workaround for those is a way of borrowing a persistent process you do not have.

RunxBuild runs the persistent half. A web service in Node, Next.js, Python, Go, Ruby, Java, .NET or Docker deploys from your GitHub repository, keeps its connection pool, runs the queue consumer, and reports build and runtime logs in one place with rollback when a release goes wrong. Managed MySQL and Postgres sit beside it on private networking with real connection limits, and autoscaling moves between a floor and a ceiling plan you choose. To price a service, its database and its storage together, the RunxBuild hosting calculator lists each as its own line item.

Useful related references:

FAQ

Why does my Vercel function return 405 on a POST?

Either you did not export a handler for that method, or the request did not arrive as a POST. A URL redirect — trailing slash normalisation, or a www-to-apex rule — makes most clients re-issue the request as a GET without a body. Post to the exact final URL rather than one that redirects.

How do I read the request body in a Vercel function?

In the current Web-standard shape, await it: const body = await request.json(). It is a stream, not a pre-parsed object, and forgetting the await leaves you holding a Promise. The older Node-style handler parses req.body for you, which is why examples differ depending on which model they were written for.

How do I fix CORS on a Vercel POST endpoint?

Export an OPTIONS handler that returns the CORS headers so the browser’s preflight succeeds, and put the same headers on the POST response as well — a passing preflight followed by a response without headers still fails. If you send cookies, name the origin explicitly rather than using a wildcard. Best of all, serve the API on the same origin so preflight never happens.

Why does my JSON parse fail with a 500?

Because request.json() throws on anything that is not valid JSON, including an empty body, and an unhandled throw becomes a 500. Wrap it in a try/catch and return a 400 instead, and check the content-type header first so form submissions can go through formData() rather than failing.

Can a serverless POST handler do long-running work?

Not beyond the plan’s execution limit. Accept the request, enqueue the job, and return 202 with a polling URL. That pattern needs something persistent to consume the queue, which another serverless function cannot be, since it has the same time limit.

#vercel serverless post request#vercel#serverless functions#api routes#cors