Ploy Start: build a serverless backend in one TypeScript file
Build a type-safe serverless backend with @meetploy/start: Zod-validated routes, Drizzle queries against SQLite, queue handlers, and durable workflows, all in one worker that deploys on every push.

A small backend usually needs four things: HTTP routes with validation, a database, a way to run work later, and a way to run multi-step work that must not be lost halfway. Assembling that from separate services means four SDKs, four sets of credentials, and a lot of glue before the first feature ships.
Ploy Start (@meetploy/start) is a type-safe framework for building a serverless backend on Ploy where those four things are methods on one builder. You chain .get(), .post(), .queue(), and .workflow(), call .build(), and the result is a worker that Ploy deploys on every push, with the database, queue, and workflow declared in ploy.yaml.
Setup
pnpm add @meetploy/start @meetploy/typespnpm add -D @meetploy/cli drizzle-orm typescript
pnpm add @meetploy/start @meetploy/typespnpm add -D @meetploy/cli drizzle-orm typescript
kind: workerbuild: pnpm buildout: distdb:DB: defaultqueue:TASKS: tasksworkflow:ORDER_FLOW: order_processing
kind: workerbuild: pnpm buildout: distdb:DB: defaultqueue:TASKS: tasksworkflow:ORDER_FLOW: order_processing
{"scripts": {"build": "tsc --noEmit && ploy build","dev": "ploy dev","types": "ploy types"}}
{"scripts": {"build": "tsc --noEmit && ploy build","dev": "ploy dev","types": "ploy types"}}
pnpm types writes an env.d.ts with DB, TASKS, and ORDER_FLOW typed. PloyEnv is then available to the builder.
Routes with Zod validation
Every route declares its params, query, body, and response schemas. The handler receives a context where all of them are already parsed and typed:
import { ploy, cors, logger, z } from "@meetploy/start";const worker = ploy<PloyEnv>().use(cors()).use(logger()).openapi({ path: "/docs", info: { title: "Orders API", version: "1.0.0" } }).get("/orders/:id",{params: z.object({ id: z.string() }),response: z.object({ id: z.string(), status: z.string() }),},async (ctx) => {return { id: ctx.params.id, status: "pending" };},).build();export default worker;
import { ploy, cors, logger, z } from "@meetploy/start";const worker = ploy<PloyEnv>().use(cors()).use(logger()).openapi({ path: "/docs", info: { title: "Orders API", version: "1.0.0" } }).get("/orders/:id",{params: z.object({ id: z.string() }),response: z.object({ id: z.string(), status: z.string() }),},async (ctx) => {return { id: ctx.params.id, status: "pending" };},).build();export default worker;
A malformed body or missing param never reaches your handler. The .openapi() call serves the generated spec at /docs and Swagger UI at /docs/ui, so the API documents itself from the same schemas.
A database with Drizzle
Define a schema with Drizzle's SQLite core and attach a typed db to the context with withDrizzle:
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";export const orders = sqliteTable("orders", {id: text("id").primaryKey(),userId: text("user_id").notNull(),amount: integer("amount").notNull(),status: text("status").notNull().default("pending"),});
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";export const orders = sqliteTable("orders", {id: text("id").primaryKey(),userId: text("user_id").notNull(),amount: integer("amount").notNull(),status: text("status").notNull().default("pending"),});
import { ploy, withDrizzle, z } from "@meetploy/start";import { eq } from "drizzle-orm";import * as schema from "./schema.js";const worker = ploy<PloyEnv>().state(withDrizzle("DB", schema)).get("/orders/:id",{params: z.object({ id: z.string() }),response: z.object({ order: z.unknown().nullable() }),},async (ctx) => {const [order] = await ctx.state.db.select().from(schema.orders).where(eq(schema.orders.id, ctx.params.id)).limit(1);return { order: order ?? null };},).build();
import { ploy, withDrizzle, z } from "@meetploy/start";import { eq } from "drizzle-orm";import * as schema from "./schema.js";const worker = ploy<PloyEnv>().state(withDrizzle("DB", schema)).get("/orders/:id",{params: z.object({ id: z.string() }),response: z.object({ order: z.unknown().nullable() }),},async (ctx) => {const [order] = await ctx.state.db.select().from(schema.orders).where(eq(schema.orders.id, ctx.params.id)).limit(1);return { order: order ?? null };},).build();
The orders table is created by a .sql migration in migrations/. Ploy applies pending migrations on production deploys and ploy dev applies them on startup.
Queues for work that can wait
Send from a route, handle with .queue(). The payload schema validates every message before the handler runs:
.post("/orders",{body: z.object({ userId: z.string(), amount: z.number() }),response: z.object({ orderId: z.string() }),},async (ctx) => {const orderId = crypto.randomUUID();await ctx.state.db.insert(schema.orders).values({ id: orderId, ...ctx.body });await ctx.env.TASKS.send({ task: "send-receipt", orderId });return { orderId };},).queue("TASKS",{ payload: z.object({ task: z.string(), orderId: z.string() }) },async (ctx) => {await sendReceipt(ctx.message.payload.orderId);ctx.ack();},)
.post("/orders",{body: z.object({ userId: z.string(), amount: z.number() }),response: z.object({ orderId: z.string() }),},async (ctx) => {const orderId = crypto.randomUUID();await ctx.state.db.insert(schema.orders).values({ id: orderId, ...ctx.body });await ctx.env.TASKS.send({ task: "send-receipt", orderId });return { orderId };},).queue("TASKS",{ payload: z.object({ task: z.string(), orderId: z.string() }) },async (ctx) => {await sendReceipt(ctx.message.payload.orderId);ctx.ack();},)
Call ctx.retry(delayMs) to requeue or ctx.deadLetter(reason) to give up; ctx.message.attempt tells you which try this is.
Workflows for work that must finish
A workflow is a function whose steps are persisted. If the worker restarts mid-run, completed steps are replayed from storage rather than re-executed, and failed steps are retried (3 times by default, configurable in ploy.yaml):
.workflow("order_processing",{input: z.object({ orderId: z.string(), amount: z.number() }),output: z.object({ paymentId: z.string() }),},async (ctx) => {const payment = await ctx.step.run("charge", async () => {return await chargeCard(ctx.input.orderId, ctx.input.amount);});await ctx.step.sleep("24h");await ctx.step.run("follow-up", async () => {await sendFollowUpEmail(ctx.input.orderId);});return { paymentId: payment.id };},)
.workflow("order_processing",{input: z.object({ orderId: z.string(), amount: z.number() }),output: z.object({ paymentId: z.string() }),},async (ctx) => {const payment = await ctx.step.run("charge", async () => {return await chargeCard(ctx.input.orderId, ctx.input.amount);});await ctx.step.sleep("24h");await ctx.step.run("follow-up", async () => {await sendFollowUpEmail(ctx.input.orderId);});return { paymentId: payment.id };},)
Trigger it from a route with ctx.env.ORDER_FLOW.trigger({ orderId, amount }), which returns an executionId you can poll with getExecution() or stop with cancel(). ctx.step.sleep("24h") is durable: nothing runs for a day, and the execution resumes on schedule.
Run and deploy
pnpm dev starts the worker with a local emulator for the database, queue, and workflow, plus a dashboard at http://localhost:4000 where you can browse tables, retry failed messages, and inspect each workflow step's input and output.
Push to GitHub, connect the repository in Ploy, and every push deploys. Preview branches get their own URL.
Frequently Asked Questions
Is Ploy Start a replacement for Hono or Express?
It is a router with the same shape, plus schema validation, OpenAPI, and first-class queue, workflow, and cron handlers. If you prefer a plain fetch handler, Ploy runs that too; Ploy Start is optional.
Can I add cron jobs?
Yes. Chain .scheduled(async (event, env) => { ... }) before .build() and declare the schedule under cron: in ploy.yaml.
Does it work with the Vite integration or Next.js?
Ploy Start is for standalone workers (kind: worker). Next.js apps use @meetploy/nextjs and handle queues and workflows in app/ploy.ts; see Deploy Next.js on Ploy.
Can I self-host the backend?
Yes. Ploy is open source and free to self-host, and the same worker runs unchanged on your own instance.