Clover ☘️

Server

Server for Clover routes

Server

Example

const { handler, clientConfig, openAPIPathsObject } = makeRequestHandler({
  input: z.object({
    name: z.string(),
  }),
  output: z.object({
    greeting: z.string(),
  }),
  run: async ({ request, input, sendOutput }) => {
    const { name } = input;
    return sendOutput({ greeting: `Hello, ${name}!` });
  },
  path: "/api/hello",
  method: "GET",
  description: "Greets the user",
});

Props

NameTypeDescription
inputz.ZodObject<any, any>A Zod schema describing the shape of your input. Clover will look for the input inside query params, path params or JSON formatted request body.
outputz.ZodObject<any, any>A Zod schema describing the shape of your output. You can use the sendOutput helper inside your run function to ensure that you conform to the output.
run({ request, input, authContext, sendOutput, sendError }) => Promise<Response>The logic you want to run. You can use the validated input, or use the raw request. The authContext contains any context returned from your authenticate function. Use sendOutput or sendError helpers to send responses.
pathstringThe relative path where your handler can be reached e.g. /api/hello-world
methodHTTPMethodGET, POST, PUT, PATCH or DELETE. This helps Clover generate appropriate documentation, and also helps it figure out where to look for the input. For example, input will be parsed from query and path parameters for GET requests.
description?stringUseful for generating OpenAPI documentation for this route.
authenticate?(request: Request) => Promise<{ authenticated: true; context: TAuthContext } | { authenticated: false; reason: string }>If supplied, marks the route as protected with Bearer auth in the documentation. Return authenticated: true with a context object to pass data to your handler, or authenticated: false with a reason for rejection (logged only, not sent to client).

Authentication

Clover supports route-level authentication with typed context. When you provide an authenticate function, it will be called before your run handler. If authentication fails, a 401 response is returned automatically.

Basic Example

const { handler } = makeRequestHandler({
  input: z.object({ name: z.string() }),
  output: z.object({ greeting: z.string(), userId: z.string() }),
  method: "GET",
  path: "/api/hello",
  authenticate: async (request) => {
    const token = request.headers.get("Authorization")?.replace("Bearer ", "");
 
    if (!token) {
      return { authenticated: false, reason: "No token provided" };
    }
 
    const user = await verifyToken(token);
    if (!user) {
      return { authenticated: false, reason: "Invalid token" };
    }
 
    // Return context that will be available in your run handler
    return { authenticated: true, context: { userId: user.id, role: user.role } };
  },
  run: async ({ input, authContext, sendOutput }) => {
    // authContext is typed based on what you return from authenticate
    return sendOutput({
      greeting: `Hello, ${input.name}!`,
      userId: authContext.userId,
    });
  },
});

Authentication Result

The authenticate function must return one of two results:

Success:

{ authenticated: true, context: TAuthContext }

The context can be any object containing user info, permissions, session data, etc. It will be passed to your run handler as authContext.

Failure:

{ authenticated: false, reason: string }

The reason is logged server-side for debugging but is not exposed to the client. The client receives a generic 401 Unauthorized response.

Type Safety

The authContext in your run handler is automatically typed based on what your authenticate function returns:

type UserContext = { userId: string; permissions: string[] };
 
const { handler } = makeRequestHandler({
  input: z.object({ resourceId: z.string() }),
  output: z.object({ allowed: z.boolean() }),
  method: "GET",
  path: "/api/check-access/:resourceId",
  authenticate: async (request): Promise<
    | { authenticated: true; context: UserContext }
    | { authenticated: false; reason: string }
  > => {
    // ... authentication logic
    return {
      authenticated: true,
      context: { userId: "user-123", permissions: ["read", "write"] }
    };
  },
  run: async ({ input, authContext, sendOutput }) => {
    // TypeScript knows authContext is UserContext
    const canAccess = authContext.permissions.includes("read");
    return sendOutput({ allowed: canAccess });
  },
});

Error Handling

If your authenticate function throws an error, Clover will:

  1. Log the error at the error level
  2. Return a 401 response (fail closed for security)

This ensures that authentication errors don't accidentally grant access.

Return values

NameTypeDescription
handler(request: Request) => ResponseAn augmented server route handler.
clientConfigIClientConfigA dummy variable used to extract types and pass them to the client. You can read more in the Client docs.
openAPIPathsObjectoas31.PathsObjectA generated OpenAPI schema for this route. You can read more about how to use this in the OpenAPI docs.

OpenAPI

Clover uses openapi3-ts and zod-openapi to generate OpenAPI schemas for your routes. Each route returns an oas31.PathsObject. You can stitch together all the schemas into a combined document like below:

// openapi.ts
 
import { OpenAPIObject, OpenAPIPathsObject } from "@protocols-fyi/clover";
import { openAPIPathsObject as someRouteOpenAPISchema } from "./some/route";
import { openAPIPathsObject as anotherRouteOpenAPISchema } from "./another/route";
 
const pathsObject: OpenAPIPathsObject = [
  someRouteOpenAPISchema,
  anotherRouteOpenAPISchema,
].reduce((acc, curr) => {
  Object.keys(curr).forEach((k) => {
    acc[k] = {
      ...acc[k],
      ...curr[k],
    };
  });
  return acc;
}, {});
 
export const document: OpenAPIObject = {
  info: {
    title: "My API",
    version: "1.0.0",
  },
  openapi: "3.0.0",
  paths: pathsObject,
};

OpenAPI Examples and Descriptions

Clover supports OpenAPI examples and descriptions using Zod's .meta() method. You can add examples and descriptions to any field in your input or output schemas, including query parameters, path parameters, request bodies, and responses.

Basic Usage

