Send email with Next.js without exposing credentials to the browser by placing the Volanea API call in a server-side Route Handler. This guide uses the Next.js App Router, native fetch, and Volanea’s POST /v1/send endpoint to send one transactional message.

The integration deliberately uses HTTP rather than an unverified framework-specific SDK. Next.js already provides a standards-based server runtime with fetch, while Volanea exposes a REST endpoint at https://api.volanea.com. That means the code below has a small dependency surface, works with a conventional Next.js deployment, and makes every request field visible for review.

What you will build

By the end of this guide, your application will have:

  • A server-only Volanea email helper.
  • An App Router Route Handler at POST /api/send-test-email.
  • A simple page with a button that triggers the route.
  • Environment variables for the Volanea secret key, verified sender, and safe test recipient.
  • Explicit error handling for authentication, validation, malformed JSON, and upstream failures.
  • An Idempotency-Key header so an application retry does not accidentally create a duplicate transactional send.

The example sends a fixed test message to an address stored on the server. That choice is intentional: a public endpoint that accepts an arbitrary recipient can become an open relay or be abused to send unwanted mail. Once the basic integration works, connect sends to authenticated application events such as account creation, password resets, invoices, or order confirmations.

Prerequisites for sending email with Next.js

Before adding code, make sure the following pieces are ready.

A Next.js application using the App Router

This guide assumes an App Router project with an app/ directory. A current Next.js project created with create-next-app is suitable. The relevant files will live under app/ and lib/.

The implementation uses a Route Handler rather than calling the email API from a Client Component. A Client Component runs in the browser, where any environment variable included in the bundle can be discovered by users. Your Volanea secret key must remain on the server.

A Volanea secret API key

Create a Volanea secret key and store it as VOLANEA_API_KEY. Volanea secret keys use the sk_… or sk_test_… form. Do not commit this key to Git, do not prefix it with NEXT_PUBLIC_, and do not pass it to React props, browser storage, logs, or analytics tools.

A test key is useful during development if your account provides one. Regardless of the key type, use a verified sender domain when you are ready to send production mail. Sending from a domain you control helps receivers evaluate the message’s authentication and reputation.

A verified From address

Set VOLANEA_FROM_EMAIL to an address on a verified sending domain, such as notifications@updates.example.com. The address must be appropriate for the domain you have configured in Volanea.

Keep transactional mail on a purpose-specific subdomain when your operational design calls for it. For example, updates.example.com can send receipts and account notices while another domain or subdomain handles other mail streams. This makes ownership and troubleshooting clearer, although domain structure alone does not replace authentication or good sending practices.

A safe test recipient

The sample uses TEST_RECIPIENT_EMAIL instead of accepting a recipient from the browser. Set it to an inbox you control. This makes the first test predictable and avoids turning the sample endpoint into a tool that visitors can misuse.

Install the dependency

The Volanea request itself uses Next.js and Node.js built-ins: native fetch performs the HTTP request, and crypto.randomUUID() creates an idempotency value. No Volanea-specific SDK is assumed or required.

Install server-only so Next.js can prevent accidental imports of the email helper into client-side code:

npm install server-only

server-only is a guard rather than a mail transport. It makes the boundary explicit: code that reads VOLANEA_API_KEY should stay in server modules. If a Client Component imports that module by mistake, Next.js will surface the problem during the build process instead of silently risking secret exposure.

If you do not yet have a Next.js application, create one first:

npx create-next-app@latest volanea-nextjs
cd volanea-nextjs
npm install server-only

Choose TypeScript when prompted if you want to copy the files in this guide exactly. JavaScript projects can use the same structure after removing type annotations.

Configure environment variables

Create a .env.local file in the root of the Next.js project. Next.js loads this file locally and keeps it out of version control when .gitignore contains the usual environment-file entries.

VOLANEA_API_KEY=sk_test_replace_with_your_secret_key
VOLANEA_FROM_EMAIL=notifications@updates.example.com
TEST_RECIPIENT_EMAIL=you@example.com

Replace all placeholder values before testing. In particular:

  • VOLANEA_API_KEY is your secret key, not a public client key.
  • VOLANEA_FROM_EMAIL must be a permitted sender on a verified Volanea domain.
  • TEST_RECIPIENT_EMAIL should be an inbox you own while testing.

Restart npm run dev after adding or changing .env.local. Environment variables are loaded when the development server starts, so a running process may not see newly added values until it restarts.

For a hosted deployment, configure the same variables through the deployment platform’s secret or environment-variable settings. Do not upload .env.local to a repository or paste a real production key into source code.

