{ Simple Frontend }

End-to-end type safety for your serverless APIs

Define your API contract once with Zod, serve it from your serverless functions, and generate TypeScript types for your clients.

Jeremy Colin Jeremy Colin
Jun 19, 2026 - 4 min read
#TypeScript#Serverless

I’ve been using serverless functions for simple API endpoints for small tools or projects I am building.

Recently I ran into a case where the schema of the data returned by the API was quite large. I wanted to make it easy for myself or an AI agent to pick up the client-side work on this project without having to dive into the serverless function code.

I had used OpenAPI schemas before to solve this type of problem and it adapted surprisingly well here.

Why this guide and what will you learn?

The goal is not only the reliability a shared contract will bring you but also the speed of having a generated, type-safe client which knows all about your endpoint.

We will keep things extremely lightweight and rely on popular tools such as zod and openapi-typescript:

Zod schema to OpenAPI schema to fully typed client with openapi-ts and openapi-fetch

If you already have a deployed OpenAPI schema and only need client-side types, see Type safe data fetching for your frontend applications.

This guide assumes you have a serverless project such as Vercel/Netlify Functions or equivalent.

Define your API contract with Zod

We start with a handler, for example a Vercel serverless handler taking a name from a query parameter and replying with a JSON greeting:

import type { VercelRequest, VercelResponse } from "@vercel/node";
export default function handler(req: VercelRequest, res: VercelResponse) {
const name = req.query["name"];
return res.status(200).json({ message: `Hello, ${name}!` });
}

We can define the following schema with Zod:

import { z } from "zod";
export const helloWorldQuerySchema = z.object({
name: z.string().meta({
description: "Name to greet",
example: "World",
}),
});

And use it like this:

import type { VercelRequest, VercelResponse } from "@vercel/node";
import { helloWorldQuerySchema } from "../src/schema.ts";
export default function handler(req: VercelRequest, res: VercelResponse) {
const parsed = helloWorldQuerySchema.safeParse(req.query);
if (!parsed.success) {
return res.status(400).json({ error: "Invalid or missing name" });
}
const { name } = parsed.data;
return res.status(200).json({ message: `Hello, ${name}!` });
}

We also have the extra benefit here that we can validate our input and return an error if we’re receiving an invalid request. Of course we were already able to do that before but now Zod gives us helpers and the code is automatically correctly typed!

Generate your OpenAPI document

Now what’s left is using zod-openapi to create our OpenAPI document:

import { z } from "zod";
import { createDocument } from "zod-openapi";
export const helloWorldQuerySchema = z.object({
name: z.string().meta({
description: "Name to greet",
example: "World",
}),
});
export const helloWorldResponseSchema = z.object({
message: z.string().meta({
description: "Greeting message",
example: "Hello, World!",
}),
});
const errorResponseSchema = z.object({
error: z.string(),
});
export const document = createDocument({
openapi: "3.1.1",
info: {
title: "Demo Serverless OpenAPI types",
version: "0.1.1",
},
paths: {
"/api/hello-world": {
get: {
description: "Returns a greeting for the given name.",
requestParams: {
query: helloWorldQuerySchema,
},
responses: {
"200": {
description: "Greeting message",
content: {
"application/json": { schema: helloWorldResponseSchema },
},
},
"400": {
description: "Invalid or missing name",
content: {
"application/json": { schema: errorResponseSchema },
},
},
},
},
},
},
});

It can feel a bit verbose but that’s typically the type of work an AI agent can do well.

Implement your /openapi.json handler

The only part left in our server is to expose this OpenAPI document. This is easy, for example, with an /openapi.json endpoint handler consuming the document we created:

import type { VercelRequest, VercelResponse } from "@vercel/node";
import { document } from "../src/schema.ts";
export default function handler(_req: VercelRequest, res: VercelResponse) {
return res.status(200).json(document);
}

Once deployed, for example on Vercel, your OpenAPI document will be available at /api/openapi.json.

Generate typed clients for your frontend

Here we will use openapi-ts. We need 2 packages:

  • openapi-typescript as a dev dependency to generate the TypeScript interfaces from our OpenAPI schema
  • openapi-fetch as a runtime dependency for the type-safe fetch client

Once installed, add a script in your client package.json to generate the types:

{
"gen:client": "openapi-typescript %YOUR_ENDPOINT%/api/openapi.json -o ./src/api/schema.d.ts"
}

Now you can use it for your fetch client:

import createClient from "openapi-fetch";
import type { paths } from "./schema";
const baseUrl = "%YOUR_ENDPOINT%";
const client = createClient<paths>({
baseUrl,
});
export async function fetchGreeting(name: string) {
const { data, error } = await client.GET("/api/hello-world", {
params: {
query: {
name,
},
},
});
return { data, error }
}

And that’s it! Now you have typed access to your endpoints including their response schema.

I have set up an end-to-end Open Source demo. It contains a Vercel serverless API as well as a live client