Skip to content
Claim your free business email

How to send email from Next.js with a route handler or a server action

Published Updated 10 min readBy the Rasket team

Sending email from Next.js with a route handler or server action: a page releasing an envelope.

What you need before the first send

To send email from Next.js you call an email API from code that runs on the server. That is the whole definition, and it is worth stating plainly because the framework gives you several places to put that call and only some of them are on the server. A Next.js email API integration is one HTTPS request; the interesting part is where you make it from and what you do with what comes back.

Three things have to exist before any of it works.

  • A verified sending domain. Mail from a domain that has not passed verification is refused before it goes anywhere. Adding one generates the DNS records to publish; the domains page shows the whole set.
  • An API key, in the environment. One key, read from process.env.RASKET_API_KEY, never written into a file that is committed.
  • A place on the server to call from. That is the part this article is about.

If you have none of those yet, the quickstart is a key, a domain and a first send in about five minutes, and everything below assumes you have been through it.

  1. Add and verify a sending domain — Add the domain the mail will come from, publish the DNS records Rasket generates for it, and wait for verification to pass. A send from an unverified domain is refused.
  2. Write a send helper — Add a server-only module that POSTs to https://api.rasket.com/emails with fetch, setting the bearer key, a User-Agent and an Idempotency-Key. There is nothing to install.
  3. Put the key in the environment — Create an API key in the dashboard and set it as RASKET_API_KEY. Do not prefix the name with NEXT_PUBLIC_, which would inline the value into the browser bundle.
  4. Write a route handler — Create app/api/send/route.ts, read the request body, send the message with an idempotency key derived from the thing that caused the send, and return the email id.
  5. Or call the same function from a server action — A file marked with the use server directive runs on the server, so a form can post straight to it without a route of your own. Validate the input there, because a server action is a public endpoint.
  6. Subscribe a webhook for delivery events — Add a second route handler, read the raw body with request.text(), verify the signature before you trust anything in it, and record the event against your own row.

Write one send helper and keep the key on the server

There is nothing to install: the send is one fetch to POST /emails, and a small helper keeps the key and the headers in one place. The server-only import makes the build fail if a client component ever imports it. Coming soon: the rasket package is not published yet.

// lib/rasket.ts — server-only: it reads the key.import "server-only";
export async function sendEmail(message: object, idempotencyKey?: string) {  const response = await fetch("https://api.rasket.com/emails", {    method: "POST",    headers: {      authorization: `Bearer ${process.env.RASKET_API_KEY}`,      "content-type": "application/json",      "user-agent": "acme-web/1.0",      ...(idempotencyKey === undefined ? {} : { "idempotency-key": idempotencyKey }),    },    body: JSON.stringify(message),  });
  if (!response.ok) {    throw new Error(`send failed: ${String(response.status)}`);  }
  return (await response.json()) as { id: string };}

Then put the key in .env.local for development, and in your host’s project settings for everything else. The signing secret for webhooks goes beside it.

RASKET_API_KEY=rk_...RASKET_WEBHOOK_SECRET=whsec_...

Never prefix the name with NEXT_PUBLIC_

Next inlines any variable whose name starts with that prefix into the browser bundle. A key that goes in there is a key anybody can read out of your JavaScript, and rotating it is the only fix.

One helper, imported wherever the app sends, is the right shape. Every send then carries the same User-Agent and reads the key the same way, so there is one place to change either.

Send from a route handler

A route handler is a file that exports an HTTP method and runs on the server. It is the right home for a send that something outside your application triggers: a webhook from your payment provider, a mobile client, a scheduled job, another service in your own estate.

// app/api/send/route.tsimport { sendEmail } from "@/lib/rasket";
export async function POST(request: Request): Promise<Response> {  const { orderId, to } = await request.json();
  const email = await sendEmail(    {      from: "Acme <receipts@send.acme.example>",      to: [to],      subject: "Your receipt",      html: "<p>Thanks for your order.</p>",    },    `receipt-${orderId}`,  );
  return Response.json({ id: email.id });}

