Skip to content
Back to Guides

Next.js

Updated

Send emails from Next.js with Lumail

Install the lumail package, keep the API key in a server-only module, and send from a route handler or a server action. About ten minutes from zero to a delivered email.

TL;DR

Run npm install lumail server-only, set LUMAIL_API_KEY in .env.local, create a lib/lumail.ts module that starts with import "server-only", then call lumail.emails.send({ from, to, subject, html }) inside a route handler or a server action. The SDK returns { data, error } instead of throwing, so check error before you use data.id.

Prerequisites

  • A Next.js 14 or later project using the App Router (app/ directory).
  • A Lumail account and an API token that starts with lum_ (Settings > API Tokens).
  • A sending domain verified in the same Lumail organization as the token.

1. Install the packages

Terminal
npm install lumail server-only

2. Set your environment variables

Put the token in .env.local. Next.js loads it on the server for next dev, and .env.local is git-ignored by default in new projects.

Do not prefix it with NEXT_PUBLIC_. That prefix inlines the value into the browser bundle, which would hand your sending key to every visitor. In production, add the same variable in your host's environment settings.

.env.local
# Server-side only. Create it in Lumail under Settings > API Tokens. LUMAIL_API_KEY=lum_your_api_token

3. Create one server-only client

Create the client once and import it everywhere. import "server-only" makes the build fail if a client component ever imports this file, so the token cannot leak by accident.

The constructor needs a string, so the module checks the variable and fails loudly at startup instead of sending unauthenticated requests later.

lib/lumail.ts
import "server-only"; import { Lumail } from "lumail"; const apiKey = process.env.LUMAIL_API_KEY; if (!apiKey) { throw new Error("LUMAIL_API_KEY is not set"); } export const lumail = new Lumail({ apiKey });

4. Send from a route handler

Route handlers are the right place when something outside React calls you: a webhook, a cron job, or a mobile client. Response.json is the standard Web API, so no Next-specific helper is needed.

to takes one recipient per email. For many recipients, loop or use lumail.emails.batch (up to 100 per request).

