Skip to content

Vercel Workflows pipeline

Vercel Workflows (Workflow DevKit, package workflow) marks orchestrators with "use workflow" and durable steps with "use step". Unhandled errors retry automatically; FatalError skips retries. Recurring syncs use a Vercel Cron Job that hits an API route calling start() from workflow/api.

See Running the enrich pipeline durably for the model.

lib/search.ts
import { samesake } from "@samesake/postgres";
import { llmRerank } from "@samesake/server";
import { products } from "../catalog.ts";
import { geminiEmbed, geminiGenerate } from "../gemini.ts";
export const app = samesake({
url: process.env.SAMESAKE_DATABASE_URL!,
collection: products,
models: { embed: geminiEmbed, generate: geminiGenerate },
rerank: llmRerank(geminiGenerate),
});

Each bundle call lives in its own step function — failures retry per step, not per workflow.

workflows/sync-catalog.ts
import { FatalError } from "workflow";
import { app } from "../lib/search.ts";
import { fetchChangedRows } from "../catalog-source.ts";
export async function syncCatalog() {
"use workflow";
await upsertStep();
await enrichStep();
}
async function upsertStep() {
"use step";
if (!process.env.SAMESAKE_DATABASE_URL) {
throw new FatalError("SAMESAKE_DATABASE_URL is not set");
}
const rows = await fetchChangedRows();
await app.enrich.upsert(rows);
}
async function enrichStep() {
"use step";
await app.enrich.enrich();
}
workflows/maintenance.ts
import { app } from "../lib/search.ts";
export async function catalogMaintenance() {
"use workflow";
await retryFailedStep();
}
// Image revalidation is a caller-owned @samesake/server workflow; retry failed rows here.
async function retryFailedStep() {
"use step";
await app.enrich.retryFailed({ limit: 500 });
}

Use import { sleep } from "workflow" and await sleep("5s") when you need a delay between steps — sleeping doesn’t consume compute.

Trigger runs with start() from workflow/api. It returns immediately; the workflow executes asynchronously.

app/api/cron/sync-catalog/route.ts
import { start } from "workflow/api";
import { syncCatalog } from "@/workflows/sync-catalog";
export async function GET() {
const run = await start(syncCatalog, []);
return Response.json({ message: "sync started", runId: run.runId });
}
app/api/cron/maintenance/route.ts
import { start } from "workflow/api";
import { catalogMaintenance } from "@/workflows/maintenance";
export async function GET() {
const run = await start(catalogMaintenance, []);
return Response.json({ message: "maintenance started", runId: run.runId });
}

Point crons at those routes in vercel.json:

vercel.json
{
"crons": [
{ "path": "/api/cron/sync-catalog", "schedule": "*/5 * * * *" },
{ "path": "/api/cron/maintenance", "schedule": "0 * * * *" }
]
}

Secure cron routes with CRON_SECRET (Vercel sends it as Authorization: Bearer …) so random HTTP clients can’t trigger full syncs.

app/api/webhooks/product/route.ts
import { start } from "workflow/api";
import { app } from "@/lib/search";
import { syncCatalog } from "@/workflows/sync-catalog";
export async function POST(request: Request) {
const product = await request.json();
await app.enrich.upsert([product]);
// Next cron picks up enrich — or start the workflow now:
const run = await start(syncCatalog, []);
return Response.json({ ok: true, runId: run.runId });
}

Vercel Workflows keep orchestration in your repo — no separate queue service if you’re already on Vercel. Each "use step" boundary matches a bundle call: a transient embed failure retries enrichStep without re-running upsertStep. samesake’s idempotent incrementality still means unchanged rows are cheap inside each step.

The runnable, tested version of this guide lives in examples/pipeline-vercel-workflows — deterministic stubs, no API key. Run bun test (Postgres-backed examples need SAMESAKE_DATABASE_URL).