Three details carry most of the weight. The key comes from the environment. The User-Agent names your application, because a request without one is refused and because it is what lets you tell your web process from your billing worker in a log six months from now. And the idempotency key is derived from the order rather than generated fresh, which is the difference between a retry that is safe and a retry that sends a second receipt.

Authenticate the route

A route handler is a public URL. Anything on the internet can POST to it, so whatever authenticates the rest of your API has to authenticate this too — a session cookie, a bearer token, a signature from the service that calls it. The one mistake worth naming is a route that sends to whatever address arrives in the body, with nothing checking that the caller is allowed to make you mail that address.

Send from a server action

A server action is a function marked with the use server directive. The framework gives it an endpoint of its own and wires a form to it, so a contact form can post straight to the function that sends the mail without a route of yours in between.

// app/contact/actions.ts"use server";
import { sendEmail } from "@/lib/rasket";
export async function sendEnquiry(formData: FormData) {  const email = String(formData.get("email") ?? "");  const message = String(formData.get("message") ?? "");
  // A server action is a public endpoint. Validate here, not only in the form.  if (!email.includes("@") || message.length === 0) {    return { ok: false as const, error: "Fill in both fields." };  }
  await sendEmail({    from: "Acme <enquiries@send.acme.example>",    to: ["sales@acme.example"],    reply_to: email,    subject: "New enquiry",    text: message,  });
  return { ok: true as const };}
// app/contact/page.tsximport { sendEnquiry } from "./actions";
export default function ContactPage() {  return (    <form action={sendEnquiry}>      <input name="email" type="email" required />      <textarea name="message" required />      <button type="submit">Send</button>    </form>  );}

The validation in that action is not decoration. A server action is a public endpoint just as a route handler is: the generated URL can be called directly, with whatever body the caller likes, and the fact that your form would never send that body is no protection at all. Validate the input inside the action, and rate-limit anything a stranger can reach.

When to use a route handler and when to use a server action
Route handlerServer action
CallerAnything, including other systemsA form in this application
URLYours, stable, documentedGenerated by the framework
ShapeRequest in, Response outArguments in, value out
AuthenticationWhatever your API usesYour session, checked in the action
Progressive enhancementNoYes, the form works without JavaScript
Best forWebhooks, mobile clients, cron jobsContact forms, invites, anything a page submits

Why a client component must never send

The rule is short: a component marked use client runs in the browser, and the browser cannot be trusted with a key. Everything it reads is compiled into the bundle the browser downloads, so process.env.RASKET_API_KEY in a client component is either undefined, which is confusing, or inlined, which is worse.

There is a second reason that survives even if you find a way around the first. A send made from the browser is a send you cannot audit. There is no server-side record that the message was asked for, nothing to rate-limit, nothing to check that this visitor is allowed to make you email that address. Keep the client component to collecting the input and calling your own endpoint; keep the send behind it.

Receive delivery events in the same app

A 200 from the send endpoint means the message was accepted, not that it arrived. What happened next reaches you as a signed webhook, and a Next.js application is a good place to receive one because a route handler is already the right shape.

