# Send Email with Next.js: App Router Tutorial

> Send email with Next.js from Route Handlers and Server Actions with an email API: a server-side key, contact forms, templates and safe retries on Vercel.

Canonical: https://www.cresca.xyz/blog/send-email-nextjs
Author: Lalit Kumar Jangid
Published: 2026-10-27
Last updated: 2026-10-27

To **send email with Next.js**, send it from the server: a Route Handler or a Server Action calls an email API with a key that never reaches the browser. With Cresca that is one `fetch` to `https://api.cresca.xyz/api/v1/emails`, an email id back, and delivery from your own verified domain within seconds. This tutorial uses the App Router and covers a server-only client, a contact form Route Handler, a Server Action, templates, environment variables on Vercel and safe retries.

**TL;DR**

- Keep the key in `CRESCA_API_KEY`, without the `NEXT_PUBLIC_` prefix.
- Put the API call in a `server-only` module and call it from a Route Handler or Server Action.
- Send user input as `text`, or through a template, never as raw HTML.
- Add an `Idempotency-Key` so a retry never sends twice.

## Before you start

1. **Verify a sending domain.** In Cresca, open [Sending Domains](https://app.cresca.xyz/sending-domains), add your domain and publish the DNS records it shows (DKIM, a custom MAIL FROM domain and an ownership record). If your DNS is on Cloudflare, Cresca can publish them for you.
2. **Add a sender** on that domain, for example `hello@acme.com`. Every `from` address you send from must be an active sender on a verified domain.
3. **Create an API key** on the [API keys](https://app.cresca.xyz/api-keys) page. Choose *Sending access* for production: it can send email but cannot read logs or change templates. The key starts with `cr_live_` and is shown once, so store it as the environment variable `CRESCA_API_KEY`.

The examples use the Next.js App Router (Next.js 14 or later) and JavaScript; they work the same in TypeScript. Add the key to `.env.local` for development. The full setup, with screenshots of each step, is in the [email API quickstart](https://www.cresca.xyz/docs/quickstart).

## Keep the API key on the server

Any environment variable that starts with `NEXT_PUBLIC_` is copied into the JavaScript bundle that every visitor downloads. Name the key `CRESCA_API_KEY`, with no prefix, and only read it in server code. The `server-only` package (`npm install server-only`) turns a mistaken import into a build error instead of a leaked key:

```bash
# .env.local (never commit this file)
CRESCA_API_KEY=cr_live_xxxxxxxxx
```

## Send email with Next.js: a server-only client

The basic request is fine for a script. In production you want three more things, and `lib/cresca.js` below adds all of them:

- **An `Idempotency-Key` header.** If the connection drops after Cresca accepted the email, a retry with the same key returns the first result instead of sending a second email. Keys last 24 hours. Build them from the event, such as `welcome/user-123` or `invoice/inv-2026-0042`, never from the time or a random value. See [idempotency](https://www.cresca.xyz/docs/idempotency).
- **Retries only where they help.** Retry network errors, timeouts, `429 rate_limit_exceeded` and `5xx` with exponential backoff, and wait for the `retry-after` header when Cresca sends it. Do not retry other `4xx` errors: the same request fails the same way.
- **Errors you can branch on.** Every error body has a stable `name` and a readable `message`, for example `{"statusCode": 422, "name": "validation_error", "message": "..."}`. Branch on `name`, because messages can be reworded.

```js
// lib/cresca.js: server-only client for the Cresca email API.
import 'server-only';

const API_URL = 'https://api.cresca.xyz/api/v1';
const ATTEMPTS = 3;

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const backoff = (attempt) => 2 ** (attempt - 1) * 1000 + Math.random() * 250;

export class CrescaError extends Error {
  constructor(status, name, message) {
    super(`${status} ${name}: ${message}`);
    this.status = status;
    this.code = name;
  }
}

export async function cresca(path, payload, { idempotencyKey } = {}) {
  for (let attempt = 1; ; attempt++) {
    let res;
    try {
      res = await fetch(API_URL + path, {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${process.env.CRESCA_API_KEY}`,
          'Content-Type': 'application/json',
          ...(idempotencyKey && { 'Idempotency-Key': idempotencyKey }),
        },
        body: JSON.stringify(payload),
        cache: 'no-store',
        signal: AbortSignal.timeout(10_000),
      });
    } catch (err) {
      if (attempt === ATTEMPTS) throw err;
      await sleep(backoff(attempt));
      continue;
    }

    const body = await res.json().catch(() => ({}));
    if (res.ok) return body;

    const name = body.name ?? 'unknown_error';
    const retryable =
      res.status >= 500 ||
      (res.status === 429 && name !== 'monthly_quota_exceeded') ||
      name === 'concurrent_idempotent_requests';
    if (!retryable || attempt === ATTEMPTS) {
      throw new CrescaError(res.status, name, body.message ?? res.statusText);
    }
    const retryAfter = Number(res.headers.get('retry-after'));
    await sleep(retryAfter > 0 ? retryAfter * 1000 : backoff(attempt));
  }
}
```

It retries at most twice, because a serverless function has a time limit; use a queue for anything that must retry for longer. `cache: "no-store"` makes sure Next.js never caches the request.

## Send a contact form email from a Route Handler

```js
// app/api/contact/route.js
import { NextResponse } from 'next/server';
import { cresca, CrescaError } from '@/lib/cresca';

