> ## Documentation Index
> Fetch the complete documentation index at: https://nuntly.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Send email from Next.js

> Send a transactional email from a Next.js App Router Server Action, then verify and handle delivery events in a Route Handler webhook.

Add a transactional email to a Next.js App Router project: a Server Action sends it with the Nuntly TypeScript SDK, and a Route Handler receives the signed webhook events that tell your app whether it was delivered or bounced. The samples use the standard `Nuntly` client, which throws typed errors, and catch them with `try/catch`.

## Prerequisites

* A Nuntly account. The free plan includes 3,000 emails per month (100 per day) and needs no credit card. See [account setup](/docs/guides/account-setup).
* A verified sending domain. See [sending domains](/docs/guides/sending-domains).
* An API key with the **Sending only** permission. See [API keys](/docs/guides/api-keys). You store it in an environment variable in the next section.
* A Next.js project that uses the App Router. The samples use the `app/` directory and the `@/` import alias that `create-next-app` configures.

## Install and configure the SDK

<Steps>
  <Step title="Install the SDK">
    The package has zero runtime dependencies and ships its own type definitions.

    <CodeGroup>
      ```bash npm theme={null}
      npm install @nuntly/sdk
      ```

      ```bash pnpm theme={null}
      pnpm add @nuntly/sdk
      ```

      ```bash yarn theme={null}
      yarn add @nuntly/sdk
      ```

      ```bash bun theme={null}
      bun add @nuntly/sdk
      ```
    </CodeGroup>
  </Step>

  <Step title="Store the API key in an environment variable">
    Add the key to `.env.local` at the root of the project. Next.js reads this file at startup, and `create-next-app` excludes it from Git.

    ```bash .env.local theme={null}
    NUNTLY_API_KEY=ntly_your_api_key
    ```

    <Warning>
      Do not prefix the variable with `NEXT_PUBLIC_`. Next.js inlines variables with that prefix into the JavaScript bundle sent to the browser ([Next.js environment variables](https://nextjs.org/docs/app/guides/environment-variables)). An unprefixed variable stays on the server.
    </Warning>
  </Step>

  <Step title="Create the client">
    Create the client once, in a module that your server code imports.

    ```typescript lib/nuntly.ts theme={null}
    import { Nuntly } from '@nuntly/sdk';

    export const nuntly = new Nuntly({ apiKey: process.env.NUNTLY_API_KEY });
    ```

    The defaults suit a web request: a 60-second timeout and 2 automatic retries with exponential backoff (500 ms initial delay, capped at 8 seconds). If you omit `apiKey`, the SDK reads `NUNTLY_API_KEY` from the environment on its own.

    <Note>
      If you prefer `{ data, error }` results to `try/catch`, create the client with `createSafeNuntly` instead. See the [SDK README](https://github.com/nuntly/nuntly-sdk-typescript#safe-mode-error-as-value).
    </Note>
  </Step>
</Steps>

## Send your first email

A Server Action runs only on the server, and your forms call it without an HTTP endpoint. That makes it the place to send an email triggered by your own UI, such as a signup form.

```typescript app/actions/send-welcome-email.ts theme={null}
'use server';

import { APIError } from '@nuntly/sdk';
import { nuntly } from '@/lib/nuntly';

export type SendState = { ok: boolean; message: string };

export async function sendWelcomeEmail(_prev: SendState, formData: FormData): Promise<SendState> {
  const to = String(formData.get('email') ?? '').trim();
  if (!to.includes('@')) {
    return { ok: false, message: 'Enter a valid email address.' };
  }

  try {
    const email = await nuntly.emails.send({
      from: 'Acme <hello@yourdomain.com>',
      to,
      subject: 'Welcome to Acme',
      html: '<p>Your account is ready. <a href="https://yourdomain.com/login">Sign in</a>.</p>',
      text: 'Your account is ready. Sign in at https://yourdomain.com/login',
      tags: [{ name: 'category', value: 'welcome' }],
    });

    console.info('Welcome email accepted', { emailId: email.id, status: email.status });
    return { ok: true, message: 'Check your inbox.' };
  } catch (error) {
    if (error instanceof APIError) {
      console.error('Welcome email rejected', { status: error.status, code: error.code, requestId: error.requestId });
    } else {
      console.error('Welcome email failed', error);
    }
    return { ok: false, message: 'The email could not be sent. Try again in a minute.' };
  }
}
```

The `from` address must use your verified domain. Sending both `html` and `text` gives mail clients that block HTML a readable fallback.

The API answers `202 Accepted`. The SDK returns the email `id` (prefixed `em_`) and its `status`, which is `queued`: Nuntly has accepted the email and delivers it after the call returns. Delivery is reported later through events, covered in [Observe what happened](#observe-what-happened).

Call the action from a Client Component with React's `useActionState` hook, as in the [Next.js forms guide](https://nextjs.org/docs/app/guides/forms):

<CodeGroup>
  ```tsx app/signup/signup-form.tsx theme={null}
  'use client';

  import { useActionState } from 'react';
  import { sendWelcomeEmail, type SendState } from '@/app/actions/send-welcome-email';

  const initialState: SendState = { ok: false, message: '' };

  export function SignupForm() {
    const [state, formAction, pending] = useActionState(sendWelcomeEmail, initialState);

    return (
      <form action={formAction}>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" required />
        <button type="submit" disabled={pending}>
          {pending ? 'Sending...' : 'Create account'}
        </button>
        <p aria-live="polite">{state.message}</p>
      </form>
    );
  }
  ```

  ```tsx app/signup/page.tsx theme={null}
  import { SignupForm } from './signup-form';

  export default function SignupPage() {
    return <SignupForm />;
  }
  ```
</CodeGroup>

Run `npm run dev`, open `/signup`, and submit your own address. The email arrives in your inbox, and its `em_` ID appears in your server logs.

### Server Action or Route Handler

Use a Server Action when your own UI triggers the send. Use a Route Handler (`app/**/route.ts`) when the trigger comes from outside your UI: a cron job, a queue worker, or another service calling your app over HTTP. Both run the same `nuntly.emails.send` call.

| | Server Action | Route Handler |
| - | - | - |
| Triggered by | Your forms and Client Components | Any HTTP client |
| File | `'use server'` module, for example `app/actions/*.ts` | `app/**/route.ts` |
| Typical use | Signup, password reset, invitation forms | Webhooks, cron jobs, service-to-service calls |
| Access control | Check the session inside the action | Authenticate every request |

<Warning>
  Route Handlers are public HTTP endpoints ([Next.js backend for frontend guide](https://nextjs.org/docs/app/guides/backend-for-frontend)). Never expose a Route Handler that reads a recipient address from the request body and sends to it without authentication: anyone who finds the URL can send email from your domain. An exported Server Action is also reachable with a direct POST request ([Next.js data security guide](https://nextjs.org/docs/app/guides/data-security)). Check the session inside any action that sends on behalf of a signed-in user.
</Warning>

## Observe what happened

A `202` confirms that Nuntly accepted the email. Delivery comes afterwards, and two places report it: the dashboard, for one email at a time, and webhooks, for your code.

### Email timeline in the dashboard

In the dashboard, open **Emails**, select the email, then click **Email Timeline**. The timeline lists every event recorded for that email with its timestamp. A typical send goes through `queued`, `processed`, `sending` and `sent`, then reaches an outcome such as `delivered`, `bounced`, `rejected` or `failed`.

A `deliveryDelayed` event marks a delay in delivery to the recipient. `opened` and `clicked` events appear when [open and click tracking](/docs/guides/sending-domains#enable-open-and-click-tracking) is enabled on the domain. Events are kept for 30 days by default; see [data retention](/docs/guides/data-retention).

### Receive webhooks in a Route Handler

Receiving webhooks is a job for a Route Handler, because Nuntly calls your app over HTTP. Each request carries a `webhook-signature` header: an HMAC-SHA256 signature of the timestamp and the raw body, computed with your webhook's signing secret. Recomputing it proves the request came from Nuntly and was not altered.

<Steps>
  <Step title="Create the webhook">
    Create a webhook in the dashboard (see [webhooks](/docs/guides/webhooks)) that points to `https://yourapp.com/api/webhooks/nuntly`, and select the events you need, for example `email.delivered`, `email.bounced` and `email.complained`.
  </Step>

  <Step title="Store the signing secret">
    Copy the signing secret, which starts with `whsec_`, into `.env.local`.

    ```bash .env.local theme={null}
    NUNTLY_WEBHOOK_SECRET=whsec_your_signing_secret
    ```
  </Step>

  <Step title="Create the Route Handler">
    The SDK's `verifyWebhook` function checks the signature and returns a typed event.

    ```typescript app/api/webhooks/nuntly/route.ts theme={null}
    import { verifyWebhook, WebhookVerificationError } from '@nuntly/sdk';

    export async function POST(request: Request) {
      const payload = await request.text();
      const signature = request.headers.get('webhook-signature') ?? '';

      let event;
      try {
        event = await verifyWebhook(payload, signature, process.env.NUNTLY_WEBHOOK_SECRET ?? '');
      } catch (error) {
        if (error instanceof WebhookVerificationError) {
          return new Response('Invalid signature', { status: 401 });
        }
        throw error;
      }

      switch (event.type) {
        case 'email.delivered':
          console.info('Email delivered', { emailId: event.data.id, eventId: event.id });
          break;
        case 'email.bounced':
          console.warn('Email bounced', {
            emailId: event.data.id,
            bounceType: event.data.bounce.bounceType,
          });
          break;
        case 'email.complained':
          console.warn('Spam complaint', { emailId: event.data.id });
          break;
        default:
          console.info('Unhandled event', { type: event.type, eventId: event.id });
      }

      return Response.json({ received: true });
    }
    ```
  </Step>
</Steps>

The handler relies on four details:

* **The raw body.** The signature covers the exact bytes Nuntly sent, so read them with `request.text()`. Parsing with `request.json()` and serializing again breaks verification. Route Handlers expose the raw body without any `bodyParser` configuration.
* **A `401` on a bad signature.** `verifyWebhook` throws `WebhookVerificationError` when the signature does not match, the header is malformed, or the timestamp is more than 5 minutes away from the current time, which blocks replayed requests.
* **Typed events.** The event is a union typed on `event.type`, so inside `case 'email.bounced'` TypeScript knows that `event.data.bounce` exists. A `Permanent` bounce type means the address cannot receive mail: stop sending to it.
* **A fast `2xx`.** Any response other than `2xx` counts as a failed delivery. Respond first and move slow work out of the request path.

Nuntly retries failed deliveries and records each attempt; see [webhook observability](/docs/guides/observability-webhooks). After an outage on your side, you can replay a missed event from the dashboard or with `POST /webhooks/{id}/events/{eventId}/replay`.

<Note>
  Because of retries and replays, the same event can arrive more than once. Deduplicate on the root `id` (prefixed `evt_`). Do not use `data.id`: it is the email ID, shared by every event of that email. A unique constraint on a table of processed event IDs is enough.
</Note>

For every event type and payload, see [sending events](/docs/guides/sending-webhooks).

## Production notes

### Idempotency keys

The SDK sends an `Idempotency-Key` header on every `emails.send` call. It generates a UUID v4 and reuses it across its own automatic retries, so a retry after a `503` cannot send a second email.

That automatic key does not cover a Server Action that runs twice: a double click, a browser resubmitting after a dropped connection, a server restart during the request. Each run is a new `send` call with a new random key. Derive the key from your own data to make the operation itself idempotent:

```typescript theme={null}
await nuntly.emails.send(
  {
    from: 'Acme <hello@yourdomain.com>',
    to: user.email,
    subject: 'Welcome to Acme',
    html: '<p>Your account is ready.</p>',
  },
  { idempotencyKey: `welcome-${user.id}` },
);
```

Nuntly keeps a key for 24 hours, and a key must be 8 to 255 characters long. A repeat with the same key and the same body returns the original response with an `Idempotent-Replayed: true` header. The same key with a different body returns `409 idempotency_key_payload_mismatch`. See [idempotency keys](/docs/guides/idempotency) for key strategies.

### Retries

The SDK retries timeouts and `408`, `409`, `429`, `500`, `502`, `503` and `504` responses, up to 2 times by default with exponential backoff from 500 ms to 8 seconds, and honors the `Retry-After` header. Change the policy with the `maxRetries` and `retry` client options.

### Error handling

An error that reaches your `catch` block is either a client error that a retry would not fix, or a failure that outlasted the retries. Every API error is an `APIError` subclass with `status`, `code` and `requestId`.

| Status | Code | Action |
| - | - | - |
| `400` | `domain_not_verified` | Finish DNS verification for the `from` domain. |
| `401` | | Check `NUNTLY_API_KEY` in this environment. |
| `404` | `domain_not_found` | Add the `from` domain to your account. |
| `409` | `idempotency_key_payload_mismatch` | Use a new key for a different email. |
| `429`, `500`, `502`, `503`, `504` | | Already retried by the SDK. Show a "try again" message. |

Return a generic message to the browser, as the Server Action above does, and log the `requestId` so a failed request can be traced. See [errors](/docs/api-reference/errors).

### Separate environments

Use one API key per environment: `.env.local` for development, your hosting provider's environment settings for preview and production. Revoking a leaked development key then has no effect on production. You can restrict a key to sending only; see [API keys](/docs/guides/api-keys).

### What not to log

Recipient email addresses are personal data, and logs are a separate store with their own retention and access rules.

* Log the `em_` email ID, the `evt_` event ID and the `requestId`. They identify the email without identifying the person.
* Do not log `to`, `from`, `subject`, the HTML body, or a full webhook payload. A `console.log(event)` in the webhook handler writes a recipient address to your logs on every delivery.
* To investigate a support request, take the email ID from your logs and open the email in the dashboard.

## Next steps

<CardGroup cols={2}>
  <Card title="Handle webhook events" icon="webhook" href="/docs/guides/integrate-webhooks">
    Signature verification details and webhook best practices
  </Card>

  <Card title="Sending events" icon="paper-plane" href="/docs/guides/sending-webhooks">
    Payload reference for every sending event
  </Card>

  <Card title="Idempotency keys" icon="repeat" href="/docs/guides/idempotency">
    Key formats, strategies, and error codes
  </Card>

  <Card title="SDK reference" icon="code" href="/docs/api-reference/sdks">
    Install and configure the Nuntly SDKs
  </Card>
</CardGroup>

## Other frameworks

<CardGroup cols={3}>
  <Card title="Send email from Express" icon="node-js" href="/docs/guides/send-email-from-express">
    Send from an Express route and verify webhooks on the raw body
  </Card>

  <Card title="Send email from NestJS" icon="node-js" href="/docs/guides/send-email-from-nestjs">
    Inject the client as a provider and send from a service
  </Card>

  <Card title="Send email from Spring Boot" icon="java" href="/docs/guides/send-email-from-spring-boot">
    Use the Spring Boot starter on Java 21
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.