Notix
Guides

Send email from Nuxt with a Nitro server route.

By the end you will have a server route any client can POST to, a contact page that calls it, the API key in private runtime config where the browser cannot see it, and the two things production needs: an idempotency key so a retried request never sends twice, and a webhook route that records delivery. It runs on the email API; the examples are for Nuxt 3 and its Nitro server.

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 can send but cannot read contacts or delete domains, which is the right shape for a key 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 must be on a verified domain; the quickstart walks through it.

The SDK.

notix-js is a typed client over the JSON API. The fetch tab on this page shows the same call through Nuxt's $fetch if you would rather not add it.

Install
npm install notix-js
# or: pnpm add notix-js
Steps

From an empty app to a delivered message.

  1. Put the key in private runtime config.

    Declare the key in runtimeConfig with an empty default and set the real value through the environment. Keys at the top level are server-only; only the public block reaches the browser, and the key never goes there.

    nuxt.config.ts
    // nuxt.config.ts
    export default defineNuxtConfig({
      runtimeConfig: {
        // Private: available on the server only. Overridden at runtime by
        // the NUXT_NOTIX_API_KEY environment variable.
        notixApiKey: "",
        notixWebhookSecret: "",
    
        // Anything under public reaches the browser. The key never goes here.
        public: {},
      },
    });
    
    .env
    # .env (never committed)
    NUXT_NOTIX_API_KEY=nx_live_...
    NUXT_NOTIX_WEBHOOK_SECRET=whsec_...
    
  2. Add a server route.

    A file under server/api is an endpoint; the .post.ts suffix limits it to POST. Pass the event to useRuntimeConfig so per-request overrides apply. The SDK answers { data, error }, so a failed send is a value you check and turn into a proper HTTP error.

    server/api/send.post.ts
    // server/api/send.post.ts
    import { Notix } from "notix-js";
    
    export default defineEventHandler(async (event) => {
      // Pass the event so runtime overrides apply per request.
      const { notixApiKey } = useRuntimeConfig(event);
      const notix = new Notix(notixApiKey);
    
      const { to, subject, html, orderId } = await readBody(event);
    
      const { data, error } = await notix.emails.send(
        { from: "receipts@acme.com", to, subject, html },
        // A retried request with the same key returns the first message.
        { idempotencyKey: `order-${orderId}-receipt` },
      );
    
      if (error) {
        throw createError({ statusCode: 502, statusMessage: error.message, data: error });
      }
      return { id: data?.emailId };
    });
    
  3. Wire a contact page to its own route.

    The page only collects the form and shows the state. The route validates, sends and answers; the key stays on the server by construction.

    server/api/contact.post.ts
    // server/api/contact.post.ts
    import { Notix } from "notix-js";
    
    export default defineEventHandler(async (event) => {
      const { notixApiKey } = useRuntimeConfig(event);
      const { email, message } = await readBody<{ email: string; message: string }>(event);
    
      if (!email?.includes("@") || !message || message.length < 10) {
        throw createError({ statusCode: 400, statusMessage: "Add a valid email and a message." });
      }
    
      const notix = new Notix(notixApiKey);
      const { error } = await notix.emails.send({
        from: "contact@acme.com",
        to: "hello@acme.com",
        replyTo: email,
        subject: `New message from ${email}`,
        text: message,
      });
    
      if (error) {
        throw createError({ statusCode: 502, statusMessage: error.message });
      }
      return { ok: true };
    });
    
    pages/contact.vue
    <!-- pages/contact.vue -->
    <script setup lang="ts">
    const email = ref("");
    const message = ref("");
    const status = ref<"idle" | "sending" | "sent" | "error">("idle");
    
    async function submit() {
      status.value = "sending";
      try {
        await $fetch("/api/contact", {
          method: "POST",
          body: { email: email.value, message: message.value },
        });
        status.value = "sent";
      } catch {
        status.value = "error";
      }
    }
    </script>
    
    <template>
      <form @submit.prevent="submit">
        <input v-model="email" type="email" required placeholder="you@example.com" />
        <textarea v-model="message" required minlength="10" />
        <button type="submit" :disabled="status === 'sending'">
          {{ status === "sending" ? "Sending…" : "Send" }}
        </button>
        <p v-if="status === 'sent'">Thanks, we got it.</p>
        <p v-if="status === 'error'" role="alert">Something went wrong. Try again.</p>
      </form>
    </template>
    
  4. Receive delivery events.

    Add a route for email.delivered, email.bounced and email.complained. Read the raw body, verify with constructEvent, and store the outcome against your own record.

    server/api/webhooks/notix.post.ts
    // server/api/webhooks/notix.post.ts
    import { Notix } from "notix-js";
    
    export default defineEventHandler(async (event) => {
      const { notixApiKey, notixWebhookSecret } = useRuntimeConfig(event);
      const webhooks = new Notix(notixApiKey).webhooks(notixWebhookSecret);
    
      // readRawBody, not readBody: the signature covers the exact bytes.
      const rawBody = (await readRawBody(event, "utf8")) ?? "";
    
      let notixEvent;
      try {
        notixEvent = webhooks.constructEvent(rawBody, { headers: getHeaders(event) });
      } catch (err) {
        throw createError({ statusCode: 400, statusMessage: (err as Error).message });
      }
    
      switch (notixEvent.type) {
        case "email.delivered":
        case "email.bounced":
        case "email.complained":
          // Record the outcome against your own order or user id here.
          break;
      }
      return "ok";
    });
    
  5. Send one and read it back.

    Submit the form, then open the message in the dashboard: the log shows its status and every event as delivery progresses.

