QStash is a serverless message queue from Upstash — client.publishJSON({ url, body, retries, delay }) enqueues a job that delivers to an HTTP endpoint. Receiver.verify({ signature, body }) validates the Upstash-Signature header in the handler. delay: "5m" or notBefore: timestamp schedule future delivery. retries: 3 automatically retries on non-2xx responses with exponential backoff. client.schedules.create({ cron: "0 9 * * *", destination: url }) creates a cron-based recurring schedule. deduplicationId prevents duplicate messages in queues. callbackUrl notifies a different endpoint on job success or failure. client.queues.upsert({ queueName, parallelism }) sets concurrency limits. QStash is stateless and HTTP-native — jobs are just POST requests to your Route Handlers. No persistent connections or workers needed. Claude Code generates QStash email job queues, image processing pipelines, scheduled reports, and retry-safe notification delivery.
CLAUDE.md for QStash
## QStash Stack
- Version: @upstash/qstash >= 2.7
- Init: const client = new Client({ token: process.env.QSTASH_TOKEN! })
- Publish: await client.publishJSON({ url: `${APP_URL}/api/jobs/send-email`, body: { userId, template } })
- Delay: await client.publishJSON({ url, body, delay: "10m" })
- Schedule: await client.schedules.create({ destination: url, cron: "0 9 * * 1-5", body: {}, retries: 3 })
- Verify: const receiver = new Receiver({ currentSigningKey: QSTASH_CURRENT_KEY, nextSigningKey: QSTASH_NEXT_KEY })
- Handler: await receiver.verify({ signature: req.headers["upstash-signature"], body: rawBody })
QStash Client Setup
// lib/queue/qstash.ts — QStash client and publisher
import { Client, Receiver, type PublishRequest } from "@upstash/qstash"
export const qstash = new Client({
token: process.env.QSTASH_TOKEN!,
})
export const qstashReceiver = new Receiver({
currentSigningKey: process.env.QSTASH_CURRENT_SIGNING_KEY!,
nextSigningKey: process.env.QSTASH_NEXT_SIGNING_KEY!,
})
const APP_URL = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: process.env.APP_URL ?? "http://localhost:3000"
// ── Job publishers ─────────────────────────────────────────────────────────
export type EmailJobPayload = {
to: string
template: "welcome" | "password_reset" | "order_confirmed" | "subscription_ending"
data: Record<string, string | number>
}
export async function enqueueEmail(payload: EmailJobPayload, delaySeconds?: number) {
return qstash.publishJSON({
url: `${APP_URL}/api/jobs/send-email`,
body: payload,
retries: 3,
delay: delaySeconds,
headers: {
"x-job-type": "email",
},
})
}
export type ImageJobPayload = {
fileKey: string
userId: string
operations: ("resize" | "webp" | "thumbnail")[]
targetWidth?: number
}
export async function enqueueImageProcessing(payload: ImageJobPayload) {
return qstash.publishJSON({
url: `${APP_URL}/api/jobs/process-image`,
body: payload,
retries: 2,
// Image jobs use a queue to limit concurrency
queue: {
name: "image-processing",
parallelism: 3,
},
})
}
export type WebhookDeliveryPayload = {
webhookId: string
eventType: string
payload: unknown
targetUrl: string
secret: string
}
export async function enqueueWebhookDelivery(
payload: WebhookDeliveryPayload,
callbackUrl?: string,
) {
return qstash.publishJSON({
url: `${APP_URL}/api/jobs/deliver-webhook`,
body: payload,
retries: 5,
retryDelay: "geometric", // 1s, 2s, 4s, 8s, 16s
callbackUrl,
failureCallbackUrl: callbackUrl ? `${APP_URL}/api/jobs/webhook-failed` : undefined,
})
}
// Batch: send multiple emails at once
export async function enqueueBatchEmails(payloads: EmailJobPayload[]) {
return qstash.batchJSON(
payloads.map(payload => ({
url: `${APP_URL}/api/jobs/send-email`,
body: payload,
retries: 3,
})),
)
}
Job Handlers (Next.js Route Handlers)
// app/api/jobs/send-email/route.ts — QStash email job handler
import { NextRequest, NextResponse } from "next/server"
import { qstashReceiver } from "@/lib/queue/qstash"
import type { EmailJobPayload } from "@/lib/queue/qstash"
import { resend } from "@/lib/email/resend"
export async function POST(request: NextRequest) {
// Verify QStash signature
const signature = request.headers.get("upstash-signature")
if (!signature) {
return NextResponse.json({ error: "Missing signature" }, { status: 401 })
}
const rawBody = await request.text()
try {
await qstashReceiver.verify({ signature, body: rawBody })
} catch {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 })
}
const payload: EmailJobPayload = JSON.parse(rawBody)
try {
await sendEmailByTemplate(payload)
return NextResponse.json({ ok: true })
} catch (err) {
// Non-2xx triggers QStash retry
console.error("[email job]", err)
return NextResponse.json({ error: "Email failed" }, { status: 500 })
}
}
async function sendEmailByTemplate(payload: EmailJobPayload) {
const { to, template, data } = payload
const templates = {
welcome: {
subject: `Welcome, ${data.name}!`,
html: `<h1>Welcome to ${data.appName}!</h1><p>Your account is ready.</p>`,
},
order_confirmed: {
subject: `Order #${data.orderId} confirmed`,
html: `<h1>Order confirmed!</h1><p>Total: ${data.total}</p>`,
},
// ... other templates
}
const tmpl = templates[template]
if (!tmpl) throw new Error(`Unknown template: ${template}`)
await resend.emails.send({
from: "[email protected]",
to,
subject: tmpl.subject,
html: tmpl.html,
})
}
Cron Schedules
// lib/queue/schedules.ts — QStash cron schedule management
import { qstash } from "./qstash"
const APP_URL = process.env.APP_URL ?? "http://localhost:3000"
export async function setupCronJobs() {
const existing = await qstash.schedules.list()
const existingDests = new Set(existing.map(s => s.destination))
// Daily 9am digest email
if (!existingDests.has(`${APP_URL}/api/jobs/daily-digest`)) {
await qstash.schedules.create({
destination: `${APP_URL}/api/jobs/daily-digest`,
cron: "0 9 * * 1-5", // Weekdays at 9am UTC
body: JSON.stringify({ type: "digest" }),
retries: 3,
})
}
// Weekly Sunday report
if (!existingDests.has(`${APP_URL}/api/jobs/weekly-report`)) {
await qstash.schedules.create({
destination: `${APP_URL}/api/jobs/weekly-report`,
cron: "0 8 * * 0", // Sundays at 8am UTC
retries: 2,
})
}
// Hourly subscription expiry check
if (!existingDests.has(`${APP_URL}/api/jobs/check-expiries`)) {
await qstash.schedules.create({
destination: `${APP_URL}/api/jobs/check-expiries`,
cron: "7 * * * *", // Every hour at :07
retries: 1,
})
}
}
// Schedule a one-time job at a specific time
export async function scheduleAt(
url: string,
body: unknown,
at: Date,
) {
const delay = Math.max(0, Math.floor((at.getTime() - Date.now()) / 1000))
return qstash.publishJSON({
url: `${APP_URL}${url}`,
body,
delay,
retries: 2,
})
}
Middleware Helper
// lib/queue/middleware.ts — reusable QStash verification wrapper
import { NextRequest, NextResponse } from "next/server"
import { qstashReceiver } from "./qstash"
type JobHandler<T> = (payload: T, request: NextRequest) => Promise<void | Response>
// Wrap a Next.js Route Handler with QStash signature verification
export function withQStash<T>(handler: JobHandler<T>) {
return async function POST(request: NextRequest): Promise<NextResponse> {
const signature = request.headers.get("upstash-signature")
// Allow direct calls in development without signature
if (process.env.NODE_ENV === "development" && !signature) {
const body = await request.json() as T
await handler(body, request)
return NextResponse.json({ ok: true })
}
if (!signature) {
return NextResponse.json({ error: "Missing signature" }, { status: 401 })
}
const rawBody = await request.text()
try {
await qstashReceiver.verify({ signature, body: rawBody })
} catch {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 })
}
try {
const payload = JSON.parse(rawBody) as T
const response = await handler(payload, request)
if (response) return response as NextResponse
return NextResponse.json({ ok: true })
} catch (err) {
console.error("[QStash job error]", err)
return NextResponse.json({ error: "Job failed" }, { status: 500 })
}
}
}
// Usage:
// export const POST = withQStash<EmailJobPayload>(async (payload) => {
// await sendEmailByTemplate(payload)
// })
For the Trigger.dev alternative when a code-native job runner with in-process task definitions, TypeScript-native flows, delays, wait-for-event, concurrency control, and a cloud dashboard for monitoring is preferred — Trigger.dev runs jobs as code in your repo rather than HTTP endpoint deliveries, making complex multi-step workflows easier to author and debug, see the Trigger.dev guide. For the Inngest alternative when event-driven background functions with automatic retries, step primitives (step.run, step.waitForEvent), and a local dev server are preferred — Inngest is similar to Trigger.dev but uses a push-based event model while QStash uses URL-based queue delivery, see the Inngest guide. The Claude Skills 360 bundle includes QStash skill sets covering job queues, scheduling, and signature verification. Start with the free tier to try serverless queue generation.