export async function POST(request) {
  const { email, message } = await request.json();
  if (typeof email !== 'string' || !email.includes('@') ||
      typeof message !== 'string' || message.length === 0 || message.length > 5000) {
    return NextResponse.json({ error: 'Please enter an email and a message.' }, { status: 400 });
  }

  try {
    await cresca('/emails', {
      from: 'Acme Website <website@acme.com>',
      to: 'team@acme.com',
      reply_to: email,
      subject: 'New contact form message',
      text: `From: ${email}\n\n${message}`,
    });
  } catch (err) {
    console.error('Contact email failed', err instanceof CrescaError ? err.code : err);
    return NextResponse.json({ error: 'Could not send your message.' }, { status: 502 });
  }

  return NextResponse.json({ ok: true });
}
```

The visitor's address goes in `reply_to`, not `from`: you can only send from your own verified domain. Using `text` means nothing the visitor typed is ever rendered as HTML. Add a rate limit or a CAPTCHA so bots cannot use the form to burn through your allowance.

## Send email from a Server Action

A Server Action can call the same client directly from a form. Server Actions are public POST endpoints, so check who is calling before you send:

```js
// app/invite/actions.js
'use server';

import { cresca } from '@/lib/cresca';
import { getCurrentUser, createInvite } from '@/lib/auth'; // your own code

export async function sendInvite(formData) {
  const user = await getCurrentUser();
  if (!user) throw new Error('Not signed in');

  const invite = await createInvite(user, String(formData.get('email')));

  await cresca(
    '/emails',
    {
      to: invite.email,
      template: {
        id: 'team-invite',
        variables: { INVITER: user.name, ACCEPT_LINK: invite.url },
      },
    },
    { idempotencyKey: `team-invite/${invite.id}` },
  );

  return { ok: true };
}
```

```jsx
// app/invite/page.jsx
import { sendInvite } from './actions';

export default function InvitePage() {
  return (
    <form action={sendInvite}>
      <input name="email" type="email" required />
      <button type="submit">Send invite</button>
    </form>
  );
}
```

## Send a template with variables

Instead of building HTML in your code, you can design the email in Cresca's template editor, mark the parts your app fills in with triple braces such as `{{{RESET_LINK}}}`, and publish it. Your app then sends only the template alias and the values. Sends always use the *published* version, so you can edit a draft without touching live email. Values are HTML-escaped, so a user's name can never change your markup. A missing variable with no fallback returns `422 validation_error` naming it. More in [templates and variables](https://www.cresca.xyz/docs/templates).

Templates suit Next.js apps well: the email's design lives in Cresca, so marketing or design can update it without a deploy, and your code only sends the alias and the values, as in the invite example above. If you already build emails with React components, render them to an HTML string on the server and pass the result as `html`.

## Attachments and batches

Attachments are sent inline as base64: up to 20 files and 7 MB in total per email. Each item has `filename`, `content` and optionally `content_type`. Set `content_id` to show an inline image with `<img src="cid:logo">`. Executable types such as .exe and .js are refused, and attaching by URL is not supported yet.

In a Route Handler, read an uploaded file with `await file.arrayBuffer()` and encode it with `Buffer.from(bytes).toString("base64")`. Keep uploads small: the whole request body is limited to 10 MB.

`POST /emails/batch` takes a JSON array of up to 100 emails, each with the same fields as a single send except `attachments` and `scheduled_at`. It is all or nothing: if one email is invalid, nothing is sent and the error message names the failing email by its zero-based index. The response is `{"data": [{"id": "..."}, ...]}` in the same order. One request counts as one request against your rate limit, so batching is how you send thousands of emails per minute.

Batches belong in a background job or a scheduled function, not in a page request. Call `cresca("/emails/batch", chunk, ...)` with up to 100 emails per chunk and one idempotency key per chunk.

## Deploying on Vercel

- Add `CRESCA_API_KEY` under Project Settings, Environment Variables, for Production (and Preview if previews should send email). Redeploy after adding it.
- Use a separate sending-access key per environment, so you can revoke one without touching the others.
- A `POST` Route Handler always runs on request, never at build time, so each form submission sends exactly one email.
- The client works in both the Node.js and Edge runtimes, because it only uses fetch.

## Errors to handle when you send email with Next.js

These are the errors a sending app actually meets. The full list is on the [errors page](https://www.cresca.xyz/docs/errors).

| Status | name | What it means | Retry? |
| --- | --- | --- | --- |
| 403 | `invalid_from_address` | The from address is not an active sender on a verified domain. | No, fix the sender |
| 422 | `validation_error` | A field is invalid, or a template variable is missing. | No, fix the request |
| 422 | `missing_required_field` | No to, from or subject (and no template that supplies them). | No |
| 429 | `rate_limit_exceeded` | More requests per second than your plan allows. | Yes, after retry-after |
| 429 | `monthly_quota_exceeded` | The plan's email allowance is used up. | No |
| 409 | `concurrent_idempotent_requests` | The same Idempotency-Key is still being processed. | Yes, shortly |
| 5xx | `application_error` | A problem on Cresca's side. | Yes, with backoff |

Recipients on your suppression list (earlier hard bounces, complaints and unsubscribes) are skipped, but the request still succeeds. See [suppression](https://www.cresca.xyz/docs/suppression).

## Check that the email was delivered

A `200` response means Cresca accepted the email, not that it reached the inbox. Open [Transactional](https://app.cresca.xyz/transactional) in the app to see each email's timeline, or look it up with a full-access key:

```bash
curl https://api.cresca.xyz/api/v1/emails/4ef9a417-02e9-4d39-ad75-9611e0fcc33c \
  -H "Authorization: Bearer $CRESCA_API_KEY"
