Notix
Guides

Send email from Next.js with a server action or a route handler.

By the end you will have a contact form that sends through a server action, a route handler other services can call, a React Email template rendered on the server, and the two things production needs: an idempotency key so a retried request never sends twice, and a webhook route that records delivery. It all runs on the email API; the App Router examples work on Next.js 14 and 15.

What you need

Three things before the first send.

An API key.

Created in the dashboard under API keys, shown once. A sending access key is enough for this guide: it can send but cannot read contacts or delete domains, which is the right shape for a key that lives on a web server.

A verified domain.

Add your domain and publish the DKIM, SPF and DMARC records Notix generates. The from address in every example below must be on a verified domain; the quickstart walks through it.

The SDK.

notix-js is a typed client over the JSON API. Add @react-email/components too if you want templates as React components.

Install
npm install notix-js @react-email/components
# or: pnpm add notix-js @react-email/components
Steps

From an empty app to a delivered message.

  1. Create one server-side client.

    Put the client in a module that can only be imported on the server. The server-only package makes a client component that imports it fail at build time instead of shipping your key to the browser.

    lib/notix.ts
    // lib/notix.ts
    import "server-only";
    import { Notix } from "notix-js";
    
    // Read on the server only. Never expose the key as NEXT_PUBLIC_*.
    export const notix = new Notix(process.env.NOTIX_API_KEY!);
    
  2. Add a route handler.

    A route handler is an HTTP endpoint in your app: anything that can POST JSON can call it. The SDK version answers { data, error }, so a failed send is a value you check, not an exception you forget to catch. The fetch version is the same call without the dependency.

    app/api/send/route.ts
    // app/api/send/route.ts
    import { NextResponse } from "next/server";
    import { notix } from "@/lib/notix";
    
    export async function POST(request: Request) {
      const { to, subject, html } = await request.json();
    
      const { data, error } = await notix.emails.send({
        from: "receipts@acme.com",
        to,
        subject,
        html,
      });
    
      if (error) {
        return NextResponse.json({ error }, { status: 502 });
      }
      return NextResponse.json({ id: data?.emailId });
    }
    
  3. Add a server action for your own forms.

    For a form inside the app, skip the endpoint. A function marked "use server" runs on the server when the form submits, receives the FormData, and returns a plain object the form can render. Validate before you send; an empty message is not worth an API call.

    app/actions.ts
    // app/actions.ts
    "use server";
    
    import { notix } from "@/lib/notix";
    
    export async function sendContactMessage(formData: FormData) {
      const email = String(formData.get("email") ?? "");
      const message = String(formData.get("message") ?? "");
    
      if (!email.includes("@") || message.length < 10) {
        return { ok: false, error: "Add a valid email and a message." };
      }
    
      const { error } = await notix.emails.send({
        from: "contact@acme.com",
        to: "hello@acme.com",
        replyTo: email,
        subject: `New message from ${email}`,
        text: message,
      });
    
      return error ? { ok: false, error: error.message } : { ok: true };
    }
    
  4. Wire the contact form.

    The form is a client component only so it can show the pending state and the result. The action itself never runs in the browser, and the key never leaves the server.

    app/contact/contact-form.tsx
    // app/contact/contact-form.tsx
    "use client";
    
    import { useActionState } from "react";
    import { sendContactMessage } from "@/app/actions";
    
    type State = { ok: boolean; error?: string } | null;
    
    export function ContactForm() {
      const [state, action, pending] = useActionState(
        async (_prev: State, formData: FormData) => sendContactMessage(formData),
        null,
      );
    
      return (
        <form action={action}>
          <input name="email" type="email" required placeholder="you@example.com" />
          <textarea name="message" required minLength={10} />
          <button type="submit" disabled={pending}>
            {pending ? "Sending…" : "Send"}
          </button>
          {state?.ok ? <p>Thanks, we got it.</p> : null}
          {state?.error ? <p role="alert">{state.error}</p> : null}
        </form>
      );
    }
    
  5. Write the email as a React component.

    Pass a React element as react and the SDK renders it to HTML on your server before the request is sent. You keep one language for the app and its mail; the API only ever sees HTML.

    emails/welcome.tsx
    // emails/welcome.tsx
    import { Html, Text, Button } from "@react-email/components";
    
    export function WelcomeEmail({ name }: { name: string }) {
      return (
        <Html>
          <Text>Hi {name}, your account is ready.</Text>
          <Button href="https://app.acme.com">Open the dashboard</Button>
        </Html>
      );
    }
    
    // anywhere on the server: the SDK renders the element to HTML for you
    import { notix } from "@/lib/notix";
    import { WelcomeEmail } from "@/emails/welcome";
    
    await notix.emails.send({
      from: "hello@acme.com",
      to: "ada@example.com",
      subject: "Welcome to Acme",
      react: <WelcomeEmail name="Ada" />,
    });
    
  6. Send one and read it back.

    Submit the form, then open the email in the dashboard: the log shows the message, its status and every event as delivery progresses. Nothing else to configure for a first send.

