Skip to content
Claim your free business email
Email API for Next.js

Send email from Next.js with an email API

Send email from Next.js in a route handler or a server action: one fetch, the key read on the server, an id back for every message.

Next.js — app/api/send/route.ts
// app/api/send/route.ts — fetch is built in.
// A server action is the same call: the key is// read on the server, never in a client component.export async function POST(request: Request) {  const apiKey = process.env.RASKET_API_KEY;  if (apiKey === undefined) {    return Response.json(      { error: "not configured" },      { status: 500 },    );  }
  const { orderId } = (await request.json()) as {    orderId: string;  };
  const response = await fetch(    "https://api.rasket.com/emails",    {      method: "POST",      headers: {        Authorization: `Bearer ${apiKey}`,        "User-Agent": "acme-billing/1.0",        "Content-Type": "application/json",        "Idempotency-Key": `order-${orderId}`,      },      body: JSON.stringify({        from: "Acme <orders@send.acme.example>",        to: ["ronald.williams@example.com"],        subject: "Your order has shipped",        html: "<p>Order 1042 shipped today.</p>",      }),    },  );
  if (!response.ok) {    return Response.json(      { error: "send failed" },      { status: 502 },    );  }
  const email = (await response.json()) as {    id: string;  };  return Response.json({ id: email.id });}

What sending from Next.js actually involves

To send email from Next.js you send it from the server half of the application — a route handler, a server action or a server component — never from the browser, because the request carries an API key. The sample below is a route handler at app/api/send/route.ts; a server action reads the same environment variable and makes the same fetch.

Last updated 2026-10-13. Every field, header and limit below is the one the emails reference carries.

What you get with the key

Five things that arrive with the first request rather than with a later plan.

  • Idempotent sends

    Put an Idempotency-Key on a send and repeat it as often as you like. For 24 hours the same key and payload return the first response instead of a second email, and a different payload under that key is refused rather than sent.

  • Signed webhooks you can replay

    Delivery, bounce, complaint, open and click posted to your endpoint with a timestamp and a signature, retried on failure and replayable from the dashboard.

  • Inbound mail on your own domain

    Receive at your domain on every plan. Each message arrives on a signed webhook with its headers, text and HTML, and its attachments behind signed links.

  • Templates with typed variables

    Publish a template, then send it by ID or alias with the values it declares. Every email records the exact version it was rendered from.

  • Campaigns and automations

    Contacts with typed properties, topics people subscribe to, and per-contact workflows that wait, branch and send — through the same pipeline as the rest of your mail.

How to send email from Next.js

Six steps, in this order. Everything before the fifth is done once, and only the fifth is about the language you write in.

  1. 1

    Create an API key

    Open API keys in the dashboard and create one. It is shown once and stored hashed, so copy it then. Put it in your environment as RASKET_API_KEY and read it from there; a key committed to a repository is a key you have to rotate.

  2. 2

    Add a sending domain

    POST /domains with a domain you control. A subdomain such as send.acme.example is the usual choice, because it keeps this mail's reputation separate from the address your people write from. The response carries a records array.

  3. 3

    Publish the DNS records

    Every row of records goes into your DNS: the DKIM TXT record at rasket._domainkey, and the CNAME that gives the domain its own return path. Nothing sends from the domain until they resolve.

  4. 4

    Verify the domain

    POST /domains/{domain_id}/verify. The domain comes back with a status on each record, and once it reads verified you can send from any address on it. An unverified sender is refused rather than quietly dropped.

  5. 5

    Send your first email from Next.js

    Post the message with the sample above. Carry an Idempotency-Key — yours or your queue's retry cannot then turn into a second email — and a User-Agent, which every request has to have. A 200 with an id means we have taken responsibility for the message.

  6. 6

    Subscribe a webhook and check its signature

    POST /webhooks with an https endpoint and the events you care about. Delivery, bounce, complaint, open and click arrive there with svix-id, svix-timestamp and svix-signature; verify against the raw body before anything parses it.

Verify webhooks in Next.js

In a route handler, call await request.text() and never await request.json(). Parsing an object and serialising it again is not the identity function — key order and whitespace both change — and the signature is over the bytes that arrived. Hand the text and the headers to new Webhook(secret).verify() from the svix package, which verifies our Svix-compatible signatures as they are.

The five steps a verifier performs, in seven languages, are on the webhooks reference.

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 guide 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.

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.

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

Questions about Next.js

Do I need an SMTP server to send email from Next.js?

No. The send is an HTTPS request from your server code, so it works the same on a Node server, on a serverless function and at the edge, where an SMTP socket is often not available at all. There is nothing to configure beyond a key and a verified domain.

Where do I keep the API key in a Next.js app?

In the environment as RASKET_API_KEY, with no NEXT_PUBLIC_ prefix — that prefix is what puts a value in the browser bundle, and a key in a browser bundle is a key anyone can read. Read it inside the route handler or server action, never in a client component.

Can I send from a server action instead of a route handler?

Yes, and it is the same few lines: read the key, build the request, await the fetch. Use a route handler when something outside your app has to trigger the mail — a queue, a cron, another service — and a server action when a form submission does.

How do I stop a retry from sending the same email twice?

Give the send an Idempotency-Key derived from the thing it is about, such as the order id, rather than a fresh random string. A serverless function that times out and is retried then repeats the same key, and for 24 hours that returns the first response instead of sending a second email.

How do I verify a Rasket webhook in Next.js?

Add a route handler, read await request.text() as the very first thing it does, and pass that text with Object.fromEntries(request.headers) to new Webhook(secret).verify() from the svix package. Answer 200 once you have stored the event and 400 when verification throws; retries are automatic and replays are available from the dashboard.

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.

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.

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.

Make your first send

Create a key, verify a domain, and post your first message from Next.js. The free plan does not expire.