If you’re sending emails inside a Next.js API route or server action, you’re probably blocking the response while you wait for your email provider to respond. That works fine when Resend or SendGrid responds in 200ms. It doesn’t work when they’re slow, rate-limiting you, or temporarily down.
On Vercel, your function has a timeout. A slow email provider burns through it. If the send fails, there’s no retry. The email is just gone. And your user is sitting there waiting for a response while all of this happens.
The fix is straightforward: don’t send the email in the request. Enqueue it as a background job, return the response immediately, and let the job run separately with automatic retries.
What we’re building
A Next.js app that handles a “invite team member” form submission. When the form is submitted, the server action enqueues a background job and immediately redirects the user. The job sends the invite email via Resend, with automatic retries if the send fails.
We’ll use Queuebase for the job queue and Resend for email delivery.
Prerequisites
- Next.js 14+ (App Router)
- A Resend account and API key
- Node.js 18+
1. Install dependencies
npm install @queuebase/nextjs resend zod
npm install -D @queuebase/cli
@queuebase/nextjs gives you the job router, client, and webhook handler. @queuebase/cli runs the local dev server. Zod handles input validation, though any Standard Schema-compatible library works.
2. Create the Resend client
// src/lib/resend.ts
import { Resend } from "resend";
export const resend = new Resend(process.env.RESEND_API_KEY);
3. Define the email job
Jobs are defined in a router, similar to tRPC procedures. Each job has a validated input schema and a handler:
// src/jobs/index.ts
import { createJobRouter, job } from "@queuebase/nextjs";
import { z } from "zod";
import { resend } from "@/lib/resend";
export const jobs = createJobRouter({
sendInviteEmail: job({
input: z.object({
to: z.string().email(),
inviterName: z.string(),
teamName: z.string(),
}),
handler: async ({ input, jobId, attempt }) => {
console.info(
`[job ${jobId}] Sending invite to ${input.to} (attempt ${attempt})`
);
await resend.emails.send({
from: "noreply@yourapp.com",
to: input.to,
subject: `${input.inviterName} invited you to ${input.teamName}`,
html: `<p>${input.inviterName} invited you to join <strong>${input.teamName}</strong>.</p>
<p><a href="https://yourapp.com/accept-invite">Accept invite</a></p>`,
});
return { sent: true };
},
defaults: {
retries: 3,
backoff: "exponential",
},
}),
});
export type JobRouter = typeof jobs;
If Resend returns an error, the job fails and Queuebase retries it automatically, up to 3 times with exponential backoff. You don’t write any retry logic.
4. Create the webhook handler
This is the endpoint Queuebase calls to execute your jobs:
// src/app/api/queuebase/route.ts
import { createHandler } from "@queuebase/nextjs/handler";
import { jobs } from "@/jobs";
export const POST = createHandler(jobs);
When a job is ready to run, Queuebase POSTs to this endpoint. The handler routes the request to the correct job and executes it.
5. Create the client
The client gives you type-safe job enqueuing:
// src/jobs/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",
});
6. Enqueue from a server action
Now use the client to enqueue the email job when the invite form is submitted:
// src/app/actions.ts
"use server";
import { redirect } from "next/navigation";
import { jobClient } from "@/jobs/client";
export async function inviteTeamMember(formData: FormData) {
const email = formData.get("email") as string;
await jobClient.sendInviteEmail.enqueue({
to: email,
inviterName: "Brock",
teamName: "Acme Inc",
});
redirect("/team?invited=true");
}
The enqueue() call validates the input against the Zod schema, sends the job to Queuebase, and returns immediately. The user gets redirected right away, they don’t wait for Resend.
7. Run it locally
In one terminal, start the Queuebase dev server:
npx queuebase dev
In another, start your Next.js app:
npm run dev
Submit the invite form. You should see the job logged in both terminals with the CLI showing the job being received and dispatched, and your Next.js terminal showing the console.info output from the handler.
What you get for free
By moving the email send to a background job, you picked up a few things without writing extra code:
- Automatic retries — if Resend is down or rate-limiting you, Queuebase retries with exponential backoff. The email gets sent when the provider recovers.
- Job persistence — jobs are stored in a database, not in memory. They survive server restarts and redeployments.
- Monitoring — the Queuebase dashboard shows every job: pending, completed, failed. You can see exactly which emails failed and why.
- Same flow in dev and production — the CLI uses SQLite locally, the hosted API uses Postgres in production. Your code doesn’t change.
Going further
This pattern works for any email: welcome emails, password resets, receipts, notifications. Once you have the job router set up, adding a new email type is just another job definition.
A few things worth exploring next:
- Scheduled jobs — send digest emails on a recurring schedule
- The full comparison — how Queuebase compares to other background job options for Next.js
- Getting started guide — the complete setup walkthrough