import { z } from "zod";
import { makeRequestHandler } from "@protocols-fyi/clover";
 
const { handler, openAPIPathsObject } = makeRequestHandler({
  input: z.object({
    name: z.string().meta({
      example: "Alice",
      description: "User's full name",
    }),
    age: z.number().meta({
      example: 30,
      description: "User's age in years",
    }),
  }),
  output: z.object({
    greeting: z.string().meta({
      example: "Hello, Alice!",
      description: "Personalized greeting message",
    }),
  }),
  method: "POST",
  path: "/api/greet",
  run: async ({ input, sendOutput }) => {
    return sendOutput({
      greeting: `Hello, ${input.name}!`,
    });
  },
});

Query Parameters with Examples

For GET requests, query parameters can include examples and descriptions:

const { handler } = makeRequestHandler({
  input: z.object({
    search: z.string().meta({
      example: "typescript",
      description: "Search query string",
    }),
    page: z.number().optional().meta({
      example: 1,
      description: "Page number for pagination",
    }),
    limit: z.number().optional().meta({
      example: 10,
      description: "Number of items per page",
    }),
  }),
  output: z.object({
    results: z.array(z.string()),
  }),
  method: "GET",
  path: "/api/search",
  run: async ({ input, sendOutput }) => {
    // Implementation
    return sendOutput({ results: [] });
  },
});

Path Parameters with Examples

Path parameters also support examples and descriptions:

const { handler } = makeRequestHandler({
  input: z.object({
    userId: z.string().uuid().meta({
      example: "550e8400-e29b-41d4-a716-446655440000",
      description: "Unique user identifier (UUID format)",
    }),
  }),
  output: z.object({
    user: z.object({
      id: z.string(),
      name: z.string(),
    }),
  }),
  method: "GET",
  path: "/api/users/:userId",
  run: async ({ input, sendOutput }) => {
    // Implementation
    return sendOutput({ user: { id: input.userId, name: "Alice" } });
  },
});

Nested Objects and Arrays

Examples work with nested objects and arrays:

const { handler } = makeRequestHandler({
  input: z.object({
    user: z.object({
      profile: z.object({
        firstName: z.string().meta({ example: "John" }),
        lastName: z.string().meta({ example: "Doe" }),
      }),
      tags: z.array(z.string()).meta({
        example: ["developer", "typescript", "react"],
        description: "User interest tags",
      }),
    }),
  }),
  output: z.object({
    success: z.boolean().meta({ example: true }),
  }),
  method: "POST",
  path: "/api/users",
  run: async ({ sendOutput }) => {
    return sendOutput({ success: true });
  },
});

Enums with Examples

Enum fields can have examples and descriptions:

const { handler } = makeRequestHandler({
  input: z.object({
    status: z.enum(["active", "inactive", "pending"]).meta({
      example: "active",
      description: "User account status",
    }),
    role: z.enum(["admin", "user", "guest"]).meta({
      example: "user",
      description: "User role in the system",
    }),
  }),
  output: z.object({
    updated: z.boolean(),
  }),
  method: "PATCH",
  path: "/api/users/:userId",
  run: async ({ sendOutput }) => {
    return sendOutput({ updated: true });
  },
});

Type Coercion

Clover handles type coercion properly in query parameters:

const { handler } = makeRequestHandler({
  input: z.object({
    // Query params are strings by default, but can be coerced to numbers
    count: z.coerce.number().meta({
      example: 42,
      description: "Count value (coerced from query string)",
    }),
    enabled: z.coerce.boolean().meta({
      example: true,
      description: "Feature flag (coerced from query string)",
    }),
  }),
  output: z.object({
    data: z.array(z.any()),
  }),
  method: "GET",
  path: "/api/items",
  run: async ({ sendOutput }) => {
    return sendOutput({ data: [] });
  },
});

Additional Metadata Options

The .meta() method supports other zod-openapi properties as well:

z.string().meta({
  example: "value",
  description: "Field description",
  // Override the entire schema if needed
  override: {
    type: "string",
    minLength: 5,
    maxLength: 100,
  },
  // ID for reusable component schemas
  id: "ComponentName",
});

For more details on zod-openapi metadata options, see the zod-openapi documentation.

Usage with frameworks

Next.js

Clover works with standard Web Request and Response APIs, which are only available in the new app directory in Next.js 13.4.

// app/hello/route.ts
 
const { handler } = makeRequestHandler({
  method: "GET",
  // ...
});
 
export { handler as GET };

Swagger UI

Setting up Swagger would vary from framework to framework, but here is an illustrative example for Next.js:

// app/openapi.json/route.ts
 
import { document } from "../../openapi";
import { NextResponse } from "next/server";
 
export const GET = () => {
  return NextResponse.json(document);
};
// app/swagger/page.tsx
 
"use client";
 
import "swagger-ui-react/swagger-ui.css";
import SwaggerUI from "swagger-ui-react";
import { useEffect, useState } from "react";
 
const SwaggerPage = () => {
  const [mounted, setMounted] = useState(false);
 
  useEffect(() => {
    setMounted(true);
  }, []);
 
  if (!mounted) {
    return null;
  }
 
  return <SwaggerUI url="/openapi.json" />;
};
 
export default SwaggerPage;

Input parsing

There are three supported input types: query parameters, path parameters and JSON request bodies. Depending on the HTTP method used, Clover will parse the input from the appropriate source.

MethodInput source
GETPath + query
DELETEPath + query
POSTPath + body
PUTPath + body
PATCHPath + body

Path parameters

Clover uses path-to-regexp to parse path parameters e.g. if you have an input schema z.object({ id: z.string() }) and path /api/users/:id, then Clover will parse the request URL to find the ID and use it to populate the input inside the run() function.

MIT 2026 © Clover.