DEV Community

Anas Sheikh
Anas Sheikh

Posted on

Cron Jobs and Scheduled Tasks in Next.js 15 The Setup I Actually Use

Next.js has no built-in concept of a background job that runs on a schedule, since it is fundamentally a request-response framework. Every scheduled task, checking for expired subscriptions, cleaning up stale records, sending a daily digest email, needs something outside the normal request cycle to trigger it. Here is how I set that up.


1. Vercel Cron for Scheduled Triggers

If the project is already deployed on Vercel, Vercel Cron is the simplest option, no separate service to manage, configured directly in the project.

// vercel.json
{
  "crons": [
    {
      "path": "/api/cron/check-subscriptions",
      "schedule": "0 0 * * *"
    },
    {
      "path": "/api/cron/cleanup-old-sessions",
      "schedule": "0 */6 * * *"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

"0 0 * * *" is standard cron syntax, midnight every day. "0 */6 * * *" runs every six hours. Vercel calls each path with a GET request on the defined schedule, which means the actual logic just needs to live behind a normal route handler.


2. The Route Handler

// app/api/cron/check-subscriptions/route.ts
import { connectDB } from '@/lib/db';
import User from '@/models/User';
import { resend } from '@/lib/email';
import { PastDueEmail } from '@/emails/PastDue';

export async function GET(request: Request) {
  await connectDB();

  const expiredUsers = await User.find({
    subscriptionStatus: 'active',
    currentPeriodEnd: { $lt: new Date() },
  });

  for (const user of expiredUsers) {
    await User.findByIdAndUpdate(user._id, { subscriptionStatus: 'expired' });

    await resend.emails.send({
      from: 'billing@pixelanas.com',
      to: user.email,
      subject: 'Your subscription has expired',
      react: PastDueEmail({ name: user.name }),
    });
  }

  return Response.json({ processed: expiredUsers.length });
}
Enter fullscreen mode Exit fullscreen mode

This runs as a normal Next.js route handler, using the same database connection and email setup as everything else in the app. Nothing special about the logic itself, the only thing that changes is what triggers it.


3. Securing the Endpoint

This is the part people skip, and it matters a lot. A cron endpoint is a public URL by default, anyone who finds it can call it directly and trigger whatever it does, repeatedly, on demand.

// app/api/cron/check-subscriptions/route.ts
export async function GET(request: Request) {
  const authHeader = request.headers.get('authorization');

  if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response('Unauthorized', { status: 401 });
  }

  // proceed with the actual job
}
Enter fullscreen mode Exit fullscreen mode

Vercel automatically sends this authorization header on its own scheduled calls when CRON_SECRET is set as an environment variable, so legitimate scheduled runs work without extra configuration, while a random request without the correct secret gets rejected outright.


4. Handling Long-Running Jobs Within Serverless Limits

Serverless functions have execution time limits, and a cron job processing thousands of records in a single request can hit that ceiling. Batching keeps each invocation fast and predictable.

// app/api/cron/cleanup-old-sessions/route.ts
const BATCH_SIZE = 500;

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

  await connectDB();

  const cutoff = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);

  const result = await Session.deleteMany({
    lastActive: { $lt: cutoff },
  }).limit(BATCH_SIZE);

  return Response.json({ deleted: result.deletedCount });
}
Enter fullscreen mode Exit fullscreen mode

For a genuinely large cleanup, running this more frequently at a smaller batch size, six times a day at 500 records instead of once a day at unlimited records, keeps each individual run well within execution limits rather than risking a timeout on a single massive run.


5. Idempotency, So a Retried Job Doesn't Double-Process

Scheduled jobs can occasionally run twice, a platform retry after a slow response, a manual re-trigger while debugging. The job itself should be safe to run more than once without causing duplicate side effects.

// โŒ Sends a duplicate email if this job runs twice for the same day
await resend.emails.send({ to: user.email, subject: 'Daily digest', ... });

// โœ… Checks whether it already ran today before sending anything
const alreadySent = await DigestLog.findOne({
  userId: user._id,
  date: today,
});

if (!alreadySent) {
  await resend.emails.send({ to: user.email, subject: 'Daily digest', ... });
  await DigestLog.create({ userId: user._id, date: today });
}
Enter fullscreen mode Exit fullscreen mode

A small log collection tracking what has already run for a given period turns an accidental double-trigger from a real problem, duplicate emails, duplicate charges, into a harmless no-op.


6. Manually Triggering a Job for Testing

Since these endpoints require the secret header, testing locally means sending that header manually rather than just visiting the URL in a browser.

curl -X GET http://localhost:3000/api/cron/check-subscriptions \
  -H "Authorization: Bearer your-local-cron-secret"
Enter fullscreen mode Exit fullscreen mode

This confirms the actual logic works correctly without waiting for the real schedule to trigger it, useful during development or when debugging a job that failed in production.


7. Alternatives to Vercel Cron

For projects not on Vercel, or needing more control over scheduling than Vercel Cron's minimum one-minute granularity allows, external services like Upstash QStash or a traditional cron service calling a webhook work the same way, an external trigger calling a secured route handler on a schedule. The route handler code itself stays identical either way, only the thing invoking it changes.


Summary

Piece Handles
vercel.json cron config Defining the schedule without a separate service
Route handler with normal app logic The actual job, using the same db/email setup as everything else
CRON_SECRET header check Preventing anyone from triggering the job by just visiting the URL
Batched processing Staying within serverless execution time limits
Idempotency check before side effects Safe behavior if the job accidentally runs twice
Manual curl trigger with the auth header Testing the job locally without waiting on the real schedule

The part that actually matters most here is treating a cron endpoint with the same seriousness as any other route that changes data or sends emails. It is a public URL until proven otherwise, and the two failure modes that actually cause damage, an unsecured endpoint and a non-idempotent job, are both fixed with a small amount of code up front.

I use this exact Vercel Cron setup, secured with a shared secret, idempotent through a log check, for subscription checks and cleanup jobs across the SaaS projects I build.

Get the templates: https://pixelanas.gumroad.com

Do you run scheduled jobs on Vercel Cron, or reach for something external? Drop it below ๐Ÿ‘‡


Anas, full-stack Next.js developer building SaaS products and premium templates. X: @ASheikh69751

Top comments (0)