Skip to main content
General
Background Tasks

Upstash QStash

Integrate Upstash QStash with your application for serverless-first background task processing.

Open MarkdownFull AI corpusFeedback

Upstash QStash delivers signed HTTP requests with retries, delays and schedules. It fits a serverless Next.js application because the worker is an ordinary route handler, not a persistent process.

Install the SDK

Terminal
npm install @upstash/qstash

Register server-only variables

Copy the token and both signing keys from the Upstash console:

.env
QSTASH_TOKEN=replace-me
QSTASH_CURRENT_SIGNING_KEY=replace-me
QSTASH_NEXT_SIGNING_KEY=replace-me

Add the variables to the server schema in lib/env.ts:

lib/env.ts
server: {
  // Existing variables...
  QSTASH_TOKEN: z.string().min(1),
  QSTASH_CURRENT_SIGNING_KEY: z.string().min(1),
  QSTASH_NEXT_SIGNING_KEY: z.string().min(1),
  QSTASH_URL: z.string().url().optional()
}

Expose them to the server-side validator in the same file:

lib/env.ts
runtimeEnv: {
  // Existing variables...
  QSTASH_TOKEN: process.env.QSTASH_TOKEN,
  QSTASH_CURRENT_SIGNING_KEY: process.env.QSTASH_CURRENT_SIGNING_KEY,
  QSTASH_NEXT_SIGNING_KEY: process.env.QSTASH_NEXT_SIGNING_KEY,
  QSTASH_URL: process.env.QSTASH_URL
}

Do not prefix these values with NEXT_PUBLIC_. Set a separate credential set in every deployed environment.

Create the publishing client

QSTASH_URL is optional for the managed service and useful when connecting to a local QStash server:

lib/qstash.ts
import 'server-only';

import { Client } from '@upstash/qstash';

import { env } from '@/lib/env';

export const qstash = new Client({
  token: env.QSTASH_TOKEN,
  ...(env.QSTASH_URL ? { baseUrl: env.QSTASH_URL } : {})
});

Add a signed worker route

QStash calls a public HTTP endpoint. Wrap the handler with the official App Router verifier so requests without a valid QStash signature are rejected:

app/api/tasks/process/route.ts
import { verifySignatureAppRouter } from '@upstash/qstash/nextjs';
import * as z from 'zod';

import { processStoredJob } from '@/lib/tasks/process-stored-job';

const payloadSchema = z.object({
  jobId: z.string().uuid()
});

async function handler(request: Request): Promise<Response> {
  const { jobId } = payloadSchema.parse(await request.json());

  await processStoredJob(jobId);

  return Response.json({ success: true });
}

export const POST = verifySignatureAppRouter(handler);

Create processStoredJob for your product. Load the job and its organization from the database instead of trusting tenant data carried in the message. Make the state transition idempotent so delivering the same jobId twice does not repeat billing, credits, email or another external side effect.

Return a non-success status or throw for transient failures that QStash should retry. Record permanent failures so they can be investigated without retrying forever.

Publish after authorization

Authorize the current user and persist a pending job before publishing its ID. Use the kit's getBaseUrl() rather than introducing another site URL variable:

lib/tasks/publish-job.ts
import 'server-only';

import { qstash } from '@/lib/qstash';
import { getBaseUrl } from '@/lib/utils';

export async function publishJob(jobId: string): Promise<string> {
  const result = await qstash.publishJSON({
    url: `${getBaseUrl()}/api/tasks/process`,
    body: { jobId },
    retries: 3
  });

  return result.messageId;
}

Do not expose this function directly to the browser. Call it from an authenticated server action, route or tRPC procedure after checking access to the organization that owns the stored job.

Test locally

The managed QStash service cannot deliver to an inaccessible localhost URL. Use the official local server or a public development tunnel.

Start local QStash in one terminal:

Terminal
npx @upstash/qstash-cli dev

Copy the printed local URL, token and signing keys into the root .env, then restart npm run dev. Publish a test job and verify all four outcomes:

  1. The producer returns a QStash message ID.
  2. The signed worker changes the stored job from pending to successful.
  3. A failing attempt is retried and remains observable.
  4. Publishing the same jobId again does not repeat its side effect.

Production checklist

  • Set managed QStash credentials in the production environment and remove any local QSTASH_URL override.
  • Confirm the destination uses the final HTTPS production origin.
  • Keep both signing keys configured so key rotation can complete safely.
  • Monitor QStash delivery logs and the application's stored job state.
  • Add alerts for exhausted retries and jobs that remain pending too long.
  • Avoid logging message bodies when they contain customer or organization data.

For delays, schedules, queues and callbacks, use the QStash TypeScript SDK documentation.