DEV Community

Cover image for Shipping a Vercel serverless API route: the short version
Suraj Nandan
Suraj Nandan

Posted on

Shipping a Vercel serverless API route: the short version

My portfolio has a small chat feature backed by a serverless function on Vercel. This post walks through the pattern end to end: the file shape, the gotchas I hit, how to keep costs sane, and the checks I run after each deploy.

If you've never written a serverless function, this is a good first one.

What "serverless function" means here

You write a file. The host runs it on demand, in response to an HTTP request, and you pay (or use free quota) per invocation instead of keeping a server running. For a small endpoint like a chat proxy, that's ideal: no server to maintain, and it scales to zero when nobody's using it.

On Vercel, any file inside the api/ folder becomes an endpoint. api/chat.ts is served at /api/chat.

The shape of a function

I use the Web-standard Request / Response style, exporting a function per HTTP method:

// api/chat.ts

export async function GET() {
  // A cheap health check: lets me verify a deploy with curl.
  return Response.json({ ready: true });
}

export async function POST(req: Request) {
  const { message } = await req.json();

  if (typeof message !== 'string' || message.length === 0 || message.length > 2000) {
    return Response.json({ error: 'Invalid message' }, { status: 400 });
  }

  // call your model here, using a key from process.env
  const reply = await askModel(message);

  return Response.json({ reply });
}
Enter fullscreen mode Exit fullscreen mode

Notes:

  • Validate input. Never pass raw user input anywhere without checking its type and size. Limits protect your bill.
  • Return proper status codes. 400 for bad input, 500 for your own failure.
  • Keep it small. A function should do one thing.

Calling it from the frontend

async function ask(message: string) {
  const res = await fetch('/api/chat', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ message }),
  });
  if (!res.ok) throw new Error('Request failed');
  return (await res.json()).reply as string;
}
Enter fullscreen mode Exit fullscreen mode

Secrets stay on the server

The model API key must never be in the browser or the repository. Put it in an environment variable in the host's dashboard, and read it in the function:

const key = process.env.ANTHROPIC_API_KEY;
if (!key) return Response.json({ error: 'Not configured' }, { status: 503 });
Enter fullscreen mode Exit fullscreen mode

For my project, the key lives in the Vercel project's environment settings, and the function simply reads it. A key committed to a repo should be considered leaked, even if the repo is private.

Gotchas I've hit

Import extensions

When importing sibling files from inside api/, include the .js extension in the import path, even though the source is TypeScript:

import { buildPrompt } from './_prompt.js';   // note: .js
Enter fullscreen mode Exit fullscreen mode

Leave it off and the deployed function may fail to resolve the module, which is the kind of thing that works locally and breaks on the host.

Files that shouldn't be endpoints

Files in api/ become routes. A helper that shouldn't be reachable from the internet should be prefixed with an underscore (_prompt.ts) or live outside api/.

Cold starts and timeouts

A function that hasn't run recently starts slower, and every function has a maximum duration. Keep work short and consider streaming for slow model calls.

Pick the smallest model that does the job

Cost matters when you're the one paying. For a portfolio chat that answers questions about my work, I chose a small, fast, inexpensive model (Claude Haiku) over a larger one. Smaller models are quicker, cheaper and usually good enough when the task is narrow.

Protect your wallet

A public endpoint that calls a paid API can be abused. Basic protections:

  • Validate and cap input length.
  • Rate limit per IP or per session.
  • Set a monthly spend limit on the provider dashboard.
  • Give the model a narrow job in the system prompt.
  • Don't expose a general-purpose proxy.

The check I run after every deploy

Remember Day 3: a deploy can fail silently. A health check makes verification trivial:

# Is the function up?
curl -s https://your-domain.com/api/chat
# {"ready":true}

# Other things worth confirming are live
curl -sI https://your-domain.com/rss.xml | head -n 1
Enter fullscreen mode Exit fullscreen mode

