Background Jobs Made Simple with Queuebase

Jonathan Wilke
4/1/2026
Most SaaS apps eventually need to run work outside the request cycle. Sending welcome emails, processing uploads, syncing data with third-party APIs, generating reports — these are all tasks that shouldn't block your users.
The typical solution involves setting up Redis, managing a separate worker process, and dealing with a whole new deployment concern. For a Next.js app running on Vercel or a similar platform, that's a lot of overhead for what should be straightforward.
Queuebase takes a different approach. It uses a callback model: you define job handlers as part of your app, and Queuebase stores, schedules, and retries jobs, then calls back to your app to execute them. No separate worker infrastructure. No Redis. Your job code lives right next to the rest of your application code.
In this guide, we'll walk through setting up Queuebase in a Supastarter project and cover the most common background job patterns.
How the callback model works
The flow is the same in development and production:
- Your app calls
jobClient.myJob.enqueue({ ... }) - The SDK POSTs the job to the Queuebase API
- Queuebase stores the job and schedules it
- When it's time to run, Queuebase POSTs back to your app's webhook endpoint
- Your handler executes the job and returns the result
In development, the Queuebase CLI runs a local server with SQLite. In production, the hosted API uses Postgres. Your code doesn't change between environments.
Getting started
Install the SDK and CLI:
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 handles syncing schedules to production.
Queuebase supports any Standard Schema-compatible validation library — Zod, Valibot, ArkType, and others. We'll use Zod in the examples below.
Defining jobs
Jobs are defined in a router, similar to how you'd define tRPC procedures. Each job has an input schema for validation and a handler function:
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;
The input is validated at enqueue time and again when the job executes. If validation fails, the job is rejected immediately — no wasted processing time. The type system carries through from enqueue() to your handler, so you get full autocompletion and compile-time checks.
Creating the client and webhook handler
The client provides a type-safe way to enqueue jobs:
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 line — Queuebase handles routing to the correct job based on the payload:
// app/api/queuebase/route.ts
import { createHandler } from "@queuebase/nextjs/handler";
import { jobs } from "@repo/jobs";
export const POST = createHandler(jobs);
Common background job patterns
Sending emails after signup
The most common use case. Instead of blocking the signup response while you send an email:
export async function signUp(formData: FormData) {
const user = await createUser(formData);
// Don't block the response — queue the email
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 the job 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 — 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",
},
}),
Notice the fail() call — if the user doesn't have a Stripe customer ID, there's no point retrying. fail() marks the job as permanently failed.
Scheduled jobs
Queuebase supports cron schedules for recurring work. Add a schedule property directly on the job definition. Scheduled jobs must have an empty input schema, since the scheduler has no payload to pass:
import { createJobRouter, job } from "@queuebase/nextjs";
import { z } from "zod";
export const jobs = createJobRouter({
dailyReport: job({
input: z.object({}),
schedule: "every weekday at 9am",
handler: async ({ jobId }) => {
const stats = await generateDailyStats();
await sendEmail({
to: "team@company.com",
subject: `Daily Report — ${new Date().toLocaleDateString()}`,
template: "daily-report",
data: stats,
});
return { sent: true };
},
}),
expiredSessionCleanup: job({
input: z.object({}),
schedule: {
cron: "every day at 2am",
timezone: "America/New_York",
overlap: "skip",
},
handler: async () => {
const deleted = await db
.delete(sessions)
.where(lt(sessions.expiresAt, new Date()));
return { deleted: deleted.rowCount };
},
}),
});
The schedule property accepts plain English ("every 5 minutes", "every monday at 9am") or raw cron expressions. Pass a config object when you need timezone or overlap control.
Schedules sync automatically when you start the dev server. For production, run npx queuebase sync as part of your deployment pipeline.
Running locally
Start the Queuebase dev server in one terminal:
npx queuebase dev
And your app in another:
pnpm dev
The dev server runs on port 3847 and uses SQLite — no external services needed. Trigger an enqueue from your app and watch the job flow through in the CLI output.
Production deployment
- Create a project at queuebase.com
- Set your environment variables (
QUEUEBASE_API_URL,QUEUEBASE_API_KEY,QUEUEBASE_CALLBACK_URL) - Run
npx queuebase syncto push job types and schedules - Deploy your app
The free tier includes 10,000 job runs per month — enough for most early-stage applications.
Why Queuebase?
If you've used background job systems before, the difference is what you don't have to do:
- 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
It's designed to be the simplest way to add background processing to a Next.js app while still being production-ready with retries, scheduling, concurrency limits, and monitoring.
To get started with Queuebase in your Supastarter project, check out our integration guide or visit docs.queuebase.com.