Ploy
Ploy Start

Routes

Define type-safe HTTP routes with Zod validation.

Routes

Ploy Start provides a type-safe routing system with automatic Zod validation for params, query strings, request bodies, and responses.

Basic Routes

Define routes using HTTP method shortcuts:

import { ploy, z } from "@meetploy/start";

const worker = ploy()
	.get(
		"/hello",
		{
			response: z.object({ message: z.string() }),
		},
		async () => {
			return { message: "Hello World!" };
		},
	)
	.build();

Route Methods

All standard HTTP methods are supported:

const worker = ploy()
  .get("/users", {...}, handler)      // GET request
  .post("/users", {...}, handler)     // POST request
  .put("/users/:id", {...}, handler)  // PUT request
  .patch("/users/:id", {...}, handler) // PATCH request
  .delete("/users/:id", {...}, handler) // DELETE request
  .build();

Path Parameters

Define path parameters with :param syntax:

.get("/users/:id", {
  params: z.object({
    id: z.string()
  }),
  response: z.object({
    user: z.object({
      id: z.string(),
      name: z.string()
    }).nullable()
  })
}, async (ctx) => {
  // ctx.params.id is typed as string
  const userId = ctx.params.id;
  return { user: await findUser(userId) };
})

Multiple path parameters:

.get("/orgs/:orgId/users/:userId", {
  params: z.object({
    orgId: z.string(),
    userId: z.string()
  }),
  response: z.object({ user: userSchema })
}, async (ctx) => {
  // ctx.params.orgId and ctx.params.userId are both typed
  return { user: await findOrgUser(ctx.params.orgId, ctx.params.userId) };
})

Query Parameters

Validate query strings with Zod:

.get("/users", {
  query: z.object({
    page: z.string().optional().default("1"),
    limit: z.string().optional().default("10"),
    search: z.string().optional()
  }),
  response: z.object({
    users: z.array(userSchema),
    total: z.number()
  })
}, async (ctx) => {
  const { page, limit, search } = ctx.query;
  // All query params are typed
  return { users: [], total: 0 };
})

Query parameters are always strings from the URL. Use .transform() to convert them:

query: z.object({
  page: z.string().transform(Number).default("1")
})

Request Body

Handle JSON request bodies on POST, PUT, and PATCH:

.post("/users", {
  body: z.object({
    name: z.string().min(1),
    email: z.string().email(),
    age: z.number().optional()
  }),
  response: z.object({
    success: z.boolean(),
    user: userSchema
  })
}, async (ctx) => {
  // ctx.body is fully typed
  const { name, email, age } = ctx.body;
  const user = await createUser({ name, email, age });
  return { success: true, user };
})

Response Schema

Every route requires a response schema for type safety and OpenAPI generation:

.get("/health", {
  response: z.object({
    status: z.enum(["ok", "degraded", "down"]),
    timestamp: z.string()
  })
}, async () => {
  return {
    status: "ok",
    timestamp: new Date().toISOString()
  };
})

Route Context

Every handler receives a typed context object:

interface RouteContext<Env, State, Params, Query, Body> {
	request: Request; // Raw Cloudflare Request
	env: Env; // Environment bindings
	ctx: ExecutionContext; // Cloudflare ExecutionContext
	state: State; // State from .state() calls
	params: Params; // Validated path parameters
	query: Query; // Validated query parameters
	body: Body; // Validated request body
	json: (data, status?) => Response;
	text: (data, status?) => Response;
	html: (data, status?) => Response;
	redirect: (url, status?) => Response;
}

Response Helpers

.get("/example", {...}, async (ctx) => {
  // Return JSON (default)
  return { data: "value" };

  // Or use helpers for other response types
  return ctx.json({ data: "value" }, 201);
  return ctx.text("Plain text response");
  return ctx.html("<h1>HTML Response</h1>");
  return ctx.redirect("/other-page", 302);
})

Accessing Environment

Access Cloudflare bindings via ctx.env:

import { ploy, z } from "@meetploy/start";
import type { Env } from "./env.js";

const worker = ploy<Env>()
	.get(
		"/send",
		{
			response: z.object({ messageId: z.string() }),
		},
		async (ctx) => {
			// Access queue binding directly
			const { messageId } = await ctx.env.TASKS.send({ task: "process" });
			return { messageId };
		},
	)
	.build();

OpenAPI Documentation

Enable automatic OpenAPI spec generation:

const worker = ploy()
	.openapi({
		path: "/docs",
		info: {
			title: "My API",
			version: "1.0.0",
			description: "API description",
		},
	})
	.get(
		"/users",
		{
			summary: "List all users",
			description: "Returns a paginated list of users",
			tags: ["Users"],
			response: z.object({ users: z.array(userSchema) }),
		},
		handler,
	)
	.build();

Access the OpenAPI spec at /docs and Swagger UI at /docs/ui.

Middleware

Add global middleware with .use():

import { cors, logger, auth } from "@meetploy/start";

const worker = ploy()
  .use(cors())
  .use(logger())
  .use(auth("Authorization"))
  .get("/protected", {...}, handler)
  .build();

Built-in Middleware

// CORS - Handle cross-origin requests
cors({
	origin: "*",
	methods: ["GET", "POST", "PUT", "DELETE"],
});

// Logger - Log requests
logger();

// Auth - Extract auth header
auth("Authorization");

// Rate Limit - Basic rate limiting
rateLimit({ maxRequests: 100, windowMs: 60000 });

Custom Middleware

const worker = ploy()
	.use(async (ctx, next) => {
		const start = Date.now();
		const response = await next();
		const duration = Date.now() - start;
		console.log(`${ctx.request.method} ${ctx.request.url} - ${duration}ms`);
		return response;
	})
	.build();

State Management

Add shared state with .state():

import { withDrizzle } from "@meetploy/start";

const worker = ploy<Env>()
  .state(withDrizzle("DB", schema))
  .state((ctx) => ({
    requestId: crypto.randomUUID()
  }))
  .get("/users", {...}, async (ctx) => {
    // ctx.state.db from withDrizzle
    // ctx.state.requestId from custom state
    const users = await ctx.state.db.select().from(schema.users);
    return { users, requestId: ctx.state.requestId };
  })
  .build();

State factories receive { env, request } and can be async:

.state(async ({ env, request }) => {
  const user = await validateToken(request.headers.get("Authorization"));
  return { user };
})

Error Handling

Errors thrown in handlers are caught and returned as 500 responses:

.get("/error", {...}, async () => {
  throw new Error("Something went wrong");
  // Returns: { error: "Something went wrong" } with status 500
})

For custom error responses, return them directly:

.get("/users/:id", {...}, async (ctx) => {
  const user = await findUser(ctx.params.id);
  if (!user) {
    return ctx.json({ error: "User not found" }, 404);
  }
  return { user };
})

How is this guide?

Last updated on

Routes