I run these against the production domain after each deploy. Ten seconds, and I know the new build is serving.

A deployment checklist

  • [ ] Env vars set in the host dashboard (production and preview if needed)
  • [ ] Input validated, length capped
  • [ ] Health endpoint works
  • [ ] No secrets in the repo
  • [ ] Spend limit set at the provider
  • [ ] Verified on the live domain, not just locally

Streaming a response

For slow model calls, streaming lets the user see text as it arrives instead of staring at a spinner. A minimal pattern with Web streams:

export async function POST(req: Request) {
  const { message } = await req.json();

  const encoder = new TextEncoder();
  const stream = new ReadableStream({
    async start(controller) {
      for await (const piece of askModelStream(message)) {   // your provider's stream
        controller.enqueue(encoder.encode(piece));
      }
      controller.close();
    },
  });

  return new Response(stream, {
    headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' },
  });
}
Enter fullscreen mode Exit fullscreen mode

On the client, read it incrementally:

const res = await fetch('/api/chat', { method: 'POST', body: JSON.stringify({ message }) });
const reader = res.body!.getReader();
const decoder = new TextDecoder();
for (;;) {
  const { value, done } = await reader.read();
  if (done) break;
  appendToUI(decoder.decode(value, { stream: true }));
}
Enter fullscreen mode Exit fullscreen mode

Test locally before deploying

The Vercel CLI can run your functions locally:

npm i -g vercel
vercel dev          # serves the site and the /api routes locally
curl -s localhost:3000/api/chat
Enter fullscreen mode Exit fullscreen mode

It reads environment variables from a local file (keep that file out of git), so you can test with a key without ever committing it.

A simple rate-limit sketch

On a serverless host, in-memory counters reset on cold starts and don't share across instances, so a robust limit needs shared storage (a small key-value store). To show the idea:

const hits = new Map<string, { count: number; reset: number }>();

function allow(ip: string, limit = 20, windowMs = 60_000) {
  const now = Date.now();
  const rec = hits.get(ip);
  if (!rec || now > rec.reset) { hits.set(ip, { count: 1, reset: now + windowMs }); return true; }
  rec.count++;
  return rec.count <= limit;
}
Enter fullscreen mode Exit fullscreen mode

Treat that as a speed bump, not a lock. For anything you pay for, back it with a provider-side spend limit.

Error handling that doesn't leak

  • Return a generic message to the client ("Something went wrong"), and log the detail on the server.
  • Never include stack traces or provider error bodies in responses.
  • Use correct status codes so the frontend can react: 400 bad input, 429 too many requests, 503 not configured.
try {
  /* ... */
} catch (err) {
  console.error('chat failed', err);
  return Response.json({ error: 'Something went wrong' }, { status: 500 });
}
Enter fullscreen mode Exit fullscreen mode

CORS in one paragraph

If the frontend and the function share a domain (as with /api/chat on the same site), you don't need CORS. If another origin calls it, you must return the right Access-Control-Allow-Origin header for specific, trusted origins, never a blanket * on an endpoint that spends your money.

Limits to know about

  • Functions have maximum run times and memory limits on a given plan.
  • Cold starts make the first request slower.
  • Local files are read-only or ephemeral. Don't treat the filesystem as storage.
  • Request and response sizes are capped.

Check your host's current limits in its documentation, since they change.

Pre-launch checklist for an AI-backed endpoint

  • [ ] Input validated and capped
  • [ ] Spend limit set with the provider
  • [ ] Rate limiting in place
  • [ ] Narrow system prompt
  • [ ] No secrets in the repo or in logs
  • [ ] Health check responds
  • [ ] Failure path returns a friendly error
  • [ ] Verified on the production URL

Key takeaways from day 25

What's the smallest useful serverless function you've shipped?


☕ Enjoyed this post? You can support my writing on Ko-fi.

Support me on Ko-fi

Top comments (0)