Skip to main content

How to Add Background Jobs to Your SaaS Starter 2026

·StarterPick Team
bullmqinngestbackground-jobsredissaas-boilerplate2026

TL;DR

Inngest is the simplest way to add background jobs to a Next.js SaaS in 2026 — no Redis needed, works in serverless, and has a great local dev experience. BullMQ is the choice when you need maximum control (priority queues, rate limiting, job chaining). For scheduled jobs: Inngest cron() or Vercel Cron + an API route.

Key Takeaways

  • Inngest: Serverless-native, no Redis, runs in Next.js API routes, great DX
  • BullMQ: Redis-backed, battle-tested, priority queues, best for high throughput
  • Trigger.dev v3: Cloud-hosted job runner, long-running tasks, no infrastructure
  • Vercel Cron: Simple scheduled tasks, free, 1-minute resolution
  • Use cases: Email sending, PDF generation, AI jobs, Stripe billing, data sync

Option 1: Inngest (Serverless-Native)

npm install inngest
// inngest/client.ts:
import { Inngest } from 'inngest';

export const inngest = new Inngest({ id: 'my-saas' });
// inngest/functions.ts — define background functions:
import { inngest } from './client';
import { sendWelcomeEmail } from '@/lib/email';
import { generateInvoicePDF } from '@/lib/pdf';

// Welcome email after signup:
export const sendWelcomeEmailFn = inngest.createFunction(
  { id: 'send-welcome-email', retries: 3 },
  { event: 'user/signed-up' },
  async ({ event, step }) => {
    const { userId, email, name } = event.data;

    // step.run() wraps each step with automatic retry:
    await step.run('send-email', () =>
      sendWelcomeEmail({ to: email, name })
    );

    // Delay 24 hours, then send onboarding tip:
    await step.sleep('wait-24h', '24h');
    await step.run('send-onboarding-tip', () =>
      sendOnboardingTip({ to: email, name })
    );
  }
);

// Invoice generation (can be slow):
export const generateInvoiceFn = inngest.createFunction(
  { id: 'generate-invoice', retries: 2 },
  { event: 'invoice/created' },
  async ({ event, step }) => {
    const { invoiceId } = event.data;

    const pdfUrl = await step.run('generate-pdf', () =>
      generateInvoicePDF(invoiceId)
    );

    await step.run('email-invoice', () =>
      sendInvoiceEmail({ invoiceId, pdfUrl })
    );
  }
);

// Weekly digest cron:
export const weeklyDigestFn = inngest.createFunction(
  { id: 'weekly-digest' },
  { cron: '0 9 * * MON' },  // Every Monday 9am UTC
  async ({ step }) => {
    const users = await step.run('fetch-users', () =>
      db.user.findMany({ where: { plan: 'pro' } })
    );

    await step.run('send-digests', () =>
      Promise.all(users.map(u => sendWeeklyDigest(u)))
    );
  }
);
// app/api/inngest/route.ts — register functions:
import { serve } from 'inngest/next';
import { inngest } from '@/inngest/client';
import { sendWelcomeEmailFn, generateInvoiceFn, weeklyDigestFn } from '@/inngest/functions';

export const { GET, POST, PUT } = serve({
  client: inngest,
  functions: [sendWelcomeEmailFn, generateInvoiceFn, weeklyDigestFn],
});
// Trigger an event from anywhere in your app:
import { inngest } from '@/inngest/client';

// After user signs up:
await inngest.send({
  name: 'user/signed-up',
  data: { userId: user.id, email: user.email, name: user.name },
});

// After invoice created:
await inngest.send({
  name: 'invoice/created',
  data: { invoiceId: invoice.id },
});

Option 2: BullMQ (High Throughput)

npm install bullmq ioredis
// lib/queues.ts — queue definitions:
import { Queue, Worker, QueueEvents } from 'bullmq';

const redisConnection = {
  host: process.env.REDIS_HOST ?? 'localhost',
  port: 6379,
};

// Define queues:
export const emailQueue = new Queue('email', { connection: redisConnection });
export const pdfQueue = new Queue('pdf', { connection: redisConnection });
export const syncQueue = new Queue('sync', {
  connection: redisConnection,
  defaultJobOptions: {
    attempts: 5,
    backoff: { type: 'exponential', delay: 2000 },
    removeOnComplete: 100,
    removeOnFail: 200,
  },
});

// Email worker:
export const emailWorker = new Worker(
  'email',
  async (job) => {
    const { type, to, data } = job.data;
    switch (type) {
      case 'welcome': await sendWelcomeEmail({ to, ...data }); break;
      case 'invoice': await sendInvoiceEmail({ to, ...data }); break;
      case 'digest': await sendWeeklyDigest({ to, ...data }); break;
    }
  },
  { connection: redisConnection, concurrency: 5 }
);

// Add jobs from API routes:
await emailQueue.add('send-welcome', {
  type: 'welcome',
  to: user.email,
  data: { name: user.name },
});

// Delayed job (send after 1 hour):
await emailQueue.add(
  'send-onboarding',
  { type: 'onboarding', to: user.email, data: { name: user.name } },
  { delay: 60 * 60 * 1000 }
);

// Repeatable cron job:
await emailQueue.add(
  'weekly-digest',
  { type: 'digest' },
  { repeat: { pattern: '0 9 * * MON' } }
);

Vercel Cron (Simple Scheduled Jobs)

// vercel.json:
// {
//   "crons": [
//     { "path": "/api/cron/daily-cleanup", "schedule": "0 2 * * *" },
//     { "path": "/api/cron/weekly-digest", "schedule": "0 9 * * MON" }
//   ]
// }

// app/api/cron/daily-cleanup/route.ts:
import { NextRequest } from 'next/server';

export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';

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

  // Your cleanup logic:
  const deleted = await db.session.deleteMany({
    where: { expiresAt: { lt: new Date() } },
  });

  return Response.json({ deleted: deleted.count });
}

Decision Guide

Use Inngest if:
  → Deploying on Vercel/Netlify (serverless)
  → Want great local dev experience (Inngest Dev Server)
  → Complex multi-step workflows with delays
  → Don't want to manage Redis

Use BullMQ if:
  → Already have Redis (Upstash, Railway)
  → High-volume jobs (1000+ per minute)
  → Need priority queues or rate limiting
  → Running on a server (Railway, Fly.io, EC2)

Use Vercel Cron if:
  → Simple scheduled tasks (cleanup, reports)
  → Already on Vercel, free tier (60 invocations/day)
  → No complex retry logic needed

Find SaaS boilerplates with job queues pre-configured at StarterPick.

Comments