How to send email from Next.js with a route handler or a server action
Published Updated 9 min readBy the Rasket team

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.
- 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.
- Install the client — Run npm install rasket in your Next.js project. The package has no dependencies of its own, so it adds one entry to the lockfile rather than a subtree.
- 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.
- Write a route handler — Create app/api/send/route.ts, read the request body, call emails.send with an idempotency key derived from the thing that caused the send, and return the email id.
- 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.
- 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.
Install the client and keep the key on the server
The client is one package with no dependencies of its own, generated from the same OpenAPI document the API validates itself against, so the request and response types are the ones the server actually accepts.
npm install rasketThen 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 instance of the client at module scope is the right shape. It holds no connection and no state beyond its configuration, so creating one per request costs you nothing but noise in the code.
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 { Rasket } from "rasket";
const rasket = new Rasket({ apiKey: process.env.RASKET_API_KEY, userAgent: "acme-web/1.0",});
export async function POST(request: Request): Promise<Response> { const { orderId, to } = await request.json();
const { body: email } = await rasket.emails.send( { from: "Acme <receipts@send.acme.example>", to: [to], subject: "Your receipt", html: "<p>Thanks for your order.</p>", }, { idempotencyKey: `receipt-${orderId}` }, );
return Response.json({ id: email.id });}Three details in that file carry most of the weight. The key comes from the environment. The userAgent 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 { Rasket } from "rasket";
const rasket = new Rasket({ apiKey: process.env.RASKET_API_KEY, userAgent: "acme-web/1.0",});
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 rasket.emails.send({ from: "Acme <enquiries@send.acme.example>", to: ["sales@acme.example"], replyTo: 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.
| Route handler | Server action | |
|---|---|---|
| Caller | Anything, including other systems | A form in this application |
| URL | Yours, stable, documented | Generated by the framework |
| Shape | Request in, Response out | Arguments in, value out |
| Authentication | Whatever your API uses | Your session, checked in the action |
| Progressive enhancement | No | Yes, the form works without JavaScript |
| Best for | Webhooks, mobile clients, cron jobs | Contact 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.tsimport { verify } from "@rasket/webhook-verify";
export const runtime = "nodejs"; // the verifier imports node:crypto
export async function POST(request: Request): Promise<Response> { const rawBody = await request.text(); // text(), never json()
try { const event = verify(rawBody, request.headers, process.env.RASKET_WEBHOOK_SECRET); 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, because the verifier imports node:crypto.
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_KEYandRASKET_WEBHOOK_SECRETin 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 Node SDK reference covers every method and the envelope each one returns, 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 client 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 verifier is the exception: it imports node:crypto, so a route that verifies a signature should stay on the Node runtime.
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
- Route Handlers — Next.js, read 2026-09-16
- Directives: use server — Next.js, read 2026-09-16
- Guides: Environment Variables — Next.js, read 2026-09-16
Related
- 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.