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 });
}
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;
}
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 });
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
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
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'
},
});
}
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 }));
}
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
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;
}
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 });
}
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
What's the smallest useful serverless function you've shipped?
☕ Enjoyed this post? You can support my writing on Ko-fi.
Comments
No comments yet. Start the discussion.