Notix
Guides

Send email from a Remix or React Router action.

A route action is the natural place to send: it runs on the server, it receives the form, and the component reads the result back. This guide builds one, adds a resource route that verifies Notix's webhook signature from the raw body, and notes the one place Remix 2 and React Router 7 differ. It runs on the email API.

What you need

Three things before the first send.

An API key.

Created in the dashboard under API keys, shown once. Set it as NOTIX_API_KEY in the server environment; a .server module reads it and the bundler keeps that module off the client.

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, published on npm. It sends with fetch, so it runs wherever your routes run.

Install
npm install notix-js
# React Router 7 framework mode (Remix's successor) or Remix 2: the route
# module shapes below are the same in both.
Steps

A client module, an action, a resource route.

  1. Create the client in a server module.

    One instance, constructed from the environment, in a file whose name ends in .server. Every route imports it from there.

    app/lib/notix.server.ts
    // app/lib/notix.server.ts
    import { Notix } from "notix-js";
    
    // The .server suffix keeps this module out of the browser bundle, so the
    // key can never end up on the client. On Node the key comes from
    // process.env; a .env file loaded by your process manager is the usual
    // source in development.
    export const notix = new Notix(process.env.NOTIX_API_KEY);
    
  2. Send from a route action.

    The action reads the form, sends with an idempotency key derived from the order, and returns a plain object. The component renders the outcome with useActionData. With JavaScript disabled the same form still posts and the same action still runs.

    app/routes/order.tsx
    // app/routes/order.tsx
    import { Form, useActionData } from "react-router";
    import type { Route } from "./+types/order";
    import { notix } from "~/lib/notix.server";
    
    // The action runs on the server for a POST to this route. React Router
    // serialises the <Form> and calls it; with JavaScript off the browser
    // posts the form itself and the same function runs.
    export async function action({ request }: Route.ActionArgs) {
      const form = await request.formData();
      const to = String(form.get("to"));
      const orderId = String(form.get("orderId"));
    
      const { data, error } = await notix.emails.send(
        {
          from: "receipts@acme.com",
          to,
          templateId: "tpl_receipt_v4",
          variables: { orderId },
        },
        // The same key on a retried submit returns the first message
        // instead of sending a second one.
        { idempotencyKey: `order-${orderId}-receipt` },
      );
    
      if (error) {
        return { ok: false as const, message: error.message };
      }
      return { ok: true as const, id: data?.emailId };
    }
    
    export default function Order() {
      const result = useActionData<typeof action>();
      return (
        <Form method="post">
          <input name="to" type="email" required />
          <input name="orderId" type="hidden" value="48213" />
          <button>Email my receipt</button>
          {result?.ok === true && <p>Sent: {result.id}</p>}
          {result?.ok === false && <p>Could not send: {result.message}</p>}
        </Form>
      );
    }
    
  3. Register the routes.

    React Router 7 lists routes in app/routes.ts; Remix 2 maps them from file names. Either way the webhook route is a resource route: a module with an action and no component.

    app/routes.ts
    // app/routes.ts (React Router 7 framework mode)
    import { type RouteConfig, route } from "@react-router/dev/routes";
    
    export default [
      route("/order", "routes/order.tsx"),
      // A resource route: a module with an action and no default component.
      route("/webhooks/notix", "routes/webhooks.notix.ts"),
    ] satisfies RouteConfig;
    
    // Remix 2 uses file routes instead: app/routes/webhooks.notix.ts maps to
    // /webhooks/notix on its own, with no config entry.
    
  4. Receive delivery events.

    Read the body as text, hand it and the request headers to constructEvent, and act on the event it returns. A bad signature throws, and the route answers 400.

    app/routes/webhooks.notix.ts
    // app/routes/webhooks.notix.ts
    import type { Route } from "./+types/webhooks.notix";
    import { notix } from "~/lib/notix.server";
    
    const webhooks = notix.webhooks(process.env.NOTIX_WEBHOOK_SECRET!);
    
    // No default export, so this route answers HTTP directly instead of
    // rendering a page. The action receives the standard Request.
    export async function action({ request }: Route.ActionArgs) {
      // The signature covers the raw bytes: read text, never request.json().
      const rawBody = await request.text();
    
      let event;
      try {
        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.
          break;
      }
      return new Response("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, and your resource route logs the same events as they arrive.

The production checklist, with batch sends, scheduling, templates by id and the retry strategy, is in the production Node.js guide, and the webhook headers and tolerance window are on the signature verification page.

FAQ

Questions, answered.

How do I send email from a Remix app?
In a route action. Export an action function from the route module, read the form data from the request, call notix.emails.send with an idempotency key, and return the result; the component reads it with useActionData. The action runs only on the server, so the API key never reaches the browser. The same shape works in React Router 7's framework mode, which is where Remix's route conventions now live.
Which version does this guide document?
React Router 7 in framework mode, which succeeded Remix 2 and keeps the same route module API: loader, action, default component, Form and useActionData. The one difference on this page is routing configuration: React Router 7 lists routes in app/routes.ts, while Remix 2 derives them from file names. The code inside each route module is the same in both.
What is a resource route and why use one for webhooks?
A route module with a loader or an action but no default component. It answers the request with a Response instead of rendering a page, which is exactly what a webhook receiver needs. It also keeps the raw body available: the action gets the standard Request, so request.text() returns the bytes Notix signed.
Why read the webhook body with request.text() and not request.json()?
The signature Notix sends is an HMAC over the exact bytes of the body, prefixed by the timestamp header. If you parse the JSON and serialise it again, key order and whitespace can change and the check fails. Read the text once, pass it and the headers to constructEvent, and only then work with the parsed event it returns.
Where does the API key live?
In process.env on a Node server, read inside a .server module so the bundler keeps it out of client code. A .env file is the usual source in development; in production the host sets the variable. On other runtimes the key comes from wherever that runtime exposes environment, for example a binding on Cloudflare Workers.
Can I point the client at a different Notix base URL?
Yes. The Notix constructor takes a base URL as its second argument for a different Notix base URL (a staging environment, say). Leave it out to use the hosted API.

On Express instead? The Express guide has the raw-body middleware. Sending password resets from the action? The reset page covers the token flow. 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.