Create the server-only Volanea email helper

Create lib/volanea.ts. This file centralizes configuration and request behavior so future messages do not duplicate authorization code, response parsing, or error handling.

// lib/volanea.ts
import "server-only";

const VOLANEA_SEND_URL = "https://api.volanea.com/v1/send";

type SendTransactionalEmailInput = {
  to: string;
  subject: string;
  html: string;
  text: string;
};

type VolaneaSendResult = {
  [key: string]: unknown;
};

function requireEnv(name: string): string {
  const value = process.env[name];

  if (!value) {
    throw new Error(`Missing required environment variable: ${name}`);
  }

  return value;
}

function parseJsonSafely(value: string): unknown {
  try {
    return JSON.parse(value);
  } catch {
    return value;
  }
}

export async function sendTransactionalEmail({
  to,
  subject,
  html,
  text,
}: SendTransactionalEmailInput): Promise<VolaneaSendResult> {
  const apiKey = requireEnv("VOLANEA_API_KEY");
  const from = requireEnv("VOLANEA_FROM_EMAIL");

  const response = await fetch(VOLANEA_SEND_URL, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${apiKey}`,
      "Content-Type": "application/json",
      "Idempotency-Key": crypto.randomUUID(),
    },
    body: JSON.stringify({
      from,
      to: [to],
      subject,
      html,
      text,
    }),
    cache: "no-store",
  });

  const responseText = await response.text();
  const responseBody = responseText
    ? parseJsonSafely(responseText)
    : null;

  if (!response.ok) {
    const details =
      typeof responseBody === "string"
        ? responseBody
        : JSON.stringify(responseBody);

    throw new Error(
      `Volanea send failed with ${response.status} ${response.statusText}: ${details}`
    );
  }

  if (
    responseBody === null ||
    typeof responseBody !== "object" ||
    Array.isArray(responseBody)
  ) {
    return { result: responseBody };
  }

  return responseBody as VolaneaSendResult;
}

A few implementation details are worth calling out.

First, the helper validates required environment variables before it makes a network request. A missing sender or key should fail clearly during development rather than produce a vague authentication error from an external service.

Second, the request sends Content-Type: application/json and serializes the body with JSON.stringify(). The endpoint expects a JSON request body. Passing an object directly as body does not serialize it correctly and commonly causes parsing or validation failures.

Third, the helper provides both html and text. HTML gives you the formatted transactional message, while the plain-text alternative supports recipients and mail clients that cannot or should not render HTML. Keep the two versions semantically equivalent: recipients should receive the same core information, links, and next action in either format.

Finally, the helper uses an idempotency key. The endpoint supports the Idempotency-Key request header for safe retries. In a production workflow, generate the value from a stable business event—for example, welcome:user_123 or receipt:order_456—rather than creating a new random value on every retry. The sample generates a UUID because it demonstrates one independent test send per button press.

Add the Next.js Route Handler

Create the following file at app/api/send-test-email/route.ts. Route Handlers execute on the server, giving them access to server-only environment variables and the helper you just created.

// app/api/send-test-email/route.ts
import { NextResponse } from "next/server";
import { sendTransactionalEmail } from "@/lib/volanea";

export const runtime = "nodejs";

export async function POST() {
  try {
    const recipient = process.env.TEST_RECIPIENT_EMAIL;

    if (!recipient) {
      return NextResponse.json(
        { error: "Missing TEST_RECIPIENT_EMAIL environment variable." },
        { status: 500 }
      );
    }

    const result = await sendTransactionalEmail({
      to: recipient,
      subject: "Your Volanea + Next.js test email",
      html: `
        <main style="font-family: Arial, sans-serif; line-height: 1.5; color: #111827;">
          <h1>It works</h1>
          <p>This transactional email was sent from a Next.js Route Handler using the Volanea REST API.</p>
          <p>Your API key stayed on the server.</p>
        </main>
      `,
      text: "It works. This transactional email was sent from a Next.js Route Handler using the Volanea REST API. Your API key stayed on the server.",
    });

    return NextResponse.json({ ok: true, result }, { status: 200 });
  } catch (error) {
    console.error("Unable to send Volanea test email", error);

    return NextResponse.json(
      {
        ok: false,
        error: "Unable to send test email. Check server logs for details.",
      },
      { status: 500 }
    );
  }
}

The route does not reveal the upstream error body to the browser. That matters because provider responses can contain details that are useful for developers but unnecessary for end users. The full error is written to the server log, while the client receives a safe, generic message.

The runtime = "nodejs" export makes the intended runtime explicit. It is a sensible default for an integration that uses server modules and may later add Node-oriented facilities such as queues, logging libraries, or SMTP fallback code. Keep outbound email handling on a server runtime; do not attempt to send directly from browser code.

Add a test page

Create or replace app/page.tsx with this minimal client page. It calls the route, displays the outcome, and does not contain any Volanea credentials.

// app/page.tsx
"use client";

import { useState } from "react";

type SendState =
  | { status: "idle" }
  | { status: "sending" }
  | { status: "success"; message: string }
  | { status: "error"; message: string };

export default function HomePage() {
  const [state, setState] = useState<SendState>({ status: "idle" });

  async function sendTestEmail() {
    setState({ status: "sending" });

    try {
      const response = await fetch("/api/send-test-email", {
        method: "POST",
      });

      const body = (await response.json()) as {
        ok?: boolean;
        error?: string;
      };

      if (!response.ok || !body.ok) {
        throw new Error(body.error ?? "The email request failed.");
      }

      setState({
        status: "success",
        message: "Test email accepted for sending. Check your test inbox.",
      });
    } catch (error) {
      setState({
        status: "error",
        message:
          error instanceof Error
            ? error.message
            : "An unexpected error occurred.",
      });
    }
  }

  const isSending = state.status === "sending";

  return (
    <main
      style={{
        maxWidth: 640,
        margin: "64px auto",
        padding: 24,
        fontFamily: "Arial, sans-serif",
      }}
    >
      <h1>Volanea email test</h1>
      <p>
        Send one transactional email through a server-side Next.js Route Handler.
      </p>

      <button
        type="button"
        onClick={sendTestEmail}
        disabled={isSending}
        style={{ padding: "10px 16px", cursor: isSending ? "wait" : "pointer" }}
      >
        {isSending ? "Sending…" : "Send test email"}
      </button>

      {state.status === "success" && (
        <p role="status" style={{ color: "green" }}>
          {state.message}
        </p>
      )}

      {state.status === "error" && (
        <p role="alert" style={{ color: "crimson" }}>
          {state.message}
        </p>
      )}
    </main>
  );
}

Start the development server and open the local URL shown in your terminal:

npm run dev

Select Send test email once. A successful API response means Volanea accepted the message request; it does not necessarily mean the message has already appeared in the inbox. Check the receiving inbox, its spam or junk folder during initial testing, and your server logs if the route returns an error.

Understand the request sent to Volanea

The helper sends a JSON request to POST https://api.volanea.com/v1/send. Its essential fields are:

{
  "from": "notifications@updates.example.com",
  "to": ["you@example.com"],
  "subject": "Your Volanea + Next.js test email",
  "html": "<p>It works</p>",
  "text": "It works"
}

from identifies the sender address. It should be an address on a domain you have verified for sending. Do not let an unauthenticated browser request choose arbitrary from addresses: sender identity is an operational policy, not a form field.

to is an array, even when sending to one recipient. The single-send endpoint can address one recipient or up to 50 recipients in one send. For an application message such as a password reset or receipt, one recipient per business event is usually easier to reason about, audit, and retry.

subject should identify the message purpose accurately. Avoid misleading subject lines, and make high-impact messages—password changes, payments, account access, and security alerts—unambiguous.

html contains the formatted email. Email HTML is more restrictive than web-page HTML: use simple table-friendly layouts if you later design more complex messages, prefer inline styles where appropriate, and test important messages in the mailbox clients your users actually use.

text is the plain-text alternative. Include key URLs in readable form and avoid making the text version merely an afterthought. A concise, useful text alternative also improves debugging because it is easy to inspect in logs and tests.

Keep Volanea credentials and email logic on the server

A reliable Next.js email integration has two separate trust boundaries: the browser-to-your-app request and your-app-to-Volanea request. The browser may ask your application to perform an action, but it should never receive a provider key or be able to redefine sensitive sender policy.

Do not use public environment variable prefixes

In Next.js, variables whose names begin with NEXT_PUBLIC_ are available to browser code. Never use names such as NEXT_PUBLIC_VOLANEA_API_KEY. Doing so exposes the credential in the JavaScript bundle and lets anyone use it outside your intended application flow.

Use ordinary server environment variable names such as VOLANEA_API_KEY. The server-only import adds another layer of protection by making accidental client imports fail.

Authenticate application actions before sending

The sample page is only for local testing. A production endpoint should normally verify that the caller is authenticated and authorized before it performs a send. For example, a signed-in user could request an email verification message only for their own account, while an internal administrative action could create a receipt only after verifying the underlying order.

Do not rely on a hidden form input, a client-side condition, or a route name to enforce these rules. Validate identity and authorization in the server-side handler where the send occurs.

Validate dynamic inputs

When the recipient, name, amount, reset URL, or message content comes from users or a database, validate it before building the email. Enforce expected data types, lengths, and formats. Escape or encode untrusted values before including them in HTML so an attacker cannot inject markup into a message.

For email addresses, validation should be a first-pass hygiene measure, not proof that the mailbox exists or accepts mail. For signup and contact flows, use an address verification process where appropriate; the email verification tool can help evaluate address quality before a workflow depends on it.

Use idempotency for reliable transactional sends

Networks are uncertain. Your server may send a request successfully but lose the response before it arrives. A worker can time out after Volanea receives the request. A user may double-submit an action. If your retry code treats every failure as definitely unsent, a receipt or password-reset email can be created more than once.

Volanea supports the Idempotency-Key header on sends. The principle is simple: use one unique key for one logical send, and reuse that same key only when retrying that exact logical send.

Choose stable keys in production

The test route uses crypto.randomUUID() because pressing its button intentionally starts a new test action. A real order receipt needs a stable identifier instead.

const idempotencyKey = `receipt:order:${order.id}`;

For a welcome email, a user identifier can be suitable if the product rule is exactly one welcome email per account:

const idempotencyKey = `welcome:user:${user.id}`;

For a password-reset request, include an identifier for the individual reset token or event. Reusing a lifetime user key would prevent legitimate future reset messages.

const idempotencyKey = `password-reset:${resetToken.id}`;

Avoid placing raw email addresses, access tokens, or other sensitive values in an idempotency key. Keys can appear in observability systems, proxy logs, or debugging output. Prefer internal opaque IDs.

Retry deliberately

Retry only transient failures: network interruptions, timeouts where the outcome is unknown, or temporary upstream availability problems. Do not automatically retry a clear authorization failure or a validation error, because the same request will fail again until configuration or data changes.

When a retry happens, preserve the idempotency key along with the original message event. For high-value workflows, store an outbox record in your own database before attempting the API call. A background worker can process that record and retry safely, which is more dependable than holding an end-user HTTP request open while external delivery infrastructure is unavailable.

Common errors

These failures are common when developers first send email with Next.js. Work from the server logs outward: your route’s log should contain the upstream status and response details without exposing them to end users.

Authentication failures: 401 or 403 responses

An authentication failure usually means the key is missing, copied incorrectly, revoked, or sent using the wrong authorization format. Confirm that VOLANEA_API_KEY is set in the environment where the Route Handler runs, restart the local server after editing .env.local, and make sure the request includes:

Authorization: `Bearer ${apiKey}`

Do not test with process.env.NEXT_PUBLIC_VOLANEA_API_KEY, and do not place the key in app/page.tsx. In hosted environments, verify that the deployment’s production environment has the key—not merely your local machine or preview environment.

Wrong Content-Type or malformed request body

The send route must declare JSON and serialize the JavaScript object:

headers: {
  "Content-Type": "application/json",
},
body: JSON.stringify(payload),

A frequent mistake is writing body: payload. Fetch expects a body type such as a string, stream, or form data, not a plain object. Another mistake is using form encoding while telling the server the request is JSON. Keep the header and body format aligned.

If the API returns a validation error, check field names and value shapes first. In this guide, to is an array, from is a verified sender address, and the message contains subject, html, and text.

Forgetting await

fetch() is asynchronous. So is sendTransactionalEmail(). If a Route Handler calls the helper without await, it can return a success response before the provider request has finished or lose the rejected promise entirely.

Correct:

const result = await sendTransactionalEmail(message);

Incorrect:

const result = sendTransactionalEmail(message);

Likewise, await response.text() or response.json() before inspecting API details. Avoid assuming an asynchronous request completed merely because the function was called.

Calling Volanea from a Client Component

A Client Component starts with the "use client" directive and executes in the browser. Calling Volanea directly there risks exposing the API key and creates an integration that any visitor can replay.

The correct flow is browser → your authenticated Next.js Route Handler or Server Action → Volanea. The browser calls /api/send-test-email; the server module reads the secret and calls the provider.

Missing environment variables

If the page responds with a generic 500 error and the server log says an environment variable is missing, confirm the variable name exactly matches the code. .env.local belongs in the project root, not inside app/ or src/ unless that directory is your actual project root.

Also remember that build-time and runtime configuration can differ. A deployment that builds successfully may still fail at runtime if the platform has not been given VOLANEA_API_KEY, VOLANEA_FROM_EMAIL, and any other required values.

Sender-domain or From-address validation errors

A valid API key cannot authorize an arbitrary sender address. Verify the sending domain in Volanea and make sure VOLANEA_FROM_EMAIL belongs to that domain. Check for common mistakes such as a typo, using a consumer mailbox address, a stale DNS setup, or a sender address from a domain owned by another service.

When moving from local testing to production, update the sender deliberately. Test-mode credentials, sandbox behavior, and production domain rules can differ, so run a production-like test after the domain is verified.

Treating an accepted send as inbox delivery

A successful send response means the provider accepted the request for processing. It is not the same as proof that a recipient opened the message, that a mailbox provider accepted it, or that it avoided spam filtering.

Use event handling and logs to follow the message lifecycle. For critical workflows, make the underlying application experience resilient: a user should be able to request another password-reset email, retrieve a receipt in-app, or see a status page rather than depending on a single inbox event.

Production patterns for transactional email

The minimal sample is intentionally direct. Production systems should keep the same server-side boundary while making message creation more durable and observable.

Put sends behind domain events

Send messages because an authoritative application event occurred, not merely because a UI button was pressed. For example, create an order receipt only after the payment and order records are committed. Create a verification message after storing the verification token. This order avoids messages that refer to an action your database never completed.

A useful pattern is an outbox table. In the same database transaction that creates the business event, store a pending email record with its recipient, template or content data, and idempotency key. A worker processes pending records, records the API result, and retries transient failures. This decouples user-facing response time from email-provider availability.

Keep email content separate from transport code

The helper should know how to authenticate and call the API. It should not become a giant collection of receipt markup, password-reset HTML, billing language, and onboarding copy. Keep message construction in focused functions or template files so developers can update content without changing transport behavior.

At a minimum, give each message a dedicated function that receives validated data and returns subject, html, and text. Test those functions independently. This catches missing variables and broken links without requiring a real send.

Log safely and measure outcomes

Log a correlation ID, internal message ID, provider response metadata, recipient identifier from your own database, and the idempotency key where appropriate. Do not log the secret key, reset token, full HTML body containing private information, or raw personally identifiable data unless your privacy and retention policies explicitly allow it.

Then connect delivery-related events back to the originating business event. A failed password-reset message, a suppressed address, or a repeated bounce should produce an actionable signal rather than disappear into generic application logs.

Next steps

After the first transactional send works, move beyond a test button and build the surrounding delivery workflow.

Webhooks let your application receive message lifecycle events. Use them to record outcomes such as delivery, bounces, complaints, and other provider events in your own system. Verify webhook requests according to the provider documentation, process them asynchronously when possible, and make your handler idempotent because a webhook delivery can be retried.

Templates let you centralize reusable message content and send using a template identifier plus data, instead of placing every HTML string inside a route. This is especially useful for receipts, invitations, verification emails, and lifecycle messages that need consistent branding and controlled content updates.

See the Volanea API reference and setup guides for the send endpoint, templates, webhooks, and related email infrastructure capabilities. Before scaling volume, also review your expected sending pattern and transactional email pricing so application architecture, retry behavior, and cost expectations align.

FAQ

Can I send email with Next.js from a Client Component?

Do not send through Volanea directly from a Client Component. Browser code cannot safely hold a secret API key. Call a server-side Route Handler or Server Action, then let that server code call the Volanea REST API.

Does this integration require a Volanea Next.js SDK?

No. This guide uses the REST API with native fetch, so it does not depend on a framework-specific SDK or fictional provider method. The only installed package is server-only, which protects the server-only module boundary.

Why does the example include both HTML and text content?

HTML provides the formatted version of the message, while text is a useful fallback for recipients or clients that do not render HTML. Supplying both also makes your transactional content more accessible and easier to inspect.

Why is the recipient stored in an environment variable?

The fixed recipient keeps the sample safe for testing. A public endpoint that accepts arbitrary recipient addresses can be abused. In production, derive the recipient from an authenticated, authorized application event and validate it on the server.

What should I use for an idempotency key?

Use a stable, non-sensitive identifier for the single logical email event, such as an internal order ID plus message type. Reuse it only when retrying the same event; generate a different key for a genuinely new email.