Vercel logo

How to run cron jobs on Vercel and Next.js

Vercel has built-in cron jobs: you declare a path and a schedule, and Vercel sends a GET request to that path on your production deployment at the right time. It works with any framework, but most people use it to call a Next.js route handler that cleans up data, sends emails or syncs an API.

This guide walks through a complete, secured setup, the plan limits that catch people out, and what to do when you need a job to run more often than the Hobby plan allows.

Short answer

Create a route that checks Authorization: Bearer $CRON_SECRET, add a crons entry to vercel.json, set the CRON_SECRET environment variable and deploy to production. On the Hobby plan a job runs at most once a day, somewhere within the hour you chose. For more frequent runs, use Pro or call the same route from an external scheduler.

Step 1: create a protected route

Your cron route is a normal public URL, so anyone who finds it can call it. Vercel solves this by sending the value of the CRON_SECRET environment variable in the Authorization header of every cron request. Check it before doing anything.

With the Next.js App Router, create app/api/cron/cleanup/route.ts:

// app/api/cron/cleanup/route.ts
export const maxDuration = 300; // seconds: up to 300 on Hobby, 800 on Pro

export async function GET(request: Request) {
  const auth = request.headers.get('authorization');
  if (!process.env.CRON_SECRET || auth !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response('Unauthorized', { status: 401 });
  }

  const deleted = await deleteExpiredSessions(); // your job
  return Response.json({ ok: true, deleted });
}

With the Pages Router, the same check goes in pages/api/cron/cleanup.ts:

import type { NextApiRequest, NextApiResponse } from 'next';

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.headers.authorization !== `Bearer ${process.env.CRON_SECRET}`) {
    return res.status(401).end('Unauthorized');
  }
  await deleteExpiredSessions();
  res.status(200).json({ ok: true });
}

Two details matter here. The !process.env.CRON_SECRET guard stops the route from accepting Bearer undefined if the variable is missing. And if you use middleware.ts for authentication, exclude /api/cron from its matcher: Vercel cron jobs do not follow redirects, so a redirect to /login means the job silently never runs.

Step 2: set CRON_SECRET

Generate a long random value (Vercel recommends at least 16 characters) and add it as an environment variable for the Production environment, either in the project settings or with the CLI:

openssl rand -hex 32
vercel env add CRON_SECRET production

Redeploy after adding it, because environment variables are read at deployment time.

Step 3: declare the schedule

Add a crons array to vercel.json at the root of the project:

{
  "crons": [
    { "path": "/api/cron/cleanup", "schedule": "0 5 * * *" }
  ]
}

If you have moved to the TypeScript configuration file introduced in December 2025, the same array goes in vercel.ts. A project can only use one configuration file.

// vercel.ts
import type { VercelConfig } from '@vercel/config/v1';

export const config: VercelConfig = {
  crons: [{ path: '/api/cron/cleanup', schedule: '0 5 * * *' }],
};

Rules for the expression:

  • It is always evaluated in UTC. 0 5 * * * is 05:00 UTC, which is 06:00 or 07:00 in Paris.
  • Day and month names such as MON or JAN are not supported. Use numbers.
  • You cannot restrict both the day of the month and the day of the week in the same expression.
  • The path must start with / and point to the exact route, without relying on a redirect.

Not sure about an expression? Check it with the cron analyzer, or start from a ready-made schedule such as every day or every hour.

Step 4: deploy and test

Cron jobs only run on production deployments, never on preview deployments. After deploying, test the route yourself with the secret:

curl -i -H "Authorization: Bearer $CRON_SECRET" \
  https://your-app.vercel.app/api/cron/cleanup

You should get 200, and 401 without the header. In your function logs, real cron invocations have the user agent vercel-cron/1.0 and an x-vercel-cron-schedule header containing the expression.

Plan limits and behaviour

HobbyProEnterprise
Cron jobs per project100100100
Most frequent scheduleOnce a dayOnce a minuteOnce a minute
Timing precisionWithin the hour (±59 min)Per minutePer minute
Function max duration (Fluid compute)300 s800 s800 s

On Hobby, a schedule of 0 8 * * * can fire at any moment between 08:00:00 and 08:59:59, and a schedule that runs more than once a day fails the deployment with the error "Hobby accounts are limited to daily cron jobs".

On every plan, keep these behaviours in mind:

  • No retries. If the function fails, Vercel does not call it again.
  • Best-effort delivery. A run can be missed, and the same run can occasionally be delivered more than once.
  • Overlaps are possible. If a job runs longer than its interval, the next run starts anyway.
  • No redirects. A 3xx response, for example from a trailing-slash or www redirect, is not followed.

Make the job safe to run twice

Because a run can be duplicated or overlap with the previous one, design the job so that running it twice does no harm. A simple way is to record each run in your database with a unique key and skip the work if the key already exists. With Postgres:

// once per day, even if the route is called twice
const runKey = `cleanup:${new Date().toISOString().slice(0, 10)}`;

const { rowCount } = await db.query(
  'INSERT INTO cron_runs (key) VALUES ($1) ON CONFLICT DO NOTHING',
  [runKey],
);
if (rowCount === 0) {
  return Response.json({ ok: true, skipped: 'already ran today' });
}

For jobs that process a queue, prefer small batches with a LIMIT and mark each row as done in the same transaction, so a second run simply finds less work.

Run more often on Hobby with an external scheduler

Because the route is protected by a secret rather than by Vercel itself, any scheduler that can send the same header can call it. This is how you run a job every 10 minutes on the Hobby plan, get minute precision, or keep a history of every call outside Vercel.

Diagram showing Vercel Cron Jobs and a Cronit job both calling the same protected Next.js route, which returns 200 when the secret matches and 401 otherwise.
One route, one secret check, two possible triggers.

To set it up in Cronit:

  1. Create a new recurring job and name it after the route.
  2. Set the method to GET and the URL to your production address, for example https://your-app.vercel.app/api/cron/cleanup. Use the final URL, so no redirect is involved.
  3. In Headers, add Authorization: Bearer followed by your CRON_SECRET.
  4. Choose the schedule, for example */10 * * * * for every 10 minutes.
  5. Remove the matching entry from vercel.json so the job does not also run once a day from Vercel.

Every call shows up in the job's execution logs with its status, so a 401 after a secret rotation or a 500 from a bad deploy is visible straight away. The function invocations still count toward your Vercel usage, exactly as with Vercel's own cron jobs.

FAQ

Why is my Vercel cron job not running?
Check, in order: that you deployed to production (preview deployments never run cron jobs), that the path matches the route exactly, that middleware or a domain redirect is not answering with a 3xx, and that the route does not return 401 because CRON_SECRET is missing in Production.
What time zone do Vercel cron jobs use?
Always UTC. There is no time zone option, so convert your local time to UTC and remember that the offset changes with daylight saving time.
Can a Vercel cron job run every minute?
On Pro and Enterprise, yes. On Hobby, the most frequent schedule is once a day; use an external scheduler to call the route more often.
Does Vercel retry failed cron jobs?
No. Log failures yourself, or use a scheduler that records every call so you can see when a run returned an error.

Run online cronjobs without server setup

Plan, schedule and run periodic tasks and scripts, from your browser with the first online cronjob platform.

Try for free