Vercel
@hatchet-dev/serverless turns a Next.js route into a serverless endpoint: the route answers Hatchet's healthcheck with the workflows it serves, verifies the signature on every request, and runs each task it's assigned. Your task code is the same code you'd run on a worker, declared with the same hatchet.task and hatchet.durableTask calls.
You need Node 22, the Vercel CLI, and a Vercel project. Durable tasks and tasks that wait for child runs need Fluid compute, which is on by default for new projects; see platform notes.
Install
pnpm add @hatchet-dev/serverless @hatchet-dev/typescript-sdk zod
pnpm add @vercel/functions ws # for durable tasks and tasks that wait for child runs@hatchet-dev/typescript-sdk (1.41.0 or later) and zod are peer dependencies. @vercel/functions and ws are optional peers: the adapter accepts the websocket through them. Without them every non-durable task is delivered as a POST and still runs, but a task that waits for a child run or reads a stream fails at that wait, and durable tasks aren't assigned to the endpoint.
Declare tasks
Declarations are runtime neutral. The same file compiles for a worker, a Cloudflare Worker or a Vercel function:
// hatchet/tasks.ts
import { hatchet } from "@hatchet-dev/serverless";
// A function of its input that never talks to Hatchet. Delivered over a signed POST.
export const echo = hatchet.task({
name: "echo",
retries: 3,
hatchetClient: false,
fn: async (input: { message: string }) => ({ echo: input.message }),
});
// Talks to Hatchet (the default): it triggers a child run and waits for the result.
export const parentEcho = hatchet.task({
name: "parent-echo",
fn: async (input: { message: string }, ctx) => ({
child: await ctx.runChild(echo, {
message: `${input.message} (from parent)`,
}),
}),
});
// A durable task: sleeps, waits and child runs survive the invocation ending.
export const sleepThenEcho = hatchet.durableTask({
name: "sleep-then-echo",
fn: async (input: { message: string }, ctx) => {
await ctx.sleepFor("10s");
const child = await ctx.spawnChild(echo, { message: input.message });
return { echo: input.message, child };
},
});
export const workflows = [echo, parentEcho, sleepThenEcho];hatchetClient: false states that a task is a pure function of its input: it never triggers child runs, publishes streams, writes logs to Hatchet or cancels anything. Hatchet delivers such a task as one signed POST and reads the response as its output. Every other task is invoked over a websocket, so it can wait for child runs and read streams while it runs. The full list of what a task can do on an endpoint, and the declaration options that don't apply there, is in the package's LIMITATIONS.md.
Add the route
// app/api/hatchet/[...hatchet]/route.ts
import { vercel } from "@hatchet-dev/serverless/vercel";
import { workflows } from "@/hatchet/tasks";
export const { GET, POST } = vercel({ workflows });
// The ceiling on one invocation: 300 s on Hobby, up to 800 s on Pro and Enterprise.
export const maxDuration = 300;POST serves the healthcheck and pure tasks; GET is the websocket upgrade for every other task. The route reads its path from the catch-all segment, so the file can live anywhere under app/. The example above produces /api/hatchet/healthcheck and /api/hatchet/trigger.
Environment variables
The endpoint holds two values. Neither is ever sent by Hatchet; you set both on the project and give the first one to Hatchet when you register the endpoint.
openssl rand -hex 32 # the signing secret; keep it for the registration
vercel env add HATCHET_SIGNING_SECRET production # paste it at the prompt
vercel env add HATCHET_CLIENT_TOKEN production # a tenant API tokenHATCHET_SIGNING_SECRET(at least 32 characters) is the key Hatchet signs every request with. The adapter rejects any request whose signature doesn't verify.HATCHET_CLIENT_TOKENis an API token for your tenant. The adapter builds the Hatchet client thatctx.runChild,ctx.putStream,ctx.logandctx.canceluse from it, and your app can build its own client from the same variable to trigger runs. Tasks declared withhatchetClient: falsedon't need it.HATCHET_ENDPOINT_ID(optional) is the endpoint's id from the dashboard. When set, the route refuses requests meant for another endpoint that shares the same secret.
Deploy and register
vercel deploy --prod # prints https://<project>.vercel.appThen register the endpoint, from the dashboard's Endpoints page (choose Vercel and enter the deployment's URL) or with the REST API:
curl -X POST \
-H "Authorization: Bearer $HATCHET_CLIENT_TOKEN" \
-H "Content-Type: application/json" \
"https://cloud.onhatchet.run/api/v1/stable/tenants/$TENANT_ID/serverless/endpoints" \
-d '{
"name": "my-app",
"kind": "VERCEL",
"healthcheckUrl": "https://my-app.vercel.app/api/hatchet/healthcheck",
"triggerUrl": "https://my-app.vercel.app/api/hatchet/trigger",
"signingSecret": "'"$HATCHET_SIGNING_SECRET"'"
}'Within one poll interval (30 seconds by default) the endpoint turns healthy and its registeredActions list the tasks above. See Managing endpoints for the fields and the SDK calls.
Register the production URL, not a preview deployment. Previews have
Deployment Protection on by default, which answers Hatchet's requests with
401 before the route runs. See platform notes.
From here on, the tasks run like any other: trigger them from the dashboard, with hatchet.run from any SDK, on a cron or from an event.
Local development
Run the app with the Vercel CLI, not next dev: the websocket upgrade only works under vc dev (CLI 54.14.2 or later). Both read .env.local (gitignored):
cat > .env.local <<EOF
HATCHET_SIGNING_SECRET=$(openssl rand -hex 32)
HATCHET_CLIENT_TOKEN=<api token>
EOF
vc devHatchet only delivers to public https URLs on port 443, so put a tunnel in front of the local app and register the tunnel's URL:
cloudflared tunnel --url http://localhost:3000 # prints https://<random>.trycloudflare.comRegister https://<random>.trycloudflare.com/api/hatchet/healthcheck and .../api/hatchet/trigger as a new endpoint, with a short pollIntervalSeconds so changes to your tasks are picked up quickly.
Under next dev the adapter reports that it doesn't serve durable tasks (the VERCEL environment variable is absent). Hatchet then delivers every non-durable task as a POST: tasks run and can trigger child runs, publish streams and write logs, but a wait for a child's result or a stream read fails, and durable tasks aren't assigned to the endpoint.
For a locally running Hatchet served over plain HTTP, add HATCHET_CLIENT_HOST_PORT=localhost:7070 and HATCHET_CLIENT_TLS_STRATEGY=none to .env.local. These are the same variables the Node SDK reads.
Options
export const { GET, POST } = vercel({
workflows,
serve: [echo, parentEcho], // the tasks this route serves (default: all)
secret: () => process.env.ORDERS_SIGNING_SECRET, // default process.env.HATCHET_SIGNING_SECRET
client: { token: process.env.HATCHET_CLIENT_TOKEN! }, // default: built from process.env
durable: false, // for a project without Fluid compute (default: on under Vercel)
});| Option | Description |
|---|---|
workflows | The declarations to serve. |
serve | A subset of the declared tasks to serve from this route, by declaration or by action id (workflow:task). The rest are still registered but answered 404 here. |
secret | The signing secret, or a function returning it. |
endpointId | The endpoint id, or a function returning it. Defaults to process.env.HATCHET_ENDPOINT_ID. |
client | The Hatchet client for the tasks: a client config, a HatchetCore, or a function returning either. Defaults to the client described by HATCHET_CLIENT_TOKEN (plus HATCHET_CLIENT_HOST_PORT and HATCHET_CLIENT_TLS_STRATEGY when set), or none. |
durable | Whether to accept the websocket. Defaults to true on Vercel when @vercel/functions and ws are installed. Set it to false on a project without Fluid compute, so Hatchet never dials the socket: every non-durable task is delivered as a POST, and durable tasks go elsewhere. |
basePath | The path the routes live under when the handlers are called without a Next.js route context. Defaults to /api/hatchet. With a catch-all route the segment decides. |
A function without Next.js
A standalone Vercel function (api/hatchet.ts, any framework or none) exports the endpoint as an http.Server, the shape Vercel serves directly:
// api/hatchet.ts
import { vercelServer } from "@hatchet-dev/serverless/vercel";
import { workflows } from "../hatchet/tasks";
export default vercelServer({ workflows }); // routes under /hatchetVercel routes only /api/hatchet itself to that file, so vercel.json needs a rewrite: { "rewrites": [{ "source": "/hatchet/:path*", "destination": "/api/hatchet" }] }.
Platform notes
- Durable tasks need Fluid compute. Vercel functions accept websocket upgrades on Fluid compute only. On a project without it, the upgrade fails at request time with
426, and the task fails without a retry. Passdurable: falseon such a project, or serve the durable tasks from another endpoint. maxDurationbounds one invocation, not a run. A durable task that waits longer than the endpoint'sinlineWaitBudgetMs(5 seconds by default) evicts itself and is invoked again when the wait is over, so a long sleep never needs a long function. Keep the budget well belowmaxDuration.- Deployment Protection answers before the route runs. Hatchet can't present a bypass token, so the production deployment that serves the endpoint must not be protected, or the Hatchet paths must be excluded. The signature check in the adapter is what protects the route.
- Payload sizes. Vercel caps request and response bodies at 4.5 MB; Hatchet caps a task's input and output at 4 MiB.
vc devversusnext dev. Only the Vercel CLI runs the websocket upgrade locally. Undernext devthe route still serves the healthcheck and every non-durable task overPOST.- Outside Vercel, on your own Node server, a Next.js route handler can't take the websocket. Use
@hatchet-dev/serverless/nodethere; see the HTTP contract.
Last updated on October 27, 2026