In production

Five things to add before real users depend on it.

A form that sends once on a laptop is not the same as one that survives a double-click, a flaky deploy and a bounced address. These are the additions that make the difference.

Idempotency keys.

Pass { idempotencyKey } with a value that names the thing being sent, such as order-4471-receipt. A retry with the same key returns the original message instead of sending a second one. This is what protects you from a double-submitted form or a serverless function that ran twice.

The error envelope.

Every failure is { error: { code, message } }. Branch on code: a validation error is yours to fix and should not be retried; a suppressed recipient means the address bounced or complained before and must not be sent to again. Log the message, show the user something calmer.

A webhook for delivery status.

Sending is the start, not the end. Add a route handler for email.delivered, email.bounced and email.complained, verify the signature with constructEvent, and store the outcome against your own record. The dashboard lets you choose which events reach the endpoint.

Environment variables and rate limits.

On Vercel, add NOTIX_API_KEY and NOTIX_WEBHOOK_SECRET as server environment variables for each environment. Rate limit the form on your side too, by IP or session: the free plan sends 100 messages a day and a bot should not be able to spend them for you.

app/api/webhooks/notix/route.ts and the idempotent send
// app/api/webhooks/notix/route.ts
import { notix } from "@/lib/notix";

const webhooks = notix.webhooks(process.env.NOTIX_WEBHOOK_SECRET!);

export async function POST(request: Request) {
  const rawBody = await request.text();
  let event;
  try {
    // HMAC-SHA256 over "timestamp.rawBody"; a bad signature throws.
    event = webhooks.constructEvent(rawBody, { headers: request.headers });
  } catch (error) {
    return new Response((error as Error).message, { status: 400 });
  }

  switch (event.type) {
    case "email.delivered":
    case "email.bounced":
    case "email.complained":
      // Record the outcome against your own order or user id here.
      break;
  }
  return new Response("ok");
}

// ...and in the sender, one key per thing you must never send twice:
await notix.emails.send(
  { from: "receipts@acme.com", to, subject, html },
  { idempotencyKey: `order-${orderId}-receipt` },
);

Batch sends, scheduling, templates by id and the full retry strategy are covered in the production Node.js guide; a pre-send deliverability check catches a broken From address or a suppressed recipient before the first message goes out.

SMTP

Using Nodemailer instead.

If the app already sends through Nodemailer, point the transport at the SMTP relay and keep your code. The relay turns each message into the same tracked, suppressed send the API makes, so the log and the webhooks work either way. Use port 465 with implicit TLS or 587 with STARTTLS; the username is notix and the password is an API key.

lib/mailer.ts
// lib/mailer.ts, if you would rather keep Nodemailer
import nodemailer from "nodemailer";

export const transporter = nodemailer.createTransport({
  host: "smtp.usenotix.dev",
  port: 465,
  secure: true, // implicit TLS; use 587 with secure: false for STARTTLS
  auth: {
    user: "notix",
    pass: process.env.NOTIX_API_KEY, // the SMTP password is an API key
  },
});
FAQ

Questions, answered.

Which email API supports React Email templates?
Notix does, through the react option on notix.emails.send. Pass a React element and the SDK renders it to HTML with @react-email/render before the request leaves your server, so the API itself only ever sees html. The one limit is batch sends, which take html or text rather than a React element; render the element yourself first if you need both.
Server action or route handler: which should I use?
Use a server action when the send is the result of a form in your own app: it runs on the server, needs no fetch code in the browser, and keeps the API key out of the client bundle by construction. Use a route handler when something outside the page calls it, such as a mobile app, a cron job or a webhook from another service.
How do I keep the API key out of the browser?
Name it NOTIX_API_KEY, not NEXT_PUBLIC_NOTIX_API_KEY, and read it only in files that run on the server: server actions, route handlers and modules that import server-only. Next.js inlines NEXT_PUBLIC_ variables into the client bundle at build time, so a key with that prefix is public the moment you deploy.
Does the free plan work for a Next.js side project?
Yes. The free plan sends 5,000 emails a month at up to 200 a day, needs no card, and does not end after a trial period. A verified domain is required, so the sending address is yours rather than a shared one. Pro, at $15 a month, raises the allowance to 50,000.

New here? Start with the plain Node.js guide for the SDK, fetch and Nodemailer side by side, or the OTP use case if the first email you need is a sign-in code. The free plan does not ask for a card.

Send your first email in five minutes.

Verify a domain, copy an API key, make one call. The free plan does not ask for a card.