Cron Jobs
Declare schedules on protected Rust routes; nextrs generates the Vercel and Cloudflare trigger plumbing
Vercel Hobby allows one imprecise cron per day. Cloudflare Workers' free tier handles minutely schedules. nextrs lets you use both without leaving your app: declare every schedule on its protected route, and the CLI generates the trigger for the selected provider. Your logic always runs in your Rust app on Vercel—the Cloudflare Worker (when one is generated) is a dumb trigger that fetches your route with a bearer secret. Delete it and you lose nothing but the schedule.
Declare the protected route and schedule
A cron target is an ordinary API route with #[nextrs::cron(schedule = "...")]
in place of #[nextrs::api]. The macro is api plus the auth gate: both trigger
providers send Authorization: Bearer $CRON_SECRET, and the handler answers
with a structured 401 before body extraction unless the secret matches. If
CRON_SECRET is unset, it fails closed with a structured 503.
// app/api/cron/refresh/route.rs
use axum::http::StatusCode;
use axum::Json;
#[nextrs::cron(schedule = "0 6 * * *")]
pub async fn get() -> Result<Json<Report>, StatusCode> {
// ... the actual work ...
}
Vercel is the default provider. For a subdaily schedule, generation warns that Vercel Hobby supports only daily crons. Opt into Cloudflare's more flexible free scheduling explicitly:
#[nextrs::cron(schedule = "*/10 * * * *", provider = "cloudflare")]
pub async fn get() -> Result<Json<Report>, StatusCode> {
// ... the actual work ...
}
To keep a protected route ready without scheduling it yet, disable the declaration while preserving its intended schedule:
#[nextrs::cron(schedule = "0 6 * * *", disabled = true)]
pub async fn get() -> Result<Json<Report>, StatusCode> {
// ... the actual work ...
}
Disabled declarations are validated but omitted from generated Vercel and
Cloudflare schedules. Remove disabled = true to enable the trigger. Fresh
scaffolds use this form for the heartbeat example and include an empty
CRON_SECRET entry in .env.example.
Schedules are five-field UTC cron expressions. Scheduled handlers are GET routes because both Vercel and the generated Cloudflare trigger send GET.
Delivery is at-least-once and imprecise, and redundant delivery from both providers must be harmless — write handlers idempotently (compute a deterministic time slot and tolerate redelivery rather than assuming exactly one call per tick).
Do the work foreground and let the status code tell the truth. A cron has
no user waiting, so there is no reason to respond early: run the job inline
and return 200 only when it actually completed. The status code is your
delivery receipt — it lands in the Worker's log and your Vercel logs, so a
failing job shows up as a failing tick. Responding 200 immediately and
pushing the work into WaitUntil makes every tick report success even when
the job blew up. (Cost is a wash: Vercel bills active CPU, not wall clock.)
Reach for background execution only when the work can exceed the function's
execution window or needs retry semantics of its own.
Generate and deploy
nextrs generate # writes .nextrs/cloudflare/{worker.js,wrangler.toml},
# and .nextrs/vercel.json (with Vercel-provider crons)
nextrs cron deploy # generate + `wrangler deploy` + sync CRON_SECRET
cron deploy discovers annotated routes, reads CRON_SECRET from the environment, deploys the Worker,
and stores the secret with it. It talks to Cloudflare one of two ways:
- API-direct (no wrangler, no Node): set
CLOUDFLARE_API_TOKEN(an API token with the Workers Scripts: Edit permission) andCLOUDFLARE_ACCOUNT_ID. The CLI uploads the Worker with the secret as a binding and sets the schedules over HTTPS. This is the CI path. - wrangler: with neither variable set, the CLI shells out to
wrangler and uses
its login (
wrangler login). Convenient on a workstation.
Set the same CRON_SECRET on the Vercel project (vercel env add CRON_SECRET) so the app can verify what the Worker sends.
Before touching Cloudflare, cron deploy runs a preflight: it fetches each
cloudflare-provider route at app.url without credentials and expects a
401. A 404 means the route isn't deployed there (wrong app.url or stale
deploy); a 200 means the route is missing its #[nextrs::cron] gate; unreachable
means the URL is wrong. Any of those aborts the deploy with the specifics —
NEXTRS_CRON_SKIP_PREFLIGHT=1 overrides when you know better.
The generated .nextrs/cloudflare/ directory is disposable — gitignore it and
regenerate on demand. Vercel-provider crons deploy with the app itself; the
Worker redeploys with nextrs cron deploy whenever schedules change.
Scaffolded apps ship with a disabled daily heartbeat starter. After setting
CRON_SECRET, remove disabled = true to generate its native Vercel trigger;
it needs no Cloudflare account. Use
app/api/cron/heartbeat/route.rs as the gated route to copy from. The
worked example examples/react-todos runs the same route every 10 minutes
through the Cloudflare shim.
See dependencies for the full tooling list.