Cron Jobs
Call a route on a schedule: cleanups, reports, syncs.
What it is
A Cron Job is a schedule in vercel.json that makes Vercel send a GET request to one of your routes, on production deployments only. The route is an ordinary Vercel Function, so it can use everything else in the lab. Because the URL is public, Vercel adds Authorization: Bearer $CRON_SECRET and the route rejects requests without it. On the Hobby plan a job can run at most once a day, at some point within the scheduled hour.
When to use it
- Cleaning up old data and files
- Sending daily digests or reports
- Syncing with an outside system on a timer
1. The job
Route
/api/cron/cleanupSchedule
0 3 * * *daily, 03:00 UTCDoesDeletes notes and uploads older than 7 days
Run it now from a terminal: vercel crons run /api/cron/cleanup. Without the secret, the route answers 401.
2. Recent runs (recorded in Redis)
Loading runs…
Show code · src/app/api/cron/cleanup/route.ts
import { type CleanupRun, cleanUp, recordRun } from "@/lib/housekeeping";
import { isAuthorized } from "@/lib/revalidate";
import { timed } from "@/lib/timed";
// Called by Vercel Cron (see vercel.json) with a GET request and
// `Authorization: Bearer $CRON_SECRET`. The URL is public, so the secret is
// what stops anyone else from triggering it.
export async function GET(request: Request) {
const secret = process.env.CRON_SECRET;
if (!secret) return Response.json({ error: "CRON_SECRET is not configured" }, { status: 503 });
if (!isAuthorized(request.headers.get("authorization"), secret)) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
const { value, ms } = await timed(() => cleanUp());
const run: CleanupRun = {
at: new Date().toISOString(),
// Vercel's scheduler identifies itself; anything else is a manual trigger.
trigger: request.headers.get("user-agent")?.startsWith("vercel-cron") ? "schedule" : "manual",
...value,
ms: Math.round(ms),
};
await recordRun(run);
return Response.json(run);
}
Show code · src/lib/housekeeping.ts
import { del, list } from "@vercel/blob";
import { dbConfigured, sql } from "@/lib/db";
import { redis, redisConfigured } from "@/lib/redis";
import { UPLOAD_PREFIX, blobConfigured } from "@/lib/uploads";
// The nightly cron job (vercel.json → /api/cron/cleanup): removes guestbook
// notes and Blob uploads older than a week, and records each run in Redis.
export const CRON_PATH = "/api/cron/cleanup";
export const CRON_SCHEDULE = "0 3 * * *"; // daily at 03:00 UTC; keep in sync with vercel.json
export const RETENTION_DAYS = 7;
export type CleanupRun = { at: string; trigger: "schedule" | "manual"; notesDeleted: number; blobsDeleted: number; ms: number };
const RUNS_KEY = "lab:cron:runs";
export const retentionCutoff = (now: Date) => new Date(now.getTime() - RETENTION_DAYS * 24 * 60 * 60 * 1000);
export async function cleanUp(now = new Date()) {
const cutoff = retentionCutoff(now);
let notesDeleted = 0;
let blobsDeleted = 0;
if (dbConfigured()) {
notesDeleted = (await sql()`DELETE FROM lab_notes WHERE created_at < ${cutoff.toISOString()} RETURNING id`).length;
}
if (blobConfigured()) {
const { blobs } = await list({ prefix: UPLOAD_PREFIX, limit: 100 });
const old = blobs.filter((b) => b.uploadedAt < cutoff).map((b) => b.pathname);
if (old.length) await del(old);
blobsDeleted = old.length;
}
return { notesDeleted, blobsDeleted };
}
// The last ten runs, newest first.
export async function recordRun(run: CleanupRun) {
if (!redisConfigured()) return;
await redis().pipeline().lpush(RUNS_KEY, JSON.stringify(run)).ltrim(RUNS_KEY, 0, 9).exec();
}
export async function recentRuns(): Promise<CleanupRun[]> {
if (!redisConfigured()) return [];
// @upstash/redis parses JSON values back into objects automatically.
return redis().lrange<CleanupRun>(RUNS_KEY, 0, 9);
}