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 * * *"
}
]
}
"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 });
}
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
}
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 });
}
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 });
}
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"
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)