app/api/welcome/route.ts
import { lumail } from "@/lib/lumail"; export async function POST(request: Request) { const { email, name } = (await request.json()) as { email?: string; name?: string; }; if (!email) { return Response.json({ error: "email is required" }, { status: 400 }); } const { data, error } = await lumail.emails.send({ from: "Acme <[email protected]>", // must be on a verified domain to: email, subject: "Welcome to Acme", html: `<p>Hi ${name ?? "there"}, your account is ready.</p>`, }); if (error) { console.error("Lumail send failed", error.name, error.message); return Response.json({ error: error.message }, { status: 502 }); } return Response.json({ id: data.id }); }

5. Or send from a server action

Server actions fit forms inside your own app: invites, contact forms, resend-verification buttons. The action runs on the server, so it can import lib/lumail.ts directly.

With useActionState, the action receives the previous state first and the FormData second, and whatever it returns is shown back in the form.

app/invite/actions.ts
"use server"; import { lumail } from "@/lib/lumail"; export type InviteState = { ok: boolean; message: string } | null; export async function sendInvite( _previous: InviteState, formData: FormData, ): Promise<InviteState> { const email = String(formData.get("email") ?? "").trim(); if (!email) return { ok: false, message: "Enter an email address." }; const { error } = await lumail.emails.send({ from: "Acme <[email protected]>", to: email, subject: "You're invited to Acme", markdown: "You have been invited to join **Acme**. [Accept the invite](https://acme.com/invite).", }); if (error) return { ok: false, message: "We could not send the invite." }; return { ok: true, message: `Invite sent to ${email}.` }; }
app/invite/invite-form.tsx
"use client"; import { useActionState } from "react"; import { sendInvite } from "./actions"; export function InviteForm() { const [state, formAction, pending] = useActionState(sendInvite, null); return ( <form action={formAction}> <input name="email" type="email" required placeholder="[email protected]" /> <button type="submit" disabled={pending}> {pending ? "Sending..." : "Send invite"} </button> {state ? <p>{state.message}</p> : null} </form> ); }

6. Test without sending real email

Send to a fixture address such as [email protected] or anything ending in .test. Lumail runs the whole path, including domain checks, and returns an eml_ id without delivering a message or touching your reputation.

Then send one email to your own inbox and run it through the mail tester to confirm SPF, DKIM and DMARC pass.

Terminal
curl -X POST http://localhost:3000/api/welcome \ -H "Content-Type: application/json" \ -d '{"email":"[email protected]","name":"Ada"}'

Handle errors

lumail.emails.send never throws on an API error. It resolves to { data: null, error }, where error.name is a stable code and error.statusCode is the HTTP status. Network failures and timeouts come back the same way with network_error or timeout and no status.

Return a useful status to your caller, log error.name, and never let an email failure crash the request that triggered it, such as a signup.

Lumail send errors
Statuserror.nameWhat to do
401missing_api_keyMissing or invalid token. Check the env var on the server that sends.
403missing_permissionThe token lacks the emails permission. Mint one that has it.
402plan limitThe organization used its plan's email volume. Upgrade or wait for the next period.
4xxvalidation_errorMissing field, more than one body format, or a from domain that is not verified.
429RECIPIENT_RATE_LIMITEDThat mailbox hit 5 sends in 10 minutes or 20 in 24 hours. Honor Retry-After.
nonenetwork_error / timeoutThe request never got an answer. Safe to retry with the same idempotency key.

Make retries safe with idempotency

The SDK does not retry POST requests, so a retry is your call. Pass an idempotencyKey as the second argument: repeating the same key returns the email that was already queued instead of sending a second one.

Derive the key from the thing that caused the email, such as the user id or the webhook event id, never from a random value generated per attempt.

app/api/welcome/route.ts
const { data, error } = await lumail.emails.send( { from: "Acme <[email protected]>", to: user.email, subject: "Welcome to Acme", html: "<p>Your account is ready.</p>", }, { idempotencyKey: `welcome:${user.id}` }, );

Verify your sending domain

Lumail only sends from a domain you have verified. Add the domain in your organization's Domains settings, then publish the SPF, DKIM and DMARC records it shows at your DNS provider. Until the domain verifies, every send fails with an error saying the domain is not authorized or verified.

Use a subdomain such as mail.yourdomain.com if your root domain already sends from another provider. Start DMARC at p=none, then tighten it once reports look clean.

Production checklist

  • lib/lumail.ts starts with import "server-only" and no file under a "use client" boundary imports it.
  • LUMAIL_API_KEY is set in your host's production environment, not only in .env.local.
  • The from address is on a domain verified in the same organization as the token, with SPF, DKIM and a DMARC record.
  • LUMAIL_API_KEY is set on every environment that sends, never committed, never in a public or client-side variable. Use one token per environment.
  • Every send that can be retried (webhooks, queues, jobs) passes an idempotency key derived from the triggering event.
  • One-time codes and magic links set tracking: { links: false, open: false } so links are not rewritten.
  • End-to-end tests send to fixture addresses such as [email protected], which return an id without sending real email.
  • A failed email is logged with error.name and never fails the user's request on its own.

Frequently asked questions

Should I send email from a route handler or a server action?

Use a server action for forms and buttons inside your own Next.js app, because it needs no fetch call and returns state to the form. Use a route handler when something outside React calls you, such as a webhook, a cron job or a mobile app.

Can I call the Lumail SDK from a client component?

No. The API token can send email from your domain, so it must stay on the server. Call a server action or a route handler from the client, and keep the lumail import in a module marked server-only.

Does the Lumail SDK work on the Edge runtime?

The SDK only uses fetch, so it does not depend on Node APIs for sending. The default Node.js runtime for route handlers is the safest choice, and it is what this guide uses.

How do I send a React Email template from Next.js?

Render the component to an HTML string with render from @react-email/render, then pass that string as html. The React Email guide covers the template, plain text and preview server.

Why does my send fail with a domain error?

The from address must be on a domain verified in the same Lumail organization as your API token. Add the domain, publish its DNS records, wait for it to verify, then retry.

How much does sending from Next.js with Lumail cost?

The Free plan includes 3,000 emails a month. Premium is $20 a month for 40,000 emails, then $0.60 per 1,000. Transactional and marketing email share the same volume, and subscribers are unlimited on every plan.

Keep building

Send your first email from code today.

3,000 emails a month free. Transactional and marketing email on one verified domain, with unlimited subscribers on every plan.