Claude Code Boilerplate
FeaturesPricingBlogDocs
Get started →

Product

  • Features
  • Pricing
  • Skills

Compare

  • vs ShipFast
  • vs MakerKit
  • vs supastarter

Resources

  • Docs
  • Blog
  • Discord

Legal

  • License
  • Privacy Policy
  • Terms of Service
Claude Code Boilerplate

© 2026 Claude Code Boilerplate. All rights reserved.

← All posts

Background jobs in Next.js App Router on Vercel -- cron routes, job patterns, and secure triggers

June 13, 2026
nextjsvercelsaasdrizzle-ormclaude-code

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.

1. Define the schedule in vercel.json

{
  "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.

2. Secure the route

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.

3. Add CRON_SECRET to your local environment

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

4. Keep the route thin

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.

5. Set maxDuration for slow jobs

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.

Common cron patterns in a SaaS

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 }.

Takeaway

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.