```

The `last_event` field moves from `queued` to `sent` and then `delivered` or `bounced`. See [email lifecycle](https://www.cresca.xyz/docs/email-lifecycle) for every status.

## Frequently asked questions

### How do I send email from a Next.js app?

Call an email API from server code: a Route Handler in app/api or a Server Action. With Cresca you POST JSON with from, to, subject and html to https://api.cresca.xyz/api/v1/emails using fetch, with an API key read from process.env.CRESCA\_API\_KEY. Never call the API from a client component.

### Can I use NEXT\_PUBLIC\_ for the API key?

No. Variables with the NEXT\_PUBLIC\_ prefix are embedded in the browser bundle, so anyone could read the key and send email as you. Use CRESCA\_API\_KEY with no prefix and read it only in Route Handlers, Server Actions or other server-only modules.

### Does this work with the Pages Router?

Yes. Put the same cresca() call in an API route under pages/api and read the body from req.body. Everything else, including the key handling and retry rules, stays the same.

### Can I use React Email with Cresca?

Yes. Render the React Email component to an HTML string on the server and send it in the html field. Alternatively, design the email in Cresca's template editor and send only the template alias and variables from Next.js.

### How many emails can I send per request?

One email can have up to 50 recipients across to, cc and bcc, and a batch request carries up to 100 emails. Requests per second depend on your plan: 2 on Free, 10 on Professional, 20 on Premium, 50 on Ultra and 100 on Enterprise. Each recipient counts as one email against the monthly allowance.

### Is Cresca free to try?

Yes. The Free plan gives you 30 days, 50 contacts and 50 emails with no credit card, which is enough to build and test your integration. API email shares your plan's email allowance with campaigns; paid plans are on the pricing page.

## The same tutorial in other languages

[Node.js](https://www.cresca.xyz/blog/send-email-nodejs) · [Python](https://www.cresca.xyz/blog/send-email-python) · [PHP](https://www.cresca.xyz/blog/send-email-php) · [Go](https://www.cresca.xyz/blog/send-email-golang) · [Ruby](https://www.cresca.xyz/blog/send-email-ruby) · [Java](https://www.cresca.xyz/blog/send-email-java) · [C# and .NET](https://www.cresca.xyz/blog/send-email-csharp) · [Laravel](https://www.cresca.xyz/blog/send-email-laravel) · [Django](https://www.cresca.xyz/blog/send-email-django) · [Ruby on Rails](https://www.cresca.xyz/blog/send-email-rails)

For every field of the Emails API with cURL examples, read the [send email API guide](https://www.cresca.xyz/blog/send-email-api). For the big picture, start with the [transactional email API overview](https://www.cresca.xyz/blog/transactional-email-api). Still deciding how to send? Read [SMTP vs API](https://www.cresca.xyz/blog/smtp-vs-api-sending).

## Start sending

Verify a domain, create a sending-access key and send your first email with Next.js in a few minutes. **[Try Cresca free for 30 days](https://app.cresca.xyz/signup)**, no credit card required, then follow the [quickstart](https://www.cresca.xyz/docs/quickstart) or browse the [API docs](https://www.cresca.xyz/docs).