// app/api/hooks/rasket/route.ts// npm install svix — the scheme is Svix-compatible, so the official package verifies it.import { Webhook } from "svix";
export const runtime = "nodejs";
const wh = new Webhook(process.env.RASKET_WEBHOOK_SECRET);
export async function POST(request: Request): Promise<Response> {  const rawBody = await request.text(); // text(), never json()
  try {    const event = wh.verify(rawBody, Object.fromEntries(request.headers));    await record(event); // store it, then answer    return new Response(null, { status: 200 });  } catch {    return new Response("invalid signature", { status: 400 });  }}

Two things in there are the whole contract. request.text() rather than request.json(): the signature is over the bytes we sent, and parsing an object and serialising it again produces a different string with a different signature. And the runtime is pinned to Node, which is the runtime the verification recipe is written for. The scheme is Svix-compatible, so the official svix package verifies it as it is; @rasket/webhook-verify, our own verifier, follows when it is published.

Answer quickly and do the work afterwards. Anything that is not a 2xx within ten seconds counts as a failed delivery and is retried, so a handler that spends eight seconds rendering a PDF will be delivered the same event again while it is still working. Store the event, answer, and process it in a queue. The events guide lists every event and its payload.

Deduplicate on the event id

Events can arrive more than once and can arrive out of order. The svix-id header is the event’s own identifier and is stable across retries and replays, so it is the key to deduplicate on. Treat the handler as a fold over a stream rather than a state machine expecting a sequence.

Deploying, and the environment variable that follows

The single most common report after a deployment is that the key is undefined in production. It is almost always the same cause: the variable lives in .env.local on somebody’s machine and was never added to the deployment’s own environment. Environment variables are read where the code runs, not copied from the machine that pushed it.

  • Set RASKET_API_KEY and RASKET_WEBHOOK_SECRET in the project settings of whatever host you deploy to, for every environment that sends.
  • Use a different key per environment. A key is the unit you revoke and the unit you filter the log by, so a preview deployment with its own key is a preview deployment you can switch off on its own.
  • Point the webhook at the deployment that should receive it. A webhook aimed at a preview URL stops working when the preview is torn down, and the deliveries pile up as failures.

Everything here works the same on the Edge runtime as on Node, with the one exception already named: the webhook route stays on Node. If you want the send to happen after the response has gone out — usually the right call for anything a user is waiting on — put it behind a queue or a background task rather than holding the request open.

From here, the emails API reference covers every field a send takes and what comes back, and the article on idempotent sends goes further into why the key should come from your data rather than from a random generator.

Frequently asked questions

Can I call the email API from a client component?

No, and the reason is the key rather than the call. Anything a client component reads is compiled into the JavaScript the browser downloads, so a key used there is a key anyone can copy out of the bundle. Put the send in a route handler or a server action and have the component post to that.

Route handler or server action: which should I use?

Use a server action when a form in your own application is the only caller, because it saves you a route and keeps the validation next to the form. Use a route handler when something outside the app calls it — a webhook from another service, a mobile client, a cron job — since that needs a stable URL and its own authentication.

Does the Edge runtime work?

Yes. The send is one HTTPS request built on fetch, with no Node-specific APIs, so it runs unchanged on the Edge runtime as well as on Node. The webhook route is the exception: the guide pins it to the Node runtime, which is what the verification recipe is written for.

Why is my key undefined in production?

Almost always because the variable exists locally in .env.local and was never added to the deployment's own environment. Set RASKET_API_KEY in your host's project settings and redeploy — environment variables are read at build and at request time, not copied from your machine.

How do I stop a retry from sending two emails?

Pass an idempotency key derived from the event that caused the send, such as an order id, rather than a fresh random string. A repeat of a keyed request inside the 24-hour window returns the original response and sends nothing, which is what makes retrying a timeout safe.

Can I preview the email before sending it?

Yes, and the simplest way is to render the same component to a string and return it from a development-only route. Because the body is just HTML in the request, anything that can produce HTML — a React email component, a template literal, a stored template — is a valid source for it.

Sources

  1. Route Handlers — Next.js, read 2026-09-16
  2. Directives: use server — Next.js, read 2026-09-16
  3. Guides: Environment Variables — Next.js, read 2026-09-16
  • Email API by stack — Send email from Node.js, Next.js, Python, Django, FastAPI, Rails, Laravel, Go, Bun, Deno, Cloudflare Workers or Supabase: one key, one POST, one sample.
  • Quickstart — Key, domain, first send — in that order.
  • Node SDK — The rasket package: typed from the API's own document, retries only what is safe.
  • Idempotency — Retry a send without sending it twice.
  • Send email from Node.js: API vs SMTP vs Nodemailer — Three ways to send email from Node.js — an HTTP API, SMTP and Nodemailer — what each one costs you, and a working send with retries and webhooks.

Start sending this morning

Sign up, verify a domain and send your first email in minutes.