Vercel has native cron support built in. You define a schedule in vercel.json, and Vercel hits a route in your app on that interval. No separate job queue, no extra infrastructure -- just an API route and a config entry.
This post covers the full pattern: config, route, service, and securing the endpoint against outside callers.
{
"crons": [
{
"path": "/api/cron/daily-digest",
"schedule": "0 9 * * *"
}
]
}
schedule is a standard cron expression. 0 9 * * * fires every day at 09:00 UTC. You can add as many cron entries as you need -- each path must map to an existing route in your app.
Vercel injects a CRON_SECRET environment variable into every deployment. When it invokes a cron route, it sends that secret as a Bearer token in the Authorization header.
Verify it at the start of the route handler before doing any work:
// app/api/cron/daily-digest/route.ts
import { NextRequest, NextResponse } from "next/server";
import { digestService } from "@/modules/digest";
export const runtime = "nodejs";
export const maxDuration = 300;
export async function GET(req: NextRequest) {
const token = req.headers.get("authorization")?.replace("Bearer ", "");
if (token !== process.env.CRON_SECRET) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
await digestService.sendDailyDigest();
return NextResponse.json({ ok: true });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 500 });
}
}
Without the CRON_SECRET check, anyone who discovers the route URL can trigger your job manually.
Vercel auto-generates CRON_SECRET for each deployment -- you do not need to set it in Vercel yourself. For local development, add a placeholder to .env:
CRON_SECRET=dev-secret-only
Then trigger the route directly during development:
curl -H "Authorization: Bearer dev-secret-only" \
http://localhost:3000/api/cron/daily-digest
The route handler does one thing: verify auth and delegate. All business logic belongs in the service layer:
// modules/digest/digest.service.ts
import { db } from "@/db/drizzle";
import { userTable } from "@/db/schema";
import { emailService } from "@/lib/email";
import { DigestEmail } from "@/emails/DigestEmail";
import React from "react";
import { eq } from "drizzle-orm";
export const digestService = {
async sendDailyDigest() {
const users = await db
.select()
.from(userTable)
.where(eq(userTable.emailVerified, true));
for (const user of users) {
await emailService.sendEmail({
to: user.email,
subject: "Your daily digest",
react: React.createElement(DigestEmail, { name: user.name }),
});
}
},
};
If sendDailyDigest throws, the route returns a 500 and Vercel records the failure in your deployment logs -- no extra observability setup needed.
Vercel functions time out after 10 seconds on the Hobby plan and 300 seconds on Pro. If your job processes many rows, set maxDuration at the top of the route file:
export const maxDuration = 300;
For jobs that could run longer than 5 minutes, break the work into batches. Track progress in the DB and process one chunk per invocation:
// process 100 users per run, pick up where the last run left off
const users = await db
.select()
.from(userTable)
.where(eq(userTable.digestSentAt, null))
.limit(100);
After processing, update digestSentAt so the next invocation skips those rows.
A few jobs that appear in almost every SaaS product and map directly to this pattern:
Schedule Route Job
0 9 * * * /api/cron/daily-digest email digest to active users
0 0 * * * /api/cron/expire-trials downgrade users past trial end date
*/5 * * * * /api/cron/process-queue drain an outbox or retry queue
0 3 * * 0 /api/cron/cleanup soft-delete old rows, archive logs
Each entry follows the same shape: a thin route that checks CRON_SECRET, calls a service method, and returns { ok: true }.
Vercel cron routes are the lowest-friction way to run background jobs in a Next.js SaaS. Define the schedule in vercel.json, verify CRON_SECRET in the route, delegate to a service, and set maxDuration if the job is slow. For long-running work, batch with a DB cursor. That is the full setup -- no separate infrastructure required.