In production

Before real users depend on it.

The route above already carries an idempotency key and checks the error envelope. The rest of the production checklist is shared with every framework and lives in one place.

Environment variables per platform.

Set NUXT_NOTIX_API_KEY and NUXT_NOTIX_WEBHOOK_SECRET as server variables on Vercel, Netlify or wherever Nitro deploys. Rate limit the contact route by IP or session too: the free plan sends 100 messages a day and a bot should not be able to spend them for you.

FAQ

Questions, answered.

Where do I put the API key in a Nuxt app?
In runtimeConfig, as a key outside the public block, with an empty default in nuxt.config.ts and the real value in the NUXT_NOTIX_API_KEY environment variable. Keys defined at the top level of runtimeConfig exist on the server only; anything under public is serialised into the page and reaches the browser. Read it with useRuntimeConfig(event) inside a server route.
Server route or server middleware?
A server route. Files under server/api become endpoints, and the .post.ts suffix limits the handler to POST so a GET answers 405 without any code. Middleware runs on every request and is the wrong place for a send. If the send is the result of a form in your own pages, the page calls the route with $fetch and the key still never leaves the server.
Why readRawBody for the webhook and readBody for the send?
The webhook signature is an HMAC over the exact body Notix sent, prefixed by the timestamp header. readBody parses the JSON, and re-serialising it can change key order or whitespace, so the signature no longer matches. readRawBody gives you the original string; hand it to constructEvent and work with the event it returns. The send route has no signature to check, so readBody is fine there.
Does this work on Vercel, Netlify and Cloudflare Pages?
Yes for the send route on all three, because Nitro builds a server for each. Set NUXT_NOTIX_API_KEY as a server environment variable on the platform. On Cloudflare Pages the SDK's webhook verifier imports node:crypto, so enable the nodejs_compat flag; the send itself is plain fetch and needs nothing extra.
What happens if a user double-submits the form?
With an idempotency key derived from the thing being sent, such as an order id, the second request returns the first message instead of sending again; a request with the same key and a different body is refused with a 409. Keys are kept for 24 hours. For a contact form with no natural id, disable the button while the request is in flight, as the example does.
Can I keep Nodemailer and just change the transport?
Yes. Point Nodemailer at smtp.usenotix.dev on port 465 with implicit TLS or 587 with STARTTLS, username notix, and an API key as the password. The relay turns each message into the same tracked, suppressed send the API makes. If you need a different Notix base URL (a staging environment, say), the Notix constructor takes it as its second argument.

The Next.js guide is the same flow with server actions, and the Node.js guide shows the SDK, fetch and Nodemailer side by side. 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.