Deploy on Vercel
A Next.js app with Postgres, crash recovery, schedules and limits, on Vercel functions.
Serverless changes three things: a function can be stopped at any moment, nothing runs between requests, and every request may land on a fresh instance. Latch is built for that. This guide sets it up.
1. A database with transactions
Use Postgres (Neon, Supabase, or any other) through a driver that supports transactions: pg, postgres, or Neon's neon-serverless pool. Neon's HTTP driver (neon-http) can't run transactions, and the adapter needs them.
Run the migrations once per deploy, from a script, not on every request:
import { migratePostgres } from "@intentface/latch-drizzle";
await migratePostgres(db); // creates and updates the latch_* tables2. The runtime
One module, imported by every route. Creating the runtime is cheap and needs no connection, so a fresh instance pays nothing for it.
import { createRuntime } from "@intentface/latch-core";
import { createPostgresAdapter, createPostgresLimitStore } from "@intentface/latch-drizzle";
import { createCronerEvaluator } from "@intentface/latch-server";
export const runtime = createRuntime<Principal>({
storage: createPostgresAdapter(db, { owner: (p) => [p.orgId, p.userId] }),
context,
agents,
durability: { enabled: true }, // answers survive a stopped function
cron: createCronerEvaluator(), // recurring schedules
limits: { store: createPostgresLimitStore(db), limitsFor },
reconstructPrincipal: async (identity) => {
const p = identity as Principal;
return (await isActiveMember(p)) ? p : null;
},
});3. The chat route
export const maxDuration = 300; // seconds: long enough for your longest answer
export async function POST(req: Request) {
const user = await requireUser(req);
const { id, message } = await req.json();
return runtime.handleChat({ agent: "assistant", chatId: id, principal: { orgId: user.orgId, userId: user.id }, message });
}An answer that runs past maxDuration isn't lost: the function stops, its lease runs out, and the next cron tick finishes the answer on another instance.
4. The cron
Vercel Cron calls a URL with GET and your CRON_SECRET as a bearer token. One small route turns that into runtime.cron(), which fires due schedules and recovers interrupted answers:
export const maxDuration = 300;
export async function GET(req: Request) {
const secret = process.env.CRON_SECRET;
// Unset, the comparison would accept "Bearer undefined".
if (!secret || req.headers.get("authorization") !== `Bearer ${secret}`) {
return new Response("Forbidden", { status: 403 });
}
const result = await runtime.cron();
if (result.errors.length) console.error("[latch cron]", result.errors);
return Response.json({ fired: result.due.fired, resumed: result.resumed.length });
}{ "crons": [{ "path": "/api/cron", "schedule": "* * * * *" }] }Set CRON_SECRET in the project's environment variables; Vercel sends it automatically.
If you mount the handler instead of writing your own routes, you don't need this one: its cron/run route accepts Vercel's request. Pass cronSecret: process.env.CRON_SECRET (createLatchApp reads it on its own) and point vercel.json at /api/latch/cron/run.
Checklist
DATABASE_URLpoints at a driver with transactions.- Migrations run on deploy.
maxDurationis set on the chat and cron routes.CRON_SECRETis set, andvercel.jsonhas the cron.- Model provider keys are in the environment.