Queuebase
← Back to blog

How to Add Background Jobs to Your Supastarter Project

By Brock Herion

Queuebase is now officially part of the Supastarter ecosystem! Jonathan and the Supastarter team have added Queuebase to their docs as a first-class integration, which means adding background jobs to your Supastarter project just got a lot simpler.

If you’re not familiar, Supastarter is a production-ready SaaS starter kit for Next.js. It handles auth, billing, teams, i18n, and all the other boilerplate you’d rather not write yourself. Most SaaS apps eventually need to run work outside the request cycle, things like sending emails, processing uploads, syncing with third-party APIs, and Queuebase was designed specifically for this.

In this post, we’ll walk through setting up Queuebase in a Supastarter project and cover the most common patterns. For the full step-by-step reference, check out our integration guide.

Install the SDK

pnpm add @queuebase/nextjs zod
pnpm add -D @queuebase/cli

The SDK provides the job router, client, and webhook handler. The CLI runs the local dev server and syncs schedules to production.

Queuebase supports any Standard Schema-compatible validation library: Zod, Valibot, ArkType, and others. We’ll use Zod in these examples.

Define your jobs

Jobs are defined in a router, similar to tRPC procedures. Each job has a validated input schema and a handler:

// packages/jobs/src/index.ts
import { createJobRouter, job } from "@queuebase/nextjs";
import { z } from "zod";

export const jobs = createJobRouter({
  sendWelcomeEmail: job({
    input: z.object({
      to: z.string().email(),
      name: z.string(),
    }),
    handler: async ({ input, jobId, attempt }) => {
      await sendEmail({
        to: input.to,
        subject: `Welcome, ${input.name}!`,
        template: "welcome",
        data: { name: input.name },
      });
      return { sent: true };
    },
    defaults: {
      retries: 3,
      backoff: "exponential",
    },
  }),
});

export type JobRouter = typeof jobs;

Inputs are validated at enqueue time and again at execution. The type system carries through from enqueue() to your handler, so you get full autocompletion and compile-time checks.

Create the client and webhook handler

The client provides type-safe job enqueuing:

// packages/jobs/src/client.ts
import { createClient } from "@queuebase/nextjs";
import { jobs } from "./index";

export const jobClient = createClient(jobs, {
  apiUrl: process.env.QUEUEBASE_API_URL ?? "http://localhost:3847",
  apiKey: process.env.QUEUEBASE_API_KEY,
  callbackUrl:
    process.env.QUEUEBASE_CALLBACK_URL ??
    "http://localhost:3000/api/queuebase",
});

The webhook handler is a single route — Queuebase handles routing to the correct job:

// apps/web/app/api/queuebase/route.ts
import { createHandler } from "@queuebase/nextjs/handler";
import { jobs } from "@repo/jobs";

export const POST = createHandler(jobs);

Common patterns

Here’s some common patterns you’ll reach for most often in a SaaS app.

Sending emails after signup

Instead of blocking the signup response while you talk to your email provider:

export async function signUp(formData: FormData) {
  const user = await createUser(formData);

  await jobClient.sendWelcomeEmail.enqueue({
    to: user.email,
    name: user.name,
  });

  redirect("/dashboard");
}

If the email provider is slow or temporarily down, the user still gets signed up. Queuebase retries automatically with exponential backoff.

Processing uploads

Image resizing, PDF generation, file conversions, anything that takes more than a second or two:

processUpload: job({
  input: z.object({
    fileUrl: z.string().url(),
    userId: z.string(),
    outputFormat: z.enum(["webp", "png", "jpg"]),
  }),
  handler: async ({ input, jobId }) => {
    const file = await fetch(input.fileUrl);
    const processed = await sharp(await file.arrayBuffer())
      .resize(1200, 630)
      .toFormat(input.outputFormat)
      .toBuffer();

    const url = await uploadToStorage(processed, `${jobId}.${input.outputFormat}`);
    await db.update(files).set({ processedUrl: url }).where(eq(files.userId, input.userId));

    return { url };
  },
  defaults: {
    retries: 2,
    backoff: "linear",
  },
}),

Syncing with external APIs

Webhook deliveries, CRM updates, analytics events, and anything that depends on a third-party service:

syncToStripe: job({
  input: z.object({
    userId: z.string(),
    plan: z.enum(["free", "pro", "enterprise"]),
  }),
  handler: async ({ input, fail }) => {
    const user = await db.query.users.findFirst({
      where: eq(users.id, input.userId),
    });

    if (!user?.stripeCustomerId) {
      fail("User has no Stripe customer ID");
      return;
    }

    await stripe.subscriptions.update(user.stripeSubscriptionId, {
      items: [{ price: PRICE_IDS[input.plan] }],
    });

    return { synced: true };
  },
  defaults: {
    retries: 5,
    backoff: "exponential",
  },
}),

The fail() call marks a job as permanently failed — if the user doesn’t have a Stripe customer ID, retrying won’t help.

Scheduled jobs

For recurring work, add a schedule property directly on the job definition:

dailyCleanup: job({
  input: z.object({}),
  schedule: "every day at 2am",
  handler: async ({ jobId }) => {
    const deleted = await db
      .delete(sessions)
      .where(lt(sessions.expiresAt, new Date()));
    return { deleted: deleted.rowCount };
  },
}),

Schedules accept plain English ("every 5 minutes", "every weekday at 9am") or cron expressions. Pass a config object for timezone or overlap control:

schedule: {
  cron: "every day at 2am",
  timezone: "America/New_York",
  overlap: "skip",
}

Running locally

Start the Queuebase dev server in one terminal:

npx queuebase dev

And your Supastarter app in another:

pnpm dev

The dev server runs on port 3847 with SQLite with no external services needed. Trigger an enqueue from your app and watch the job flow through in the CLI output.

Going to production

  1. Create a project at queuebase.com
  2. Set your environment variables (QUEUEBASE_API_URL, QUEUEBASE_API_KEY, QUEUEBASE_CALLBACK_URL)
  3. Run npx queuebase sync to push job types and schedules
  4. Deploy your app

The free tier includes 10,000 job runs per month, enough for most early-stage apps.

What you don’t have to set up

If you’ve used background job systems before, the difference is what you skip:

  • No Redis or separate database - Queuebase handles job storage
  • No worker process - jobs execute in your app via the webhook handler
  • No deployment complexity - same deployment, same infrastructure
  • Full type safety - input validation and type inference from enqueue to handler

For the complete setup walkthrough, check out our Supastarter integration guide. You can also find Queuebase directly in the Supastarter docs.